Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8b553da
refactor: remove unused CreateIntentConfigurationWithTimedRefundSapient
ScreamingHawk Jul 27, 2026
195627c
feat: add optional payload gate leaf to CreateIntentTree/CreateIntent…
ScreamingHawk Jul 27, 2026
4b4377d
feat: gate sapient signer leaves behind the payload gate too
ScreamingHawk Jul 29, 2026
100e352
fix: skip the calls gate when there are no call batches
ScreamingHawk Jul 29, 2026
26a73e4
fix: cap payloadGateLeaf to weight 1 in wrapPayloadGate
ScreamingHawk Jul 29, 2026
1f1286b
refactor: merge calls and sapient behind a single payload gate
ScreamingHawk Jul 29, 2026
80db92b
fix: reject WalletConfigTreeNestedLeaf in leafWeight
ScreamingHawk Jul 30, 2026
680c9dd
fix: match full signer identity in BuildIntentConfigurationSignature
ScreamingHawk Jul 30, 2026
ed9047d
feat: thread payloadGateLeafNode through GetIntentConfigurationSignature
ScreamingHawk Jul 30, 2026
df27a83
fix: embed supplied signatures deterministically in intent config sig…
ScreamingHawk Jul 30, 2026
6237ebe
refactor: replace optional leaf params with functional options
ScreamingHawk Jul 30, 2026
9e09633
fix: ignore nil IntentConfigOption values
ScreamingHawk Jul 30, 2026
52d9fc6
fix: reject payload gate signer among gated leaves
ScreamingHawk Jul 31, 2026
5560a42
fix: reject payload-matching leaves as payload gate
ScreamingHawk Jul 31, 2026
4cde50e
fix: reject typed-nil payload gate leaves
ScreamingHawk Jul 31, 2026
18cf2c0
refactor: rename payloadGate to gate
ScreamingHawk Jul 31, 2026
a512cd1
fix: reject gate signer at any depth in gated subtree
ScreamingHawk Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions core/v3/v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
198 changes: 164 additions & 34 deletions intent_config.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package sequence

import (
"context"
"fmt"
"math/big"

Expand Down Expand Up @@ -186,47 +185,175 @@ func CreateAnyAddressSubdigestTree(calls []*v3.CallsPayload) ([]v3.WalletConfigT
return leaves, nil
}

func createIntentTree(mainSigner common.Address, calls []*v3.CallsPayload, additionalLeaves ...v3.WalletConfigTree) (*v3.WalletConfigTree, error) {
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))

// 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 gateLeaf's weight + 1, so any signer-leaf gate
// weight is safe by construction (the gate alone cannot meet it).
func wrapGate(gateLeaf v3.WalletConfigTree, gateableLeaves ...v3.WalletConfigTree) (v3.WalletConfigTree, error) {
gateSigner, gateWeight, err := signerLeaf(gateLeaf)
if err != nil {
return nil, fmt.Errorf("invalid gateLeafNode: %w", err)
}
if gateWeight.Sign() <= 0 {
return nil, fmt.Errorf("invalid gateLeafNode: weight must be > 0")
}
if gateWeight.Cmp(maxUint64) > 0 {
return nil, fmt.Errorf("invalid gateLeafNode: weight is too large")
}
// The gate's identity anywhere in the gated subtree satisfies both sides of the outer
// threshold with one signature, letting the gate authorize alone
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,
Threshold: 1,
Tree: v3.WalletConfigTreeNodes(gateableLeaves...),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the sapient signature before call-gate cancellation

When building a signature for the sapient alternative against a payload that does not match any configured call digest, this shared inner tree causes WalletConfigTreeAnyAddressSubdigestLeaf.signersWeight to report maximum weight without checking the payload. If the payload-gate signer responds first, BuildRegularSignature therefore considers the outer gate satisfied and cancels the other signing goroutines, so the supplied sapient signature can be omitted nondeterministically; recovery then gives the call leaf zero weight and rejects the otherwise valid gate-plus-sapient authorization. Ensure signature construction embeds the required supplied sapient signature rather than letting the payload-independent call leaf trigger early cancellation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is incorrect. Any gated leaf is able to satisfy the nested tree leaf. When building the signature, unused nodes are ignored from validation so the zero weight case is impossible.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reproduced this. With both signatures supplied, 221/1000 builds were byte-for-byte gate-only. We should not cancel explicitly provided signatures here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for verifying! Fixing. This gap is upstream in existing code. I'm putting a fix here for Trails only and making a follow up PR to address upstream.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
return &v3.WalletConfigTreeNestedLeaf{
Weight: 1,
Threshold: uint16(gateWeight.Uint64()) + uint16(gateableTree.Weight),
Tree: v3.WalletConfigTreeNodes(gateLeaf, gateableTree),
}, nil
}

// 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 core.Signer{}, nil, fmt.Errorf("nil leaf")
}
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:
// 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)
}
}

