From 44583db67099afc6a95776378f7ef7a322cb2b1a Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Thu, 18 Jun 2026 17:56:35 +0300 Subject: [PATCH 1/5] feat(blockchain): carry an ADR-015 deposit reference cross-chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SubmitDeposit now takes a DepositDestination{Account, Ref}: the opaque ADR-015 sub-account reference travels alongside the account as side-data, never interpreted on-chain, so deposits are filterable per (account, ref). - EVM / SOL emit it in the deposit call / instruction; the Solana program artifact + binding are refreshed to the reference-carrying deposit_sol/deposit_spl. - XRPL moves from a DestinationTag to a ynet-account memo (20-byte account followed by the 32-byte reference), matching the observe-side decoder. - BTC has no side-data channel on a plain send — the account is encoded in the per-account deposit address — so a non-zero reference is rejected. Co-Authored-By: Claude Fable 5 --- pkg/blockchain/btc/depositor.go | 16 ++++-- pkg/blockchain/btc/vault_integration_test.go | 2 +- pkg/blockchain/evm/depositor.go | 10 ++-- pkg/blockchain/evm/vault_integration_test.go | 2 +- pkg/blockchain/sol/artifacts/custody.json | 37 ++++++++++++- pkg/blockchain/sol/artifacts/custody.so | Bin 367320 -> 368016 bytes pkg/blockchain/sol/custody/instructions.go | 16 +++++- pkg/blockchain/sol/custody/types.go | 15 ++++- pkg/blockchain/sol/depositor.go | 11 ++-- pkg/blockchain/sol/vault_integration_test.go | 2 +- pkg/blockchain/xrpl/depositor.go | 48 ++++++++++++---- pkg/blockchain/xrpl/depositor_test.go | 52 ++++++++++++++++++ pkg/blockchain/xrpl/vault_integration_test.go | 4 +- pkg/blockchain/xrpl/wire.go | 23 -------- pkg/core/blockchain.go | 17 +++++- 15 files changed, 191 insertions(+), 64 deletions(-) create mode 100644 pkg/blockchain/xrpl/depositor_test.go diff --git a/pkg/blockchain/btc/depositor.go b/pkg/blockchain/btc/depositor.go index c3cecea..23aa881 100644 --- a/pkg/blockchain/btc/depositor.go +++ b/pkg/blockchain/btc/depositor.go @@ -63,20 +63,26 @@ func NewDepositor(net *chaincfg.Params, rpc RPC, signer sign.Signer, vaultPubkey // DepositorAddress returns the depositor's own P2WPKH funding address. func (d *Depositor) DepositorAddress() string { return d.depositAddr.EncodeAddress() } -// SubmitDeposit sends `amount` satoshis from the depositor's wallet to the per-account -// deposit address for `account`. asset must be native BTC ("" or "BTC"). Builds, -// signs (P2WPKH), and broadcasts the funding tx. -func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, account string) (core.TxRef, error) { +// SubmitDeposit sends `amount` satoshis from the depositor's wallet to the +// per-account deposit address for dest.Account. asset must be native BTC ("" or +// "BTC"). Builds, signs (P2WPKH), and broadcasts the funding tx. A non-zero +// dest.Ref is rejected: the account is encoded in the deposit address and a +// plain BTC send has no side-data channel for a sub-account (ADR-015 has no BTC +// reference). +func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, dest core.DepositDestination) (core.TxRef, error) { if a := strings.ToUpper(strings.TrimSpace(asset)); a != "" && a != "BTC" { return core.TxRef{}, fmt.Errorf("btc: only native BTC deposits supported, got asset %q", asset) } + if dest.Ref != ([32]byte{}) { + return core.TxRef{}, fmt.Errorf("btc: deposit reference not supported") + } amt := amount.BigInt() if !amt.IsInt64() || amt.Int64() <= 0 { return core.TxRef{}, fmt.Errorf("btc: amount %s not a positive int64 satoshi value", amount.String()) } sats := amt.Int64() - depositAddr, _, err := DepositAddress(account, d.threshold, d.vaultPubkeys, d.net) + depositAddr, _, err := DepositAddress(dest.Account, d.threshold, d.vaultPubkeys, d.net) if err != nil { return core.TxRef{}, fmt.Errorf("btc: derive deposit address: %w", err) } diff --git a/pkg/blockchain/btc/vault_integration_test.go b/pkg/blockchain/btc/vault_integration_test.go index 856d171..3ac4afc 100644 --- a/pkg/blockchain/btc/vault_integration_test.go +++ b/pkg/blockchain/btc/vault_integration_test.go @@ -93,7 +93,7 @@ func TestIntegrationBTC_DepositAndWithdraw(t *testing.T) { node.generateToAddress(ctx, t, 1, miner) // ── Deposit flow ────────────────────────────────────────────────────────── - depRef, err := depositor.SubmitDeposit(ctx, "BTC", decimal.NewFromInt(20_000_000), account) // 0.2 BTC + depRef, err := depositor.SubmitDeposit(ctx, "BTC", decimal.NewFromInt(20_000_000), core.DepositDestination{Account: account}) // 0.2 BTC if err != nil { t.Fatalf("Deposit: %v", err) } diff --git a/pkg/blockchain/evm/depositor.go b/pkg/blockchain/evm/depositor.go index 3b4bb75..9a03748 100644 --- a/pkg/blockchain/evm/depositor.go +++ b/pkg/blockchain/evm/depositor.go @@ -40,13 +40,13 @@ func NewDepositor(client *ethclient.Client, custodyAddr common.Address, signer s // non-zero hex address) it approves the vault then calls // Custody.deposit(account, asset, amount); for the zero address it sends native // ETH with msg.value == amount. Blocks until the deposit tx mines. -func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, account string) (core.TxRef, error) { +func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, dest core.DepositDestination) (core.TxRef, error) { assetAddr, err := depositAssetAddress(asset) if err != nil { return core.TxRef{}, err } amt := amount.BigInt() - accountAddr := common.HexToAddress(account) + accountAddr := common.HexToAddress(dest.Account) if assetAddr == (common.Address{}) { opts, _, err := signerTransactOpts(ctx, d.client, d.signer) @@ -54,9 +54,7 @@ func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount deci return core.TxRef{}, err } opts.Value = amt - // Zero depositReference: this depositor models no sub-account; the - // reference is opaque, log-only, and bytes32(0) means "none" (ADR-015). - tx, err := d.custody.Deposit(opts, accountAddr, common.Address{}, amt, [32]byte{}) + tx, err := d.custody.Deposit(opts, accountAddr, common.Address{}, amt, dest.Ref) if err != nil { return core.TxRef{}, fmt.Errorf("ETH deposit: %w", err) } @@ -87,7 +85,7 @@ func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount deci if err != nil { return core.TxRef{}, err } - tx, err := d.custody.Deposit(depositOpts, accountAddr, assetAddr, amt, [32]byte{}) + tx, err := d.custody.Deposit(depositOpts, accountAddr, assetAddr, amt, dest.Ref) if err != nil { return core.TxRef{}, fmt.Errorf("ERC20 deposit: %w", err) } diff --git a/pkg/blockchain/evm/vault_integration_test.go b/pkg/blockchain/evm/vault_integration_test.go index 992d32f..af01072 100644 --- a/pkg/blockchain/evm/vault_integration_test.go +++ b/pkg/blockchain/evm/vault_integration_test.go @@ -102,7 +102,7 @@ func TestIntegrationEVM_DepositAndWithdraw(t *testing.T) { account := crypto.PubkeyToAddress(deployerKey.PublicKey) const zeroAsset = "0x0000000000000000000000000000000000000000" // native ETH depositAmt := decimal.NewFromInt(1_000_000_000_000) // 1e12 wei - depRef, err := depositor.SubmitDeposit(ctx, zeroAsset, depositAmt, account.Hex()) + depRef, err := depositor.SubmitDeposit(ctx, zeroAsset, depositAmt, core.DepositDestination{Account: account.Hex()}) if err != nil { t.Fatalf("Deposit: %v", err) } diff --git a/pkg/blockchain/sol/artifacts/custody.json b/pkg/blockchain/sol/artifacts/custody.json index 3e9f1c3..21e3d34 100644 --- a/pkg/blockchain/sol/artifacts/custody.json +++ b/pkg/blockchain/sol/artifacts/custody.json @@ -10,7 +10,8 @@ { "name": "deposit_sol", "docs": [ - "Native SOL deposit crediting the 20-byte clearnet `account`." + "Native SOL deposit crediting the 20-byte clearnet `account`, with an", + "optional ADR-015 sub-account `reference` ([0u8; 32] for none)." ], "discriminator": [ 108, @@ -93,6 +94,15 @@ ] } }, + { + "name": "reference", + "type": { + "array": [ + "u8", + 32 + ] + } + }, { "name": "amount", "type": "u64" @@ -102,7 +112,8 @@ { "name": "deposit_spl", "docs": [ - "SPL token deposit crediting the 20-byte clearnet `account`." + "SPL token deposit crediting the 20-byte clearnet `account`, with an", + "optional ADR-015 sub-account `reference` ([0u8; 32] for none)." ], "discriminator": [ 224, @@ -371,6 +382,15 @@ ] } }, + { + "name": "reference", + "type": { + "array": [ + "u8", + 32 + ] + } + }, { "name": "amount", "type": "u64" @@ -922,7 +942,9 @@ "watcher decodes these from the self-CPI inner instructions and turns each", "into a `chains.DepositEvent`. `mint == Pubkey::default()` denotes native", "SOL (the analogue of EVM's `asset == address(0)`). `account` is the 20-byte", - "clearnet account the deposit credits." + "clearnet account the deposit credits. `reference` is the ADR-015 opaque", + "sub-account selector ([0u8; 32] when absent); never interpreted on-chain,", + "the watcher folds it into the account URI as a /tag/ suffix." ], "type": { "kind": "struct", @@ -940,6 +962,15 @@ ] } }, + { + "name": "reference", + "type": { + "array": [ + "u8", + 32 + ] + } + }, { "name": "mint", "type": "pubkey" diff --git a/pkg/blockchain/sol/artifacts/custody.so b/pkg/blockchain/sol/artifacts/custody.so index b5668340aed1352a09d761b5b196452073d62903..e36b12d0b991f7fe4bee568b62f476842a5a96f7 100755 GIT binary patch delta 51131 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 TckK)J-Q{XG suffix. type Deposited struct { Depositor solanago.PublicKey `json:"depositor"` Account [20]uint8 `json:"account"` + Reference [32]uint8 `json:"reference"` Mint solanago.PublicKey `json:"mint"` Amount uint64 `json:"amount"` } @@ -130,6 +133,11 @@ func (obj Deposited) MarshalWithEncoder(encoder *binary.Encoder) (err error) { if err != nil { return errors.NewField("Account", err) } + // Serialize `Reference`: + err = encoder.Encode(obj.Reference) + if err != nil { + return errors.NewField("Reference", err) + } // Serialize `Mint`: err = encoder.Encode(obj.Mint) if err != nil { @@ -164,6 +172,11 @@ func (obj *Deposited) UnmarshalWithDecoder(decoder *binary.Decoder) (err error) if err != nil { return errors.NewField("Account", err) } + // Deserialize `Reference`: + err = decoder.Decode(&obj.Reference) + if err != nil { + return errors.NewField("Reference", err) + } // Deserialize `Mint`: err = decoder.Decode(&obj.Mint) if err != nil { diff --git a/pkg/blockchain/sol/depositor.go b/pkg/blockchain/sol/depositor.go index dae9794..e2bbbf8 100644 --- a/pkg/blockchain/sol/depositor.go +++ b/pkg/blockchain/sol/depositor.go @@ -60,9 +60,10 @@ func NewDepositor(rpcURL string, programID solana.PublicKey, signer sign.Signer, func (d *Depositor) DepositorAddress() string { return d.depositorPub.String() } // SubmitDeposit transfers `amount` of `asset` into the vault, crediting clearnet -// `account` (20-byte hex). asset is "" / "SOL" for native or a base58 mint. -func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, account string) (core.TxRef, error) { - acct, err := parseClearnetAccount(account) +// dest.Account (20-byte hex) with the optional ADR-015 dest.Ref sub-account +// reference. asset is "" / "SOL" for native or a base58 mint. +func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, dest core.DepositDestination) (core.TxRef, error) { + acct, err := parseClearnetAccount(dest.Account) if err != nil { return core.TxRef{}, err } @@ -79,7 +80,7 @@ func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount deci var ix solana.Instruction if mint.IsZero() { ix, err = custody.NewDepositSolInstruction( - acct, lamports, + acct, dest.Ref, lamports, d.depositorPub, d.vaultPDA, solana.SystemProgramID, d.eventAuth, d.programID, ) } else { @@ -92,7 +93,7 @@ func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount deci return core.TxRef{}, fmt.Errorf("sol: vault ATA: %w", e) } ix, err = custody.NewDepositSplInstruction( - acct, lamports, + acct, dest.Ref, lamports, d.depositorPub, mint, depositorATA, d.vaultPDA, vaultATA, solana.TokenProgramID, solana.SPLAssociatedTokenAccountProgramID, d.eventAuth, d.programID, ) diff --git a/pkg/blockchain/sol/vault_integration_test.go b/pkg/blockchain/sol/vault_integration_test.go index a80da52..fe8281b 100644 --- a/pkg/blockchain/sol/vault_integration_test.go +++ b/pkg/blockchain/sol/vault_integration_test.go @@ -119,7 +119,7 @@ func TestIntegrationSOL_DepositAndWithdraw(t *testing.T) { t.Fatalf("NewDepositor: %v", err) } const account = "00000000000000000000000000000000000000a1" // 20-byte clearnet addr - depRef, err := dep.SubmitDeposit(ctx, "SOL", decimal.NewFromInt(100_000_000), account) + depRef, err := dep.SubmitDeposit(ctx, "SOL", decimal.NewFromInt(100_000_000), core.DepositDestination{Account: account}) if err != nil { t.Fatalf("Deposit: %v", err) } diff --git a/pkg/blockchain/xrpl/depositor.go b/pkg/blockchain/xrpl/depositor.go index 2ffbb2c..10cf454 100644 --- a/pkg/blockchain/xrpl/depositor.go +++ b/pkg/blockchain/xrpl/depositor.go @@ -2,6 +2,7 @@ package xrpl import ( "context" + "encoding/hex" "fmt" "strings" @@ -15,9 +16,10 @@ import ( "github.com/layer-3/clearnet-sdk/pkg/sign" ) -// Depositor sends a tagged Payment from the depositor's account (the key the -// sign.Signer holds) to the vault, crediting a clearnet account via the -// DestinationTag. It implements core.VaultDepositor. Native XRP and issued +// Depositor sends a Payment from the depositor's account (the key the +// sign.Signer holds) to the vault, crediting a clearnet account via a +// `ynet-account` memo (a 20-byte account followed by a 32-byte ADR-015 +// reference). It implements core.VaultDepositor. Native XRP and issued // currencies ("CUR.rIssuer") are both supported. type Depositor struct { client *rpc.Client @@ -44,11 +46,12 @@ func NewDepositor(rpcURL, vaultAddress string, signer sign.Signer) (*Depositor, // DepositorAddress returns the depositor's classic r-address. func (d *Depositor) DepositorAddress() string { return d.id.ClassicAddress } -// SubmitDeposit sends `amount` of `asset` to the vault, crediting `account` via its -// DestinationTag. asset is "" / "XRP" for native or "CUR.rIssuer" for an issued -// currency; account must be of the form xrpl- (the tag the watcher credits). -func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, account string) (core.TxRef, error) { - tag, err := parseDepositTag(account) +// SubmitDeposit sends `amount` of `asset` to the vault, crediting dest.Account +// via a `ynet-account` memo carrying the 20-byte account and the 32-byte +// ADR-015 dest.Ref. asset is "" / "XRP" for native or "CUR.rIssuer" for an +// issued currency; dest.Account is the 20-byte clearnet account (hex). +func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, dest core.DepositDestination) (core.TxRef, error) { + memo, err := accountMemo(dest) if err != nil { return core.TxRef{}, err } @@ -58,12 +61,14 @@ func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount deci } payment := transaction.Payment{ - BaseTx: transaction.BaseTx{Account: types.Address(d.id.ClassicAddress)}, + BaseTx: transaction.BaseTx{ + Account: types.Address(d.id.ClassicAddress), + Memos: []types.MemoWrapper{memo}, + }, Destination: types.Address(d.vaultAddress), Amount: xrplAmount, } flatTx := payment.Flatten() - flatTx["DestinationTag"] = tag if err := d.client.Autofill(&flatTx); err != nil { return core.TxRef{}, fmt.Errorf("xrpl: autofill: %w", err) } @@ -88,6 +93,29 @@ func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount deci } } +// accountMemoType is the MemoType (as plain text, hex-encoded on the wire) +// that marks the ynet-account memo carrying the deposit destination. +const accountMemoType = "ynet-account" + +// accountMemo builds the ynet-account memo: MemoData is the 20-byte clearnet +// account followed by the 32-byte ADR-015 reference (zero for no sub-account), +// hex-encoded; MemoType is "ynet-account", hex-encoded. +func accountMemo(dest core.DepositDestination) (types.MemoWrapper, error) { + raw := strings.TrimPrefix(strings.TrimSpace(dest.Account), "0x") + account, err := hex.DecodeString(raw) + if err != nil { + return types.MemoWrapper{}, fmt.Errorf("xrpl: account not hex: %w", err) + } + if len(account) != 20 { + return types.MemoWrapper{}, fmt.Errorf("xrpl: account must be 20 bytes, got %d", len(account)) + } + data := append(account, dest.Ref[:]...) + return types.MemoWrapper{Memo: types.Memo{ + MemoType: hex.EncodeToString([]byte(accountMemoType)), + MemoData: hex.EncodeToString(data), + }}, nil +} + // VerifyDeposit reports the on-chain status of the deposit tx in ref (matched by // hash, ref.Raw). XRPL finality is binary — a validated transaction cannot be // reorged — so minConf is not a depth here: a validated tx is DepositConfirmed, diff --git a/pkg/blockchain/xrpl/depositor_test.go b/pkg/blockchain/xrpl/depositor_test.go new file mode 100644 index 0000000..b8f5717 --- /dev/null +++ b/pkg/blockchain/xrpl/depositor_test.go @@ -0,0 +1,52 @@ +package xrpl + +import ( + "bytes" + "encoding/hex" + "testing" + + "github.com/layer-3/clearnet-sdk/pkg/core" +) + +// TestAccountMemo verifies the ynet-account memo encodes the 20-byte account +// followed by the 32-byte ADR-015 reference, matching what a deposit watcher +// decodes (MemoType "ynet-account", MemoData = account || reference, both hex). +func TestAccountMemo(t *testing.T) { + var ref [32]byte + ref[0], ref[31] = 0xAB, 0xCD + dest := core.DepositDestination{Account: "0x00000000000000000000000000000000000000a2", Ref: ref} + + mw, err := accountMemo(dest) + if err != nil { + t.Fatalf("accountMemo: %v", err) + } + + if got, want := mw.Memo.MemoType, hex.EncodeToString([]byte("ynet-account")); got != want { + t.Errorf("MemoType: got %s, want %s", got, want) + } + + data, err := hex.DecodeString(mw.Memo.MemoData) + if err != nil { + t.Fatalf("MemoData not hex: %v", err) + } + if len(data) != 52 { + t.Fatalf("MemoData length: got %d, want 52 (20 account + 32 reference)", len(data)) + } + wantAccount := [20]byte{18: 0x00, 19: 0xa2} + if !bytes.Equal(data[:20], wantAccount[:]) { + t.Errorf("account bytes: got %x", data[:20]) + } + if !bytes.Equal(data[20:], ref[:]) { + t.Errorf("reference bytes: got %x, want %x", data[20:], ref[:]) + } +} + +// TestAccountMemo_RejectsBadAccount rejects an account that is not 20 bytes. +func TestAccountMemo_RejectsBadAccount(t *testing.T) { + if _, err := accountMemo(core.DepositDestination{Account: "0xdead"}); err == nil { + t.Error("short account accepted") + } + if _, err := accountMemo(core.DepositDestination{Account: "not-hex"}); err == nil { + t.Error("non-hex account accepted") + } +} diff --git a/pkg/blockchain/xrpl/vault_integration_test.go b/pkg/blockchain/xrpl/vault_integration_test.go index 5765bce..9b20d29 100644 --- a/pkg/blockchain/xrpl/vault_integration_test.go +++ b/pkg/blockchain/xrpl/vault_integration_test.go @@ -9,7 +9,6 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" - "fmt" "net/http" "os" "strings" @@ -46,7 +45,6 @@ const ( genesisSeed = "snoPBrXtMeMyMHUVTgbuqAfg1SUTb" // rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh, ~100B XRP xrplSignerCount = 3 xrplQuorum = 2 - depositTag = 42 ) func TestIntegrationXRPL_DepositAndWithdraw(t *testing.T) { @@ -89,7 +87,7 @@ func TestIntegrationXRPL_DepositAndWithdraw(t *testing.T) { if err != nil { t.Fatalf("NewDepositor: %v", err) } - depRef, err := dep.SubmitDeposit(ctx, "XRP", decimal.NewFromInt(100_000_000), fmt.Sprintf("xrpl-%d", depositTag)) // 100 XRP + depRef, err := dep.SubmitDeposit(ctx, "XRP", decimal.NewFromInt(100_000_000), core.DepositDestination{Account: "00000000000000000000000000000000000000a2"}) // 100 XRP if err != nil { t.Fatalf("Deposit: %v", err) } diff --git a/pkg/blockchain/xrpl/wire.go b/pkg/blockchain/xrpl/wire.go index f3cf52d..585bd91 100644 --- a/pkg/blockchain/xrpl/wire.go +++ b/pkg/blockchain/xrpl/wire.go @@ -39,29 +39,6 @@ var canonicalAllowedFields = map[string]struct{}{ "SigningPubKey": {}, "Flags": {}, } -// parseDepositTag extracts the XRPL DestinationTag from a crediting account. -// -// The custody deposit watcher derives the credited account FROM the tag — -// `core.UserURI("xrpl-" + tag)` — so the tag is the primary identifier, not a -// hash of anything. The account therefore must be of the form `xrpl-` -// (optionally as the last segment of a yellow:// URI); this reverses that -// mapping to recover the uint32 tag the depositor must set. -func parseDepositTag(account string) (uint32, error) { - seg := account - if i := strings.LastIndex(seg, "/"); i >= 0 { - seg = seg[i+1:] - } - rest, ok := strings.CutPrefix(strings.ToLower(seg), "xrpl-") - if !ok { - return 0, fmt.Errorf("xrpl: account %q must be of the form xrpl- (or yellow://.../user/xrpl-)", account) - } - n, err := strconv.ParseUint(rest, 10, 32) - if err != nil { - return 0, fmt.Errorf("xrpl: bad deposit tag in account %q: %w", account, err) - } - return uint32(n), nil -} - // Identity is a signer's XRPL classic address + signing pubkey hex. type Identity struct { ClassicAddress string diff --git a/pkg/core/blockchain.go b/pkg/core/blockchain.go index 2dd4847..927eb6a 100644 --- a/pkg/core/blockchain.go +++ b/pkg/core/blockchain.go @@ -72,13 +72,24 @@ func (s DepositStatus) String() string { } } +// DepositDestination identifies who a deposit credits. Account is the L1 / +// clearnet account; Ref is the opaque ADR-015 sub-account reference, carried +// alongside the account as side-data (a zero Ref means no sub-account). The +// reference is never interpreted on-chain — it is emitted so deposits are +// filterable per (Account, Ref); an observer folds it into the account URI. +// Chains without a reference channel (BTC) reject a non-zero Ref. +type DepositDestination struct { + Account string + Ref [32]byte +} + // VaultDepositor moves funds into the L1 vault. The implementation owns the // depositor's signing identity (a sign.Signer supplied at construction) and // executes the deposit on its chain: a contract call (EVM), a funding tx to a -// derived address (BTC), or a tagged Payment (XRPL). It expects only the asset, -// amount, and crediting clearnet account. +// derived address (BTC), or a memo-tagged Payment (XRPL). It expects only the +// asset, amount, and crediting destination. type VaultDepositor interface { - SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, account string) (TxRef, error) + SubmitDeposit(ctx context.Context, asset string, amount decimal.Decimal, dest DepositDestination) (TxRef, error) // VerifyDeposit reports whether the deposit identified by ref (a TxRef // returned by SubmitDeposit) is present and final on chain — a pure read for // replay/audit. minConf is the confirmation depth required for From 45d37ddf6380f1a45fad03b868e1fdd492e1dd56 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Fri, 19 Jun 2026 11:51:16 +0300 Subject: [PATCH 2/5] fix(btc): reject incomplete sweep in rotation validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RotationFinalizer.Validate checked that every input present in the packed sweep was a valid, confirmed vault UTXO, but never checked that the sweep spent ALL currently-owned UTXOs. A sweep that silently omitted some inputs would still pass, and once the rotation pivot lands the old vault is abandoned — any unswept UTXO is stranded there with no path to move it under the new signer set. Add a completeness check: re-list the owned UTXO set at the configured confirmation depth and require exact set-equality with the tx inputs, erroring on a count mismatch or any omitted owned UTXO. Reuses the current-vault finalizer already built for input summation. Adds TestRotationValidateRejectsPartialSweep: a full sweep validates, a sweep with one input dropped is rejected naming the omitted UTXO. Co-Authored-By: Claude Fable 5 --- pkg/blockchain/btc/rotation_finalizer.go | 31 ++++ pkg/blockchain/btc/rotation_finalizer_test.go | 166 ++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/pkg/blockchain/btc/rotation_finalizer.go b/pkg/blockchain/btc/rotation_finalizer.go index 005f6a0..f65f649 100644 --- a/pkg/blockchain/btc/rotation_finalizer.go +++ b/pkg/blockchain/btc/rotation_finalizer.go @@ -186,6 +186,37 @@ func (f *RotationFinalizer) Validate(ctx context.Context, opID [32]byte, packed if cap := EstimateFeeSats(len(tx.TxIn), 2, f.cfg.FeeCapSatPerVByte); f.cfg.FeeCapSatPerVByte > 0 && fee > cap { return fmt.Errorf("btc rotation validate: fee %d exceeds ceiling %d", fee, cap) } + + // Completeness: a rotation sweep must spend every currently-owned UTXO. The + // checks above only prove each PRESENT input is a valid vault UTXO — they do + // not catch a sweep that silently omits some. An incomplete sweep would + // strand those funds at the old vault, which is abandoned the moment the + // pivot lands. Re-list the owned set and require exact set-equality with the + // tx's inputs. + unspent, err := cur.rpc.ListUnspent(ctx, int(f.cfg.ConfirmationDepth), cur.watchAddresses()) + if err != nil { + return fmt.Errorf("btc rotation validate: list vault utxos: %w", err) + } + owned, err := cur.toUTXOs(unspent) + if err != nil { + return err + } + want := make(map[string]struct{}, len(owned)) + for _, u := range owned { + want[fmt.Sprintf("%s:%d", u.TxID.String(), u.Vout)] = struct{}{} + } + got := make(map[string]struct{}, len(tx.TxIn)) + for _, in := range tx.TxIn { + got[fmt.Sprintf("%s:%d", in.PreviousOutPoint.Hash.String(), in.PreviousOutPoint.Index)] = struct{}{} + } + if len(want) != len(got) { + return fmt.Errorf("btc rotation validate: spends %d inputs, expected all %d owned utxos", len(got), len(want)) + } + for k := range want { + if _, ok := got[k]; !ok { + return fmt.Errorf("btc rotation validate: omits owned utxo %s", k) + } + } return nil } diff --git a/pkg/blockchain/btc/rotation_finalizer_test.go b/pkg/blockchain/btc/rotation_finalizer_test.go index 505f587..294d509 100644 --- a/pkg/blockchain/btc/rotation_finalizer_test.go +++ b/pkg/blockchain/btc/rotation_finalizer_test.go @@ -2,6 +2,9 @@ package btc import ( "bytes" + "context" + "encoding/hex" + "strings" "testing" "github.com/btcsuite/btcd/chaincfg" @@ -116,3 +119,166 @@ func TestBuildSweepTx_OpReturnMarker(t *testing.T) { t.Errorf("inputs = %d, want %d", len(tx.TxIn), len(utxos)) } } + +// stubRotationRPC is a minimal RPC seam for exercising Validate without a node. +// ListUnspent reports the owned UTXO set; GetTxOut answers each input as a +// confirmed output of vaultScript so sumValidatedInputs accepts it. The other +// methods are unused by Validate. +type stubRotationRPC struct { + unspent []Unspent + vaultScript string // hex pkScript every owned UTXO pays to + confs int64 +} + +func (s *stubRotationRPC) ListUnspent(_ context.Context, _ int, _ []string) ([]Unspent, error) { + return s.unspent, nil +} + +func (s *stubRotationRPC) GetTxOut(_ context.Context, txid string, vout uint32, _ bool) (*TxOut, error) { + for _, u := range s.unspent { + if u.TxID == txid && u.Vout == vout { + return &TxOut{AmountSats: u.AmountSats, ScriptPubKey: s.vaultScript, Confirmations: s.confs}, nil + } + } + return nil, nil +} + +func (s *stubRotationRPC) SendRawTransaction(context.Context, string) (string, error) { + return "", nil +} +func (s *stubRotationRPC) EstimateSmartFeeSatPerVByte(context.Context, int, int64) (int64, error) { + return 5, nil +} +func (s *stubRotationRPC) GetBlockCount(context.Context) (int64, error) { return 0, nil } +func (s *stubRotationRPC) GetBlockHash(context.Context, int64) (string, error) { return "", nil } +func (s *stubRotationRPC) GetBlockTxids(context.Context, string) ([]string, error) { + return nil, nil +} +func (s *stubRotationRPC) GetRawTransaction(context.Context, string) (*RawTx, error) { + return nil, nil +} + +// stubVaultStore returns a fixed current vault and accepts any pivot. +type stubVaultStore struct { + pubkeys [][]byte + threshold int +} + +func (s *stubVaultStore) Current(context.Context) ([][]byte, int, error) { + return s.pubkeys, s.threshold, nil +} +func (s *stubVaultStore) Pivot(context.Context, [][]byte, int) error { return nil } + +// TestRotationValidateRejectsPartialSweep proves the completeness guard: a sweep +// that omits an owned UTXO is rejected (it would strand that UTXO at the old +// vault), while the full sweep over the same owned set validates. +func TestRotationValidateRejectsPartialSweep(t *testing.T) { + net := &chaincfg.RegressionNetParams + ctx := context.Background() + + // Current vault: a 2-of-2 P2WSH whose first key is this node's signer. + keys := make([]*sign.KeySigner, 2) + pubs := make([][]byte, 2) + for i := range keys { + k, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("gen key: %v", err) + } + keys[i] = sign.NewKeySignerFromECDSA(k) + pubs[i] = keys[i].PublicKey() + } + signer := keys[0] + + redeem, err := RedeemScript(2, pubs) + if err != nil { + t.Fatalf("RedeemScript: %v", err) + } + vaultAddr, err := VaultAddress(redeem, net) + if err != nil { + t.Fatalf("VaultAddress: %v", err) + } + vaultScript, err := PkScript(vaultAddr) + if err != nil { + t.Fatalf("PkScript: %v", err) + } + + // New vault: a distinct 2-of-2 set the sweep pays into. + newPubs := make([]string, 2) + for i := range newPubs { + k, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("gen new key: %v", err) + } + newPubs[i] = hex.EncodeToString(sign.NewKeySignerFromECDSA(k).PublicKey()) + } + + // Three owned UTXOs. + owned := make([]UTXO, 3) + for i := range owned { + var h chainhash.Hash + h[0] = byte(0x10 + i) + owned[i] = UTXO{TxID: h, Vout: uint32(i), Amount: 1_000_000} + } + unspent := make([]Unspent, len(owned)) + for i, u := range owned { + unspent[i] = Unspent{TxID: u.TxID.String(), Vout: u.Vout, AmountSats: u.Amount, Confirmations: 100} + } + + vaultScriptHex := strings.ToLower(hex.EncodeToString(vaultScript)) + for i := range unspent { + unspent[i].ScriptPubKey = vaultScriptHex // so toUTXOs resolves them as owned + } + + cfg := Config{ConfirmationDepth: 1, FeeConfTarget: 6, FallbackFeeRate: 5, FeeCapSatPerVByte: 100} + rpc := &stubRotationRPC{ + unspent: unspent, + vaultScript: vaultScriptHex, + confs: 100, + } + store := &stubVaultStore{pubkeys: pubs, threshold: 2} + + rf, err := NewRotationFinalizer(net, rpc, signer, store, cfg) + if err != nil { + t.Fatalf("NewRotationFinalizer: %v", err) + } + + var opID [32]byte + opID[0], opID[31] = 0xAB, 0xCD + + newVaultAddr, _, _, err := rf.newVaultAddress(newPubs, 2) + if err != nil { + t.Fatalf("newVaultAddress: %v", err) + } + + // A full sweep over all owned UTXOs validates. + full, err := buildSweepTx(owned, newVaultAddr, opID, 5) + if err != nil { + t.Fatalf("buildSweepTx (full): %v", err) + } + fullBytes, err := serializeTx(full) + if err != nil { + t.Fatalf("serialize full: %v", err) + } + if err := rf.Validate(ctx, opID, fullBytes, newPubs, 2); err != nil { + t.Fatalf("full sweep rejected: %v", err) + } + + // Drop one input: the sweep now omits an owned UTXO and must be rejected. + dropped := owned[len(owned)-1] + partial, err := buildSweepTx(owned[:len(owned)-1], newVaultAddr, opID, 5) + if err != nil { + t.Fatalf("buildSweepTx (partial): %v", err) + } + partialBytes, err := serializeTx(partial) + if err != nil { + t.Fatalf("serialize partial: %v", err) + } + err = rf.Validate(ctx, opID, partialBytes, newPubs, 2) + if err == nil { + t.Fatal("partial sweep accepted, want rejection") + } + missing := dropped.TxID.String() + if !strings.Contains(err.Error(), "owned utxo") && !strings.Contains(err.Error(), missing) { + t.Errorf("error %q does not mention the omitted owned utxo %s", err, missing) + } +} From bc2c38c20e6eb4ace73ddb951de782982420adf3 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Fri, 19 Jun 2026 11:51:48 +0300 Subject: [PATCH 3/5] fix(evm): estimate gas for signer rotation submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RotationFinalizer.Submit relied on the contract binding's implicit gas auto-estimation for the updateSigners call. updateSigners takes dynamic-array arguments (address[], uint256, bytes[]), and the binding's built-in estimate trips a known go-ethereum gotcha on dynamic args that returns a too-low gas limit (or an estimation error outright), so the rotation transaction can revert out-of-gas — a liveness failure on the signer-rotation path. Estimate gas explicitly, mirroring the withdrawal path: pack the updateSigners calldata, run a single eth_estimateGas against it, and set opts.GasLimit to the result padded by the configured multiplier. Called after applyFees and before the binding call. A comment documents the dynamic-array gotcha so the explicit estimate is not removed later. Co-Authored-By: Claude Fable 5 --- pkg/blockchain/evm/rotation_finalizer.go | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/pkg/blockchain/evm/rotation_finalizer.go b/pkg/blockchain/evm/rotation_finalizer.go index 5e3b858..64e077b 100644 --- a/pkg/blockchain/evm/rotation_finalizer.go +++ b/pkg/blockchain/evm/rotation_finalizer.go @@ -8,6 +8,7 @@ import ( "math/big" "sort" + ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" @@ -165,6 +166,9 @@ func (f *RotationFinalizer) Submit(ctx context.Context, packed []byte, signature if err := applyFees(ctx, f.client, f.fees, opts); err != nil { return core.TxRef{}, err } + if err := f.estimateGas(ctx, opts, addrs, big.NewInt(int64(p.NewThreshold)), sigs); err != nil { + return core.TxRef{}, err + } tx, err := f.custody.UpdateSigners(opts, addrs, big.NewInt(int64(p.NewThreshold)), sigs) if err != nil { return core.TxRef{}, fmt.Errorf("updateSigners: %w", err) @@ -199,6 +203,37 @@ func (f *RotationFinalizer) VerifyRotation(ctx context.Context, newSigners []str // --- helpers --- +// estimateGas sets opts.GasLimit for the updateSigners call. We do NOT rely on +// the binding's built-in gas auto-estimation: updateSigners takes dynamic-array +// args (address[], uint256, bytes[]), and the binding's implicit estimate trips +// a known go-ethereum gotcha on dynamic args that yields a too-low limit (or an +// outright estimation error), so the rotation tx would revert out-of-gas. We +// pack the calldata explicitly, run a single eth_estimateGas against it, and pad +// by the configured multiplier — mirroring the withdrawal path. +func (f *RotationFinalizer) estimateGas(ctx context.Context, opts *bind.TransactOpts, newSigners []common.Address, newThreshold *big.Int, sigs [][]byte) error { + abi, err := CustodyMetaData.GetAbi() + if err != nil { + return fmt.Errorf("parse ABI: %w", err) + } + data, err := abi.Pack("updateSigners", newSigners, newThreshold, sigs) + if err != nil { + return fmt.Errorf("pack updateSigners calldata: %w", err) + } + est, err := f.client.EstimateGas(ctx, ethereum.CallMsg{ + From: f.signerAddr, + To: &f.vaultAddr, + Data: data, + GasTipCap: opts.GasTipCap, + GasFeeCap: opts.GasFeeCap, + GasPrice: opts.GasPrice, + }) + if err != nil { + return fmt.Errorf("estimate gas: %w", err) + } + opts.GasLimit = uint64(float64(est) * f.fees.gasLimitMultiplier()) + return nil +} + func (f *RotationFinalizer) digestFromPacked(packed []byte) ([32]byte, error) { var p evmRotPacked if err := json.Unmarshal(packed, &p); err != nil { From e22d20b1418d598e88e9911ea670fe588220328d Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Fri, 19 Jun 2026 12:31:51 +0300 Subject: [PATCH 4/5] feat(btc): bound withdrawal inputs and add consolidation SelectUTXOs gains a maxInputs bound (Config.MaxInputsPerWithdrawal); when covering a withdrawal would exceed it, the vault is too fragmented for a standard-size tx, signalled by the new ErrTooFragmented. ConsolidationFinalizer folds a bounded batch of the vault's smallest UTXOs back into one base-vault output (Pack/Validate/Sign/Submit), shrinking the count so withdrawals keep fitting. It is partial by design (same vault, no pivot), so unlike the rotation sweep it carries no completeness rule; Sign/Submit reuse the current-vault machinery. Co-Authored-By: Claude Fable 5 --- pkg/blockchain/btc/consolidation_finalizer.go | 288 ++++++++++++++++++ .../btc/consolidation_finalizer_test.go | 165 ++++++++++ pkg/blockchain/btc/depositor.go | 2 +- pkg/blockchain/btc/txbuild.go | 19 +- pkg/blockchain/btc/withdrawal_finalizer.go | 12 +- 5 files changed, 483 insertions(+), 3 deletions(-) create mode 100644 pkg/blockchain/btc/consolidation_finalizer.go create mode 100644 pkg/blockchain/btc/consolidation_finalizer_test.go diff --git a/pkg/blockchain/btc/consolidation_finalizer.go b/pkg/blockchain/btc/consolidation_finalizer.go new file mode 100644 index 0000000..b555074 --- /dev/null +++ b/pkg/blockchain/btc/consolidation_finalizer.go @@ -0,0 +1,288 @@ +package btc + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "sort" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + + "github.com/layer-3/clearnet-sdk/pkg/core" + "github.com/layer-3/clearnet-sdk/pkg/sign" +) + +// defaultConsolidationBatchMax bounds how many of the vault's smallest UTXOs a +// single consolidation fold spends when Config.ConsolidationBatchMax is unset. +// Kept well under the ~800-input standard-tx ceiling so every fold is relayable. +const defaultConsolidationBatchMax = 200 + +// ConsolidationFinalizer folds a bounded batch of the vault's smallest UTXOs +// back into a single base-vault output, shrinking the UTXO count so withdrawals +// keep fitting a standard-size tx (the counterpart to SelectUTXOs' maxInputs +// bound / ErrTooFragmented). +// +// It is mechanically a withdrawal-to-self: same vault, current keys, no pivot. +// Unlike the rotation sweep it is partial by design (a bounded batch, not the +// full set), so it carries no completeness rule. Pack/Validate are the +// fold-specific mechanics; Sign/Submit reuse the current-vault withdrawal +// machinery, exactly as RotationFinalizer does. +type ConsolidationFinalizer struct { + net *chaincfg.Params + rpc RPC + signer sign.Signer + store VaultStore + cfg Config + accounts []string // per-account deposit URIs whose UTXOs are also foldable +} + +// NewConsolidationFinalizer builds the BTC consolidation finalizer. signer is +// this node's vault key; store supplies the current vault (consolidation never +// pivots, so Pivot is unused). accountURIs are the per-account deposit accounts +// whose tagged-address UTXOs are eligible to fold alongside the base vault. +func NewConsolidationFinalizer(net *chaincfg.Params, rpc RPC, signer sign.Signer, store VaultStore, cfg Config, accountURIs ...string) (*ConsolidationFinalizer, error) { + if signer.Algorithm() != sign.AlgSecp256k1 { + return nil, fmt.Errorf("btc: consolidation signer must be secp256k1, got %s", signer.Algorithm()) + } + return &ConsolidationFinalizer{ + net: net, + rpc: rpc, + signer: signer, + store: store, + cfg: cfg, + accounts: accountURIs, + }, nil +} + +// currentVault builds a withdrawal finalizer over the current vault, registering +// the deposit accounts so the spend-script set covers the base vault plus every +// tagged deposit address whose UTXOs may be folded. It provides the shared +// UTXO/sign/merge machinery. +func (f *ConsolidationFinalizer) currentVault(ctx context.Context) (*WithdrawalFinalizer, error) { + pubkeys, threshold, err := f.store.Current(ctx) + if err != nil { + return nil, fmt.Errorf("btc: read current vault: %w", err) + } + cur, err := NewWithdrawalFinalizer(f.net, f.rpc, f.signer, pubkeys, threshold, f.cfg) + if err != nil { + return nil, fmt.Errorf("btc: build current vault: %w", err) + } + if err := cur.RegisterDepositAccounts(f.accounts...); err != nil { + return nil, err + } + return cur, nil +} + +func (f *ConsolidationFinalizer) batchMax() int { + if f.cfg.ConsolidationBatchMax > 0 { + return f.cfg.ConsolidationBatchMax + } + return defaultConsolidationBatchMax +} + +// listOwned returns every currently-spendable owned UTXO (base vault + each +// registered deposit address) at the configured confirmation depth. +func (f *ConsolidationFinalizer) listOwned(ctx context.Context, cur *WithdrawalFinalizer) ([]UTXO, error) { + unspent, err := f.rpc.ListUnspent(ctx, int(f.cfg.ConfirmationDepth), cur.watchAddresses()) + if err != nil { + return nil, fmt.Errorf("btc consolidate: list vault utxos: %w", err) + } + return cur.toUTXOs(unspent) +} + +// OwnedUTXOCount reports the number of currently-spendable owned UTXOs. The +// consolidation trigger uses it to decide whether a fold is warranted. +func (f *ConsolidationFinalizer) OwnedUTXOCount(ctx context.Context) (int, error) { + cur, err := f.currentVault(ctx) + if err != nil { + return 0, err + } + owned, err := f.listOwned(ctx, cur) + if err != nil { + return 0, err + } + return len(owned), nil +} + +// Due reports whether a periodic fold should run now: the owned UTXO count +// exceeds target AND the current fee rate is at or below feeCeilingSatVb (a +// low-fee window). feeCeilingSatVb <= 0 disables the fee gate. +func (f *ConsolidationFinalizer) Due(ctx context.Context, target int, feeCeilingSatVb int64) (bool, error) { + count, err := f.OwnedUTXOCount(ctx) + if err != nil { + return false, err + } + if count <= target { + return false, nil + } + feeRate, err := f.rpc.EstimateSmartFeeSatPerVByte(ctx, f.cfg.FeeConfTarget, f.cfg.FallbackFeeRate) + if err != nil { + return false, fmt.Errorf("btc consolidate: estimate fee: %w", err) + } + if feeCeilingSatVb > 0 && feeRate > feeCeilingSatVb { + return false, nil + } + return true, nil +} + +// Pack selects the vault's smallest spendable UTXOs (up to the batch max) and +// folds them into a single base-vault output minus fee, with consolidationID in +// an OP_RETURN. Smallest-first keeps the large coins intact for largest-first +// withdrawal selection and shrinks the count fastest. The change computes to +// zero (recipient == vault), so the result is the two-output form +// [baseVault(total-fee), OP_RETURN(consolidationID)]. +func (f *ConsolidationFinalizer) Pack(ctx context.Context, consolidationID [32]byte) ([]byte, error) { + cur, err := f.currentVault(ctx) + if err != nil { + return nil, err + } + utxos, err := f.listOwned(ctx, cur) + if err != nil { + return nil, err + } + if len(utxos) < 2 { + return nil, errors.New("btc consolidate: fewer than 2 spendable utxos; nothing to fold") + } + // Smallest-amount-first, BIP-69 tiebreak for a deterministic batch. + sort.Slice(utxos, func(i, j int) bool { + if utxos[i].Amount != utxos[j].Amount { + return utxos[i].Amount < utxos[j].Amount + } + if c := bytes.Compare(utxos[i].TxID[:], utxos[j].TxID[:]); c != 0 { + return c < 0 + } + return utxos[i].Vout < utxos[j].Vout + }) + if max := f.batchMax(); len(utxos) > max { + utxos = utxos[:max] + } + + feeRate, err := f.rpc.EstimateSmartFeeSatPerVByte(ctx, f.cfg.FeeConfTarget, f.cfg.FallbackFeeRate) + if err != nil { + return nil, fmt.Errorf("btc consolidate: estimate fee: %w", err) + } + var total int64 + for _, u := range utxos { + total += u.Amount + } + // Two outputs: the base vault + the OP_RETURN marker. No change. + fee := EstimateFeeSats(len(utxos), 2, feeRate) + amount := total - fee + if amount < dustThresholdSats { + return nil, fmt.Errorf("btc consolidate: post-fee amount %d below dust (total %d, fee %d); batch not worth folding", amount, total, fee) + } + tx, err := BuildUnsignedTx(utxos, cur.vaultAddr, amount, cur.vaultAddr, consolidationID, fee) + if err != nil { + return nil, err + } + return serializeTx(tx) +} + +// Validate is the follower-side trust boundary for a fold. A vault→vault fold +// cannot move funds out of custody, so validation is deliberately lenient (no +// completeness rule, no byte-identical build): exactly two outputs, output 0 +// paying the base vault, output 1 an OP_RETURN(consolidationID); every input a +// confirmed owned UTXO (nothing foreign dragged in); the batch within the size +// bound; and the implied fee within the griefing ceiling. +func (f *ConsolidationFinalizer) Validate(ctx context.Context, consolidationID [32]byte, packed []byte) error { + cur, err := f.currentVault(ctx) + if err != nil { + return err + } + tx, err := deserializeTx(packed) + if err != nil { + return fmt.Errorf("btc consolidate validate: %w", err) + } + if err := validateFixedTxFields(tx); err != nil { + return fmt.Errorf("btc consolidate validate: %w", err) + } + if n := len(tx.TxOut); n != 2 { + return fmt.Errorf("btc consolidate validate: expected 2 outputs, got %d", n) + } + if !bytes.Equal(tx.TxOut[0].PkScript, cur.vaultScript) { + return errors.New("btc consolidate validate: output 0 is not the base vault") + } + wantOpReturn, err := txscript.NullDataScript(consolidationID[:]) + if err != nil { + return fmt.Errorf("btc consolidate validate: opreturn script: %w", err) + } + if tx.TxOut[1].Value != 0 || !bytes.Equal(tx.TxOut[1].PkScript, wantOpReturn) { + return errors.New("btc consolidate validate: output 1 is not OP_RETURN ") + } + if len(tx.TxIn) < 2 { + return fmt.Errorf("btc consolidate validate: %d inputs; a fold spends at least 2", len(tx.TxIn)) + } + if max := f.batchMax(); len(tx.TxIn) > max { + return fmt.Errorf("btc consolidate validate: %d inputs exceed batch max %d", len(tx.TxIn), max) + } + + // Every input must be a confirmed, owned UTXO. Re-list the owned set and + // require the inputs to be a subset of it (lenient: no full-set match). + owned, err := f.listOwned(ctx, cur) + if err != nil { + return err + } + byOutpoint := make(map[string]int64, len(owned)) + for _, u := range owned { + byOutpoint[fmt.Sprintf("%s:%d", u.TxID.String(), u.Vout)] = u.Amount + } + var totalIn int64 + for _, in := range tx.TxIn { + key := fmt.Sprintf("%s:%d", in.PreviousOutPoint.Hash.String(), in.PreviousOutPoint.Index) + amt, ok := byOutpoint[key] + if !ok { + return fmt.Errorf("btc consolidate validate: input %s is not a confirmed owned utxo", key) + } + totalIn += amt + } + + fee := totalIn - tx.TxOut[0].Value // output 1 is the zero-value OP_RETURN + if fee < 0 { + return fmt.Errorf("btc consolidate validate: outputs exceed inputs (fee %d)", fee) + } + if cap := EstimateFeeSats(len(tx.TxIn), 2, f.cfg.FeeCapSatPerVByte); f.cfg.FeeCapSatPerVByte > 0 && fee > cap { + return fmt.Errorf("btc consolidate validate: fee %d exceeds ceiling %d", fee, cap) + } + return nil +} + +// Sign produces this node's per-input signatures over the fold, delegating to +// the current-vault signing machinery (the fold spends base-vault and deposit +// inputs under the current redeem scripts, exactly as a withdrawal). +func (f *ConsolidationFinalizer) Sign(ctx context.Context, packed []byte) ([]byte, error) { + cur, err := f.currentVault(ctx) + if err != nil { + return nil, err + } + return cur.Sign(ctx, packed) +} + +// Submit assembles the witnesses from the collected shares and broadcasts the +// fold, returning its hash. Idempotent on an already-known/spent reply (the +// UTXO-model analogue of a re-submit guard). +func (f *ConsolidationFinalizer) Submit(ctx context.Context, packed []byte, shares [][]byte) (core.TxRef, error) { + cur, err := f.currentVault(ctx) + if err != nil { + return core.TxRef{}, err + } + merged, err := cur.merge(ctx, packed, shares) + if err != nil { + return core.TxRef{}, err + } + tx, err := deserializeTx(merged) + if err != nil { + return core.TxRef{}, fmt.Errorf("btc consolidate submit: %w", err) + } + hash := [32]byte(tx.TxHash()) + txid := hashToTxid(hash) + if _, err := f.rpc.SendRawTransaction(ctx, hex.EncodeToString(merged)); err != nil { + if isAlreadyKnown(err) { + return core.TxRef{Hash: hash, Raw: txid}, nil + } + return core.TxRef{}, fmt.Errorf("btc consolidate submit: sendrawtransaction: %w", err) + } + return core.TxRef{Hash: hash, Raw: txid}, nil +} diff --git a/pkg/blockchain/btc/consolidation_finalizer_test.go b/pkg/blockchain/btc/consolidation_finalizer_test.go new file mode 100644 index 0000000..436b4cb --- /dev/null +++ b/pkg/blockchain/btc/consolidation_finalizer_test.go @@ -0,0 +1,165 @@ +package btc + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "strings" + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/layer-3/clearnet-sdk/pkg/sign" +) + +// TestSelectUTXOsRespectsMaxInputs proves the fragmentation bound: when covering +// the amount would need more than maxInputs inputs, SelectUTXOs returns +// ErrTooFragmented instead of an oversized selection; within the bound (or +// unbounded) it selects normally. +func TestSelectUTXOsRespectsMaxInputs(t *testing.T) { + // Ten 100-sat UTXOs; an 800-sat withdrawal needs many inputs once the fee + // (which grows per input) is folded in. + utxos := make([]UTXO, 10) + for i := range utxos { + var h chainhash.Hash + h[0] = byte(i + 1) + utxos[i] = UTXO{TxID: h, Vout: 0, Amount: 100} + } + + // Bounded at 3 inputs: coverage can't be reached, so it must signal fragmentation. + if _, _, err := SelectUTXOs(utxos, 800, 1, 2, 3); !errors.Is(err, ErrTooFragmented) { + t.Fatalf("bounded selection: got %v, want ErrTooFragmented", err) + } + + // Unbounded (maxInputs=0) with a tiny amount + zero-ish fee selects fine. + sel, _, err := SelectUTXOs(utxos, 150, 0, 2, 0) + if err != nil { + t.Fatalf("unbounded selection: unexpected error %v", err) + } + if len(sel) == 0 { + t.Fatal("unbounded selection returned no inputs") + } +} + +// consolidationFixture builds a current 2-of-2 P2WSH vault, a ConsolidationFinalizer +// over a stub RPC seeded with `n` owned UTXOs, and returns the pieces a test needs. +func consolidationFixture(t *testing.T, n int) (*ConsolidationFinalizer, []UTXO, []byte) { + t.Helper() + net := &chaincfg.RegressionNetParams + + keys := make([]*sign.KeySigner, 2) + pubs := make([][]byte, 2) + for i := range keys { + k, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("gen key: %v", err) + } + keys[i] = sign.NewKeySignerFromECDSA(k) + pubs[i] = keys[i].PublicKey() + } + redeem, err := RedeemScript(2, pubs) + if err != nil { + t.Fatalf("RedeemScript: %v", err) + } + vaultAddr, err := VaultAddress(redeem, net) + if err != nil { + t.Fatalf("VaultAddress: %v", err) + } + vaultScript, err := PkScript(vaultAddr) + if err != nil { + t.Fatalf("PkScript: %v", err) + } + vaultScriptHex := strings.ToLower(hex.EncodeToString(vaultScript)) + + owned := make([]UTXO, n) + unspent := make([]Unspent, n) + for i := range owned { + var h chainhash.Hash + h[0] = byte(0x20 + i) + owned[i] = UTXO{TxID: h, Vout: uint32(i), Amount: 1_000_000} + unspent[i] = Unspent{ + TxID: h.String(), Vout: uint32(i), AmountSats: 1_000_000, + Confirmations: 100, ScriptPubKey: vaultScriptHex, + } + } + + cfg := Config{ConfirmationDepth: 1, FeeConfTarget: 6, FallbackFeeRate: 5, FeeCapSatPerVByte: 100} + rpc := &stubRotationRPC{unspent: unspent, vaultScript: vaultScriptHex, confs: 100} + store := &stubVaultStore{pubkeys: pubs, threshold: 2} + + cf, err := NewConsolidationFinalizer(net, rpc, keys[0], store, cfg) + if err != nil { + t.Fatalf("NewConsolidationFinalizer: %v", err) + } + return cf, owned, vaultScript +} + +// TestConsolidationPackValidate covers the happy path (Pack builds the two-output +// self-fold, Validate accepts it) and the two follower trust-boundary rejections: +// an output 0 not paying the base vault, and an input that is not an owned UTXO. +func TestConsolidationPackValidate(t *testing.T) { + ctx := context.Background() + cf, _, vaultScript := consolidationFixture(t, 3) + + var cid [32]byte + cid[0], cid[31] = 0xC0, 0xDE + + packed, err := cf.Pack(ctx, cid) + if err != nil { + t.Fatalf("Pack: %v", err) + } + tx, err := deserializeTx(packed) + if err != nil { + t.Fatalf("deserialize: %v", err) + } + if len(tx.TxOut) != 2 { + t.Fatalf("outputs = %d, want 2 (base vault + OP_RETURN)", len(tx.TxOut)) + } + if !bytes.Equal(tx.TxOut[0].PkScript, vaultScript) { + t.Error("output 0 is not the base vault") + } + marker, err := txscript.NullDataScript(cid[:]) + if err != nil { + t.Fatalf("marker: %v", err) + } + if tx.TxOut[1].Value != 0 || !bytes.Equal(tx.TxOut[1].PkScript, marker) { + t.Error("output 1 is not OP_RETURN(consolidationID)") + } + + // Honest fold validates. + if err := cf.Validate(ctx, cid, packed); err != nil { + t.Fatalf("honest fold rejected: %v", err) + } + + // Tamper: output 0 redirected away from the base vault. + bad := tx.Copy() + bad.TxOut[0].PkScript = marker // anything that isn't the vault script + badBytes, err := serializeTx(bad) + if err != nil { + t.Fatalf("serialize tampered: %v", err) + } + if err := cf.Validate(ctx, cid, badBytes); err == nil { + t.Error("fold with output 0 not the base vault was accepted") + } + + // Tamper: splice in a foreign (non-owned) input. + foreign := tx.Copy() + var fh chainhash.Hash + fh[0] = 0xFF + foreign.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&fh, 0), nil, nil)) // NewTxIn defaults to a final sequence + foreignBytes, err := serializeTx(foreign) + if err != nil { + t.Fatalf("serialize foreign: %v", err) + } + err = cf.Validate(ctx, cid, foreignBytes) + if err == nil { + t.Error("fold with a foreign input was accepted") + } else if !strings.Contains(err.Error(), "owned utxo") { + t.Errorf("error %q does not flag the non-owned input", err) + } +} diff --git a/pkg/blockchain/btc/depositor.go b/pkg/blockchain/btc/depositor.go index 23aa881..d4e576d 100644 --- a/pkg/blockchain/btc/depositor.go +++ b/pkg/blockchain/btc/depositor.go @@ -101,7 +101,7 @@ func (d *Depositor) SubmitDeposit(ctx context.Context, asset string, amount deci return core.TxRef{}, fmt.Errorf("btc: estimate fee: %w", err) } // numFixedOutputs = recipient (deposit address); change is sized in. - selected, feeSats, err := SelectUTXOs(utxos, sats, feeRate, 1) + selected, feeSats, err := SelectUTXOs(utxos, sats, feeRate, 1, 0) if err != nil { return core.TxRef{}, err } diff --git a/pkg/blockchain/btc/txbuild.go b/pkg/blockchain/btc/txbuild.go index ac06510..dd5e4a7 100644 --- a/pkg/blockchain/btc/txbuild.go +++ b/pkg/blockchain/btc/txbuild.go @@ -1,12 +1,19 @@ package btc import ( + "errors" "fmt" "sort" "github.com/btcsuite/btcd/wire" ) +// ErrTooFragmented reports that covering a withdrawal would require more inputs +// than the configured maxInputs bound — i.e. the vault's UTXO set is too +// fragmented to build a standard-size transaction. Callers detect it (via +// errors.Is) to trigger a consolidation fold and retry the withdrawal. +var ErrTooFragmented = errors.New("btc: utxo set too fragmented for a standard-size tx") + // validateFixedTxFields asserts the fixed fields the BIP-143 SIGHASH_ALL digest // commits to, matching what the canonical builders produce: version // wire.TxVersion, locktime 0, and final (non-RBF) input sequences. The sighash @@ -54,7 +61,12 @@ func EstimateFeeSats(numInputs, numOutputs int, satPerVByte int64) int64 { // and accumulated greedily until they cover amount + fee, where the fee grows // with each added input. numFixedOutputs is the count of always-present outputs // (recipient + OP_RETURN = 2); a change output is assumed for fee sizing. -func SelectUTXOs(available []UTXO, amount int64, satPerVByte int64, numFixedOutputs int) (selected []UTXO, feeSats int64, err error) { +// +// maxInputs bounds the input count so the resulting tx stays within Bitcoin's +// standard-size relay limit. When covering the amount would need more than +// maxInputs inputs, it returns ErrTooFragmented (the caller then consolidates +// and retries). maxInputs <= 0 means unbounded. +func SelectUTXOs(available []UTXO, amount int64, satPerVByte int64, numFixedOutputs, maxInputs int) (selected []UTXO, feeSats int64, err error) { if amount <= 0 { return nil, 0, fmt.Errorf("btc: non-positive amount %d", amount) } @@ -78,6 +90,11 @@ func SelectUTXOs(available []UTXO, amount int64, satPerVByte int64, numFixedOutp if total >= amount+fee { return pool[:n], fee, nil } + // Coverage not reached at n inputs; if n has hit the bound, adding more + // would exceed a standard-size tx — signal the need to consolidate. + if maxInputs > 0 && n >= maxInputs { + return nil, 0, ErrTooFragmented + } } return nil, 0, fmt.Errorf("btc: insufficient vault balance: have %d, need %d + fee at %d sat/vB", total, amount, satPerVByte) diff --git a/pkg/blockchain/btc/withdrawal_finalizer.go b/pkg/blockchain/btc/withdrawal_finalizer.go index a219152..ffec6ac 100644 --- a/pkg/blockchain/btc/withdrawal_finalizer.go +++ b/pkg/blockchain/btc/withdrawal_finalizer.go @@ -30,6 +30,16 @@ type Config struct { FeeConfTarget int // estimatesmartfee confirmation target (blocks) FallbackFeeRate int64 // sat/vByte used when the node can't estimate FeeCapSatPerVByte int64 // ceiling Validate accepts on a canonical tx + + // MaxInputsPerWithdrawal bounds how many inputs a withdrawal may select + // before SelectUTXOs returns ErrTooFragmented (0 = unbounded). A fragmented + // vault that hits this should consolidate (see ConsolidationFinalizer) and + // retry. + MaxInputsPerWithdrawal int + // ConsolidationBatchMax bounds how many of the vault's smallest UTXOs one + // consolidation fold spends (0 => a sane default well under the standard-tx + // input ceiling). + ConsolidationBatchMax int } // WithdrawalFinalizer is the Bitcoin m-of-n P2WSH vault withdrawal path. It @@ -157,7 +167,7 @@ func (f *WithdrawalFinalizer) Pack(ctx context.Context, op *core.WithdrawalOp, w if err != nil { return nil, fmt.Errorf("btc: estimate fee: %w", err) } - selected, feeSats, err := SelectUTXOs(utxos, amount, feeRate, 2) + selected, feeSats, err := SelectUTXOs(utxos, amount, feeRate, 2, f.cfg.MaxInputsPerWithdrawal) if err != nil { return nil, err } From 56e8121c557453d92e738786fc01908409ea2a41 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Fri, 19 Jun 2026 12:31:52 +0300 Subject: [PATCH 5/5] feat(xrpl): size multisign fee against the live quorum Pack autofills the multi-sign fee from the construction-time threshold, so a quorum-raising rotation underpays once the SignerQuorum grows. Add an optional ThresholdResolver hook (SetThresholdResolver) the withdrawal and rotation finalizers consult for the live SignerQuorum; unset keeps the static threshold. Co-Authored-By: Claude Fable 5 --- pkg/blockchain/xrpl/rotation_finalizer.go | 33 ++++++++++++- .../xrpl/threshold_resolver_test.go | 46 +++++++++++++++++++ pkg/blockchain/xrpl/withdrawal_finalizer.go | 41 ++++++++++++++++- 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 pkg/blockchain/xrpl/threshold_resolver_test.go diff --git a/pkg/blockchain/xrpl/rotation_finalizer.go b/pkg/blockchain/xrpl/rotation_finalizer.go index a0d8fb3..0d5a9fd 100644 --- a/pkg/blockchain/xrpl/rotation_finalizer.go +++ b/pkg/blockchain/xrpl/rotation_finalizer.go @@ -28,6 +28,28 @@ type RotationFinalizer struct { threshold int // current SignerQuorum — sizes the multi-sign fee signer sign.Signer id Identity + + // resolveThreshold, when set, supplies the live SignerQuorum used to size the + // multi-sign fee autofill. Lets a quorum-raising rotation pay a fee sized + // against the vault's current (outgoing) quorum rather than a boot-time + // threshold. nil falls back to threshold. + resolveThreshold func(context.Context) (int, error) +} + +// SetThresholdResolver installs a hook that resolves the live SignerQuorum used +// to size the fee autofill. Optional; unset uses the static construction-time +// threshold. +func (f *RotationFinalizer) SetThresholdResolver(fn func(context.Context) (int, error)) { + f.resolveThreshold = fn +} + +// LiveQuorum returns the vault's current on-chain SignerQuorum. Callers wire it +// as the ThresholdResolver (and reuse it for the ceremony collect count) so a +// quorum-raising rotation sizes the fee and quorum against live state rather +// than the boot-time threshold. +func (f *RotationFinalizer) LiveQuorum(_ context.Context) (int, error) { + _, q, err := fetchLiveSignerList(f.client, f.vaultAddress) + return q, err } var _ core.SignerRotationFinalizer = (*RotationFinalizer)(nil) @@ -57,7 +79,7 @@ func NewRotationFinalizer(rpcURL, vaultAddress string, threshold int, signer sig // newThreshold (each member weight 1), returning its sorted-key JSON. opID is // ignored: XRPL binds rotation replay to the account Sequence (autofilled here), // so the operation identity is not embedded in the payload. -func (f *RotationFinalizer) Pack(_ context.Context, _ [32]byte, newSigners []string, newThreshold int) ([]byte, error) { +func (f *RotationFinalizer) Pack(ctx context.Context, _ [32]byte, newSigners []string, newThreshold int) ([]byte, error) { entries, err := signerEntries(newSigners, newThreshold) if err != nil { return nil, err @@ -68,7 +90,14 @@ func (f *RotationFinalizer) Pack(_ context.Context, _ [32]byte, newSigners []str "SignerQuorum": uint32(newThreshold), "SignerEntries": entries, } - if err := f.client.AutofillMultisigned(&flatTx, uint64(f.threshold)); err != nil { + quorum := f.threshold + if f.resolveThreshold != nil { + quorum, err = f.resolveThreshold(ctx) + if err != nil { + return nil, fmt.Errorf("xrpl: resolve live quorum: %w", err) + } + } + if err := f.client.AutofillMultisigned(&flatTx, uint64(quorum)); err != nil { return nil, fmt.Errorf("xrpl: autofill: %w", err) } delete(flatTx, "LastLedgerSequence") diff --git a/pkg/blockchain/xrpl/threshold_resolver_test.go b/pkg/blockchain/xrpl/threshold_resolver_test.go new file mode 100644 index 0000000..7448eb8 --- /dev/null +++ b/pkg/blockchain/xrpl/threshold_resolver_test.go @@ -0,0 +1,46 @@ +package xrpl + +import ( + "context" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + + "github.com/layer-3/clearnet-sdk/pkg/sign" +) + +// TestFeeQuorumResolver covers the live-quorum fee hook: with no resolver the +// fee autofill uses the static construction-time threshold; with one set it uses +// the resolved live SignerQuorum (so a quorum-raising rotation pays a correctly +// sized fee without a fleet restart); and a resolver error propagates. +func TestFeeQuorumResolver(t *testing.T) { + k, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("gen key: %v", err) + } + signer := sign.NewKeySignerFromECDSA(k) + + f, err := NewWithdrawalFinalizer("http://127.0.0.1:1", "rVaultAddressNotARealAccount11111111", 5, signer, nil) + if err != nil { + t.Fatalf("NewWithdrawalFinalizer: %v", err) + } + ctx := context.Background() + + // No resolver → static threshold. + if q, err := f.feeQuorum(ctx); err != nil || q != 5 { + t.Fatalf("default feeQuorum = (%d, %v), want (5, nil)", q, err) + } + + // Resolver set → live quorum. + f.SetThresholdResolver(func(context.Context) (int, error) { return 9, nil }) + if q, err := f.feeQuorum(ctx); err != nil || q != 9 { + t.Fatalf("resolved feeQuorum = (%d, %v), want (9, nil)", q, err) + } + + // Resolver error propagates. + f.SetThresholdResolver(func(context.Context) (int, error) { return 0, errors.New("boom") }) + if _, err := f.feeQuorum(ctx); err == nil { + t.Fatal("resolver error was swallowed") + } +} diff --git a/pkg/blockchain/xrpl/withdrawal_finalizer.go b/pkg/blockchain/xrpl/withdrawal_finalizer.go index 79fcfc7..950eced 100644 --- a/pkg/blockchain/xrpl/withdrawal_finalizer.go +++ b/pkg/blockchain/xrpl/withdrawal_finalizer.go @@ -36,6 +36,19 @@ type WithdrawalFinalizer struct { signer sign.Signer id Identity tickets TicketProvider + + // resolveThreshold, when set, supplies the live SignerQuorum used to size the + // multi-sign fee autofill in Pack. It lets a quorum-raising rotation take + // effect without a fleet restart: the fee is sized against the vault's current + // quorum rather than the boot-time threshold. nil falls back to threshold. + resolveThreshold func(context.Context) (int, error) +} + +// SetThresholdResolver installs a hook that resolves the live SignerQuorum used +// to size the fee autofill (see resolveThreshold). Optional; callers that leave +// it unset get the static threshold passed at construction. +func (f *WithdrawalFinalizer) SetThresholdResolver(fn func(context.Context) (int, error)) { + f.resolveThreshold = fn } var _ core.VaultWithdrawalFinalizer = (*WithdrawalFinalizer)(nil) @@ -61,6 +74,28 @@ func NewWithdrawalFinalizer(rpcURL, vaultAddress string, threshold int, signer s }, nil } +// LiveQuorum returns the vault's current on-chain SignerQuorum. Callers wire it +// as the ThresholdResolver (and reuse it for the ceremony collect count) so a +// quorum-raising rotation sizes the fee and quorum against live state rather +// than the boot-time threshold. +func (f *WithdrawalFinalizer) LiveQuorum(_ context.Context) (int, error) { + _, q, err := fetchLiveSignerList(f.client, f.vaultAddress) + return q, err +} + +// feeQuorum returns the SignerQuorum used to size the multi-sign fee: the live +// value from resolveThreshold when set, else the static threshold. +func (f *WithdrawalFinalizer) feeQuorum(ctx context.Context) (int, error) { + if f.resolveThreshold == nil { + return f.threshold, nil + } + q, err := f.resolveThreshold(ctx) + if err != nil { + return 0, fmt.Errorf("xrpl: resolve live quorum: %w", err) + } + return q, nil +} + // Pack binds a Ticket and builds the autofilled multi-sign Payment, returning // its sorted-key JSON. func (f *WithdrawalFinalizer) Pack(ctx context.Context, op *core.WithdrawalOp, withdrawalID [32]byte) ([]byte, error) { @@ -84,7 +119,11 @@ func (f *WithdrawalFinalizer) Pack(ctx context.Context, op *core.WithdrawalOp, w } flatTx := payment.Flatten() flatTx["Sequence"] = uint32(0) - if err := f.client.AutofillMultisigned(&flatTx, uint64(f.threshold)); err != nil { + quorum, err := f.feeQuorum(ctx) + if err != nil { + return nil, err + } + if err := f.client.AutofillMultisigned(&flatTx, uint64(quorum)); err != nil { return nil, fmt.Errorf("xrpl: autofill: %w", err) } flatTx["Sequence"] = uint32(0)