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 c3cecea..d4e576d 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) } @@ -95,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/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) + } +} 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/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/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 } 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/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 { 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 b566834..e36b12d 100755 Binary files a/pkg/blockchain/sol/artifacts/custody.so and b/pkg/blockchain/sol/artifacts/custody.so differ diff --git a/pkg/blockchain/sol/custody/instructions.go b/pkg/blockchain/sol/custody/instructions.go index 2f50ba9..328ea9b 100644 --- a/pkg/blockchain/sol/custody/instructions.go +++ b/pkg/blockchain/sol/custody/instructions.go @@ -12,10 +12,11 @@ import ( ) // Builds a "deposit_sol" instruction. -// 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). func NewDepositSolInstruction( // Params: accountParam [20]uint8, + referenceParam [32]uint8, amountParam uint64, // Accounts: @@ -39,6 +40,11 @@ func NewDepositSolInstruction( if err != nil { return nil, errors.NewField("accountParam", err) } + // Serialize `referenceParam`: + err = enc__.Encode(referenceParam) + if err != nil { + return nil, errors.NewField("referenceParam", err) + } // Serialize `amountParam`: err = enc__.Encode(amountParam) if err != nil { @@ -70,10 +76,11 @@ func NewDepositSolInstruction( } // Builds a "deposit_spl" instruction. -// 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). func NewDepositSplInstruction( // Params: accountParam [20]uint8, + referenceParam [32]uint8, amountParam uint64, // Accounts: @@ -101,6 +108,11 @@ func NewDepositSplInstruction( if err != nil { return nil, errors.NewField("accountParam", err) } + // Serialize `referenceParam`: + err = enc__.Encode(referenceParam) + if err != nil { + return nil, errors.NewField("referenceParam", err) + } // Serialize `amountParam`: err = enc__.Encode(amountParam) if err != nil { diff --git a/pkg/blockchain/sol/custody/types.go b/pkg/blockchain/sol/custody/types.go index 0827ef1..0ddeadc 100644 --- a/pkg/blockchain/sol/custody/types.go +++ b/pkg/blockchain/sol/custody/types.go @@ -111,10 +111,13 @@ func UnmarshalConfig(buf []byte) (*Config, error) { // 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 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/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/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/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) 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