// IntentConfigOption configures the optional leaves of an intent configuration tree.
// A nil IntentConfigOption is ignored.
type IntentConfigOption func(*intentConfigOptions)

type intentConfigOptions struct {
gateLeafNode v3.WalletConfigTree
sapientSignerLeafNode v3.WalletConfigTree
}

// 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 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
// 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 {
if opt != nil {
opt(&options)
}
}
return options
}

func createIntentTree(
mainSigner common.Address,
calls []*v3.CallsPayload,
gateLeafNode v3.WalletConfigTree,
sapientSignerLeafNode v3.WalletConfigTree,
) (*v3.WalletConfigTree, error) {
var leaves []v3.WalletConfigTree

// Create the subdigest leaves from the batched transactions.
leaves, err := CreateAnyAddressSubdigestTree(calls)
gateableLeaves, err := CreateAnyAddressSubdigestTree(calls)
if err != nil {
return nil, err
}

for _, leaf := range additionalLeaves {
if leaf != nil {
leaves = append(leaves, leaf)
// Add the sapient signer leaf to the gateable leaves.
if sapientSignerLeafNode != nil {
gateableLeaves = append(gateableLeaves, sapientSignerLeafNode)
Comment on lines +296 to +298

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish same-address sapient signers by image hash

When payloadGateLeafNode and sapientSignerLeafNode use the same verifying contract address with different image hashes, the new combined configuration contains two distinct core.Signer values, but BuildIntentConfigurationSignature selects each supplied signature using only signer.Address. Both signing requests can consequently receive the first same-address signature, leaving one leaf encoded with a signature for the wrong image hash and making valid gate-plus-sapient authorization fail. Match the full signer identity, including sapient status and image hash, or reject this otherwise valid configuration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No this is a valid (though unlikely) case. A sapient may recover two different and valid image hashes when provided different signatures

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and that is why matching only Address is wrong here. We need to match the full core.Signer, including ImageHash.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for verifying! Fixing. For additional context, this was a pre-existing bug only surfaced by this implementation. In practice, it would not be hit as the gate and sapient leaves will not use the same address (configured downstream in Trails).

}

// If there are any gateable leaves, wrap them in a gate if a gate leaf is provided.
if len(gateableLeaves) > 0 {
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 gateLeaf's co-signature.
gate, err := wrapGate(gateLeafNode, gateableLeaves...)
if err != nil {
return nil, err
}
leaves = append(leaves, gate)
}
}

// Create the main signer leaf (with weight 1).
// 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.
func CreateIntentTree(mainSigner common.Address, calls []*v3.CallsPayload, sapientSignerLeafNode v3.WalletConfigTree) (*v3.WalletConfigTree, error) {
return createIntentTree(mainSigner, calls, sapientSignerLeafNode)
// `CreateIntentTree` creates a tree from a list of intent operations and a main signer
// address. See WithGate and WithSapientSigner for the optional leaves; with no
// options the legacy tree shape is preserved.
func CreateIntentTree(
mainSigner common.Address,
calls []*v3.CallsPayload,
opts ...IntentConfigOption,
) (*v3.WalletConfigTree, error) {
options := applyIntentConfigOptions(opts)
return createIntentTree(mainSigner, calls, options.gateLeafNode, options.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,
gateLeafNode v3.WalletConfigTree,
sapientSignerLeafNode v3.WalletConfigTree,
) (*v3.WalletConfig, error) {
tree, err := createIntentTree(mainSigner, calls, gateLeafNode, sapientSignerLeafNode)
if err != nil {
return nil, err
}
Expand All @@ -238,35 +365,37 @@ 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 WithGate and WithSapientSigner
// for the optional leaves.
func CreateIntentConfiguration(
mainSigner common.Address,
calls []*v3.CallsPayload,
checkpoint uint64,
opts ...IntentConfigOption,
) (*v3.WalletConfig, error) {
options := applyIntentConfigOptions(opts)
return createIntentConfiguration(mainSigner, calls, checkpoint, options.gateLeafNode, options.sapientSignerLeafNode)
}

// `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.Address == signerSignature.Signer.Address {
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)
Expand All @@ -284,10 +413,11 @@ func GetIntentConfigurationSignature(
mainSigner common.Address,
calls []*v3.CallsPayload,
checkpoint uint64,
sapientSignerLeafNode v3.WalletConfigTree,
signerSignatures []*core.SignerSignature,
opts ...IntentConfigOption,
) ([]byte, error) {
config, err := createIntentConfiguration(mainSigner, calls, checkpoint, sapientSignerLeafNode)
options := applyIntentConfigOptions(opts)
config, err := createIntentConfiguration(mainSigner, calls, checkpoint, options.gateLeafNode, options.sapientSignerLeafNode)
if err != nil {
return nil, err
}
Expand Down
Loading
Loading