diff --git a/pkg/consensus/batch_rb1_gate_test.go b/pkg/consensus/batch_rb1_gate_test.go index 8212b0bd..5fb719b9 100644 --- a/pkg/consensus/batch_rb1_gate_test.go +++ b/pkg/consensus/batch_rb1_gate_test.go @@ -67,6 +67,8 @@ func callIntent(t *testing.T, l callLeg) *CertenIntent { legs := []map[string]interface{}{{ "legId": "leg-0", "chain": "evm", "chainId": l.chainID, "from": "0x32b4687bE3c02d52e2d94Dc1cFAF03a0E5af0C8B", "executionPayload": ep, + // The chain's live anchor, as the fake names it (declared_anchor.go). + "anchorContract": map[string]interface{}{"address": testAnchor(l.chainID).Hex(), "functionSelector": BatchAnchorCreateSignature}, }} b, err := json.Marshal(map[string]interface{}{"protocol": "CERTEN", "version": "2.0", "legs": legs}) if err != nil { diff --git a/pkg/consensus/batch_refusal.go b/pkg/consensus/batch_refusal.go index 5cddd36a..0563b8cf 100644 --- a/pkg/consensus/batch_refusal.go +++ b/pkg/consensus/batch_refusal.go @@ -108,6 +108,11 @@ func (bv *BFTValidator) planBatch(ci *CertenIntent, commitHeight uint64) (*batch return nil, refuse(fmt.Errorf("intent %s: %w", ci.IntentID, err)) } + // Settled on the anchor each leg declares, or refused naming both (declared_anchor.go, RB4-F9). + if err := CheckDeclaredAnchors(ci, bv.batchEnqueuer.AnchorOf); err != nil { + return nil, refuse(fmt.Errorf("intent %s: %w", ci.IntentID, err)) + } + // The ADI URL is keccak'd into the member's Merkle leaf, and the account contract recomputes // that leaf from its OWN immutable adiURL; see memberADIURL. adiURL, err := memberADIURL(ci) diff --git a/pkg/consensus/batch_refusal_test.go b/pkg/consensus/batch_refusal_test.go index 60507b71..c3f55c93 100644 --- a/pkg/consensus/batch_refusal_test.go +++ b/pkg/consensus/batch_refusal_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" "time" + + "github.com/ethereum/go-ethereum/common" ) // ============================================================================= @@ -22,13 +24,14 @@ import ( // queued all-or-nothing. type fakeEnqueuer struct { - checkErr map[int64]error // CheckMember result per chain - addErr map[int64]error // EnqueueForBatch/EnqueueOnDemand result per chain (after the first add) - queued map[string]bool - removed []string - adds int - after map[int64]SequencePredecessor // EnqueueAfter's predecessor per chain - order []int64 // chains in the order they were queued + checkErr map[int64]error // CheckMember result per chain + addErr map[int64]error // EnqueueForBatch/EnqueueOnDemand result per chain (after the first add) + queued map[string]bool + removed []string + adds int + after map[int64]SequencePredecessor // EnqueueAfter's predecessor per chain + order []int64 // chains in the order they were queued + anchorErr map[int64]error // AnchorOf failure per chain } func newFakeEnqueuer() *fakeEnqueuer { @@ -75,6 +78,18 @@ func (f *fakeEnqueuer) CheckMember(_ bool, _ string, _ string, chainID int64, _ return f.checkErr[chainID] } +// testAnchor is the anchor the fake names for a chain; batchableIntent's legs declare it. +func testAnchor(chainID int64) common.Address { + return common.HexToAddress(fmt.Sprintf("0x%040x", 0xa0000000+chainID)) +} + +func (f *fakeEnqueuer) AnchorOf(chainID int64) (common.Address, error) { + if err := f.anchorErr[chainID]; err != nil { + return common.Address{}, err + } + return testAnchor(chainID), nil +} + func (f *fakeEnqueuer) RemoveMember(_ bool, intentID string, chainID int64, _ [32]byte) { key := fmt.Sprintf("%s|%d", intentID, chainID) delete(f.queued, key) @@ -89,7 +104,8 @@ func batchableIntent(t *testing.T, id string, chains ...int64) *CertenIntent { for i, c := range chains { legs = append(legs, map[string]interface{}{ "legId": fmt.Sprintf("leg-%d", i), "chain": "evm", "chainId": c, - "from": "0x32b4687bE3c02d52e2d94Dc1cFAF03a0E5af0C8B", + "from": "0x32b4687bE3c02d52e2d94Dc1cFAF03a0E5af0C8B", + "anchorContract": map[string]interface{}{"address": testAnchor(c).Hex(), "functionSelector": BatchAnchorCreateSignature}, "executionPayload": map[string]interface{}{ "target": "0x1111111111111111111111111111111111111111", "value": "1000", "chainId": c, }, diff --git a/pkg/consensus/bft_integration.go b/pkg/consensus/bft_integration.go index 306870dd..f1e49bdd 100644 --- a/pkg/consensus/bft_integration.go +++ b/pkg/consensus/bft_integration.go @@ -311,6 +311,10 @@ type BatchEnqueuer interface { CheckMember(onDemand bool, intentID, adiURL string, chainID int64, account [20]byte, operationID [32]byte, legs interface{}, commitHeight uint64) error + // AnchorOf names the anchor the batch path settles chainID's members on (CERTEN_ANCHOR_V8_). + // An error is CERTEN unable to name it, never the intent's defect. + AnchorOf(chainID int64) (common.Address, error) + // EnqueueAfter queues a later member of a sequential cross-chain intent: settled only once its // predecessor (the intent's member on after.ChainID, queued first) has its outcome on chain. // Same errors as EnqueueForBatch. diff --git a/pkg/consensus/declared_anchor.go b/pkg/consensus/declared_anchor.go new file mode 100644 index 00000000..78767e02 --- /dev/null +++ b/pkg/consensus/declared_anchor.go @@ -0,0 +1,102 @@ +// Copyright 2026 Certen Protocol + +package consensus + +import ( + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// ============================================================================= +// A leg names the anchor its chain settles on, or is refused by name +// ============================================================================= +// +// Every leg of a signed intent declares the anchor its chain settles on (anchorContract: address, +// functionSelector). The batch path never read it: it settles on the chain's configured +// CertenAnchorV8 (CERTEN_ANCHOR_V8_) with createBatchAnchor, whatever the leg said, and the +// validator block recorded the declaration as "what will execute" anyway - the declared address, or +// the anchor's type string when there was none, with call data it made up (a sha256 "selector" and +// sha256(expiry) for a uint256). Intents were signed declaring a retired anchor (0x8398D7EB…5339, +// commitAnchor) and settled on another (RB4-F9). +// +// So an intent is settled only if each of its legs declares the anchor it will actually be settled +// on, and the call made on it; otherwise it is refused, before anything is signed, naming both. +// The bridge declares the live anchor since RB4-B1. The check is admission (pre-signing), where +// every honest validator applies it; the validator block then records the declaration, which is +// now the truth. + +// ErrDeclaredAnchorNotLive is a leg that declares an anchor, or a call on it, other than the one its +// chain settles on. +var ErrDeclaredAnchorNotLive = errors.New("declared anchor is not the chain's live anchor") + +// BatchAnchorCreateSignature is the call the batch path makes on a chain's anchor (step 1 of a +// member's settlement; pkg/execution settlement_steps.go packs it from the same ABI). +const BatchAnchorCreateSignature = "createBatchAnchor(bytes32,bytes32,uint256,bytes32,uint256)" + +// BatchAnchorCreateSelector is BatchAnchorCreateSignature's 4-byte selector. +var BatchAnchorCreateSelector = func() [4]byte { + var s [4]byte + copy(s[:], crypto.Keccak256([]byte(BatchAnchorCreateSignature))[:4]) + return s +}() + +// DeclaredSelector reads a leg's declared function as a 4-byte selector: either the function's +// signature (as the bridge declares it) or its selector in hex. +func DeclaredSelector(declared string) ([4]byte, error) { + var s [4]byte + d := strings.TrimSpace(declared) + if d == "" { + return s, errors.New("no function declared") + } + if strings.Contains(d, "(") { + copy(s[:], crypto.Keccak256([]byte(d))[:4]) + return s, nil + } + raw, err := hex.DecodeString(strings.TrimPrefix(strings.ToLower(d), "0x")) + if err != nil || len(raw) != 4 { + return s, fmt.Errorf("%q is neither a function signature nor a 4-byte selector", declared) + } + copy(s[:], raw) + return s, nil +} + +// CheckDeclaredAnchors refuses an intent any of whose legs declares an anchor other than its chain's +// live one (anchorOf), or a call on it other than createBatchAnchor. anchorOf failing is CERTEN +// unable to name the chain's anchor now: that is retried (ErrBatchUnavailable), never held against +// the intent. +func CheckDeclaredAnchors(ci *CertenIntent, anchorOf func(chainID int64) (common.Address, error)) error { + env, err := ci.ParseCrossChain() + if err != nil { + return fmt.Errorf("%w: its legs cannot be read: %v", ErrDeclaredAnchorNotLive, err) + } + for i, leg := range env.Legs { + live, err := anchorOf(leg.ChainID) + if err != nil { + return fmt.Errorf("%w: chain %d's anchor cannot be named: %v", ErrBatchUnavailable, leg.ChainID, err) + } + declared := strings.TrimSpace(leg.AnchorContract.Address) + if !common.IsHexAddress(declared) { + return fmt.Errorf("%w: leg %d (chain %d) declares no anchor address (%q); its chain settles on %s", + ErrDeclaredAnchorNotLive, i, leg.ChainID, declared, live.Hex()) + } + if common.HexToAddress(declared) != live { + return fmt.Errorf("%w: leg %d declares anchor %s on chain %d, which settles on %s", + ErrDeclaredAnchorNotLive, i, common.HexToAddress(declared).Hex(), leg.ChainID, live.Hex()) + } + sel, err := DeclaredSelector(leg.AnchorContract.FunctionSelector) + if err != nil { + return fmt.Errorf("%w: leg %d (chain %d): %v; the anchor is called with %s", + ErrDeclaredAnchorNotLive, i, leg.ChainID, err, BatchAnchorCreateSignature) + } + if sel != BatchAnchorCreateSelector { + return fmt.Errorf("%w: leg %d (chain %d) declares the call 0x%x (%s); the anchor is called with %s (0x%x)", + ErrDeclaredAnchorNotLive, i, leg.ChainID, sel, leg.AnchorContract.FunctionSelector, BatchAnchorCreateSignature, BatchAnchorCreateSelector) + } + } + return nil +} diff --git a/pkg/consensus/declared_anchor_test.go b/pkg/consensus/declared_anchor_test.go new file mode 100644 index 00000000..cecfdabd --- /dev/null +++ b/pkg/consensus/declared_anchor_test.go @@ -0,0 +1,133 @@ +package consensus + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +// ============================================================================= +// RB4-F9: a leg is settled on the anchor it declares, and the block records what will execute +// ============================================================================= +// +// The batch path settled every leg on the chain's configured anchor with createBatchAnchor while the +// intent declared another (a retired 0x8398D7EB…5339 commitAnchor, or nothing), and the validator +// block recorded the declaration as "what will execute": the declared address or the anchor's type +// string, with call data made up from sha256 (a "selector" and sha256(expiry) for a uint256). + +// withAnchor rewrites leg i's declared anchor. +func withAnchor(t *testing.T, ci *CertenIntent, i int, anchor map[string]interface{}) *CertenIntent { + t.Helper() + var env map[string]interface{} + if err := json.Unmarshal(ci.CrossChainData, &env); err != nil { + t.Fatal(err) + } + leg := env["legs"].([]interface{})[i].(map[string]interface{}) + if anchor == nil { + delete(leg, "anchorContract") + } else { + leg["anchorContract"] = anchor + } + b, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + ci.CrossChainData = b + return ci +} + +func TestTheBatchAnchorSelectorIsCreateBatchAnchor(t *testing.T) { + if got := fmt.Sprintf("0x%x", BatchAnchorCreateSelector); got != "0x34597e5a" { + t.Fatalf("createBatchAnchor selector %s, want 0x34597e5a", got) + } +} + +func TestALegIsSettledOnTheAnchorItDeclares(t *testing.T) { + const retired = "0x8398D7EB4bF1C1F3D7F8aF9e5eFbDfC0c1b85339" + for name, anchor := range map[string]map[string]interface{}{ + "the live anchor, by signature": {"address": testAnchor(84532).Hex(), "functionSelector": BatchAnchorCreateSignature}, + "the live anchor, by selector": {"address": strings.ToLower(testAnchor(84532).Hex()), "functionSelector": "0x34597e5a"}, + } { + if err := enqueue(refusalValidator(newFakeEnqueuer()), withAnchor(t, batchableIntent(t, "i1", 84532), 0, anchor)); err != nil { + t.Errorf("%s: refused: %v", name, err) + } + } + for name, c := range map[string]struct { + anchor map[string]interface{} + names []string + }{ + "a retired anchor": {map[string]interface{}{"address": retired, "functionSelector": "commitAnchor(bytes32,bytes)"}, []string{common.HexToAddress(retired).Hex(), testAnchor(84532).Hex()}}, + "the live anchor, but another call": {map[string]interface{}{"address": testAnchor(84532).Hex(), "functionSelector": "commitAnchor(bytes32,bytes)"}, []string{"createBatchAnchor"}}, + "no anchor at all": {nil, []string{"declares no anchor address", testAnchor(84532).Hex()}}, + "an anchor type, no address": {map[string]interface{}{"type": "evm_contract"}, []string{"declares no anchor address"}}, + "no call": {map[string]interface{}{"address": testAnchor(84532).Hex()}, []string{"no function declared"}}, + } { + f := newFakeEnqueuer() + err := enqueue(refusalValidator(f), withAnchor(t, batchableIntent(t, "i1", 84532), 0, c.anchor)) + var r *BatchRefusal + if !errors.As(err, &r) || !r.Permanent || !errors.Is(err, ErrDeclaredAnchorNotLive) { + t.Errorf("%s: want a permanent refusal naming ErrDeclaredAnchorNotLive, got %v", name, err) + continue + } + for _, n := range c.names { + if !strings.Contains(err.Error(), n) { + t.Errorf("%s: the refusal does not name %q: %v", name, n, err) + } + } + if f.adds != 0 { + t.Errorf("%s: a refused intent was queued", name) + } + } + // One leg of two on the wrong anchor refuses the intent. + err := enqueue(refusalValidator(newFakeEnqueuer()), withAnchor(t, batchableIntent(t, "i2", 84532, 421614), 1, + map[string]interface{}{"address": testAnchor(84532).Hex(), "functionSelector": BatchAnchorCreateSignature})) + if !errors.Is(err, ErrDeclaredAnchorNotLive) { + t.Errorf("a second leg declaring another chain's anchor was not refused: %v", err) + } +} + +func TestAnAnchorCERTENCannotNameIsRetriedNotRefused(t *testing.T) { + f := newFakeEnqueuer() + f.anchorErr = map[int64]error{84532: errors.New("chain 84532 has no CertenAnchorV8 configured")} + err := enqueue(refusalValidator(f), batchableIntent(t, "i1", 84532)) + var r *BatchRefusal + if !errors.As(err, &r) || r.Permanent || !errors.Is(err, ErrBatchUnavailable) { + t.Fatalf("CERTEN unable to name the anchor must be retried, got %v", err) + } +} + +func TestTheBlockRecordsTheCallThatWillExecute(t *testing.T) { + whole := AccumulateAnchorReference{BlockHash: strings.Repeat("ab", 32), BlockHeight: 1234, TxHash: strings.Repeat("cd", 32), AccountURL: "acc://org.acme/data"} + build := func(ci *CertenIntent) (*ValidatorBlock, error) { + return NewValidatorBlockBuilder(BuilderConfig{ValidatorID: "validator-test", BLSValidatorSetPubKey: "aa"}).BuildFromIntent(BuilderInputs{ + Intent: ci, + Governance: GovernanceInputs{BLSAggregateSignature: "bb", GovernanceLevel: "G2"}, + Execution: ExecutionInputs{Stage: ExecutionStagePre, ProofClass: "on_cadence", ValidatorSignatures: []string{"cc"}}, + AnchorRef: whole, + BlockHeight: 7, + }) + } + vb, err := build(batchableIntent(t, "i1", 84532)) + if err != nil { + t.Fatal(err) + } + tg := vb.CrossChainProof.ChainTargets[0] + if tg.ContractAddress != testAnchor(84532).Hex() || tg.FunctionSelector != "0x34597e5a" { + t.Fatalf("chain target %s %s; want the anchor %s called with createBatchAnchor (0x34597e5a)", tg.ContractAddress, tg.FunctionSelector, testAnchor(84532).Hex()) + } + raw, err := json.Marshal(vb.CrossChainProof.ChainTargets) + if err != nil { + t.Fatal(err) + } + if tg.EncodedCallData != "" || strings.Contains(string(raw), "encoded_call_data") { + t.Fatalf("the block states call data that nothing will send: %s", raw) + } + // A leg with no address is not given its anchor type as one. + if vb, err := build(withAnchor(t, batchableIntent(t, "i2", 84532), 0, map[string]interface{}{"type": "evm_contract"})); err == nil { + t.Fatalf("built with contract address %q for a leg that declares none", vb.CrossChainProof.ChainTargets[0].ContractAddress) + } +} diff --git a/pkg/consensus/validator_block.go b/pkg/consensus/validator_block.go index 3985ea2d..a3334165 100644 --- a/pkg/consensus/validator_block.go +++ b/pkg/consensus/validator_block.go @@ -137,9 +137,12 @@ type ChainTarget struct { ChainID int64 `json:"chain_id"` // [INTENT] - 11155111 for Sepolia, -3 for TON Testnet ContractAddress string `json:"contract_address"` // [INTENT] - Anchor contract address FunctionSelector string `json:"function_selector"` // [INTENT] - Function selector - EncodedCallData string `json:"encoded_call_data"` // [DERIVED] - ABI encoded call data - Commitment string `json:"commitment"` // [DERIVED] - Per-leg commitment hash - Expiry string `json:"expiry"` // [DERIVED FROM INTENT] - RFC3339 from ReplayData.ExpiresAt + // EncodedCallData is not set (RB4-F9): the batch anchor's call data carries the batch root, which does + // not exist when the block is built. Blocks built before carried a value made up from sha256; the field + // stays, omitted when empty, so those blocks still hash to their recorded bundle id. + EncodedCallData string `json:"encoded_call_data,omitempty"` + Commitment string `json:"commitment"` // [DERIVED] - Per-leg commitment hash + Expiry string `json:"expiry"` // [DERIVED FROM INTENT] - RFC3339 from ReplayData.ExpiresAt } // ExternalChainResult represents the result of an external chain operation diff --git a/pkg/consensus/validator_block_builder.go b/pkg/consensus/validator_block_builder.go index efe6f386..7d611155 100644 --- a/pkg/consensus/validator_block_builder.go +++ b/pkg/consensus/validator_block_builder.go @@ -14,6 +14,7 @@ import ( "time" "github.com/certen/independant-validator/pkg/commitment" + "github.com/ethereum/go-ethereum/common" ) // ValidatorBlockBuilder constructs ValidatorBlock from CertenIntent and validator context @@ -97,21 +98,17 @@ func (builder *ValidatorBlockBuilder) BuildFromIntent(inputs BuilderInputs) (*Va return nil, fmt.Errorf("compute leg commitment for leg %d: %w", i, err) } - // Encode call data for this leg - encodedCallData, err := builder.encodeAnchorCallData(leg, legCommitment, expiryString) + // What will execute for this leg: its chain's anchor, called with createBatchAnchor - the leg's + // declaration, which admission has already held to the chain's live anchor (declared_anchor.go). + // No call data: the batch anchor's carries the batch root, which does not exist until the batch + // closes; it was made up here from sha256 (RB4-F9). + target, err := legChainTarget(leg) if err != nil { - return nil, fmt.Errorf("encode call data for leg %d: %w", i, err) - } - - chainTargets[i] = ChainTarget{ - Chain: leg.Chain, - ChainID: leg.ChainID, - ContractAddress: resolveAnchorIdentifier(leg), - FunctionSelector: leg.AnchorContract.FunctionSelector, - EncodedCallData: encodedCallData, - Commitment: legCommitment, - Expiry: expiryString, + return nil, fmt.Errorf("leg %d: %w", i, err) } + target.Commitment = legCommitment + target.Expiry = expiryString + chainTargets[i] = target commitments[i] = legCommitment } @@ -298,65 +295,24 @@ func (builder *ValidatorBlockBuilder) parseIntentBlobs(intent *CertenIntent) (*I return &intentData, &crossChainData, &govData, &replayData, nil } -// buildChainTargets builds ChainTarget array from cross-chain legs -func (builder *ValidatorBlockBuilder) buildChainTargets(crossChainData *CrossChainEnvelope, expiryString string) ([]ChainTarget, error) { - targets := make([]ChainTarget, len(crossChainData.Legs)) - - for i, leg := range crossChainData.Legs { - // Compute per-leg commitment - convert CCLeg to map for commitment function - legData, err := json.Marshal(leg) - if err != nil { - return nil, fmt.Errorf("marshal leg %d: %w", i, err) - } - var legMap map[string]interface{} - if err := json.Unmarshal(legData, &legMap); err != nil { - return nil, fmt.Errorf("unmarshal leg %d to map: %w", i, err) - } - legCommitment, err := commitment.ComputeLegCommitment(legMap) - if err != nil { - return nil, fmt.Errorf("compute commitment for leg %d: %w", i, err) - } - - // ABI-encode the call data for the anchor contract - encodedCallData, err := builder.encodeAnchorCallData(leg, legCommitment, expiryString) - if err != nil { - return nil, fmt.Errorf("ABI encode call data for leg %d: %w", i, err) - } - - targets[i] = ChainTarget{ - Chain: leg.Chain, - ChainID: leg.ChainID, - ContractAddress: resolveAnchorIdentifier(leg), - FunctionSelector: leg.AnchorContract.FunctionSelector, - EncodedCallData: encodedCallData, - Commitment: legCommitment, - Expiry: expiryString, - } - } - - return targets, nil -} - -// resolveAnchorIdentifier returns the anchor/program/contract identifier for a leg, -// checking chain-specific fields when the EVM Address field is empty. -func resolveAnchorIdentifier(leg CCLeg) string { - if leg.AnchorContract.Address != "" { - return leg.AnchorContract.Address - } - if leg.AnchorContract.ProgramID != "" { - return leg.AnchorContract.ProgramID - } - if leg.AnchorContract.ContractID != "" { - return leg.AnchorContract.ContractID - } - if leg.AnchorContract.ModuleAddress != "" { - return leg.AnchorContract.ModuleAddress - } - // Fallback: use the type field as a placeholder so invariant doesn't fail - if leg.AnchorContract.Type != "" { - return leg.AnchorContract.Type +// legChainTarget is the call a leg's chain will execute: the anchor the leg declares (an EVM address; +// its type string was put here when it had none, "so invariant doesn't fail") with the selector of the +// call it declares. +func legChainTarget(leg CCLeg) (ChainTarget, error) { + addr := strings.TrimSpace(leg.AnchorContract.Address) + if !common.IsHexAddress(addr) { + return ChainTarget{}, fmt.Errorf("chain %d: the leg declares no anchor address (%q)", leg.ChainID, addr) } - return "" + sel, err := DeclaredSelector(leg.AnchorContract.FunctionSelector) + if err != nil { + return ChainTarget{}, fmt.Errorf("chain %d: %w", leg.ChainID, err) + } + return ChainTarget{ + Chain: leg.Chain, + ChainID: leg.ChainID, + ContractAddress: common.HexToAddress(addr).Hex(), + FunctionSelector: "0x" + hex.EncodeToString(sel[:]), + }, nil } // buildMerkleBranches constructs Merkle branches for authorization leaves @@ -387,44 +343,3 @@ func (builder *ValidatorBlockBuilder) buildMerkleBranches(leaves []Authorization return branches } - -// encodeAnchorCallData ABI-encodes the call data for Ethereum anchor contracts -func (builder *ValidatorBlockBuilder) encodeAnchorCallData(leg CCLeg, commitment, expiry string) (string, error) { - // TODO: Replace with true Ethereum ABI encoding using keccak256 selector - // and uint256 expiry. This simplified encoding is ONLY OK for dev/test. - // Simplified ABI encoding for anchor function call - // Typically this would be: anchorCommitment(bytes32 commitment, uint256 expiry, bytes calldata proof) - - // Convert hex commitment to bytes32 - commitmentBytes, err := hex.DecodeString(strings.TrimPrefix(commitment, "0x")) - if err != nil { - return "", fmt.Errorf("invalid commitment hex: %w", err) - } - - // Pad to 32 bytes if needed - if len(commitmentBytes) < 32 { - padded := make([]byte, 32) - copy(padded[32-len(commitmentBytes):], commitmentBytes) - commitmentBytes = padded - } - - // Create minimal ABI-encoded call data - // Function selector (first 4 bytes of keccak256("anchorCommitment(bytes32,uint256,bytes)")) - functionSelector := leg.AnchorContract.FunctionSelector - if functionSelector == "" { - // Default anchor commitment function selector - hash := sha256.Sum256([]byte("anchorCommitment(bytes32,uint256,bytes)")) - functionSelector = hex.EncodeToString(hash[:4]) - } - - // Simple encoding: selector + commitment (32 bytes) + expiry timestamp - // This is a simplified implementation - real ABI encoding would be more complex - callData := functionSelector - callData += hex.EncodeToString(commitmentBytes) - - // Add expiry as uint256 (32 bytes, simplified) - expiryHash := sha256.Sum256([]byte(expiry)) - callData += hex.EncodeToString(expiryHash[:]) - - return "0x" + callData, nil -} diff --git a/pkg/execution/batch_assembly.go b/pkg/execution/batch_assembly.go index 6671d3a0..e3845772 100644 --- a/pkg/execution/batch_assembly.go +++ b/pkg/execution/batch_assembly.go @@ -865,6 +865,16 @@ func (s *BatchStack) CheckMember( return nil } +// AnchorOf names the CertenAnchorV8 the batch path settles chainID's members on - the resolver's, +// read from CERTEN_ANCHOR_V8_. Consensus refuses a leg that declares any other (RB4-F9). +func (s *BatchStack) AnchorOf(chainID int64) (common.Address, error) { + if s == nil || s.Resolver == nil { + return common.Address{}, fmt.Errorf("no chain resolver") + } + _, anchor, err := s.Resolver.Endpoint(chainID) + return anchor, err +} + // RemoveMember takes a member back out of its lane, dedupe entry included, as if it had never been // queued. Consensus uses it to keep a multi-chain intent all-or-nothing: if one chain's member // cannot be queued, the members already queued for its other chains are rolled back. diff --git a/pkg/execution/declared_anchor_selector_test.go b/pkg/execution/declared_anchor_selector_test.go new file mode 100644 index 00000000..4e4b8198 --- /dev/null +++ b/pkg/execution/declared_anchor_selector_test.go @@ -0,0 +1,21 @@ +// Copyright 2026 Certen Protocol + +package execution + +import ( + "bytes" + "testing" + + "github.com/certen/independant-validator/pkg/consensus" +) + +// RB4-F9: consensus admits a leg only if it declares the call the batch path makes on its anchor. +// That call is packed here, from the batch ABI; the two must be the same selector. +func TestConsensusNamesTheCallTheBatchPathMakes(t *testing.T) { + if !bytes.Equal(consensus.BatchAnchorCreateSelector[:], createBatchAnchorMethod.ID) { + t.Fatalf("consensus admits 0x%x, the batch path calls 0x%x", consensus.BatchAnchorCreateSelector, createBatchAnchorMethod.ID) + } + if batchAnchorCreateSelector != "34597e5a" { + t.Fatalf("the write-back names step 1 as %s, not createBatchAnchor", batchAnchorCreateSelector) + } +}