Skip to content

Commit bd1bc68

Browse files
ScreamingHawkclaudecursoragent
authored
Add PausableSapient support to Trails intents (#367)
* refactor: remove unused CreateIntentConfigurationWithTimedRefundSapient 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. * 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. * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix: cap payloadGateLeaf to weight 1 in wrapPayloadGate 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 <noreply@anthropic.com> * 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. * 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. * 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 <noreply@anthropic.com> * 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. * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <cursoragent@cursor.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1396641 commit bd1bc68

5 files changed

Lines changed: 758 additions & 128 deletions

File tree

‎core/v3/v3.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2096,6 +2096,30 @@ func (c *WalletConfig) BuildNoChainIDSignature(ctx context.Context, sign core.Si
20962096
}}, nil
20972097
}
20982098

2099+
// BuildRegularSignatureFromSignatures builds a regular signature directly from
2100+
// pre-collected signer signatures, with no signing orchestration. Use this instead of
2101+
// BuildRegularSignature when all signatures are already in hand: BuildRegularSignature
2102+
// cancels outstanding signers once the config threshold looks met, and payload-independent
2103+
// leaves (e.g. WalletConfigTreeAnyAddressSubdigestLeaf) can satisfy the threshold early,
2104+
// nondeterministically dropping supplied signatures that recovery still needs. Signers
2105+
// without a matching entry are encoded as their image hash; no signing power validation
2106+
// is performed.
2107+
func (c *WalletConfig) BuildRegularSignatureFromSignatures(signerSignatures map[core.Signer]core.SignerSignature, checkpointerData ...[]byte) core.Signature[*WalletConfig] {
2108+
var cpData []byte
2109+
if len(checkpointerData) > 0 {
2110+
cpData = checkpointerData[0]
2111+
}
2112+
2113+
return &RegularSignature{&Signature{
2114+
NoChainId: false,
2115+
Threshold: c.Threshold_,
2116+
Checkpoint: c.Checkpoint_,
2117+
Tree: c.Tree.buildSignatureTree(signerSignatures),
2118+
Checkpointer: c.Checkpointer,
2119+
CheckpointerData: cpData,
2120+
}}
2121+
}
2122+
20992123
type WalletConfigTree interface {
21002124
core.ImageHashable
21012125

‎intent_config.go‎

Lines changed: 164 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package sequence
22

33
import (
4-
"context"
54
"fmt"
65
"math/big"
76

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

189-
func createIntentTree(mainSigner common.Address, calls []*v3.CallsPayload, additionalLeaves ...v3.WalletConfigTree) (*v3.WalletConfigTree, error) {
188+
var maxUint256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
189+
var maxUint64 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 64), big.NewInt(1))
190+
191+
// wrapGate requires gateLeaf's co-signature alongside any one of
192+
// gateableLeaves. An inner threshold-1 nest OR's the groups and contributes weight 1 at
193+
// most, so satisfying many groups still cannot clear the outer threshold without the gate
194+
// leaf. The outer threshold is gateLeaf's weight + 1, so any signer-leaf gate
195+
// weight is safe by construction (the gate alone cannot meet it).
196+
func wrapGate(gateLeaf v3.WalletConfigTree, gateableLeaves ...v3.WalletConfigTree) (v3.WalletConfigTree, error) {
197+
gateSigner, gateWeight, err := signerLeaf(gateLeaf)
198+
if err != nil {
199+
return nil, fmt.Errorf("invalid gateLeafNode: %w", err)
200+
}
201+
if gateWeight.Sign() <= 0 {
202+
return nil, fmt.Errorf("invalid gateLeafNode: weight must be > 0")
203+
}
204+
if gateWeight.Cmp(maxUint64) > 0 {
205+
return nil, fmt.Errorf("invalid gateLeafNode: weight is too large")
206+
}
207+
// The gate's identity anywhere in the gated subtree satisfies both sides of the outer
208+
// threshold with one signature, letting the gate authorize alone
209+
gatedSigners := (&v3.WalletConfig{Tree: v3.WalletConfigTreeNodes(gateableLeaves...)}).Signers()
210+
if _, ok := gatedSigners[gateSigner]; ok {
211+
return nil, fmt.Errorf("invalid gateLeafNode: gate signer must not appear among gated leaves")
212+
}
213+
gateableTree := &v3.WalletConfigTreeNestedLeaf{
214+
Weight: 1,
215+
Threshold: 1,
216+
Tree: v3.WalletConfigTreeNodes(gateableLeaves...),
217+
}
218+
return &v3.WalletConfigTreeNestedLeaf{
219+
Weight: 1,
220+
Threshold: uint16(gateWeight.Uint64()) + uint16(gateableTree.Weight),
221+
Tree: v3.WalletConfigTreeNodes(gateLeaf, gateableTree),
222+
}, nil
223+
}
224+
225+
// signerLeaf returns the signer identity and contribution weight of a single terminal leaf
226+
func signerLeaf(tree v3.WalletConfigTree) (core.Signer, *big.Int, error) {
227+
if tree == nil {
228+
return core.Signer{}, nil, fmt.Errorf("nil leaf")
229+
}
230+
switch t := tree.(type) {
231+
case *v3.WalletConfigTreeAddressLeaf:
232+
if t == nil {
233+
return core.Signer{}, nil, fmt.Errorf("nil leaf")
234+
}
235+
return core.Signer{Address: t.Address}, big.NewInt(int64(t.Weight)), nil
236+
case *v3.WalletConfigTreeSapientSignerLeaf:
237+
if t == nil {
238+
return core.Signer{}, nil, fmt.Errorf("nil leaf")
239+
}
240+
return core.SapientSigner(t.Address, t.ImageHash_.Hash), big.NewInt(int64(t.Weight)), nil
241+
case *v3.WalletConfigTreeSubdigestLeaf, v3.WalletConfigTreeSubdigestLeaf,
242+
*v3.WalletConfigTreeAnyAddressSubdigestLeaf, v3.WalletConfigTreeAnyAddressSubdigestLeaf:
243+
// Payload-matching leaves report a signerless identity and maxUint256 weight.
244+
return core.Signer{}, new(big.Int).Set(maxUint256), nil
245+
default:
246+
return core.Signer{}, nil, fmt.Errorf("unsupported leaf type %T", tree)
247+
}
248+
}
249+
250+
// IntentConfigOption configures the optional leaves of an intent configuration tree.
251+
// A nil IntentConfigOption is ignored.
252+
type IntentConfigOption func(*intentConfigOptions)
253+
254+
type intentConfigOptions struct {
255+
gateLeafNode v3.WalletConfigTree
256+
sapientSignerLeafNode v3.WalletConfigTree
257+
}
258+
259+
// WithGate gates the calls and the sapient signer leaf behind leaf's co-signature:
260+
// either group, plus leaf's signature, authorizes the wallet (see wrapGate). The
261+
// main signer is never gated.
262+
func WithGate(leaf v3.WalletConfigTree) IntentConfigOption {
263+
return func(o *intentConfigOptions) { o.gateLeafNode = leaf }
264+
}
265+
266+
// WithSapientSigner adds leaf (e.g. a timed-refund or gasless-deposit signer) as an
267+
// authorizer alongside the calls' subdigest leaves.
268+
func WithSapientSigner(leaf v3.WalletConfigTree) IntentConfigOption {
269+
return func(o *intentConfigOptions) { o.sapientSignerLeafNode = leaf }
270+
}
271+
272+
func applyIntentConfigOptions(opts []IntentConfigOption) intentConfigOptions {
273+
var options intentConfigOptions
274+
for _, opt := range opts {
275+
if opt != nil {
276+
opt(&options)
277+
}
278+
}
279+
return options
280+
}
281+
282+
func createIntentTree(
283+
mainSigner common.Address,
284+
calls []*v3.CallsPayload,
285+
gateLeafNode v3.WalletConfigTree,
286+
sapientSignerLeafNode v3.WalletConfigTree,
287+
) (*v3.WalletConfigTree, error) {
288+
var leaves []v3.WalletConfigTree
289+
190290
// Create the subdigest leaves from the batched transactions.
191-
leaves, err := CreateAnyAddressSubdigestTree(calls)
291+
gateableLeaves, err := CreateAnyAddressSubdigestTree(calls)
192292
if err != nil {
193293
return nil, err
194294
}
195295

196-
for _, leaf := range additionalLeaves {
197-
if leaf != nil {
198-
leaves = append(leaves, leaf)
296+
// Add the sapient signer leaf to the gateable leaves.
297+
if sapientSignerLeafNode != nil {
298+
gateableLeaves = append(gateableLeaves, sapientSignerLeafNode)
299+
}
300+
301+
// If there are any gateable leaves, wrap them in a gate if a gate leaf is provided.
302+
if len(gateableLeaves) > 0 {
303+
if gateLeafNode == nil {
304+
// No gate: preserve flat structure so counterfactual addresses stay stable.
305+
leaves = append(leaves, gateableLeaves...)
306+
} else {
307+
// Calls and sapient share one gate; either needs gateLeaf's co-signature.
308+
gate, err := wrapGate(gateLeafNode, gateableLeaves...)
309+
if err != nil {
310+
return nil, err
311+
}
312+
leaves = append(leaves, gate)
199313
}
200314
}
201315

202-
// Create the main signer leaf (with weight 1).
316+
// Add the main signer leaf to the leaves (ungated).
203317
mainSignerLeaf := &v3.WalletConfigTreeAddressLeaf{
204318
Weight: 1,
205319
Address: mainSigner,
206320
}
207321

208-
// If the length of the leaves is 1
322+
// If the length of the leaves is 1.
209323
if len(leaves) == 1 {
210324
tree := v3.WalletConfigTreeNodes(mainSignerLeaf, leaves[0])
211325
return &tree, nil
212326
}
213327

214-
// Create a tree from the subdigest leaves.
328+
// Create a tree from the (gated) leaves.
215329
tree := v3.WalletConfigTreeNodes(leaves...)
216330

217-
// Construct the new wallet config using:
331+
// Construct the new wallet config.
218332
fullTree := v3.WalletConfigTreeNodes(mainSignerLeaf, tree)
219333

220334
return &fullTree, nil
221335
}
222336

223-
// `CreateIntentTree` creates a tree from a list of intent operations and a main signer address.
224-
func CreateIntentTree(mainSigner common.Address, calls []*v3.CallsPayload, sapientSignerLeafNode v3.WalletConfigTree) (*v3.WalletConfigTree, error) {
225-
return createIntentTree(mainSigner, calls, sapientSignerLeafNode)
337+
// `CreateIntentTree` creates a tree from a list of intent operations and a main signer
338+
// address. See WithGate and WithSapientSigner for the optional leaves; with no
339+
// options the legacy tree shape is preserved.
340+
func CreateIntentTree(
341+
mainSigner common.Address,
342+
calls []*v3.CallsPayload,
343+
opts ...IntentConfigOption,
344+
) (*v3.WalletConfigTree, error) {
345+
options := applyIntentConfigOptions(opts)
346+
return createIntentTree(mainSigner, calls, options.gateLeafNode, options.sapientSignerLeafNode)
226347
}
227348

228-
func createIntentConfiguration(mainSigner common.Address, calls []*v3.CallsPayload, checkpoint uint64, additionalLeaves ...v3.WalletConfigTree) (*v3.WalletConfig, error) {
229-
tree, err := createIntentTree(mainSigner, calls, additionalLeaves...)
349+
func createIntentConfiguration(
350+
mainSigner common.Address,
351+
calls []*v3.CallsPayload,
352+
checkpoint uint64,
353+
gateLeafNode v3.WalletConfigTree,
354+
sapientSignerLeafNode v3.WalletConfigTree,
355+
) (*v3.WalletConfig, error) {
356+
tree, err := createIntentTree(mainSigner, calls, gateLeafNode, sapientSignerLeafNode)
230357
if err != nil {
231358
return nil, err
232359
}
@@ -238,35 +365,37 @@ func createIntentConfiguration(mainSigner common.Address, calls []*v3.CallsPaylo
238365
}, nil
239366
}
240367

241-
// `CreateIntentConfiguration` creates a wallet configuration where the intent's transaction batches are grouped into the initial subdigest.
242-
func CreateIntentConfiguration(mainSigner common.Address, calls []*v3.CallsPayload, checkpoint uint64, sapientSignerLeafNode v3.WalletConfigTree) (*v3.WalletConfig, error) {
243-
return createIntentConfiguration(mainSigner, calls, checkpoint, sapientSignerLeafNode)
368+
// `CreateIntentConfiguration` creates a wallet configuration where the intent's transaction
369+
// batches are grouped into the initial subdigest. See WithGate and WithSapientSigner
370+
// for the optional leaves.
371+
func CreateIntentConfiguration(
372+
mainSigner common.Address,
373+
calls []*v3.CallsPayload,
374+
checkpoint uint64,
375+
opts ...IntentConfigOption,
376+
) (*v3.WalletConfig, error) {
377+
options := applyIntentConfigOptions(opts)
378+
return createIntentConfiguration(mainSigner, calls, checkpoint, options.gateLeafNode, options.sapientSignerLeafNode)
244379
}
245380

246381
// `BuildIntentConfigurationSignature` creates a signature for an already-built intent configuration
247-
// that can be used to bypass chain ID validation.
382+
// that can be used to bypass chain ID validation. All supplied signer signatures are
383+
// embedded deterministically; signers without a supplied signature are encoded as their
384+
// image hash.
248385
func BuildIntentConfigurationSignature(config *v3.WalletConfig, signerSignatures []*core.SignerSignature) ([]byte, error) {
249386
if config == nil {
250387
return nil, fmt.Errorf("intent configuration is nil")
251388
}
252389

253-
signingFunc := func(ctx context.Context, signer core.Signer, _ []core.SignerSignature) (core.SignerSignatureType, []byte, error) {
254-
for _, signerSignature := range signerSignatures {
255-
if signer.Address == signerSignature.Signer.Address {
256-
return signerSignature.Type, signerSignature.Signature, nil
257-
}
390+
signatures := make(map[core.Signer]core.SignerSignature, len(signerSignatures))
391+
for _, signerSignature := range signerSignatures {
392+
if signerSignature != nil {
393+
signatures[signerSignature.Signer] = *signerSignature
258394
}
259-
return 0, nil, nil
260395
}
261396

262-
// Build the signature using BuildNoChainIDSignature, which allows us to inject custom signatures via SigningFunction.
263-
// Set validateSigningPower to false, as we are not necessarily providing signatures for all parts of the config.
264-
sig, err := config.BuildRegularSignature(context.Background(), signingFunc, false)
265-
if err != nil {
266-
return nil, fmt.Errorf("failed to build regular signature: %w", err)
267-
}
397+
sig := config.BuildRegularSignatureFromSignatures(signatures)
268398

269-
// Get the signature data
270399
data, err := sig.Data()
271400
if err != nil {
272401
return nil, fmt.Errorf("failed to get signature data: %w", err)
@@ -284,10 +413,11 @@ func GetIntentConfigurationSignature(
284413
mainSigner common.Address,
285414
calls []*v3.CallsPayload,
286415
checkpoint uint64,
287-
sapientSignerLeafNode v3.WalletConfigTree,
288416
signerSignatures []*core.SignerSignature,
417+
opts ...IntentConfigOption,
289418
) ([]byte, error) {
290-
config, err := createIntentConfiguration(mainSigner, calls, checkpoint, sapientSignerLeafNode)
419+
options := applyIntentConfigOptions(opts)
420+
config, err := createIntentConfiguration(mainSigner, calls, checkpoint, options.gateLeafNode, options.sapientSignerLeafNode)
291421
if err != nil {
292422
return nil, err
293423
}

0 commit comments

Comments
 (0)