From 8b553daca02205515a4b0bb1db8de2836cb65b0c Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Mon, 27 Jul 2026 15:12:24 +1200 Subject: [PATCH 01/17] refactor: remove unused CreateIntentConfigurationWithTimedRefundSapient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper hardcodes checkpoint=0, which no real caller can use (intent wallets need a salt-derived checkpoint), and has no production callers anywhere in the workspace — only its own test and two trails-watchtower test files exercise it. Callers build the timed-refund sapient leaf directly via TimedRefundSapientImageHash and pass it to CreateIntentConfiguration, which remains unchanged. --- intent_config_test.go | 45 +++++++------------------------ intent_config_timed_refund.go | 51 ----------------------------------- 2 files changed, 9 insertions(+), 87 deletions(-) diff --git a/intent_config_test.go b/intent_config_test.go index dff27614..74c429c5 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -340,16 +340,15 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { timedRefundSigner := common.HexToAddress("0x3333333333333333333333333333333333333333") destination := common.HexToAddress("0x4444444444444444444444444444444444444444") - config, err := sequence.CreateIntentConfigurationWithTimedRefundSapient( - mainSigner, - []*v3.CallsPayload{&payload}, - sequence.TimedRefundIntentConfigurationSigner{ - Address: timedRefundSigner, - Destination: destination, - UnlockTimestamp: 1_750_000_000, - Weight: 1, - }, - ) + timedRefundImageHash, err := sequence.TimedRefundSapientImageHash(destination, 1_750_000_000) + require.NoError(t, err) + timedRefundLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: timedRefundSigner, + ImageHash_: timedRefundImageHash, + } + + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, timedRefundLeaf) require.NoError(t, err) require.NotNil(t, config) @@ -385,32 +384,6 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { require.NotEqual(t, plainSignature, signature) } -func TestCreateIntentConfigurationWithTimedRefundSapient_ZeroWeight(t *testing.T) { - payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ - { - To: common.HexToAddress("0x1111111111111111111111111111111111111111"), - Value: nil, - Data: []byte{0x12, 0x34}, - GasLimit: big.NewInt(0), - DelegateCall: false, - OnlyFallback: false, - BehaviorOnError: v3.BehaviorOnErrorRevert, - }, - }, big.NewInt(0), big.NewInt(0)) - - _, err := sequence.CreateIntentConfigurationWithTimedRefundSapient( - common.HexToAddress("0x2222222222222222222222222222222222222222"), - []*v3.CallsPayload{&payload}, - sequence.TimedRefundIntentConfigurationSigner{ - Address: common.HexToAddress("0x3333333333333333333333333333333333333333"), - Destination: common.HexToAddress("0x4444444444444444444444444444444444444444"), - UnlockTimestamp: 1_750_000_000, - Weight: 0, - }, - ) - require.EqualError(t, err, "timed refund sapient signer weight is zero") -} - func TestTimedRefundSapientImageHash(t *testing.T) { destination := common.HexToAddress("0x4444444444444444444444444444444444444444") diff --git a/intent_config_timed_refund.go b/intent_config_timed_refund.go index c1220064..bae36ff0 100644 --- a/intent_config_timed_refund.go +++ b/intent_config_timed_refund.go @@ -8,20 +8,10 @@ import ( "github.com/0xsequence/ethkit/go-ethereum/common" "github.com/0xsequence/ethkit/go-ethereum/crypto" "github.com/0xsequence/go-sequence/core" - v3 "github.com/0xsequence/go-sequence/core/v3" ) var timedRefundSapientImageHashArguments = mustTimedRefundSapientImageHashArguments() -// TimedRefundIntentConfigurationSigner represents the dedicated timed-refund sapient signer -// attached to an intent configuration. Weight must be greater than zero. -type TimedRefundIntentConfigurationSigner struct { - Address common.Address - Destination common.Address - UnlockTimestamp uint64 - Weight uint8 -} - // TimedRefundSapientImageHashPreimage is the typed preimage for a timed-refund sapient signer. // It preserves the refund destination and unlock timestamp alongside the irreversible hash. type TimedRefundSapientImageHashPreimage struct { @@ -42,47 +32,6 @@ func (p *TimedRefundSapientImageHashPreimage) ImageHash() core.ImageHash { return imageHash } -// CreateIntentConfigurationWithTimedRefundSapient creates an intent configuration that includes -// a timed-refund sapient signer leaf in addition to the default any-address subdigests. -func CreateIntentConfigurationWithTimedRefundSapient( - mainSigner common.Address, - calls []*v3.CallsPayload, - timedRefundSigner TimedRefundIntentConfigurationSigner, -) (*v3.WalletConfig, error) { - timedRefundLeaf, err := createTimedRefundSapientSignerLeaf(timedRefundSigner) - if err != nil { - return nil, err - } - - return createIntentConfiguration(mainSigner, calls, 0, timedRefundLeaf) -} - -func createTimedRefundSapientSignerLeaf(signer TimedRefundIntentConfigurationSigner) (*v3.WalletConfigTreeSapientSignerLeaf, error) { - if signer.Address == (common.Address{}) { - return nil, fmt.Errorf("timed refund sapient signer address is zero") - } - if signer.Destination == (common.Address{}) { - return nil, fmt.Errorf("timed refund destination is zero") - } - if signer.UnlockTimestamp == 0 { - return nil, fmt.Errorf("timed refund unlock timestamp is zero") - } - if signer.Weight == 0 { - return nil, fmt.Errorf("timed refund sapient signer weight is zero") - } - - imageHash, err := TimedRefundSapientImageHash(signer.Destination, signer.UnlockTimestamp) - if err != nil { - return nil, err - } - - return &v3.WalletConfigTreeSapientSignerLeaf{ - Weight: signer.Weight, - Address: signer.Address, - ImageHash_: imageHash, - }, nil -} - func mustTimedRefundSapientImageHashArguments() abi.Arguments { stringType, err := abi.NewType("string", "", nil) if err != nil { From 195627cd45b9b0264787183314ce94b68c00f4cc Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Mon, 27 Jul 2026 15:14:46 +1200 Subject: [PATCH 02/17] feat: add optional payload gate leaf to CreateIntentTree/CreateIntentConfiguration CreateIntentTree and CreateIntentConfiguration take a new payloadGateLeafNode parameter: when set, the wallet is satisfied by [calls && payloadGateLeafNode] signing together (a 2-of-2 subtree that caps the calls' any-address-subdigest leaves' own, otherwise uncapped, weight) OR by sapientSignerLeafNode, untouched. This lets a caller gate payload execution behind a revocable signer (e.g. a pausable contract) while leaving other leaves (e.g. a timed-refund signer) unaffected. Passing nil preserves the exact legacy tree shape, so already-derived counterfactual addresses do not change. --- intent_config.go | 82 +++++++++++++++---- intent_config_test.go | 186 +++++++++++++++++++++++++++++++++++++++--- testutil/testutil.go | 2 +- 3 files changed, 242 insertions(+), 28 deletions(-) diff --git a/intent_config.go b/intent_config.go index e0c2aaba..c6a4fcef 100644 --- a/intent_config.go +++ b/intent_config.go @@ -186,17 +186,52 @@ func CreateAnyAddressSubdigestTree(calls []*v3.CallsPayload) ([]v3.WalletConfigT return leaves, nil } -func createIntentTree(mainSigner common.Address, calls []*v3.CallsPayload, additionalLeaves ...v3.WalletConfigTree) (*v3.WalletConfigTree, error) { +// wrapPayloadGate pairs callsLeaves (the calls' own any-address-subdigest leaves) with +// payloadGateLeaf under a 2-of-2 subtree: satisfying it requires both a signature from +// payloadGateLeaf and callsLeaves' own threshold to be met (weight 1 each, gate threshold +// 2), so withholding payloadGateLeaf's signature makes the whole gate unsatisfiable +// regardless of callsLeaves' own (possibly uncapped, e.g. any-address-subdigest) weight. +// payloadGateLeaf must carry weight 1. +func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, callsLeaves ...v3.WalletConfigTree) v3.WalletConfigTree { + inner := &v3.WalletConfigTreeNestedLeaf{ + Weight: 1, + Threshold: 1, + Tree: v3.WalletConfigTreeNodes(callsLeaves...), + } + gate := v3.WalletConfigTreeNodes(payloadGateLeaf, inner) + return &v3.WalletConfigTreeNestedLeaf{ + Weight: 1, + Threshold: 2, + Tree: gate, + } +} + +func createIntentTree( + mainSigner common.Address, + calls []*v3.CallsPayload, + payloadGateLeafNode v3.WalletConfigTree, + sapientSignerLeafNode v3.WalletConfigTree, +) (*v3.WalletConfigTree, error) { // Create the subdigest leaves from the batched transactions. - leaves, err := CreateAnyAddressSubdigestTree(calls) + subdigestLeaves, err := CreateAnyAddressSubdigestTree(calls) if err != nil { return nil, err } - for _, leaf := range additionalLeaves { - if leaf != nil { - leaves = append(leaves, leaf) - } + var leaves []v3.WalletConfigTree + + if payloadGateLeafNode != nil { + // calls && payloadGateLeafNode must match together; sapientSignerLeafNode below is + // untouched by this gate either way. + leaves = append(leaves, wrapPayloadGate(payloadGateLeafNode, subdigestLeaves...)) + } else { + // No gate: preserve the exact flat structure of the original (pre-gating) tree so + // already-derived counterfactual addresses do not change. + leaves = append(leaves, subdigestLeaves...) + } + + if sapientSignerLeafNode != nil { + leaves = append(leaves, sapientSignerLeafNode) } // Create the main signer leaf (with weight 1). @@ -221,12 +256,23 @@ func createIntentTree(mainSigner common.Address, calls []*v3.CallsPayload, addit } // `CreateIntentTree` creates a tree from a list of intent operations and a main signer address. -func CreateIntentTree(mainSigner common.Address, calls []*v3.CallsPayload, sapientSignerLeafNode v3.WalletConfigTree) (*v3.WalletConfigTree, error) { - return createIntentTree(mainSigner, calls, sapientSignerLeafNode) +func CreateIntentTree( + mainSigner common.Address, + calls []*v3.CallsPayload, + payloadGateLeafNode v3.WalletConfigTree, + sapientSignerLeafNode v3.WalletConfigTree, +) (*v3.WalletConfigTree, error) { + return createIntentTree(mainSigner, calls, payloadGateLeafNode, sapientSignerLeafNode) } -func createIntentConfiguration(mainSigner common.Address, calls []*v3.CallsPayload, checkpoint uint64, additionalLeaves ...v3.WalletConfigTree) (*v3.WalletConfig, error) { - tree, err := createIntentTree(mainSigner, calls, additionalLeaves...) +func createIntentConfiguration( + mainSigner common.Address, + calls []*v3.CallsPayload, + checkpoint uint64, + payloadGateLeafNode v3.WalletConfigTree, + sapientSignerLeafNode v3.WalletConfigTree, +) (*v3.WalletConfig, error) { + tree, err := createIntentTree(mainSigner, calls, payloadGateLeafNode, sapientSignerLeafNode) if err != nil { return nil, err } @@ -238,9 +284,17 @@ func createIntentConfiguration(mainSigner common.Address, calls []*v3.CallsPaylo }, nil } -// `CreateIntentConfiguration` creates a wallet configuration where the intent's transaction batches are grouped into the initial subdigest. -func CreateIntentConfiguration(mainSigner common.Address, calls []*v3.CallsPayload, checkpoint uint64, sapientSignerLeafNode v3.WalletConfigTree) (*v3.WalletConfig, error) { - return createIntentConfiguration(mainSigner, calls, checkpoint, sapientSignerLeafNode) +// `CreateIntentConfiguration` creates a wallet configuration where the intent's transaction +// batches are grouped into the initial subdigest. See CreateIntentTree for +// payloadGateLeafNode and sapientSignerLeafNode semantics. +func CreateIntentConfiguration( + mainSigner common.Address, + calls []*v3.CallsPayload, + checkpoint uint64, + payloadGateLeafNode v3.WalletConfigTree, + sapientSignerLeafNode v3.WalletConfigTree, +) (*v3.WalletConfig, error) { + return createIntentConfiguration(mainSigner, calls, checkpoint, payloadGateLeafNode, sapientSignerLeafNode) } // `BuildIntentConfigurationSignature` creates a signature for an already-built intent configuration @@ -287,7 +341,7 @@ func GetIntentConfigurationSignature( sapientSignerLeafNode v3.WalletConfigTree, signerSignatures []*core.SignerSignature, ) ([]byte, error) { - config, err := createIntentConfiguration(mainSigner, calls, checkpoint, sapientSignerLeafNode) + config, err := createIntentConfiguration(mainSigner, calls, checkpoint, nil, sapientSignerLeafNode) if err != nil { return nil, err } diff --git a/intent_config_test.go b/intent_config_test.go index 74c429c5..bfc8e602 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -215,7 +215,7 @@ func TestCreateIntentTree_Valid(t *testing.T) { }, big.NewInt(0), big.NewInt(0)) t.Run("One batch", func(t *testing.T) { - tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1}, nil) + tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1}, nil, nil) require.NoError(t, err) require.NotNil(t, tree) @@ -239,7 +239,7 @@ func TestCreateIntentTree_Valid(t *testing.T) { }) t.Run("Two batches", func(t *testing.T) { - tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1, &payload2}, nil) + tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1, &payload2}, nil, nil) require.NoError(t, err) require.NotNil(t, tree) @@ -267,7 +267,7 @@ func TestCreateIntentTree_Valid(t *testing.T) { }) t.Run("Three batches", func(t *testing.T) { - tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1, &payload2, &payload3}, nil) + tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1, &payload2, &payload3}, nil, nil) require.NoError(t, err) // spew.Dump(tree) @@ -318,7 +318,7 @@ func TestCreateIntentConfiguration_Valid(t *testing.T) { // Use a valid main signer address. mainSigner := common.HexToAddress("0x1111111111111111111111111111111111111111") - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) require.NoError(t, err) require.NotNil(t, config) } @@ -348,7 +348,7 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { ImageHash_: timedRefundImageHash, } - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, timedRefundLeaf) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, timedRefundLeaf) require.NoError(t, err) require.NotNil(t, config) @@ -364,7 +364,7 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { require.Equal(t, uint64(1_750_000_000), preimage.UnlockTimestamp) require.Equal(t, expectedSapientImageHash, preimage.ImageHash().Hash) - plainConfig, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil) + plainConfig, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) require.NoError(t, err) require.NotEqual(t, plainConfig.ImageHash().Hash, config.ImageHash().Hash) @@ -384,6 +384,166 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { require.NotEqual(t, plainSignature, signature) } +// With payloadGateLeaf nil (the default/legacy case), the tree must keep the exact flat +// shape it had before this parameter existed: Node(mainSignerLeaf, Node(subdigestLeaf, +// additionalLeaf)) — no extra nesting — so already-derived counterfactual addresses do +// not change. +func TestCreateIntentConfigurationPayloadGateLeafNilUnchanged(t *testing.T) { + payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ + { + To: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Value: nil, + Data: []byte{0x12, 0x34}, + GasLimit: big.NewInt(0), + DelegateCall: false, + OnlyFallback: false, + BehaviorOnError: v3.BehaviorOnErrorRevert, + }, + }, big.NewInt(0), big.NewInt(0)) + + mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") + sapientSignerLeafNode := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: common.HexToAddress("0x3333333333333333333333333333333333333333"), + ImageHash_: core.ImageHash{Hash: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111")}, + } + + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, sapientSignerLeafNode) + require.NoError(t, err) + + top, ok := config.Tree.(*v3.WalletConfigTreeNode) + require.True(t, ok) + ownerLeaf, ok := top.Left.(*v3.WalletConfigTreeAddressLeaf) + require.True(t, ok) + require.Equal(t, mainSigner, ownerLeaf.Address) + + rest, ok := top.Right.(*v3.WalletConfigTreeNode) + require.True(t, ok) + _, subdigestOk := rest.Left.(*v3.WalletConfigTreeAnyAddressSubdigestLeaf) + require.True(t, subdigestOk) + require.Same(t, sapientSignerLeafNode, rest.Right) +} + +func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { + payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ + { + To: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Value: nil, + Data: []byte{0x12, 0x34}, + GasLimit: big.NewInt(0), + DelegateCall: false, + OnlyFallback: false, + BehaviorOnError: v3.BehaviorOnErrorRevert, + }, + }, big.NewInt(0), big.NewInt(0)) + + mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") + peerSigner := common.HexToAddress("0x72030E1dbf0a847196ae62EA3ee84BD7ce99D6c1") + peerSignerLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: peerSigner, + ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(1))}, + } + + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, peerSignerLeaf, nil) + require.NoError(t, err) + require.NotNil(t, config) + + sapientLeaf := findSapientSignerLeaf(config.Tree, peerSigner) + require.NotNil(t, sapientLeaf) + + // The gate must change the counterfactual address relative to an ungated config. + plainConfig, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) + require.NoError(t, err) + require.NotEqual(t, plainConfig.ImageHash().Hash, config.ImageHash().Hash) + + // A signature that does not include the peer signer's co-signature must not meet the + // gate's threshold, even though the any-address-subdigest leaf matches the payload + // (which alone would satisfy an ungated config's threshold). + signatureWithoutPeerSig, err := sequence.BuildIntentConfigurationSignature(config, nil) + require.NoError(t, err) + + sigWithoutPeerSig, err := v3.Core.DecodeSignature(signatureWithoutPeerSig) + require.NoError(t, err) + + recoveredConfig, weight, err := sigWithoutPeerSig.Recover(context.Background(), payload, nil) + require.NoError(t, err) + require.Equal(t, config.ImageHash().Hash, recoveredConfig.ImageHash().Hash) + require.Truef(t, weight.Cmp(big.NewInt(int64(config.Threshold()))) < 0, + "recovered weight %v must not meet threshold %v without the peer signer's co-signature", weight, config.Threshold()) + + // Including the peer signer's co-signature must produce a different signature + // encoding than omitting it, proving the leaf is actually wired into the built signature. + signatureWithPeerSig, err := sequence.BuildIntentConfigurationSignature(config, []*core.SignerSignature{ + { + Signer: core.SapientSigner(peerSigner, peerSignerLeaf.ImageHash_.Hash), + Signature: []byte{}, + Type: core.SignerSignatureTypeSapientCompact, + }, + }) + require.NoError(t, err) + require.NotEqual(t, signatureWithoutPeerSig, signatureWithPeerSig) +} + +// additionalLeaves (e.g. a timed-refund leaf) must sit as a plain sibling of the +// [calls && payloadGateLeaf] gate, completely untouched by it, so they keep working even +// while the gate leaf withholds its signature (e.g. a paused contract). +func TestCreateIntentConfigurationWithPayloadGateLeaf_AdditionalLeavesUntouched(t *testing.T) { + payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ + { + To: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Value: nil, + Data: []byte{0x12, 0x34}, + GasLimit: big.NewInt(0), + DelegateCall: false, + OnlyFallback: false, + BehaviorOnError: v3.BehaviorOnErrorRevert, + }, + }, big.NewInt(0), big.NewInt(0)) + + mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") + peerSigner := common.HexToAddress("0x72030E1dbf0a847196ae62EA3ee84BD7ce99D6c1") + peerSignerLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: peerSigner, + ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(1))}, + } + timedRefundSigner := common.HexToAddress("0x4444444444444444444444444444444444444444") + destination := common.HexToAddress("0x5555555555555555555555555555555555555555") + + timedRefundImageHash, err := sequence.TimedRefundSapientImageHash(destination, 1_750_000_000) + require.NoError(t, err) + timedRefundLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: timedRefundSigner, + ImageHash_: timedRefundImageHash, + } + + config, err := sequence.CreateIntentConfiguration( + mainSigner, + []*v3.CallsPayload{&payload}, + 0, + peerSignerLeaf, + timedRefundLeaf, + ) + require.NoError(t, err) + + // timedRefundLeaf must be reachable directly, not nested inside the gate. + top, ok := config.Tree.(*v3.WalletConfigTreeNode) + require.True(t, ok) + rest, ok := top.Right.(*v3.WalletConfigTreeNode) + require.True(t, ok) + _, gateIsLeft := rest.Left.(*v3.WalletConfigTreeNestedLeaf) + require.True(t, gateIsLeft) + untouchedLeaf, ok := rest.Right.(*v3.WalletConfigTreeSapientSignerLeaf) + require.True(t, ok) + require.Equal(t, timedRefundSigner, untouchedLeaf.Address) + + // The gate leaf must be reachable inside the gate; timedRefundLeaf outside it. + require.NotNil(t, findSapientSignerLeaf(config.Tree, peerSigner)) + require.NotNil(t, findSapientSignerLeaf(config.Tree, timedRefundSigner)) +} + func TestTimedRefundSapientImageHash(t *testing.T) { destination := common.HexToAddress("0x4444444444444444444444444444444444444444") @@ -442,7 +602,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { t.Run("signature matches subdigest", func(t *testing.T) { // Create the intent configuration - config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil) + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil) require.NoError(t, err) // Create the signature @@ -545,7 +705,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { t.Run("signer signature included in the signature tree", func(t *testing.T) { // Create the intent configuration - config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, sapientSignerLeafNode) + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, sapientSignerLeafNode) require.NoError(t, err) // Create the signature @@ -972,7 +1132,7 @@ func TestIntentConfigurationAddress(t *testing.T) { ) // Create intent configuration - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) require.NoError(t, err) // Calculate image hash @@ -1022,7 +1182,7 @@ func TestIntentConfigurationAddress(t *testing.T) { ) // Create intent configuration - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload1, &payload2}, 0, nil) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload1, &payload2}, 0, nil, nil) require.NoError(t, err) // Calculate image hash @@ -1064,9 +1224,9 @@ func TestIntentConfigurationAddress_WithCheckpoint(t *testing.T) { checkpoint2 := uint64(2) // Create intent configuration - config1, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, checkpoint1, nil) + config1, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, checkpoint1, nil, nil) require.NoError(t, err) - config2, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, checkpoint2, nil) + config2, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, checkpoint2, nil, nil) require.NoError(t, err) // Checkpoints should be set correctly @@ -1120,7 +1280,7 @@ func TestIntentConfigurationAddress_RealWorldExample(t *testing.T) { }, big.NewInt(0), big.NewInt(0)) // Create intent configuration - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload1, &payload2}, 0, nil) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload1, &payload2}, 0, nil, nil) require.NoError(t, err) // Calculate image hash diff --git a/testutil/testutil.go b/testutil/testutil.go index 8ac69afd..058e99b7 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -728,7 +728,7 @@ func (c *TestChain) V3DummySequenceWalletWithIntentConfig(seed uint64, calls []* } // Create an intent config - intentConfig, err := sequence.CreateIntentConfiguration(owner.Address(), calls, 0, nil) + intentConfig, err := sequence.CreateIntentConfiguration(owner.Address(), calls, 0, nil, nil) if err != nil { return nil, err } From 4b4377dc4c677b3cff74399d14f04eecaa7a0738 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 30 Jul 2026 08:45:01 +1200 Subject: [PATCH 03/17] feat: gate sapient signer leaves behind the payload gate too CreateIntentTree/CreateIntentConfiguration now wrap sapientSignerLeafNode in its own independent 2-of-2 subtree with payloadGateLeafNode, the same way the calls leaves already are, when both are provided. A deposit or timed-refund sapient leaf is a payload-execution path just like the calls leaves, so withholding the gate leaf's signature blocks it too. mainSigner is never gated, so the owner can always act (e.g. recover funds) regardless of the gate's state. Co-Authored-By: Claude Fable 5 --- intent_config.go | 35 ++++++++++++++--------- intent_config_test.go | 64 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/intent_config.go b/intent_config.go index c6a4fcef..649b1189 100644 --- a/intent_config.go +++ b/intent_config.go @@ -186,17 +186,16 @@ func CreateAnyAddressSubdigestTree(calls []*v3.CallsPayload) ([]v3.WalletConfigT return leaves, nil } -// wrapPayloadGate pairs callsLeaves (the calls' own any-address-subdigest leaves) with -// payloadGateLeaf under a 2-of-2 subtree: satisfying it requires both a signature from -// payloadGateLeaf and callsLeaves' own threshold to be met (weight 1 each, gate threshold -// 2), so withholding payloadGateLeaf's signature makes the whole gate unsatisfiable -// regardless of callsLeaves' own (possibly uncapped, e.g. any-address-subdigest) weight. -// payloadGateLeaf must carry weight 1. -func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, callsLeaves ...v3.WalletConfigTree) v3.WalletConfigTree { +// wrapPayloadGate requires both payloadGateLeaf and protectedLeaves' own threshold to be +// satisfied (weight 1 each, gate threshold 2), so withholding payloadGateLeaf's signature +// blocks protectedLeaves regardless of their own weight. payloadGateLeaf must carry weight +// 1. Safe to call more than once with the same payloadGateLeaf: one signature satisfies +// every occurrence. +func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, protectedLeaves ...v3.WalletConfigTree) v3.WalletConfigTree { inner := &v3.WalletConfigTreeNestedLeaf{ Weight: 1, Threshold: 1, - Tree: v3.WalletConfigTreeNodes(callsLeaves...), + Tree: v3.WalletConfigTreeNodes(protectedLeaves...), } gate := v3.WalletConfigTreeNodes(payloadGateLeaf, inner) return &v3.WalletConfigTreeNestedLeaf{ @@ -221,8 +220,7 @@ func createIntentTree( var leaves []v3.WalletConfigTree if payloadGateLeafNode != nil { - // calls && payloadGateLeafNode must match together; sapientSignerLeafNode below is - // untouched by this gate either way. + // calls && payloadGateLeafNode must match together. leaves = append(leaves, wrapPayloadGate(payloadGateLeafNode, subdigestLeaves...)) } else { // No gate: preserve the exact flat structure of the original (pre-gating) tree so @@ -231,10 +229,17 @@ func createIntentTree( } if sapientSignerLeafNode != nil { - leaves = append(leaves, sapientSignerLeafNode) + if payloadGateLeafNode != nil { + // Gated the same way as the calls leaves. mainSignerLeaf below is the only + // leaf left unaffected by the gate. + leaves = append(leaves, wrapPayloadGate(payloadGateLeafNode, sapientSignerLeafNode)) + } else { + leaves = append(leaves, sapientSignerLeafNode) + } } - // Create the main signer leaf (with weight 1). + // Create the main signer leaf (with weight 1). Never gated: the owner must remain able + // to act (e.g. recover funds) regardless of the gate's paused state. mainSignerLeaf := &v3.WalletConfigTreeAddressLeaf{ Weight: 1, Address: mainSigner, @@ -255,7 +260,11 @@ func createIntentTree( return &fullTree, nil } -// `CreateIntentTree` creates a tree from a list of intent operations and a main signer address. +// `CreateIntentTree` creates a tree from a list of intent operations and a main signer +// address. When payloadGateLeafNode is set, the calls leaves and sapientSignerLeafNode (if +// provided) are each gated behind it in their own 2-of-2 subtree (see wrapPayloadGate). +// mainSigner is never gated. Passing nil for payloadGateLeafNode preserves the legacy tree +// shape. func CreateIntentTree( mainSigner common.Address, calls []*v3.CallsPayload, diff --git a/intent_config_test.go b/intent_config_test.go index bfc8e602..ddb3b7fb 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -485,10 +485,12 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { require.NotEqual(t, signatureWithoutPeerSig, signatureWithPeerSig) } -// additionalLeaves (e.g. a timed-refund leaf) must sit as a plain sibling of the -// [calls && payloadGateLeaf] gate, completely untouched by it, so they keep working even -// while the gate leaf withholds its signature (e.g. a paused contract). -func TestCreateIntentConfigurationWithPayloadGateLeaf_AdditionalLeavesUntouched(t *testing.T) { +// A sapient signer leaf (e.g. a timed-refund or gasless-deposit leaf) passed as +// sapientSignerLeafNode is gated the same way as the calls leaves: it also requires +// payloadGateLeaf's co-signature, in its own independent 2-of-2 subtree. mainSignerLeaf is +// the only leaf never gated, so the owner can always act (e.g. recover funds) regardless of +// the gate's paused state. +func TestCreateIntentConfigurationWithPayloadGateLeaf_SapientLeafGated(t *testing.T) { payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ { To: common.HexToAddress("0x1111111111111111111111111111111111111111"), @@ -528,20 +530,60 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_AdditionalLeavesUntouched( ) require.NoError(t, err) - // timedRefundLeaf must be reachable directly, not nested inside the gate. + // mainSignerLeaf stays a plain sibling, never wrapped by any gate. top, ok := config.Tree.(*v3.WalletConfigTreeNode) require.True(t, ok) - rest, ok := top.Right.(*v3.WalletConfigTreeNode) + ownerLeaf, ok := top.Left.(*v3.WalletConfigTreeAddressLeaf) require.True(t, ok) - _, gateIsLeft := rest.Left.(*v3.WalletConfigTreeNestedLeaf) - require.True(t, gateIsLeft) - untouchedLeaf, ok := rest.Right.(*v3.WalletConfigTreeSapientSignerLeaf) + require.Equal(t, mainSigner, ownerLeaf.Address) + + // Both the calls leaves and timedRefundLeaf must now be reachable only inside a gate + // (NestedLeaf), as siblings of each other. + rest, ok := top.Right.(*v3.WalletConfigTreeNode) require.True(t, ok) - require.Equal(t, timedRefundSigner, untouchedLeaf.Address) + _, callsGateOk := rest.Left.(*v3.WalletConfigTreeNestedLeaf) + require.True(t, callsGateOk, "calls leaves must be gated") + _, sapientGateOk := rest.Right.(*v3.WalletConfigTreeNestedLeaf) + require.True(t, sapientGateOk, "timedRefundLeaf must now be gated too") - // The gate leaf must be reachable inside the gate; timedRefundLeaf outside it. require.NotNil(t, findSapientSignerLeaf(config.Tree, peerSigner)) require.NotNil(t, findSapientSignerLeaf(config.Tree, timedRefundSigner)) + + // Absent any signatures, neither gate has anything to recover: weight must be 0. Real + // providers are only invoked to check an actually-embedded sapient signature, so this + // stays a pure offline check. + signatureNoSigs, err := sequence.BuildIntentConfigurationSignature(config, nil) + require.NoError(t, err) + decodedNoSigs, err := v3.Core.DecodeSignature(signatureNoSigs) + require.NoError(t, err) + _, weightNoSigs, err := decodedNoSigs.Recover(context.Background(), payload, nil) + require.NoError(t, err) + require.Truef(t, weightNoSigs.Cmp(big.NewInt(int64(config.Threshold()))) < 0, + "recovered weight %v must not meet threshold %v with no signatures at all", weightNoSigs, config.Threshold()) + + // Providing only the gate's co-signature (withholding timedRefundLeaf's own signature) + // must change the encoding, proving the gate leaf is wired into timedRefundLeaf's new + // gate position, not just the pre-existing calls gate. + peerSignature := &core.SignerSignature{ + Signer: core.SapientSigner(peerSigner, peerSignerLeaf.ImageHash_.Hash), + Signature: []byte{}, + Type: core.SignerSignatureTypeSapientCompact, + } + signatureGateOnly, err := sequence.BuildIntentConfigurationSignature(config, []*core.SignerSignature{peerSignature}) + require.NoError(t, err) + require.NotEqual(t, signatureNoSigs, signatureGateOnly) + + // Adding timedRefundLeaf's own signature alongside the gate's co-signature must change + // the encoding again, proving timedRefundLeaf's signature is actually consumed from its + // new (gated) position in the tree. + timedRefundSignature := &core.SignerSignature{ + Signer: core.SapientSigner(timedRefundSigner, timedRefundLeaf.ImageHash_.Hash), + Signature: []byte{}, + Type: core.SignerSignatureTypeSapientCompact, + } + signatureGateAndTimedRefund, err := sequence.BuildIntentConfigurationSignature(config, []*core.SignerSignature{peerSignature, timedRefundSignature}) + require.NoError(t, err) + require.NotEqual(t, signatureGateOnly, signatureGateAndTimedRefund) } func TestTimedRefundSapientImageHash(t *testing.T) { From 100e3521af8db5311947e654d9b12b7c336e4b39 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 30 Jul 2026 09:22:04 +1200 Subject: [PATCH 04/17] fix: skip the calls gate when there are no call batches A sapient-only config (empty calls) left wrapPayloadGate's inner threshold-1 node wrapping zero protected leaves, producing a NestedLeaf with a nil Tree that panics on ImageHash or any other tree traversal. Omit the calls gate entirely when there are no calls to gate, leaving the separately gated sapient leaf intact. Co-Authored-By: Claude Fable 5 --- intent_config.go | 4 +++- intent_config_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/intent_config.go b/intent_config.go index 649b1189..0b7f2076 100644 --- a/intent_config.go +++ b/intent_config.go @@ -219,7 +219,9 @@ func createIntentTree( var leaves []v3.WalletConfigTree - if payloadGateLeafNode != nil { + if len(subdigestLeaves) == 0 { + // No calls to gate (sapient-only config): omit the calls gate entirely. + } else if payloadGateLeafNode != nil { // calls && payloadGateLeafNode must match together. leaves = append(leaves, wrapPayloadGate(payloadGateLeafNode, subdigestLeaves...)) } else { diff --git a/intent_config_test.go b/intent_config_test.go index ddb3b7fb..af5ebdbf 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -485,6 +485,39 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { require.NotEqual(t, signatureWithoutPeerSig, signatureWithPeerSig) } +// A sapient-only config (calls is empty) must not build a broken calls gate: with no +// subdigest leaves to wrap, wrapPayloadGate's inner threshold-1 node would otherwise end up +// with a nil Tree, panicking on ImageHash or any other tree traversal. The calls gate must +// simply be omitted, leaving the separately gated sapient leaf intact. +func TestCreateIntentConfigurationWithPayloadGateLeaf_EmptyCallsOmitsCallsGate(t *testing.T) { + mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") + peerSigner := common.HexToAddress("0x72030E1dbf0a847196ae62EA3ee84BD7ce99D6c1") + peerSignerLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: peerSigner, + ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(1))}, + } + timedRefundSigner := common.HexToAddress("0x4444444444444444444444444444444444444444") + destination := common.HexToAddress("0x5555555555555555555555555555555555555555") + timedRefundImageHash, err := sequence.TimedRefundSapientImageHash(destination, 1_750_000_000) + require.NoError(t, err) + timedRefundLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: timedRefundSigner, + ImageHash_: timedRefundImageHash, + } + + config, err := sequence.CreateIntentConfiguration(mainSigner, nil, 0, peerSignerLeaf, timedRefundLeaf) + require.NoError(t, err) + require.NotNil(t, config) + + // Must not panic computing the image hash — this is exactly what a nil inner Tree breaks. + require.NotEqual(t, common.Hash{}, config.ImageHash().Hash) + + require.NotNil(t, findSapientSignerLeaf(config.Tree, timedRefundSigner)) + require.NotNil(t, findSapientSignerLeaf(config.Tree, peerSigner)) +} + // A sapient signer leaf (e.g. a timed-refund or gasless-deposit leaf) passed as // sapientSignerLeafNode is gated the same way as the calls leaves: it also requires // payloadGateLeaf's co-signature, in its own independent 2-of-2 subtree. mainSignerLeaf is From 26a73e4848cb29bbe564713266a476315c63e65f Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 30 Jul 2026 09:23:07 +1200 Subject: [PATCH 05/17] fix: cap payloadGateLeaf to weight 1 in wrapPayloadGate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit payloadGateLeafNode is an opaque, caller-supplied v3.WalletConfigTree. Inserted raw, a misweighted or malicious leaf (weight >= 2) could meet wrapPayloadGate's threshold-2 requirement on its own, with the protected leaves contributing nothing — silently collapsing "calls && gate" down to "gate alone". Wrap it in its own weight-1 nested leaf so its contribution is capped regardless of its declared weight. Also rewrites the sapient-leaf-gated signature test to check each leaf's wiring independently instead of combining two signatures in one BuildIntentConfigurationSignature call: once the gate leaf alone already meets the wallet's overall threshold (via the calls gate's auto-satisfying any-address-subdigest leaf), that combination races BuildRegularSignature's early-cancellation against collecting the other signer's signature, an existing behavior unrelated to this fix. Co-Authored-By: Claude Fable 5 --- intent_config.go | 28 +++++++++---------- intent_config_test.go | 64 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/intent_config.go b/intent_config.go index 0b7f2076..5660548a 100644 --- a/intent_config.go +++ b/intent_config.go @@ -186,18 +186,22 @@ func CreateAnyAddressSubdigestTree(calls []*v3.CallsPayload) ([]v3.WalletConfigT return leaves, nil } -// wrapPayloadGate requires both payloadGateLeaf and protectedLeaves' own threshold to be -// satisfied (weight 1 each, gate threshold 2), so withholding payloadGateLeaf's signature -// blocks protectedLeaves regardless of their own weight. payloadGateLeaf must carry weight -// 1. Safe to call more than once with the same payloadGateLeaf: one signature satisfies -// every occurrence. +// wrapPayloadGate requires both payloadGateLeaf and protectedLeaves (weight 1 each, +// threshold 2). payloadGateLeaf is capped to weight 1 via its own nested leaf, since it's an +// opaque caller-supplied tree that could otherwise satisfy the gate alone. Safe to call more +// than once with the same payloadGateLeaf. func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, protectedLeaves ...v3.WalletConfigTree) v3.WalletConfigTree { + cappedGate := &v3.WalletConfigTreeNestedLeaf{ + Weight: 1, + Threshold: 1, + Tree: payloadGateLeaf, + } inner := &v3.WalletConfigTreeNestedLeaf{ Weight: 1, Threshold: 1, Tree: v3.WalletConfigTreeNodes(protectedLeaves...), } - gate := v3.WalletConfigTreeNodes(payloadGateLeaf, inner) + gate := v3.WalletConfigTreeNodes(cappedGate, inner) return &v3.WalletConfigTreeNestedLeaf{ Weight: 1, Threshold: 2, @@ -232,16 +236,14 @@ func createIntentTree( if sapientSignerLeafNode != nil { if payloadGateLeafNode != nil { - // Gated the same way as the calls leaves. mainSignerLeaf below is the only - // leaf left unaffected by the gate. + // Gated the same way as the calls leaves. leaves = append(leaves, wrapPayloadGate(payloadGateLeafNode, sapientSignerLeafNode)) } else { leaves = append(leaves, sapientSignerLeafNode) } } - // Create the main signer leaf (with weight 1). Never gated: the owner must remain able - // to act (e.g. recover funds) regardless of the gate's paused state. + // Main signer leaf (weight 1). Never gated, so the owner can always act. mainSignerLeaf := &v3.WalletConfigTreeAddressLeaf{ Weight: 1, Address: mainSigner, @@ -263,10 +265,8 @@ func createIntentTree( } // `CreateIntentTree` creates a tree from a list of intent operations and a main signer -// address. When payloadGateLeafNode is set, the calls leaves and sapientSignerLeafNode (if -// provided) are each gated behind it in their own 2-of-2 subtree (see wrapPayloadGate). -// mainSigner is never gated. Passing nil for payloadGateLeafNode preserves the legacy tree -// shape. +// address. When payloadGateLeafNode is set, calls and sapientSignerLeafNode are each gated +// behind it (see wrapPayloadGate); mainSigner never is. nil preserves the legacy tree shape. func CreateIntentTree( mainSigner common.Address, calls []*v3.CallsPayload, diff --git a/intent_config_test.go b/intent_config_test.go index af5ebdbf..56d1d435 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -485,6 +485,51 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { require.NotEqual(t, signatureWithoutPeerSig, signatureWithPeerSig) } +// payloadGateLeafNode is an opaque v3.WalletConfigTree the caller controls; an overweighted +// or otherwise misweighted leaf must not be able to satisfy the gate on its own. wrapPayloadGate +// caps it to weight 1 behind its own nested leaf, regardless of the leaf's declared weight. +func TestCreateIntentConfigurationWithPayloadGateLeaf_OverweightedGateLeafCapped(t *testing.T) { + payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ + { + To: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Value: nil, + Data: []byte{0x12, 0x34}, + GasLimit: big.NewInt(0), + DelegateCall: false, + OnlyFallback: false, + BehaviorOnError: v3.BehaviorOnErrorRevert, + }, + }, big.NewInt(0), big.NewInt(0)) + + mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") + peerSigner := common.HexToAddress("0x72030E1dbf0a847196ae62EA3ee84BD7ce99D6c1") + // Overweighted on purpose: this alone must not be enough to satisfy the gate's + // threshold-2 requirement without the calls leaves also contributing. + overweightedGateLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 5, + Address: peerSigner, + ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(1))}, + } + + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, overweightedGateLeaf, nil) + require.NoError(t, err) + + top, ok := config.Tree.(*v3.WalletConfigTreeNode) + require.True(t, ok) + outerGate, ok := top.Right.(*v3.WalletConfigTreeNestedLeaf) + require.True(t, ok) + require.Equal(t, uint16(2), outerGate.Threshold) + + gateNode, ok := outerGate.Tree.(*v3.WalletConfigTreeNode) + require.True(t, ok) + + cappedGate, ok := gateNode.Left.(*v3.WalletConfigTreeNestedLeaf) + require.True(t, ok, "payloadGateLeafNode must be capped behind its own weight-1 nested leaf") + require.Equal(t, uint8(1), cappedGate.Weight) + require.Equal(t, uint16(1), cappedGate.Threshold) + require.Same(t, overweightedGateLeaf, cappedGate.Tree) +} + // A sapient-only config (calls is empty) must not build a broken calls gate: with no // subdigest leaves to wrap, wrapPayloadGate's inner threshold-1 node would otherwise end up // with a nil Tree, panicking on ImageHash or any other tree traversal. The calls gate must @@ -594,29 +639,28 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_SapientLeafGated(t *testin require.Truef(t, weightNoSigs.Cmp(big.NewInt(int64(config.Threshold()))) < 0, "recovered weight %v must not meet threshold %v with no signatures at all", weightNoSigs, config.Threshold()) - // Providing only the gate's co-signature (withholding timedRefundLeaf's own signature) - // must change the encoding, proving the gate leaf is wired into timedRefundLeaf's new - // gate position, not just the pre-existing calls gate. + // Each leaf's signature must change the encoding on its own, proving it's wired into + // its gated position. Checked independently (not combined) because the gate leaf alone + // already meets the overall threshold via the calls gate's auto-satisfying subdigest + // leaf, which would race BuildRegularSignature's early-cancellation against collecting + // the other signer's signature. peerSignature := &core.SignerSignature{ Signer: core.SapientSigner(peerSigner, peerSignerLeaf.ImageHash_.Hash), Signature: []byte{}, Type: core.SignerSignatureTypeSapientCompact, } - signatureGateOnly, err := sequence.BuildIntentConfigurationSignature(config, []*core.SignerSignature{peerSignature}) + signatureWithGate, err := sequence.BuildIntentConfigurationSignature(config, []*core.SignerSignature{peerSignature}) require.NoError(t, err) - require.NotEqual(t, signatureNoSigs, signatureGateOnly) + require.NotEqual(t, signatureNoSigs, signatureWithGate) - // Adding timedRefundLeaf's own signature alongside the gate's co-signature must change - // the encoding again, proving timedRefundLeaf's signature is actually consumed from its - // new (gated) position in the tree. timedRefundSignature := &core.SignerSignature{ Signer: core.SapientSigner(timedRefundSigner, timedRefundLeaf.ImageHash_.Hash), Signature: []byte{}, Type: core.SignerSignatureTypeSapientCompact, } - signatureGateAndTimedRefund, err := sequence.BuildIntentConfigurationSignature(config, []*core.SignerSignature{peerSignature, timedRefundSignature}) + signatureWithTimedRefund, err := sequence.BuildIntentConfigurationSignature(config, []*core.SignerSignature{timedRefundSignature}) require.NoError(t, err) - require.NotEqual(t, signatureGateOnly, signatureGateAndTimedRefund) + require.NotEqual(t, signatureNoSigs, signatureWithTimedRefund) } func TestTimedRefundSapientImageHash(t *testing.T) { From 1f1286b4b4b640711dd1f8935ae6e4f3a2766c77 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Thu, 30 Jul 2026 11:02:01 +1200 Subject: [PATCH 06/17] refactor: merge calls and sapient behind a single payload gate Share one OR-nest gate (threshold = gateWeight+1) so either group needs the payload gate's co-signature, without separate per-leaf gates or weight-1 rejection. --- intent_config.go | 95 ++++++++++++++++++++++++++----------------- intent_config_test.go | 73 ++++++--------------------------- 2 files changed, 70 insertions(+), 98 deletions(-) diff --git a/intent_config.go b/intent_config.go index 5660548a..fdfa4e89 100644 --- a/intent_config.go +++ b/intent_config.go @@ -186,26 +186,46 @@ func CreateAnyAddressSubdigestTree(calls []*v3.CallsPayload) ([]v3.WalletConfigT return leaves, nil } -// wrapPayloadGate requires both payloadGateLeaf and protectedLeaves (weight 1 each, -// threshold 2). payloadGateLeaf is capped to weight 1 via its own nested leaf, since it's an -// opaque caller-supplied tree that could otherwise satisfy the gate alone. Safe to call more -// than once with the same payloadGateLeaf. -func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, protectedLeaves ...v3.WalletConfigTree) v3.WalletConfigTree { - cappedGate := &v3.WalletConfigTreeNestedLeaf{ - Weight: 1, - Threshold: 1, - Tree: payloadGateLeaf, +// wrapPayloadGate requires payloadGateLeaf's co-signature alongside any one of +// gateableLeaves. An inner threshold-1 nest OR's the groups and contributes weight 1 at +// most, so satisfying many groups still cannot clear the outer threshold without the gate +// leaf. The outer threshold is payloadGateLeaf's weight + 1, so any gate weight is safe by +// construction (the gate alone cannot meet it). +func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, gateableLeaves ...v3.WalletConfigTree) (v3.WalletConfigTree, error) { + gateWeight, err := leafWeight(payloadGateLeaf) + if err != nil { + return nil, fmt.Errorf("invalid payloadGateLeafNode: %w", err) + } + if gateWeight == 0 { + return nil, fmt.Errorf("invalid payloadGateLeafNode: weight must be > 0") } - inner := &v3.WalletConfigTreeNestedLeaf{ + gateableTree := &v3.WalletConfigTreeNestedLeaf{ Weight: 1, Threshold: 1, - Tree: v3.WalletConfigTreeNodes(protectedLeaves...), + Tree: v3.WalletConfigTreeNodes(gateableLeaves...), } - gate := v3.WalletConfigTreeNodes(cappedGate, inner) return &v3.WalletConfigTreeNestedLeaf{ Weight: 1, - Threshold: 2, - Tree: gate, + Threshold: uint16(gateWeight) + 1, + Tree: v3.WalletConfigTreeNodes(payloadGateLeaf, gateableTree), + }, nil +} + +// leafWeight returns the contribution weight of a single leaf. Nodes and other types whose +// effective weight can't be read from outside package v3 are rejected. +func leafWeight(tree v3.WalletConfigTree) (uint8, error) { + if tree == nil { + return 0, fmt.Errorf("nil leaf") + } + switch t := tree.(type) { + case *v3.WalletConfigTreeAddressLeaf: + return t.Weight, nil + case *v3.WalletConfigTreeSapientSignerLeaf: + return t.Weight, nil + case *v3.WalletConfigTreeNestedLeaf: + return t.Weight, nil + default: + return 0, fmt.Errorf("unsupported leaf type %T", tree) } } @@ -215,58 +235,59 @@ func createIntentTree( payloadGateLeafNode v3.WalletConfigTree, sapientSignerLeafNode v3.WalletConfigTree, ) (*v3.WalletConfigTree, error) { + var leaves []v3.WalletConfigTree + // Create the subdigest leaves from the batched transactions. - subdigestLeaves, err := CreateAnyAddressSubdigestTree(calls) + gateableLeaves, err := CreateAnyAddressSubdigestTree(calls) if err != nil { return nil, err } - var leaves []v3.WalletConfigTree - - if len(subdigestLeaves) == 0 { - // No calls to gate (sapient-only config): omit the calls gate entirely. - } else if payloadGateLeafNode != nil { - // calls && payloadGateLeafNode must match together. - leaves = append(leaves, wrapPayloadGate(payloadGateLeafNode, subdigestLeaves...)) - } else { - // No gate: preserve the exact flat structure of the original (pre-gating) tree so - // already-derived counterfactual addresses do not change. - leaves = append(leaves, subdigestLeaves...) + // Add the sapient signer leaf to the gateable leaves. + if sapientSignerLeafNode != nil { + gateableLeaves = append(gateableLeaves, sapientSignerLeafNode) } - if sapientSignerLeafNode != nil { - if payloadGateLeafNode != nil { - // Gated the same way as the calls leaves. - leaves = append(leaves, wrapPayloadGate(payloadGateLeafNode, sapientSignerLeafNode)) + // If there are any gateable leaves, wrap them in a gate if a payload gate leaf is provided. + if len(gateableLeaves) > 0 { + if payloadGateLeafNode == nil { + // No gate: preserve flat structure so counterfactual addresses stay stable. + leaves = append(leaves, gateableLeaves...) } else { - leaves = append(leaves, sapientSignerLeafNode) + // Calls and sapient share one gate; either needs payloadGateLeaf's co-signature. + gate, err := wrapPayloadGate(payloadGateLeafNode, gateableLeaves...) + if err != nil { + return nil, err + } + leaves = append(leaves, gate) } } - // Main signer leaf (weight 1). Never gated, so the owner can always act. + // Add the main signer leaf to the leaves (ungated). mainSignerLeaf := &v3.WalletConfigTreeAddressLeaf{ Weight: 1, Address: mainSigner, } - // If the length of the leaves is 1 + // If the length of the leaves is 1. if len(leaves) == 1 { tree := v3.WalletConfigTreeNodes(mainSignerLeaf, leaves[0]) return &tree, nil } - // Create a tree from the subdigest leaves. + // Create a tree from the (gated) leaves. tree := v3.WalletConfigTreeNodes(leaves...) - // Construct the new wallet config using: + // Construct the new wallet config. fullTree := v3.WalletConfigTreeNodes(mainSignerLeaf, tree) return &fullTree, nil } // `CreateIntentTree` creates a tree from a list of intent operations and a main signer -// address. When payloadGateLeafNode is set, calls and sapientSignerLeafNode are each gated -// behind it (see wrapPayloadGate); mainSigner never is. nil preserves the legacy tree shape. +// address. When payloadGateLeafNode is set, calls and sapientSignerLeafNode share one gate +// that requires its co-signature (see wrapPayloadGate); mainSigner never is. nil preserves +// the legacy tree shape. func CreateIntentTree( mainSigner common.Address, calls []*v3.CallsPayload, diff --git a/intent_config_test.go b/intent_config_test.go index 56d1d435..1155dd52 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -485,55 +485,9 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { require.NotEqual(t, signatureWithoutPeerSig, signatureWithPeerSig) } -// payloadGateLeafNode is an opaque v3.WalletConfigTree the caller controls; an overweighted -// or otherwise misweighted leaf must not be able to satisfy the gate on its own. wrapPayloadGate -// caps it to weight 1 behind its own nested leaf, regardless of the leaf's declared weight. -func TestCreateIntentConfigurationWithPayloadGateLeaf_OverweightedGateLeafCapped(t *testing.T) { - payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ - { - To: common.HexToAddress("0x1111111111111111111111111111111111111111"), - Value: nil, - Data: []byte{0x12, 0x34}, - GasLimit: big.NewInt(0), - DelegateCall: false, - OnlyFallback: false, - BehaviorOnError: v3.BehaviorOnErrorRevert, - }, - }, big.NewInt(0), big.NewInt(0)) - - mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") - peerSigner := common.HexToAddress("0x72030E1dbf0a847196ae62EA3ee84BD7ce99D6c1") - // Overweighted on purpose: this alone must not be enough to satisfy the gate's - // threshold-2 requirement without the calls leaves also contributing. - overweightedGateLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ - Weight: 5, - Address: peerSigner, - ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(1))}, - } - - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, overweightedGateLeaf, nil) - require.NoError(t, err) - - top, ok := config.Tree.(*v3.WalletConfigTreeNode) - require.True(t, ok) - outerGate, ok := top.Right.(*v3.WalletConfigTreeNestedLeaf) - require.True(t, ok) - require.Equal(t, uint16(2), outerGate.Threshold) - - gateNode, ok := outerGate.Tree.(*v3.WalletConfigTreeNode) - require.True(t, ok) - - cappedGate, ok := gateNode.Left.(*v3.WalletConfigTreeNestedLeaf) - require.True(t, ok, "payloadGateLeafNode must be capped behind its own weight-1 nested leaf") - require.Equal(t, uint8(1), cappedGate.Weight) - require.Equal(t, uint16(1), cappedGate.Threshold) - require.Same(t, overweightedGateLeaf, cappedGate.Tree) -} - -// A sapient-only config (calls is empty) must not build a broken calls gate: with no -// subdigest leaves to wrap, wrapPayloadGate's inner threshold-1 node would otherwise end up -// with a nil Tree, panicking on ImageHash or any other tree traversal. The calls gate must -// simply be omitted, leaving the separately gated sapient leaf intact. +// A sapient-only config (calls is empty) must not build a broken calls group: with no +// subdigest leaves, only the sapient leaf is gated behind payloadGateLeaf. ImageHash must +// still succeed (a nil inner Tree would panic on traversal). func TestCreateIntentConfigurationWithPayloadGateLeaf_EmptyCallsOmitsCallsGate(t *testing.T) { mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") peerSigner := common.HexToAddress("0x72030E1dbf0a847196ae62EA3ee84BD7ce99D6c1") @@ -564,10 +518,10 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_EmptyCallsOmitsCallsGate(t } // A sapient signer leaf (e.g. a timed-refund or gasless-deposit leaf) passed as -// sapientSignerLeafNode is gated the same way as the calls leaves: it also requires -// payloadGateLeaf's co-signature, in its own independent 2-of-2 subtree. mainSignerLeaf is -// the only leaf never gated, so the owner can always act (e.g. recover funds) regardless of -// the gate's paused state. +// sapientSignerLeafNode shares payloadGateLeaf's gate with the calls leaves: either group +// alone, plus payloadGateLeaf's co-signature, is sufficient. mainSignerLeaf is the only leaf +// never gated, so the owner can always act (e.g. recover funds) regardless of the gate's +// paused state. func TestCreateIntentConfigurationWithPayloadGateLeaf_SapientLeafGated(t *testing.T) { payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ { @@ -615,14 +569,11 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_SapientLeafGated(t *testin require.True(t, ok) require.Equal(t, mainSigner, ownerLeaf.Address) - // Both the calls leaves and timedRefundLeaf must now be reachable only inside a gate - // (NestedLeaf), as siblings of each other. - rest, ok := top.Right.(*v3.WalletConfigTreeNode) - require.True(t, ok) - _, callsGateOk := rest.Left.(*v3.WalletConfigTreeNestedLeaf) - require.True(t, callsGateOk, "calls leaves must be gated") - _, sapientGateOk := rest.Right.(*v3.WalletConfigTreeNestedLeaf) - require.True(t, sapientGateOk, "timedRefundLeaf must now be gated too") + // The calls leaves and timedRefundLeaf share a single merged gate + // (threshold = gateWeight+1), not two separate gates. + mergedGate, ok := top.Right.(*v3.WalletConfigTreeNestedLeaf) + require.True(t, ok, "calls and timedRefundLeaf must share one gate") + require.Equal(t, uint16(peerSignerLeaf.Weight)+1, mergedGate.Threshold, "outer gate is gateWeight+1") require.NotNil(t, findSapientSignerLeaf(config.Tree, peerSigner)) require.NotNil(t, findSapientSignerLeaf(config.Tree, timedRefundSigner)) From 80db92b9210e2243e018b0d4498e425c1978e215 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 07:11:40 +1200 Subject: [PATCH 07/17] fix: reject WalletConfigTreeNestedLeaf in leafWeight leafWeight backs wrapPayloadGate's cap on payloadGateLeaf's contribution. Only terminal leaf types (address, sapient signer) have a weight that actually bounds what they contribute; a NestedLeaf's declared Weight doesn't bound its subtree, so accepting it let a caller understate the gate leaf's real contribution. --- intent_config.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/intent_config.go b/intent_config.go index fdfa4e89..2e1588ad 100644 --- a/intent_config.go +++ b/intent_config.go @@ -211,8 +211,7 @@ func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, gateableLeaves ...v3.W }, nil } -// leafWeight returns the contribution weight of a single leaf. Nodes and other types whose -// effective weight can't be read from outside package v3 are rejected. +// leafWeight returns the contribution weight of a single terminal leaf. func leafWeight(tree v3.WalletConfigTree) (uint8, error) { if tree == nil { return 0, fmt.Errorf("nil leaf") @@ -222,8 +221,6 @@ func leafWeight(tree v3.WalletConfigTree) (uint8, error) { return t.Weight, nil case *v3.WalletConfigTreeSapientSignerLeaf: return t.Weight, nil - case *v3.WalletConfigTreeNestedLeaf: - return t.Weight, nil default: return 0, fmt.Errorf("unsupported leaf type %T", tree) } From 680c9dd96794a159dc554dee7262952249cc2625 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 07:36:28 +1200 Subject: [PATCH 08/17] fix: match full signer identity in BuildIntentConfigurationSignature signingFunc matched signerSignatures by Address only, so two Signer values sharing an address but differing in IsSapient/ImageHash (e.g. a payload gate and a sapient signer leaf on the same contract) could both receive the first same-address signature, leaving one leaf signed for the wrong image hash. Co-Authored-By: Claude Sonnet 5 --- intent_config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/intent_config.go b/intent_config.go index 2e1588ad..8e649a1b 100644 --- a/intent_config.go +++ b/intent_config.go @@ -335,7 +335,7 @@ func BuildIntentConfigurationSignature(config *v3.WalletConfig, signerSignatures signingFunc := func(ctx context.Context, signer core.Signer, _ []core.SignerSignature) (core.SignerSignatureType, []byte, error) { for _, signerSignature := range signerSignatures { - if signer.Address == signerSignature.Signer.Address { + if signer == signerSignature.Signer { return signerSignature.Type, signerSignature.Signature, nil } } From ed9047d06586b44dcf82a54383811ba7112039d8 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 07:48:57 +1200 Subject: [PATCH 09/17] feat: thread payloadGateLeafNode through GetIntentConfigurationSignature GetIntentConfigurationSignature previously hardcoded nil for the payload gate leaf when building its intent configuration, so a caller with a pausable gate configured got a signature for the wrong (ungated) wallet config. Add the payloadGateLeafNode param, matching CreateIntentConfiguration's signature, and update all callers. Add subtests covering GetIntentConfigurationSignature with the gate alone and with the gate plus a sapient signer leaf. --- intent_config.go | 3 +- intent_config_test.go | 148 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 140 insertions(+), 11 deletions(-) diff --git a/intent_config.go b/intent_config.go index 8e649a1b..3dc42c19 100644 --- a/intent_config.go +++ b/intent_config.go @@ -367,10 +367,11 @@ func GetIntentConfigurationSignature( mainSigner common.Address, calls []*v3.CallsPayload, checkpoint uint64, + payloadGateLeafNode v3.WalletConfigTree, sapientSignerLeafNode v3.WalletConfigTree, signerSignatures []*core.SignerSignature, ) ([]byte, error) { - config, err := createIntentConfiguration(mainSigner, calls, checkpoint, nil, sapientSignerLeafNode) + config, err := createIntentConfiguration(mainSigner, calls, checkpoint, payloadGateLeafNode, sapientSignerLeafNode) if err != nil { return nil, err } diff --git a/intent_config_test.go b/intent_config_test.go index 1155dd52..19da6ab2 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -379,7 +379,7 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { require.NoError(t, err) require.Equal(t, config.ImageHash().Hash, recoveredConfig.ImageHash().Hash) - plainSignature, err := sequence.GetIntentConfigurationSignature(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) + plainSignature, err := sequence.GetIntentConfigurationSignature(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil, nil) require.NoError(t, err) require.NotEqual(t, plainSignature, signature) } @@ -676,7 +676,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { require.NoError(t, err) // Create the signature - signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil) + signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil, nil) require.NoError(t, err) // fmt.Println("==> signature", common.Bytes2Hex(signature)) @@ -751,10 +751,10 @@ func TestGetIntentConfigurationSignature(t *testing.T) { }, big.NewInt(0), big.NewInt(0)) // Create signatures for each payload as separate batches - sig1, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload1}, 0, nil, nil) + sig1, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload1}, 0, nil, nil, nil) require.NoError(t, err) - sig2, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload2}, 0, nil, nil) + sig2, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload2}, 0, nil, nil, nil) require.NoError(t, err) // Verify signatures are different @@ -763,10 +763,10 @@ func TestGetIntentConfigurationSignature(t *testing.T) { t.Run("same transactions produce same signatures", func(t *testing.T) { // Use the payload directly - sig1, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil) + sig1, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil, nil) require.NoError(t, err) - sig2, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil) + sig2, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil, nil) require.NoError(t, err) // Verify signatures are the same @@ -779,7 +779,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { require.NoError(t, err) // Create the signature - signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, sapientSignerLeafNode, []*core.SignerSignature{signerSignature}) + signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, sapientSignerLeafNode, []*core.SignerSignature{signerSignature}) require.NoError(t, err) sapientLeaf := findSapientSignerLeaf(config.Tree, sapientSignerAddress) @@ -816,6 +816,134 @@ func TestGetIntentConfigurationSignature(t *testing.T) { // Verify the signature contains the sapient signature require.Contains(t, common.Bytes2Hex(sigDataStr), sapientSignerSignature[2:], "signature should contain the sapient signer signature") }) + + t.Run("payload gate signature included in the signature tree", func(t *testing.T) { + gateContract := testChain.UniDeploy(t, "MOCK_SAPIENT", 1) + gateSignerAddress := gateContract.Address + gateImageHash := common.HexToHash("0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF123456789A") + gateLeafNode := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: gateSignerAddress, + ImageHash_: core.ImageHash{Hash: gateImageHash}, + } + gateSignature := &core.SignerSignature{ + Signer: core.Signer{ + Address: gateSignerAddress, + IsSapient: true, + ImageHash: gateImageHash, + }, + Signature: gateImageHash.Bytes(), + Type: core.SignerSignatureTypeSapient, + } + + // Create the intent configuration + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, nil) + require.NoError(t, err) + + // Create the signature + signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, nil, []*core.SignerSignature{gateSignature}) + require.NoError(t, err) + + gateLeaf := findSapientSignerLeaf(config.Tree, gateSignerAddress) + require.NotNil(t, gateLeaf) + require.Equal(t, gateImageHash, gateLeaf.ImageHash_.Hash) + + // Verify the signature can be decoded + sig, err := v3.Core.DecodeSignature(signature) + require.NoError(t, err, "signature should be decodable") + + // Get the config from the signature + recoveredSignerSignatures := map[core.Signer]core.SignerSignature{} + recoveredConfig, _, err := sig.Recover(context.Background(), payload, testChain.Provider, recoveredSignerSignatures) + require.NoError(t, err) + require.NotNil(t, recoveredConfig, "recovered config should not be nil") + require.Len(t, recoveredSignerSignatures, 1, "expected exactly one recovered gate signer signature") + var recoveredGateSig core.SignerSignature + for signer, sig := range recoveredSignerSignatures { + if signer.Address == gateSignerAddress { + recoveredGateSig = sig + break + } + } + require.NotNil(t, recoveredGateSig.Signature, "gate signer signature should be recovered") + require.Equal(t, gateSignature.Signature, recoveredGateSig.Signature, "recovered gate signature should match") + + // Get the full signature in string + sigDataStr, err := sig.Data() + require.NoError(t, err) + + // Verify the signature contains the gate signature + require.Contains(t, common.Bytes2Hex(sigDataStr), gateImageHash.Hex()[2:], "signature should contain the gate signer signature") + }) + + t.Run("payload gate and sapient signer signatures included in the signature tree", func(t *testing.T) { + gateContract := testChain.UniDeploy(t, "MOCK_SAPIENT", 2) + gateSignerAddress := gateContract.Address + gateImageHash := common.HexToHash("0xFEDCBA0987654321FEDCBA0987654321FEDCBA0987654321FEDCBA098765432") + gateLeafNode := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: gateSignerAddress, + ImageHash_: core.ImageHash{Hash: gateImageHash}, + } + gateSignature := &core.SignerSignature{ + Signer: core.Signer{ + Address: gateSignerAddress, + IsSapient: true, + ImageHash: gateImageHash, + }, + Signature: gateImageHash.Bytes(), + Type: core.SignerSignatureTypeSapient, + } + + // Create the intent configuration + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode) + require.NoError(t, err) + + sapientLeaf := findSapientSignerLeaf(config.Tree, sapientSignerAddress) + require.NotNil(t, sapientLeaf) + require.Equal(t, sapientImageHash, sapientLeaf.ImageHash_.Hash) + + gateLeaf := findSapientSignerLeaf(config.Tree, gateSignerAddress) + require.NotNil(t, gateLeaf) + require.Equal(t, gateImageHash, gateLeaf.ImageHash_.Hash) + + // Checked independently (not combined in one call) because the gate leaf alone + // already meets the overall threshold via the calls gate's auto-satisfying + // subdigest leaf, which would race BuildRegularSignature's early-cancellation + // against collecting the other signer's signature. + signatureNoSigs, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode, nil) + require.NoError(t, err) + + signatureWithGateSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode, []*core.SignerSignature{gateSignature}) + require.NoError(t, err) + require.NotEqual(t, signatureNoSigs, signatureWithGateSig, "including the gate signature must change the encoding") + + sigWithGateSig, err := v3.Core.DecodeSignature(signatureWithGateSig) + require.NoError(t, err, "signature should be decodable") + gateRecoveredSignatures := map[core.Signer]core.SignerSignature{} + _, _, err = sigWithGateSig.Recover(context.Background(), payload, testChain.Provider, gateRecoveredSignatures) + require.NoError(t, err) + require.Len(t, gateRecoveredSignatures, 1, "expected exactly one recovered gate signer signature") + for signer, sig := range gateRecoveredSignatures { + require.Equal(t, gateSignerAddress, signer.Address) + require.Equal(t, gateSignature.Signature, sig.Signature, "recovered gate signature should match") + } + + signatureWithSapientSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode, []*core.SignerSignature{signerSignature}) + require.NoError(t, err) + require.NotEqual(t, signatureNoSigs, signatureWithSapientSig, "including the sapient signature must change the encoding") + + sigWithSapientSig, err := v3.Core.DecodeSignature(signatureWithSapientSig) + require.NoError(t, err, "signature should be decodable") + sapientRecoveredSignatures := map[core.Signer]core.SignerSignature{} + _, _, err = sigWithSapientSig.Recover(context.Background(), payload, testChain.Provider, sapientRecoveredSignatures) + require.NoError(t, err) + require.Len(t, sapientRecoveredSignatures, 1, "expected exactly one recovered sapient signer signature") + for signer, sig := range sapientRecoveredSignatures { + require.Equal(t, sapientSignerAddress, signer.Address) + require.Equal(t, signerSignature.Signature, sig.Signature, "recovered sapient signature should match") + } + }) } func TestGetIntentConfigurationSignature_MultipleTransactions(t *testing.T) { @@ -846,7 +974,7 @@ func TestGetIntentConfigurationSignature_MultipleTransactions(t *testing.T) { }, big.NewInt(0), big.NewInt(0)) // Create a signature - sig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload1}, 0, nil, nil) + sig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload1}, 0, nil, nil, nil) require.NoError(t, err) // Convert the full signature into a hex string. @@ -917,7 +1045,7 @@ func TestIntentTransactionToGuestModuleDeployAndCall(t *testing.T) { require.NotZero(t, mainSigner) // Generate a configuration signature for the batch. - intentConfigSig, err := sequence.GetIntentConfigurationSignature(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) + intentConfigSig, err := sequence.GetIntentConfigurationSignature(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil, nil) require.NoError(t, err) // fmt.Println("==> bundle.Digest", bundle.Digest().Hash) @@ -1075,7 +1203,7 @@ func TestIntentTransactionToGuestModuleDeployAndCallMultiplePayloads(t *testing. require.NotZero(t, mainSigner) // Generate a configuration signature for both batches - intentConfigSig, err := sequence.GetIntentConfigurationSignature(mainSigner, payloads, 0, nil, nil) + intentConfigSig, err := sequence.GetIntentConfigurationSignature(mainSigner, payloads, 0, nil, nil, nil) require.NoError(t, err) fmt.Printf("--- Intent Config Signature (for all payloads) ---\n%s\n", common.Bytes2Hex(intentConfigSig)) From df27a83d05a46d5dc98c9a5d950a1a33a690350b Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 08:56:12 +1200 Subject: [PATCH 10/17] fix: embed supplied signatures deterministically in intent config signatures BuildIntentConfigurationSignature routed pre-collected signatures through BuildRegularSignature's signing orchestrator, which cancels outstanding signers once the config threshold looks met. Subdigest leaves report max weight regardless of payload, so a payload gate signature arriving first satisfied the threshold and nondeterministically dropped the sapient signature that recovery of a non-matching payload still needs. Add WalletConfig.BuildRegularSignatureFromSignatures, which builds the signature tree directly from the supplied signatures with no orchestration or cancellation, and use it in BuildIntentConfigurationSignature. Co-Authored-By: Claude Fable 5 --- core/v3/v3.go | 24 ++++++++++++++++++++ intent_config.go | 23 +++++++------------ intent_config_test.go | 52 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/core/v3/v3.go b/core/v3/v3.go index a0e20d3b..e9574498 100644 --- a/core/v3/v3.go +++ b/core/v3/v3.go @@ -2096,6 +2096,30 @@ func (c *WalletConfig) BuildNoChainIDSignature(ctx context.Context, sign core.Si }}, nil } +// BuildRegularSignatureFromSignatures builds a regular signature directly from +// pre-collected signer signatures, with no signing orchestration. Use this instead of +// BuildRegularSignature when all signatures are already in hand: BuildRegularSignature +// cancels outstanding signers once the config threshold looks met, and payload-independent +// leaves (e.g. WalletConfigTreeAnyAddressSubdigestLeaf) can satisfy the threshold early, +// nondeterministically dropping supplied signatures that recovery still needs. Signers +// without a matching entry are encoded as their image hash; no signing power validation +// is performed. +func (c *WalletConfig) BuildRegularSignatureFromSignatures(signerSignatures map[core.Signer]core.SignerSignature, checkpointerData ...[]byte) core.Signature[*WalletConfig] { + var cpData []byte + if len(checkpointerData) > 0 { + cpData = checkpointerData[0] + } + + return &RegularSignature{&Signature{ + NoChainId: false, + Threshold: c.Threshold_, + Checkpoint: c.Checkpoint_, + Tree: c.Tree.buildSignatureTree(signerSignatures), + Checkpointer: c.Checkpointer, + CheckpointerData: cpData, + }} +} + type WalletConfigTree interface { core.ImageHashable diff --git a/intent_config.go b/intent_config.go index 3dc42c19..68b741f6 100644 --- a/intent_config.go +++ b/intent_config.go @@ -1,7 +1,6 @@ package sequence import ( - "context" "fmt" "math/big" @@ -327,29 +326,23 @@ func CreateIntentConfiguration( } // `BuildIntentConfigurationSignature` creates a signature for an already-built intent configuration -// that can be used to bypass chain ID validation. +// that can be used to bypass chain ID validation. All supplied signer signatures are +// embedded deterministically; signers without a supplied signature are encoded as their +// image hash. func BuildIntentConfigurationSignature(config *v3.WalletConfig, signerSignatures []*core.SignerSignature) ([]byte, error) { if config == nil { return nil, fmt.Errorf("intent configuration is nil") } - signingFunc := func(ctx context.Context, signer core.Signer, _ []core.SignerSignature) (core.SignerSignatureType, []byte, error) { - for _, signerSignature := range signerSignatures { - if signer == signerSignature.Signer { - return signerSignature.Type, signerSignature.Signature, nil - } + signatures := make(map[core.Signer]core.SignerSignature, len(signerSignatures)) + for _, signerSignature := range signerSignatures { + if signerSignature != nil { + signatures[signerSignature.Signer] = *signerSignature } - return 0, nil, nil } - // Build the signature using BuildNoChainIDSignature, which allows us to inject custom signatures via SigningFunction. - // Set validateSigningPower to false, as we are not necessarily providing signatures for all parts of the config. - sig, err := config.BuildRegularSignature(context.Background(), signingFunc, false) - if err != nil { - return nil, fmt.Errorf("failed to build regular signature: %w", err) - } + sig := config.BuildRegularSignatureFromSignatures(signatures) - // Get the signature data data, err := sig.Data() if err != nil { return nil, fmt.Errorf("failed to get signature data: %w", err) diff --git a/intent_config_test.go b/intent_config_test.go index 19da6ab2..ec098c78 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -591,10 +591,7 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_SapientLeafGated(t *testin "recovered weight %v must not meet threshold %v with no signatures at all", weightNoSigs, config.Threshold()) // Each leaf's signature must change the encoding on its own, proving it's wired into - // its gated position. Checked independently (not combined) because the gate leaf alone - // already meets the overall threshold via the calls gate's auto-satisfying subdigest - // leaf, which would race BuildRegularSignature's early-cancellation against collecting - // the other signer's signature. + // its gated position. peerSignature := &core.SignerSignature{ Signer: core.SapientSigner(peerSigner, peerSignerLeaf.ImageHash_.Hash), Signature: []byte{}, @@ -612,6 +609,22 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_SapientLeafGated(t *testin signatureWithTimedRefund, err := sequence.BuildIntentConfigurationSignature(config, []*core.SignerSignature{timedRefundSignature}) require.NoError(t, err) require.NotEqual(t, signatureNoSigs, signatureWithTimedRefund) + + // Supplying both signatures must embed both, deterministically. The gate leaf alone + // meets the config threshold via the calls gate's payload-independent subdigest leaf, + // so an early-cancelling builder could drop the other signature depending on goroutine + // scheduling; every build must include both signatures and produce identical bytes. + both := []*core.SignerSignature{peerSignature, timedRefundSignature} + signatureCombined, err := sequence.BuildIntentConfigurationSignature(config, both) + require.NoError(t, err) + require.NotEqual(t, signatureNoSigs, signatureCombined) + require.NotEqual(t, signatureWithGate, signatureCombined, "combined signature must also embed the timed refund signature") + require.NotEqual(t, signatureWithTimedRefund, signatureCombined, "combined signature must also embed the gate signature") + for range 50 { + rebuilt, err := sequence.BuildIntentConfigurationSignature(config, both) + require.NoError(t, err) + require.Equal(t, signatureCombined, rebuilt, "combined signature must be deterministic") + } } func TestTimedRefundSapientImageHash(t *testing.T) { @@ -907,10 +920,8 @@ func TestGetIntentConfigurationSignature(t *testing.T) { require.NotNil(t, gateLeaf) require.Equal(t, gateImageHash, gateLeaf.ImageHash_.Hash) - // Checked independently (not combined in one call) because the gate leaf alone - // already meets the overall threshold via the calls gate's auto-satisfying - // subdigest leaf, which would race BuildRegularSignature's early-cancellation - // against collecting the other signer's signature. + // Each signature is checked independently first to prove its leaf's wiring, then + // combined to prove a single build embeds both. signatureNoSigs, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode, nil) require.NoError(t, err) @@ -943,6 +954,31 @@ func TestGetIntentConfigurationSignature(t *testing.T) { require.Equal(t, sapientSignerAddress, signer.Address) require.Equal(t, signerSignature.Signature, sig.Signature, "recovered sapient signature should match") } + + // Supplying both signatures in one call must embed both: the gate leaf alone meets + // the config threshold via the calls gate's payload-independent subdigest leaf, so + // an early-cancelling builder could nondeterministically drop the sapient signature. + signatureCombined, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode, []*core.SignerSignature{gateSignature, signerSignature}) + require.NoError(t, err) + require.NotEqual(t, signatureWithGateSig, signatureCombined, "combined signature must also embed the sapient signature") + require.NotEqual(t, signatureWithSapientSig, signatureCombined, "combined signature must also embed the gate signature") + + sigCombined, err := v3.Core.DecodeSignature(signatureCombined) + require.NoError(t, err, "signature should be decodable") + combinedRecoveredSignatures := map[core.Signer]core.SignerSignature{} + _, _, err = sigCombined.Recover(context.Background(), payload, testChain.Provider, combinedRecoveredSignatures) + require.NoError(t, err) + require.Len(t, combinedRecoveredSignatures, 2, "expected both the gate and sapient signer signatures recovered") + for signer, sig := range combinedRecoveredSignatures { + switch signer.Address { + case gateSignerAddress: + require.Equal(t, gateSignature.Signature, sig.Signature, "recovered gate signature should match") + case sapientSignerAddress: + require.Equal(t, signerSignature.Signature, sig.Signature, "recovered sapient signature should match") + default: + require.Failf(t, "unexpected recovered signer", "address %s", signer.Address) + } + } }) } From 6237ebec6f1245a4ea39cfd03f5196ec4cc9b6d9 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 10:26:29 +1200 Subject: [PATCH 11/17] refactor: replace optional leaf params with functional options Add IntentConfigOption with WithPayloadGate and WithSapientSigner so CreateIntentTree, CreateIntentConfiguration and GetIntentConfigurationSignature take named options instead of adjacent positional WalletConfigTree params. Future optional leaves become additive options rather than signature breaks. Co-Authored-By: Claude Fable 5 --- intent_config.go | 56 ++++++++++++++++++++++++--------- intent_config_test.go | 72 +++++++++++++++++++++---------------------- testutil/testutil.go | 2 +- 3 files changed, 79 insertions(+), 51 deletions(-) diff --git a/intent_config.go b/intent_config.go index 68b741f6..cdf0da3b 100644 --- a/intent_config.go +++ b/intent_config.go @@ -225,6 +225,35 @@ func leafWeight(tree v3.WalletConfigTree) (uint8, error) { } } +// IntentConfigOption configures the optional leaves of an intent configuration tree. +type IntentConfigOption func(*intentConfigOptions) + +type intentConfigOptions struct { + payloadGateLeafNode v3.WalletConfigTree + sapientSignerLeafNode v3.WalletConfigTree +} + +// WithPayloadGate gates the calls and the sapient signer leaf behind leaf's co-signature: +// either group, plus leaf's signature, authorizes the wallet (see wrapPayloadGate). The +// main signer is never gated. +func WithPayloadGate(leaf v3.WalletConfigTree) IntentConfigOption { + return func(o *intentConfigOptions) { o.payloadGateLeafNode = leaf } +} + +// WithSapientSigner adds leaf (e.g. a timed-refund or gasless-deposit signer) as an +// authorizer alongside the calls' subdigest leaves. +func WithSapientSigner(leaf v3.WalletConfigTree) IntentConfigOption { + return func(o *intentConfigOptions) { o.sapientSignerLeafNode = leaf } +} + +func applyIntentConfigOptions(opts []IntentConfigOption) intentConfigOptions { + var options intentConfigOptions + for _, opt := range opts { + opt(&options) + } + return options +} + func createIntentTree( mainSigner common.Address, calls []*v3.CallsPayload, @@ -281,16 +310,15 @@ func createIntentTree( } // `CreateIntentTree` creates a tree from a list of intent operations and a main signer -// address. When payloadGateLeafNode is set, calls and sapientSignerLeafNode share one gate -// that requires its co-signature (see wrapPayloadGate); mainSigner never is. nil preserves -// the legacy tree shape. +// address. See WithPayloadGate and WithSapientSigner for the optional leaves; with no +// options the legacy tree shape is preserved. func CreateIntentTree( mainSigner common.Address, calls []*v3.CallsPayload, - payloadGateLeafNode v3.WalletConfigTree, - sapientSignerLeafNode v3.WalletConfigTree, + opts ...IntentConfigOption, ) (*v3.WalletConfigTree, error) { - return createIntentTree(mainSigner, calls, payloadGateLeafNode, sapientSignerLeafNode) + options := applyIntentConfigOptions(opts) + return createIntentTree(mainSigner, calls, options.payloadGateLeafNode, options.sapientSignerLeafNode) } func createIntentConfiguration( @@ -313,16 +341,16 @@ func createIntentConfiguration( } // `CreateIntentConfiguration` creates a wallet configuration where the intent's transaction -// batches are grouped into the initial subdigest. See CreateIntentTree for -// payloadGateLeafNode and sapientSignerLeafNode semantics. +// batches are grouped into the initial subdigest. See WithPayloadGate and WithSapientSigner +// for the optional leaves. func CreateIntentConfiguration( mainSigner common.Address, calls []*v3.CallsPayload, checkpoint uint64, - payloadGateLeafNode v3.WalletConfigTree, - sapientSignerLeafNode v3.WalletConfigTree, + opts ...IntentConfigOption, ) (*v3.WalletConfig, error) { - return createIntentConfiguration(mainSigner, calls, checkpoint, payloadGateLeafNode, sapientSignerLeafNode) + options := applyIntentConfigOptions(opts) + return createIntentConfiguration(mainSigner, calls, checkpoint, options.payloadGateLeafNode, options.sapientSignerLeafNode) } // `BuildIntentConfigurationSignature` creates a signature for an already-built intent configuration @@ -360,11 +388,11 @@ func GetIntentConfigurationSignature( mainSigner common.Address, calls []*v3.CallsPayload, checkpoint uint64, - payloadGateLeafNode v3.WalletConfigTree, - sapientSignerLeafNode v3.WalletConfigTree, signerSignatures []*core.SignerSignature, + opts ...IntentConfigOption, ) ([]byte, error) { - config, err := createIntentConfiguration(mainSigner, calls, checkpoint, payloadGateLeafNode, sapientSignerLeafNode) + options := applyIntentConfigOptions(opts) + config, err := createIntentConfiguration(mainSigner, calls, checkpoint, options.payloadGateLeafNode, options.sapientSignerLeafNode) if err != nil { return nil, err } diff --git a/intent_config_test.go b/intent_config_test.go index ec098c78..95ce2e70 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -215,7 +215,7 @@ func TestCreateIntentTree_Valid(t *testing.T) { }, big.NewInt(0), big.NewInt(0)) t.Run("One batch", func(t *testing.T) { - tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1}, nil, nil) + tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1}) require.NoError(t, err) require.NotNil(t, tree) @@ -239,7 +239,7 @@ func TestCreateIntentTree_Valid(t *testing.T) { }) t.Run("Two batches", func(t *testing.T) { - tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1, &payload2}, nil, nil) + tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1, &payload2}) require.NoError(t, err) require.NotNil(t, tree) @@ -267,7 +267,7 @@ func TestCreateIntentTree_Valid(t *testing.T) { }) t.Run("Three batches", func(t *testing.T) { - tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1, &payload2, &payload3}, nil, nil) + tree, err := sequence.CreateIntentTree(common.Address{}, []*v3.CallsPayload{&payload1, &payload2, &payload3}) require.NoError(t, err) // spew.Dump(tree) @@ -318,7 +318,7 @@ func TestCreateIntentConfiguration_Valid(t *testing.T) { // Use a valid main signer address. mainSigner := common.HexToAddress("0x1111111111111111111111111111111111111111") - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0) require.NoError(t, err) require.NotNil(t, config) } @@ -348,7 +348,7 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { ImageHash_: timedRefundImageHash, } - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, timedRefundLeaf) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, sequence.WithSapientSigner(timedRefundLeaf)) require.NoError(t, err) require.NotNil(t, config) @@ -364,7 +364,7 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { require.Equal(t, uint64(1_750_000_000), preimage.UnlockTimestamp) require.Equal(t, expectedSapientImageHash, preimage.ImageHash().Hash) - plainConfig, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) + plainConfig, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0) require.NoError(t, err) require.NotEqual(t, plainConfig.ImageHash().Hash, config.ImageHash().Hash) @@ -379,7 +379,7 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { require.NoError(t, err) require.Equal(t, config.ImageHash().Hash, recoveredConfig.ImageHash().Hash) - plainSignature, err := sequence.GetIntentConfigurationSignature(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil, nil) + plainSignature, err := sequence.GetIntentConfigurationSignature(mainSigner, []*v3.CallsPayload{&payload}, 0, nil) require.NoError(t, err) require.NotEqual(t, plainSignature, signature) } @@ -408,7 +408,7 @@ func TestCreateIntentConfigurationPayloadGateLeafNilUnchanged(t *testing.T) { ImageHash_: core.ImageHash{Hash: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111")}, } - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, sapientSignerLeafNode) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) top, ok := config.Tree.(*v3.WalletConfigTreeNode) @@ -445,7 +445,7 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(1))}, } - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, peerSignerLeaf, nil) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, sequence.WithPayloadGate(peerSignerLeaf)) require.NoError(t, err) require.NotNil(t, config) @@ -453,7 +453,7 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { require.NotNil(t, sapientLeaf) // The gate must change the counterfactual address relative to an ungated config. - plainConfig, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) + plainConfig, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0) require.NoError(t, err) require.NotEqual(t, plainConfig.ImageHash().Hash, config.ImageHash().Hash) @@ -506,7 +506,7 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_EmptyCallsOmitsCallsGate(t ImageHash_: timedRefundImageHash, } - config, err := sequence.CreateIntentConfiguration(mainSigner, nil, 0, peerSignerLeaf, timedRefundLeaf) + config, err := sequence.CreateIntentConfiguration(mainSigner, nil, 0, sequence.WithPayloadGate(peerSignerLeaf), sequence.WithSapientSigner(timedRefundLeaf)) require.NoError(t, err) require.NotNil(t, config) @@ -557,8 +557,8 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_SapientLeafGated(t *testin mainSigner, []*v3.CallsPayload{&payload}, 0, - peerSignerLeaf, - timedRefundLeaf, + sequence.WithPayloadGate(peerSignerLeaf), + sequence.WithSapientSigner(timedRefundLeaf), ) require.NoError(t, err) @@ -685,11 +685,11 @@ func TestGetIntentConfigurationSignature(t *testing.T) { t.Run("signature matches subdigest", func(t *testing.T) { // Create the intent configuration - config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil) + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0) require.NoError(t, err) // Create the signature - signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil, nil) + signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil) require.NoError(t, err) // fmt.Println("==> signature", common.Bytes2Hex(signature)) @@ -764,10 +764,10 @@ func TestGetIntentConfigurationSignature(t *testing.T) { }, big.NewInt(0), big.NewInt(0)) // Create signatures for each payload as separate batches - sig1, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload1}, 0, nil, nil, nil) + sig1, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload1}, 0, nil) require.NoError(t, err) - sig2, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload2}, 0, nil, nil, nil) + sig2, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload2}, 0, nil) require.NoError(t, err) // Verify signatures are different @@ -776,10 +776,10 @@ func TestGetIntentConfigurationSignature(t *testing.T) { t.Run("same transactions produce same signatures", func(t *testing.T) { // Use the payload directly - sig1, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil, nil) + sig1, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil) require.NoError(t, err) - sig2, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, nil, nil) + sig2, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil) require.NoError(t, err) // Verify signatures are the same @@ -788,11 +788,11 @@ func TestGetIntentConfigurationSignature(t *testing.T) { t.Run("signer signature included in the signature tree", func(t *testing.T) { // Create the intent configuration - config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, sapientSignerLeafNode) + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) // Create the signature - signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, sapientSignerLeafNode, []*core.SignerSignature{signerSignature}) + signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{signerSignature}, sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) sapientLeaf := findSapientSignerLeaf(config.Tree, sapientSignerAddress) @@ -850,11 +850,11 @@ func TestGetIntentConfigurationSignature(t *testing.T) { } // Create the intent configuration - config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, nil) + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, sequence.WithPayloadGate(gateLeafNode)) require.NoError(t, err) // Create the signature - signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, nil, []*core.SignerSignature{gateSignature}) + signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{gateSignature}, sequence.WithPayloadGate(gateLeafNode)) require.NoError(t, err) gateLeaf := findSapientSignerLeaf(config.Tree, gateSignerAddress) @@ -909,7 +909,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { } // Create the intent configuration - config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode) + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) sapientLeaf := findSapientSignerLeaf(config.Tree, sapientSignerAddress) @@ -922,10 +922,10 @@ func TestGetIntentConfigurationSignature(t *testing.T) { // Each signature is checked independently first to prove its leaf's wiring, then // combined to prove a single build embeds both. - signatureNoSigs, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode, nil) + signatureNoSigs, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) - signatureWithGateSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode, []*core.SignerSignature{gateSignature}) + signatureWithGateSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{gateSignature}, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) require.NotEqual(t, signatureNoSigs, signatureWithGateSig, "including the gate signature must change the encoding") @@ -940,7 +940,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { require.Equal(t, gateSignature.Signature, sig.Signature, "recovered gate signature should match") } - signatureWithSapientSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode, []*core.SignerSignature{signerSignature}) + signatureWithSapientSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{signerSignature}, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) require.NotEqual(t, signatureNoSigs, signatureWithSapientSig, "including the sapient signature must change the encoding") @@ -958,7 +958,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { // Supplying both signatures in one call must embed both: the gate leaf alone meets // the config threshold via the calls gate's payload-independent subdigest leaf, so // an early-cancelling builder could nondeterministically drop the sapient signature. - signatureCombined, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, gateLeafNode, sapientSignerLeafNode, []*core.SignerSignature{gateSignature, signerSignature}) + signatureCombined, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{gateSignature, signerSignature}, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) require.NotEqual(t, signatureWithGateSig, signatureCombined, "combined signature must also embed the sapient signature") require.NotEqual(t, signatureWithSapientSig, signatureCombined, "combined signature must also embed the gate signature") @@ -1010,7 +1010,7 @@ func TestGetIntentConfigurationSignature_MultipleTransactions(t *testing.T) { }, big.NewInt(0), big.NewInt(0)) // Create a signature - sig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload1}, 0, nil, nil, nil) + sig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload1}, 0, nil) require.NoError(t, err) // Convert the full signature into a hex string. @@ -1081,7 +1081,7 @@ func TestIntentTransactionToGuestModuleDeployAndCall(t *testing.T) { require.NotZero(t, mainSigner) // Generate a configuration signature for the batch. - intentConfigSig, err := sequence.GetIntentConfigurationSignature(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil, nil) + intentConfigSig, err := sequence.GetIntentConfigurationSignature(mainSigner, []*v3.CallsPayload{&payload}, 0, nil) require.NoError(t, err) // fmt.Println("==> bundle.Digest", bundle.Digest().Hash) @@ -1239,7 +1239,7 @@ func TestIntentTransactionToGuestModuleDeployAndCallMultiplePayloads(t *testing. require.NotZero(t, mainSigner) // Generate a configuration signature for both batches - intentConfigSig, err := sequence.GetIntentConfigurationSignature(mainSigner, payloads, 0, nil, nil, nil) + intentConfigSig, err := sequence.GetIntentConfigurationSignature(mainSigner, payloads, 0, nil) require.NoError(t, err) fmt.Printf("--- Intent Config Signature (for all payloads) ---\n%s\n", common.Bytes2Hex(intentConfigSig)) @@ -1366,7 +1366,7 @@ func TestIntentConfigurationAddress(t *testing.T) { ) // Create intent configuration - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0) require.NoError(t, err) // Calculate image hash @@ -1416,7 +1416,7 @@ func TestIntentConfigurationAddress(t *testing.T) { ) // Create intent configuration - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload1, &payload2}, 0, nil, nil) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload1, &payload2}, 0) require.NoError(t, err) // Calculate image hash @@ -1458,9 +1458,9 @@ func TestIntentConfigurationAddress_WithCheckpoint(t *testing.T) { checkpoint2 := uint64(2) // Create intent configuration - config1, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, checkpoint1, nil, nil) + config1, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, checkpoint1) require.NoError(t, err) - config2, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, checkpoint2, nil, nil) + config2, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, checkpoint2) require.NoError(t, err) // Checkpoints should be set correctly @@ -1514,7 +1514,7 @@ func TestIntentConfigurationAddress_RealWorldExample(t *testing.T) { }, big.NewInt(0), big.NewInt(0)) // Create intent configuration - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload1, &payload2}, 0, nil, nil) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload1, &payload2}, 0) require.NoError(t, err) // Calculate image hash diff --git a/testutil/testutil.go b/testutil/testutil.go index 058e99b7..88976e07 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -728,7 +728,7 @@ func (c *TestChain) V3DummySequenceWalletWithIntentConfig(seed uint64, calls []* } // Create an intent config - intentConfig, err := sequence.CreateIntentConfiguration(owner.Address(), calls, 0, nil, nil) + intentConfig, err := sequence.CreateIntentConfiguration(owner.Address(), calls, 0) if err != nil { return nil, err } From 9e096336b8f46300991e75ae97a4313c0defda0f Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 11:10:39 +1200 Subject: [PATCH 12/17] fix: ignore nil IntentConfigOption values An untyped nil is assignable to the variadic option type, so legacy calls passing nil for the removed positional leaf params compile and then panicked at invocation. Skip nil options instead; nil meant "no leaf" under the positional API and now means "no option", preserving identical behavior for those callers. Co-Authored-By: Claude Fable 5 --- intent_config.go | 5 ++++- intent_config_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/intent_config.go b/intent_config.go index cdf0da3b..a991a50f 100644 --- a/intent_config.go +++ b/intent_config.go @@ -226,6 +226,7 @@ func leafWeight(tree v3.WalletConfigTree) (uint8, error) { } // IntentConfigOption configures the optional leaves of an intent configuration tree. +// A nil IntentConfigOption is ignored. type IntentConfigOption func(*intentConfigOptions) type intentConfigOptions struct { @@ -249,7 +250,9 @@ func WithSapientSigner(leaf v3.WalletConfigTree) IntentConfigOption { func applyIntentConfigOptions(opts []IntentConfigOption) intentConfigOptions { var options intentConfigOptions for _, opt := range opts { - opt(&options) + if opt != nil { + opt(&options) + } } return options } diff --git a/intent_config_test.go b/intent_config_test.go index 95ce2e70..ead6aee0 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -323,6 +323,31 @@ func TestCreateIntentConfiguration_Valid(t *testing.T) { require.NotNil(t, config) } +func TestCreateIntentConfigurationNilOptionsIgnored(t *testing.T) { + payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ + { + To: common.Address{}, + Value: nil, + Data: nil, + GasLimit: big.NewInt(0), + DelegateCall: false, + OnlyFallback: false, + BehaviorOnError: v3.BehaviorOnErrorRevert, + }, + }, big.NewInt(0), big.NewInt(0)) + + mainSigner := common.HexToAddress("0x1111111111111111111111111111111111111111") + + // Legacy callers passed nil for the removed positional leaf params; those nils now + // arrive as nil options and must mean "no option", not panic. + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, nil, nil) + require.NoError(t, err) + + plainConfig, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0) + require.NoError(t, err) + require.Equal(t, plainConfig.ImageHash().Hash, config.ImageHash().Hash) +} + func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ { From 52d9fc60c2d7e8a89d0bdb0bec8f9ea43e2f3871 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 12:37:30 +1200 Subject: [PATCH 13/17] fix: reject payload gate signer among gated leaves A gated signer leaf sharing the gate's identity satisfies both sides of the outer threshold with one signature, letting the gate authorize alone. Replace leafWeight with signerLeaf, which also returns the leaf's signer identity, and reject configs where the gate signer appears among the gated leaves. Co-Authored-By: Claude Fable 5 --- intent_config.go | 25 ++++++++++++-------- intent_config_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/intent_config.go b/intent_config.go index a991a50f..44eaf63c 100644 --- a/intent_config.go +++ b/intent_config.go @@ -191,13 +191,20 @@ func CreateAnyAddressSubdigestTree(calls []*v3.CallsPayload) ([]v3.WalletConfigT // leaf. The outer threshold is payloadGateLeaf's weight + 1, so any gate weight is safe by // construction (the gate alone cannot meet it). func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, gateableLeaves ...v3.WalletConfigTree) (v3.WalletConfigTree, error) { - gateWeight, err := leafWeight(payloadGateLeaf) + gateSigner, gateWeight, err := signerLeaf(payloadGateLeaf) if err != nil { return nil, fmt.Errorf("invalid payloadGateLeafNode: %w", err) } - if gateWeight == 0 { + if gateWeight.Sign() <= 0 { return nil, fmt.Errorf("invalid payloadGateLeafNode: weight must be > 0") } + // A gated signer leaf sharing the gate's identity satisfies both sides of the outer + // threshold with one signature, letting the gate authorize alone + for _, leaf := range gateableLeaves { + if signer, _, err := signerLeaf(leaf); err == nil && signer == gateSigner { + return nil, fmt.Errorf("invalid payloadGateLeafNode: gate signer must not appear among gated leaves") + } + } gateableTree := &v3.WalletConfigTreeNestedLeaf{ Weight: 1, Threshold: 1, @@ -205,23 +212,23 @@ func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, gateableLeaves ...v3.W } return &v3.WalletConfigTreeNestedLeaf{ Weight: 1, - Threshold: uint16(gateWeight) + 1, + Threshold: uint16(gateWeight.Uint64()) + uint16(gateableTree.Weight), Tree: v3.WalletConfigTreeNodes(payloadGateLeaf, gateableTree), }, nil } -// leafWeight returns the contribution weight of a single terminal leaf. -func leafWeight(tree v3.WalletConfigTree) (uint8, error) { +// signerLeaf returns the signer identity and contribution weight of a single terminal leaf +func signerLeaf(tree v3.WalletConfigTree) (core.Signer, *big.Int, error) { if tree == nil { - return 0, fmt.Errorf("nil leaf") + return core.Signer{}, nil, fmt.Errorf("nil leaf") } switch t := tree.(type) { case *v3.WalletConfigTreeAddressLeaf: - return t.Weight, nil + return core.Signer{Address: t.Address}, big.NewInt(int64(t.Weight)), nil case *v3.WalletConfigTreeSapientSignerLeaf: - return t.Weight, nil + return core.SapientSigner(t.Address, t.ImageHash_.Hash), big.NewInt(int64(t.Weight)), nil default: - return 0, fmt.Errorf("unsupported leaf type %T", tree) + return core.Signer{}, nil, fmt.Errorf("unsupported leaf type %T", tree) } } diff --git a/intent_config_test.go b/intent_config_test.go index ead6aee0..96bbacd4 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -510,6 +510,60 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { require.NotEqual(t, signatureWithoutPeerSig, signatureWithPeerSig) } +// A gated signer leaf sharing the gate's identity would satisfy both sides of the outer +// threshold with one signature, so the config must be rejected at construction. +func TestCreateIntentConfigurationPayloadGateDuplicateSapientRejected(t *testing.T) { + payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ + { + To: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Value: nil, + Data: []byte{0x12, 0x34}, + GasLimit: big.NewInt(0), + DelegateCall: false, + OnlyFallback: false, + BehaviorOnError: v3.BehaviorOnErrorRevert, + }, + }, big.NewInt(0), big.NewInt(0)) + + mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") + gateSigner := common.HexToAddress("0x72030E1dbf0a847196ae62EA3ee84BD7ce99D6c1") + gateImageHash := core.ImageHash{Hash: common.BigToHash(big.NewInt(1))} + gateLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: gateSigner, + ImageHash_: gateImageHash, + } + + t.Run("identical leaf in both roles is rejected", func(t *testing.T) { + _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, + sequence.WithPayloadGate(gateLeaf), sequence.WithSapientSigner(gateLeaf)) + require.ErrorContains(t, err, "gate signer must not appear among gated leaves") + }) + + t.Run("same signer with different weight is rejected", func(t *testing.T) { + heavierLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 2, + Address: gateSigner, + ImageHash_: gateImageHash, + } + _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, + sequence.WithPayloadGate(gateLeaf), sequence.WithSapientSigner(heavierLeaf)) + require.ErrorContains(t, err, "gate signer must not appear among gated leaves") + }) + + t.Run("same address with different image hash is allowed", func(t *testing.T) { + otherImageHashLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: gateSigner, + ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(2))}, + } + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, + sequence.WithPayloadGate(gateLeaf), sequence.WithSapientSigner(otherImageHashLeaf)) + require.NoError(t, err) + require.NotNil(t, config) + }) +} + // A sapient-only config (calls is empty) must not build a broken calls group: with no // subdigest leaves, only the sapient leaf is gated behind payloadGateLeaf. ImageHash must // still succeed (a nil inner Tree would panic on traversal). From 5560a42f25ecc15f173c2e25fdbc2afdafb5a8a7 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 12:38:35 +1200 Subject: [PATCH 14/17] fix: reject payload-matching leaves as payload gate Payload-matching leaves (subdigest and any-address-subdigest) carry no signer and match any weight requirement, so one used as the gate would authorize alone. Report them from signerLeaf with a signerless identity and maxUint256 weight, and cap the gate weight at maxUint64 so they are rejected before the threshold conversion can truncate. Co-Authored-By: Claude Fable 5 --- intent_config.go | 14 ++++++++++++-- intent_config_test.go | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/intent_config.go b/intent_config.go index 44eaf63c..6d47e745 100644 --- a/intent_config.go +++ b/intent_config.go @@ -185,11 +185,14 @@ func CreateAnyAddressSubdigestTree(calls []*v3.CallsPayload) ([]v3.WalletConfigT return leaves, nil } +var maxUint256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) +var maxUint64 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(1)) + // wrapPayloadGate requires payloadGateLeaf's co-signature alongside any one of // gateableLeaves. An inner threshold-1 nest OR's the groups and contributes weight 1 at // most, so satisfying many groups still cannot clear the outer threshold without the gate -// leaf. The outer threshold is payloadGateLeaf's weight + 1, so any gate weight is safe by -// construction (the gate alone cannot meet it). +// leaf. The outer threshold is payloadGateLeaf's weight + 1, so any signer-leaf gate +// weight is safe by construction (the gate alone cannot meet it). func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, gateableLeaves ...v3.WalletConfigTree) (v3.WalletConfigTree, error) { gateSigner, gateWeight, err := signerLeaf(payloadGateLeaf) if err != nil { @@ -198,6 +201,9 @@ func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, gateableLeaves ...v3.W if gateWeight.Sign() <= 0 { return nil, fmt.Errorf("invalid payloadGateLeafNode: weight must be > 0") } + if gateWeight.Cmp(maxUint64) > 0 { + return nil, fmt.Errorf("invalid payloadGateLeafNode: weight is too large") + } // A gated signer leaf sharing the gate's identity satisfies both sides of the outer // threshold with one signature, letting the gate authorize alone for _, leaf := range gateableLeaves { @@ -227,6 +233,10 @@ func signerLeaf(tree v3.WalletConfigTree) (core.Signer, *big.Int, error) { return core.Signer{Address: t.Address}, big.NewInt(int64(t.Weight)), nil case *v3.WalletConfigTreeSapientSignerLeaf: return core.SapientSigner(t.Address, t.ImageHash_.Hash), big.NewInt(int64(t.Weight)), nil + case *v3.WalletConfigTreeSubdigestLeaf, v3.WalletConfigTreeSubdigestLeaf, + *v3.WalletConfigTreeAnyAddressSubdigestLeaf, v3.WalletConfigTreeAnyAddressSubdigestLeaf: + // Payload-matching leaves report a signerless identity and maxUint256 weight. + return core.Signer{}, new(big.Int).Set(maxUint256), nil default: return core.Signer{}, nil, fmt.Errorf("unsupported leaf type %T", tree) } diff --git a/intent_config_test.go b/intent_config_test.go index 96bbacd4..4bf77ccc 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -551,6 +551,27 @@ func TestCreateIntentConfigurationPayloadGateDuplicateSapientRejected(t *testing require.ErrorContains(t, err, "gate signer must not appear among gated leaves") }) + t.Run("payload-matching leaf as gate is rejected", func(t *testing.T) { + subdigestGate := &v3.WalletConfigTreeAnyAddressSubdigestLeaf{ + Digest: common.BigToHash(big.NewInt(3)), + } + _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, + sequence.WithPayloadGate(subdigestGate)) + require.ErrorContains(t, err, "weight is too large") + }) + + t.Run("gate weight above 1 is allowed", func(t *testing.T) { + heavyGate := &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 2, + Address: gateSigner, + ImageHash_: gateImageHash, + } + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, + sequence.WithPayloadGate(heavyGate)) + require.NoError(t, err) + require.NotNil(t, config) + }) + t.Run("same address with different image hash is allowed", func(t *testing.T) { otherImageHashLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ Weight: 1, From 4cde50e6ac431537f134aa4f50254950859b3c3d Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 12:43:15 +1200 Subject: [PATCH 15/17] fix: reject typed-nil payload gate leaves A typed-nil leaf pointer passes the interface nil check, so signerLeaf dereferenced it and CreateIntentConfiguration panicked instead of returning an invalid-gate error. Check the concrete pointer for nil in both signer cases before reading its fields. Co-Authored-By: Claude Fable 5 --- intent_config.go | 6 ++++++ intent_config_test.go | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/intent_config.go b/intent_config.go index 6d47e745..69a002bf 100644 --- a/intent_config.go +++ b/intent_config.go @@ -230,8 +230,14 @@ func signerLeaf(tree v3.WalletConfigTree) (core.Signer, *big.Int, error) { } switch t := tree.(type) { case *v3.WalletConfigTreeAddressLeaf: + if t == nil { + return core.Signer{}, nil, fmt.Errorf("nil leaf") + } return core.Signer{Address: t.Address}, big.NewInt(int64(t.Weight)), nil case *v3.WalletConfigTreeSapientSignerLeaf: + if t == nil { + return core.Signer{}, nil, fmt.Errorf("nil leaf") + } return core.SapientSigner(t.Address, t.ImageHash_.Hash), big.NewInt(int64(t.Weight)), nil case *v3.WalletConfigTreeSubdigestLeaf, v3.WalletConfigTreeSubdigestLeaf, *v3.WalletConfigTreeAnyAddressSubdigestLeaf, v3.WalletConfigTreeAnyAddressSubdigestLeaf: diff --git a/intent_config_test.go b/intent_config_test.go index 4bf77ccc..8a902ac2 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -510,6 +510,28 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { require.NotEqual(t, signatureWithoutPeerSig, signatureWithPeerSig) } +// A typed-nil gate leaf passes the interface nil check, so signerLeaf must reject it +// before dereferencing the concrete pointer. +func TestCreateIntentConfigurationPayloadGateTypedNilRejected(t *testing.T) { + payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ + { + To: common.HexToAddress("0x1111111111111111111111111111111111111111"), + Value: nil, + Data: []byte{0x12, 0x34}, + GasLimit: big.NewInt(0), + DelegateCall: false, + OnlyFallback: false, + BehaviorOnError: v3.BehaviorOnErrorRevert, + }, + }, big.NewInt(0), big.NewInt(0)) + mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") + + var gate *v3.WalletConfigTreeSapientSignerLeaf + _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, + sequence.WithPayloadGate(gate)) + require.ErrorContains(t, err, "nil leaf") +} + // A gated signer leaf sharing the gate's identity would satisfy both sides of the outer // threshold with one signature, so the config must be rejected at construction. func TestCreateIntentConfigurationPayloadGateDuplicateSapientRejected(t *testing.T) { From 18cf2c013d9dcc4e2505490670ea98b44dcba92a Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 13:24:29 +1200 Subject: [PATCH 16/17] refactor: rename payloadGate to gate The gate co-signs more than just the payload (e.g. sapient leaves), so drop the payload- prefix from the leaf, option, and helper names. Co-authored-by: Cursor --- intent_config.go | 52 ++++++++++++++++++++-------------------- intent_config_test.go | 56 +++++++++++++++++++++---------------------- 2 files changed, 54 insertions(+), 54 deletions(-) diff --git a/intent_config.go b/intent_config.go index 69a002bf..19d994b4 100644 --- a/intent_config.go +++ b/intent_config.go @@ -188,27 +188,27 @@ func CreateAnyAddressSubdigestTree(calls []*v3.CallsPayload) ([]v3.WalletConfigT var maxUint256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) var maxUint64 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(1)) -// wrapPayloadGate requires payloadGateLeaf's co-signature alongside any one of +// wrapGate requires gateLeaf's co-signature alongside any one of // gateableLeaves. An inner threshold-1 nest OR's the groups and contributes weight 1 at // most, so satisfying many groups still cannot clear the outer threshold without the gate -// leaf. The outer threshold is payloadGateLeaf's weight + 1, so any signer-leaf gate +// leaf. The outer threshold is gateLeaf's weight + 1, so any signer-leaf gate // weight is safe by construction (the gate alone cannot meet it). -func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, gateableLeaves ...v3.WalletConfigTree) (v3.WalletConfigTree, error) { - gateSigner, gateWeight, err := signerLeaf(payloadGateLeaf) +func wrapGate(gateLeaf v3.WalletConfigTree, gateableLeaves ...v3.WalletConfigTree) (v3.WalletConfigTree, error) { + gateSigner, gateWeight, err := signerLeaf(gateLeaf) if err != nil { - return nil, fmt.Errorf("invalid payloadGateLeafNode: %w", err) + return nil, fmt.Errorf("invalid gateLeafNode: %w", err) } if gateWeight.Sign() <= 0 { - return nil, fmt.Errorf("invalid payloadGateLeafNode: weight must be > 0") + return nil, fmt.Errorf("invalid gateLeafNode: weight must be > 0") } if gateWeight.Cmp(maxUint64) > 0 { - return nil, fmt.Errorf("invalid payloadGateLeafNode: weight is too large") + return nil, fmt.Errorf("invalid gateLeafNode: weight is too large") } // A gated signer leaf sharing the gate's identity satisfies both sides of the outer // threshold with one signature, letting the gate authorize alone for _, leaf := range gateableLeaves { if signer, _, err := signerLeaf(leaf); err == nil && signer == gateSigner { - return nil, fmt.Errorf("invalid payloadGateLeafNode: gate signer must not appear among gated leaves") + return nil, fmt.Errorf("invalid gateLeafNode: gate signer must not appear among gated leaves") } } gateableTree := &v3.WalletConfigTreeNestedLeaf{ @@ -219,7 +219,7 @@ func wrapPayloadGate(payloadGateLeaf v3.WalletConfigTree, gateableLeaves ...v3.W return &v3.WalletConfigTreeNestedLeaf{ Weight: 1, Threshold: uint16(gateWeight.Uint64()) + uint16(gateableTree.Weight), - Tree: v3.WalletConfigTreeNodes(payloadGateLeaf, gateableTree), + Tree: v3.WalletConfigTreeNodes(gateLeaf, gateableTree), }, nil } @@ -253,15 +253,15 @@ func signerLeaf(tree v3.WalletConfigTree) (core.Signer, *big.Int, error) { type IntentConfigOption func(*intentConfigOptions) type intentConfigOptions struct { - payloadGateLeafNode v3.WalletConfigTree + gateLeafNode v3.WalletConfigTree sapientSignerLeafNode v3.WalletConfigTree } -// WithPayloadGate gates the calls and the sapient signer leaf behind leaf's co-signature: -// either group, plus leaf's signature, authorizes the wallet (see wrapPayloadGate). The +// WithGate gates the calls and the sapient signer leaf behind leaf's co-signature: +// either group, plus leaf's signature, authorizes the wallet (see wrapGate). The // main signer is never gated. -func WithPayloadGate(leaf v3.WalletConfigTree) IntentConfigOption { - return func(o *intentConfigOptions) { o.payloadGateLeafNode = leaf } +func WithGate(leaf v3.WalletConfigTree) IntentConfigOption { + return func(o *intentConfigOptions) { o.gateLeafNode = leaf } } // WithSapientSigner adds leaf (e.g. a timed-refund or gasless-deposit signer) as an @@ -283,7 +283,7 @@ func applyIntentConfigOptions(opts []IntentConfigOption) intentConfigOptions { func createIntentTree( mainSigner common.Address, calls []*v3.CallsPayload, - payloadGateLeafNode v3.WalletConfigTree, + gateLeafNode v3.WalletConfigTree, sapientSignerLeafNode v3.WalletConfigTree, ) (*v3.WalletConfigTree, error) { var leaves []v3.WalletConfigTree @@ -299,14 +299,14 @@ func createIntentTree( gateableLeaves = append(gateableLeaves, sapientSignerLeafNode) } - // If there are any gateable leaves, wrap them in a gate if a payload gate leaf is provided. + // If there are any gateable leaves, wrap them in a gate if a gate leaf is provided. if len(gateableLeaves) > 0 { - if payloadGateLeafNode == nil { + if gateLeafNode == nil { // No gate: preserve flat structure so counterfactual addresses stay stable. leaves = append(leaves, gateableLeaves...) } else { - // Calls and sapient share one gate; either needs payloadGateLeaf's co-signature. - gate, err := wrapPayloadGate(payloadGateLeafNode, gateableLeaves...) + // Calls and sapient share one gate; either needs gateLeaf's co-signature. + gate, err := wrapGate(gateLeafNode, gateableLeaves...) if err != nil { return nil, err } @@ -336,7 +336,7 @@ func createIntentTree( } // `CreateIntentTree` creates a tree from a list of intent operations and a main signer -// address. See WithPayloadGate and WithSapientSigner for the optional leaves; with no +// address. See WithGate and WithSapientSigner for the optional leaves; with no // options the legacy tree shape is preserved. func CreateIntentTree( mainSigner common.Address, @@ -344,17 +344,17 @@ func CreateIntentTree( opts ...IntentConfigOption, ) (*v3.WalletConfigTree, error) { options := applyIntentConfigOptions(opts) - return createIntentTree(mainSigner, calls, options.payloadGateLeafNode, options.sapientSignerLeafNode) + return createIntentTree(mainSigner, calls, options.gateLeafNode, options.sapientSignerLeafNode) } func createIntentConfiguration( mainSigner common.Address, calls []*v3.CallsPayload, checkpoint uint64, - payloadGateLeafNode v3.WalletConfigTree, + gateLeafNode v3.WalletConfigTree, sapientSignerLeafNode v3.WalletConfigTree, ) (*v3.WalletConfig, error) { - tree, err := createIntentTree(mainSigner, calls, payloadGateLeafNode, sapientSignerLeafNode) + tree, err := createIntentTree(mainSigner, calls, gateLeafNode, sapientSignerLeafNode) if err != nil { return nil, err } @@ -367,7 +367,7 @@ func createIntentConfiguration( } // `CreateIntentConfiguration` creates a wallet configuration where the intent's transaction -// batches are grouped into the initial subdigest. See WithPayloadGate and WithSapientSigner +// batches are grouped into the initial subdigest. See WithGate and WithSapientSigner // for the optional leaves. func CreateIntentConfiguration( mainSigner common.Address, @@ -376,7 +376,7 @@ func CreateIntentConfiguration( opts ...IntentConfigOption, ) (*v3.WalletConfig, error) { options := applyIntentConfigOptions(opts) - return createIntentConfiguration(mainSigner, calls, checkpoint, options.payloadGateLeafNode, options.sapientSignerLeafNode) + return createIntentConfiguration(mainSigner, calls, checkpoint, options.gateLeafNode, options.sapientSignerLeafNode) } // `BuildIntentConfigurationSignature` creates a signature for an already-built intent configuration @@ -418,7 +418,7 @@ func GetIntentConfigurationSignature( opts ...IntentConfigOption, ) ([]byte, error) { options := applyIntentConfigOptions(opts) - config, err := createIntentConfiguration(mainSigner, calls, checkpoint, options.payloadGateLeafNode, options.sapientSignerLeafNode) + config, err := createIntentConfiguration(mainSigner, calls, checkpoint, options.gateLeafNode, options.sapientSignerLeafNode) if err != nil { return nil, err } diff --git a/intent_config_test.go b/intent_config_test.go index 8a902ac2..6c785f48 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -409,11 +409,11 @@ func TestCreateIntentConfigurationWithTimedRefundSapient(t *testing.T) { require.NotEqual(t, plainSignature, signature) } -// With payloadGateLeaf nil (the default/legacy case), the tree must keep the exact flat +// With gateLeaf nil (the default/legacy case), the tree must keep the exact flat // shape it had before this parameter existed: Node(mainSignerLeaf, Node(subdigestLeaf, // additionalLeaf)) — no extra nesting — so already-derived counterfactual addresses do // not change. -func TestCreateIntentConfigurationPayloadGateLeafNilUnchanged(t *testing.T) { +func TestCreateIntentConfigurationGateLeafNilUnchanged(t *testing.T) { payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ { To: common.HexToAddress("0x1111111111111111111111111111111111111111"), @@ -449,7 +449,7 @@ func TestCreateIntentConfigurationPayloadGateLeafNilUnchanged(t *testing.T) { require.Same(t, sapientSignerLeafNode, rest.Right) } -func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { +func TestCreateIntentConfigurationWithGateLeaf(t *testing.T) { payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ { To: common.HexToAddress("0x1111111111111111111111111111111111111111"), @@ -470,7 +470,7 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(1))}, } - config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, sequence.WithPayloadGate(peerSignerLeaf)) + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, sequence.WithGate(peerSignerLeaf)) require.NoError(t, err) require.NotNil(t, config) @@ -512,7 +512,7 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf(t *testing.T) { // A typed-nil gate leaf passes the interface nil check, so signerLeaf must reject it // before dereferencing the concrete pointer. -func TestCreateIntentConfigurationPayloadGateTypedNilRejected(t *testing.T) { +func TestCreateIntentConfigurationGateTypedNilRejected(t *testing.T) { payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ { To: common.HexToAddress("0x1111111111111111111111111111111111111111"), @@ -528,13 +528,13 @@ func TestCreateIntentConfigurationPayloadGateTypedNilRejected(t *testing.T) { var gate *v3.WalletConfigTreeSapientSignerLeaf _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, - sequence.WithPayloadGate(gate)) + sequence.WithGate(gate)) require.ErrorContains(t, err, "nil leaf") } // A gated signer leaf sharing the gate's identity would satisfy both sides of the outer // threshold with one signature, so the config must be rejected at construction. -func TestCreateIntentConfigurationPayloadGateDuplicateSapientRejected(t *testing.T) { +func TestCreateIntentConfigurationGateDuplicateSapientRejected(t *testing.T) { payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ { To: common.HexToAddress("0x1111111111111111111111111111111111111111"), @@ -558,7 +558,7 @@ func TestCreateIntentConfigurationPayloadGateDuplicateSapientRejected(t *testing t.Run("identical leaf in both roles is rejected", func(t *testing.T) { _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, - sequence.WithPayloadGate(gateLeaf), sequence.WithSapientSigner(gateLeaf)) + sequence.WithGate(gateLeaf), sequence.WithSapientSigner(gateLeaf)) require.ErrorContains(t, err, "gate signer must not appear among gated leaves") }) @@ -569,7 +569,7 @@ func TestCreateIntentConfigurationPayloadGateDuplicateSapientRejected(t *testing ImageHash_: gateImageHash, } _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, - sequence.WithPayloadGate(gateLeaf), sequence.WithSapientSigner(heavierLeaf)) + sequence.WithGate(gateLeaf), sequence.WithSapientSigner(heavierLeaf)) require.ErrorContains(t, err, "gate signer must not appear among gated leaves") }) @@ -578,7 +578,7 @@ func TestCreateIntentConfigurationPayloadGateDuplicateSapientRejected(t *testing Digest: common.BigToHash(big.NewInt(3)), } _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, - sequence.WithPayloadGate(subdigestGate)) + sequence.WithGate(subdigestGate)) require.ErrorContains(t, err, "weight is too large") }) @@ -589,7 +589,7 @@ func TestCreateIntentConfigurationPayloadGateDuplicateSapientRejected(t *testing ImageHash_: gateImageHash, } config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, - sequence.WithPayloadGate(heavyGate)) + sequence.WithGate(heavyGate)) require.NoError(t, err) require.NotNil(t, config) }) @@ -601,16 +601,16 @@ func TestCreateIntentConfigurationPayloadGateDuplicateSapientRejected(t *testing ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(2))}, } config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, - sequence.WithPayloadGate(gateLeaf), sequence.WithSapientSigner(otherImageHashLeaf)) + sequence.WithGate(gateLeaf), sequence.WithSapientSigner(otherImageHashLeaf)) require.NoError(t, err) require.NotNil(t, config) }) } // A sapient-only config (calls is empty) must not build a broken calls group: with no -// subdigest leaves, only the sapient leaf is gated behind payloadGateLeaf. ImageHash must +// subdigest leaves, only the sapient leaf is gated behind gateLeaf. ImageHash must // still succeed (a nil inner Tree would panic on traversal). -func TestCreateIntentConfigurationWithPayloadGateLeaf_EmptyCallsOmitsCallsGate(t *testing.T) { +func TestCreateIntentConfigurationWithGateLeaf_EmptyCallsOmitsCallsGate(t *testing.T) { mainSigner := common.HexToAddress("0x2222222222222222222222222222222222222222") peerSigner := common.HexToAddress("0x72030E1dbf0a847196ae62EA3ee84BD7ce99D6c1") peerSignerLeaf := &v3.WalletConfigTreeSapientSignerLeaf{ @@ -628,7 +628,7 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_EmptyCallsOmitsCallsGate(t ImageHash_: timedRefundImageHash, } - config, err := sequence.CreateIntentConfiguration(mainSigner, nil, 0, sequence.WithPayloadGate(peerSignerLeaf), sequence.WithSapientSigner(timedRefundLeaf)) + config, err := sequence.CreateIntentConfiguration(mainSigner, nil, 0, sequence.WithGate(peerSignerLeaf), sequence.WithSapientSigner(timedRefundLeaf)) require.NoError(t, err) require.NotNil(t, config) @@ -640,11 +640,11 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_EmptyCallsOmitsCallsGate(t } // A sapient signer leaf (e.g. a timed-refund or gasless-deposit leaf) passed as -// sapientSignerLeafNode shares payloadGateLeaf's gate with the calls leaves: either group -// alone, plus payloadGateLeaf's co-signature, is sufficient. mainSignerLeaf is the only leaf +// sapientSignerLeafNode shares gateLeaf's gate with the calls leaves: either group +// alone, plus gateLeaf's co-signature, is sufficient. mainSignerLeaf is the only leaf // never gated, so the owner can always act (e.g. recover funds) regardless of the gate's // paused state. -func TestCreateIntentConfigurationWithPayloadGateLeaf_SapientLeafGated(t *testing.T) { +func TestCreateIntentConfigurationWithGateLeaf_SapientLeafGated(t *testing.T) { payload := v3.NewCallsPayload(common.Address{}, testChain.ChainID(), []v3.Call{ { To: common.HexToAddress("0x1111111111111111111111111111111111111111"), @@ -679,7 +679,7 @@ func TestCreateIntentConfigurationWithPayloadGateLeaf_SapientLeafGated(t *testin mainSigner, []*v3.CallsPayload{&payload}, 0, - sequence.WithPayloadGate(peerSignerLeaf), + sequence.WithGate(peerSignerLeaf), sequence.WithSapientSigner(timedRefundLeaf), ) require.NoError(t, err) @@ -952,7 +952,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { require.Contains(t, common.Bytes2Hex(sigDataStr), sapientSignerSignature[2:], "signature should contain the sapient signer signature") }) - t.Run("payload gate signature included in the signature tree", func(t *testing.T) { + t.Run("gate signature included in the signature tree", func(t *testing.T) { gateContract := testChain.UniDeploy(t, "MOCK_SAPIENT", 1) gateSignerAddress := gateContract.Address gateImageHash := common.HexToHash("0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF123456789A") @@ -972,11 +972,11 @@ func TestGetIntentConfigurationSignature(t *testing.T) { } // Create the intent configuration - config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, sequence.WithPayloadGate(gateLeafNode)) + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, sequence.WithGate(gateLeafNode)) require.NoError(t, err) // Create the signature - signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{gateSignature}, sequence.WithPayloadGate(gateLeafNode)) + signature, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{gateSignature}, sequence.WithGate(gateLeafNode)) require.NoError(t, err) gateLeaf := findSapientSignerLeaf(config.Tree, gateSignerAddress) @@ -1011,7 +1011,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { require.Contains(t, common.Bytes2Hex(sigDataStr), gateImageHash.Hex()[2:], "signature should contain the gate signer signature") }) - t.Run("payload gate and sapient signer signatures included in the signature tree", func(t *testing.T) { + t.Run("gate and sapient signer signatures included in the signature tree", func(t *testing.T) { gateContract := testChain.UniDeploy(t, "MOCK_SAPIENT", 2) gateSignerAddress := gateContract.Address gateImageHash := common.HexToHash("0xFEDCBA0987654321FEDCBA0987654321FEDCBA0987654321FEDCBA098765432") @@ -1031,7 +1031,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { } // Create the intent configuration - config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) + config, err := sequence.CreateIntentConfiguration(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, sequence.WithGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) sapientLeaf := findSapientSignerLeaf(config.Tree, sapientSignerAddress) @@ -1044,10 +1044,10 @@ func TestGetIntentConfigurationSignature(t *testing.T) { // Each signature is checked independently first to prove its leaf's wiring, then // combined to prove a single build embeds both. - signatureNoSigs, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) + signatureNoSigs, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, nil, sequence.WithGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) - signatureWithGateSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{gateSignature}, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) + signatureWithGateSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{gateSignature}, sequence.WithGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) require.NotEqual(t, signatureNoSigs, signatureWithGateSig, "including the gate signature must change the encoding") @@ -1062,7 +1062,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { require.Equal(t, gateSignature.Signature, sig.Signature, "recovered gate signature should match") } - signatureWithSapientSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{signerSignature}, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) + signatureWithSapientSig, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{signerSignature}, sequence.WithGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) require.NotEqual(t, signatureNoSigs, signatureWithSapientSig, "including the sapient signature must change the encoding") @@ -1080,7 +1080,7 @@ func TestGetIntentConfigurationSignature(t *testing.T) { // Supplying both signatures in one call must embed both: the gate leaf alone meets // the config threshold via the calls gate's payload-independent subdigest leaf, so // an early-cancelling builder could nondeterministically drop the sapient signature. - signatureCombined, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{gateSignature, signerSignature}, sequence.WithPayloadGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) + signatureCombined, err := sequence.GetIntentConfigurationSignature(eoa1.Address(), []*v3.CallsPayload{&payload}, 0, []*core.SignerSignature{gateSignature, signerSignature}, sequence.WithGate(gateLeafNode), sequence.WithSapientSigner(sapientSignerLeafNode)) require.NoError(t, err) require.NotEqual(t, signatureWithGateSig, signatureCombined, "combined signature must also embed the sapient signature") require.NotEqual(t, signatureWithSapientSig, signatureCombined, "combined signature must also embed the gate signature") From a512cd160fa3c2dc0d9723bb623110111d45812d Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 31 Jul 2026 13:26:37 +1200 Subject: [PATCH 17/17] fix: reject gate signer at any depth in gated subtree Gated leaves may be nested trees, so check the gate identity against the full recursive signer set (WalletConfig.Signers) instead of only top-level terminal leaves. Co-Authored-By: Claude Fable 5 --- intent_config.go | 9 ++++----- intent_config_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/intent_config.go b/intent_config.go index 19d994b4..f7687a8f 100644 --- a/intent_config.go +++ b/intent_config.go @@ -204,12 +204,11 @@ func wrapGate(gateLeaf v3.WalletConfigTree, gateableLeaves ...v3.WalletConfigTre if gateWeight.Cmp(maxUint64) > 0 { return nil, fmt.Errorf("invalid gateLeafNode: weight is too large") } - // A gated signer leaf sharing the gate's identity satisfies both sides of the outer + // The gate's identity anywhere in the gated subtree satisfies both sides of the outer // threshold with one signature, letting the gate authorize alone - for _, leaf := range gateableLeaves { - if signer, _, err := signerLeaf(leaf); err == nil && signer == gateSigner { - return nil, fmt.Errorf("invalid gateLeafNode: gate signer must not appear among gated leaves") - } + gatedSigners := (&v3.WalletConfig{Tree: v3.WalletConfigTreeNodes(gateableLeaves...)}).Signers() + if _, ok := gatedSigners[gateSigner]; ok { + return nil, fmt.Errorf("invalid gateLeafNode: gate signer must not appear among gated leaves") } gateableTree := &v3.WalletConfigTreeNestedLeaf{ Weight: 1, diff --git a/intent_config_test.go b/intent_config_test.go index 6c785f48..b1be9485 100644 --- a/intent_config_test.go +++ b/intent_config_test.go @@ -573,6 +573,44 @@ func TestCreateIntentConfigurationGateDuplicateSapientRejected(t *testing.T) { require.ErrorContains(t, err, "gate signer must not appear among gated leaves") }) + t.Run("gate signer inside a nested leaf is rejected", func(t *testing.T) { + nested := &v3.WalletConfigTreeNestedLeaf{ + Weight: 1, + Threshold: 1, + Tree: gateLeaf, + } + _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, + sequence.WithGate(gateLeaf), sequence.WithSapientSigner(nested)) + require.ErrorContains(t, err, "gate signer must not appear among gated leaves") + }) + + t.Run("gate signer inside a node branch is rejected", func(t *testing.T) { + otherLeaf := &v3.WalletConfigTreeAddressLeaf{ + Weight: 1, + Address: common.HexToAddress("0x3333333333333333333333333333333333333333"), + } + branch := v3.WalletConfigTreeNodes(otherLeaf, gateLeaf) + _, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, + sequence.WithGate(gateLeaf), sequence.WithSapientSigner(branch)) + require.ErrorContains(t, err, "gate signer must not appear among gated leaves") + }) + + t.Run("nested tree without the gate signer is allowed", func(t *testing.T) { + nested := &v3.WalletConfigTreeNestedLeaf{ + Weight: 1, + Threshold: 1, + Tree: &v3.WalletConfigTreeSapientSignerLeaf{ + Weight: 1, + Address: common.HexToAddress("0x4444444444444444444444444444444444444444"), + ImageHash_: core.ImageHash{Hash: common.BigToHash(big.NewInt(4))}, + }, + } + config, err := sequence.CreateIntentConfiguration(mainSigner, []*v3.CallsPayload{&payload}, 0, + sequence.WithGate(gateLeaf), sequence.WithSapientSigner(nested)) + require.NoError(t, err) + require.NotNil(t, config) + }) + t.Run("payload-matching leaf as gate is rejected", func(t *testing.T) { subdigestGate := &v3.WalletConfigTreeAnyAddressSubdigestLeaf{ Digest: common.BigToHash(big.NewInt(3)),