From a77ba049f08abe388598384861a457f0016e89c9 Mon Sep 17 00:00:00 2001 From: Jason-Gregoire Date: Mon, 28 Sep 2026 16:47:10 -0400 Subject: [PATCH] Refuse by name an intent whose legs declare an anchor or call other than their chain's live anchor and createBatchAnchor, and record in each chain target the anchor and selector that will execute instead of a type string and call data made up from sha256. --- pkg/consensus/batch_rb1_gate_test.go | 2 + pkg/consensus/batch_refusal.go | 5 + pkg/consensus/batch_refusal_test.go | 32 +++- pkg/consensus/bft_integration.go | 4 + pkg/consensus/declared_anchor.go | 102 +++++++++++++ pkg/consensus/declared_anchor_test.go | 133 +++++++++++++++++ pkg/consensus/validator_block.go | 9 +- pkg/consensus/validator_block_builder.go | 139 ++++-------------- pkg/execution/batch_assembly.go | 10 ++ .../declared_anchor_selector_test.go | 21 +++ 10 files changed, 334 insertions(+), 123 deletions(-) create mode 100644 pkg/consensus/declared_anchor.go create mode 100644 pkg/consensus/declared_anchor_test.go create mode 100644 pkg/execution/declared_anchor_selector_test.go 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) + } +}