Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkg/consensus/batch_rb1_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions pkg/consensus/batch_refusal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 24 additions & 8 deletions pkg/consensus/batch_refusal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"strings"
"testing"
"time"

"github.com/ethereum/go-ethereum/common"
)

// =============================================================================
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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,
},
Expand Down
4 changes: 4 additions & 0 deletions pkg/consensus/bft_integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_<chainId>).
// 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.
Expand Down
102 changes: 102 additions & 0 deletions pkg/consensus/declared_anchor.go
Original file line number Diff line number Diff line change
@@ -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_<chainId>) 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
}
133 changes: 133 additions & 0 deletions pkg/consensus/declared_anchor_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 6 additions & 3 deletions pkg/consensus/validator_block.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading