btc

package
v0.5.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package btc implements the Bitcoin custody vault adapter. The vault is a native SegWit P2WSH (BIP 141) address whose redeem script is an m-of-n OP_CHECKMULTISIG over the providers' secp256k1 public keys — no smart contract. See custody docs/btc_spec.md.

This file holds the chain primitives that are a pure function of their inputs: redeem-script construction, vault address derivation, the deterministic unsigned-transaction builder, BIP-143 sighash computation, and witness assembly. They carry no daemon state and no network access, so every provider that observes the same authorized withdrawal and the same UTXO set builds a byte-identical transaction — the precondition for non-interactive multisig signing.

Index

Constants

This section is empty.

Variables

View Source
var ErrTooFragmented = errors.New("btc: utxo set too fragmented for a standard-size tx")

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.

Functions

func AccountTag

func AccountTag(accountURI string) []byte

AccountTag derives the per-account script tag from a clearnet account URI: the 32-byte SHA256 of the URI.

func AssembleWitness

func AssembleWitness(redeemScript []byte, orderedSigs [][]byte) wire.TxWitness

AssembleWitness builds the witness stack for a P2WSH multisig input:

[ <empty>, sig_1, ..., sig_m, redeemScript ]

The leading empty element is the OP_CHECKMULTISIG off-by-one workaround. Each signature is DER-encoded with the SIGHASH_ALL type byte already appended, and the slice MUST already be ordered to match the position of the corresponding public key in the redeem script.

func BuildUnsignedTx

func BuildUnsignedTx(
	utxos []UTXO,
	recipient btcutil.Address,
	amount int64,
	vault btcutil.Address,
	withdrawalID [32]byte,
	feeSats int64,
) (*wire.MsgTx, error)

BuildUnsignedTx constructs the canonical unsigned withdrawal transaction. Inputs are the selected vault UTXOs (sorted BIP-69) so construction is order-independent. Outputs are emitted in a fixed canonical order: recipient, then change back to the vault (omitted if below dust), then a zero-value OP_RETURN carrying the 32-byte clearnet WithdrawalID.

func DepositAddress

func DepositAddress(accountURI string, threshold int, pubkeys [][]byte, net *chaincfg.Params) (btcutil.Address, []byte, error)

DepositAddress derives the per-account deposit P2WSH address and its witness script. Watch-only: a pure function of accountURI plus the base pubkeys, no private keys involved.

func EstimateFeeSats

func EstimateFeeSats(numInputs, numOutputs int, satPerVByte int64) int64

EstimateFeeSats returns the fee for a transaction with numInputs P2WSH inputs and numOutputs outputs at the given rate.

func PkScript

func PkScript(addr btcutil.Address) ([]byte, error)

PkScript returns the scriptPubKey for an address.

func RedeemScript

func RedeemScript(threshold int, pubkeys [][]byte) ([]byte, error)

RedeemScript builds the m-of-n OP_CHECKMULTISIG redeem script over the given compressed secp256k1 public keys. Keys are sorted lexicographically (BIP 67) before assembly so the script — and therefore the vault address — is independent of the order in which keys were supplied.

func SighashAll

func SighashAll(
	tx *wire.MsgTx,
	inputIdx int,
	redeemScript []byte,
	amount int64,
	prevFetcher txscript.PrevOutputFetcher,
) ([]byte, error)

SighashAll computes the BIP-143 SIGHASH_ALL digest for one input.

func TaggedRedeemScript

func TaggedRedeemScript(tag []byte, threshold int, pubkeys [][]byte) ([]byte, error)

TaggedRedeemScript prefixes the m-of-n redeem script with `<tag> OP_DROP`, yielding a distinct witness script — and therefore a distinct P2WSH address — per tag while leaving the signing semantics identical: OP_DROP discards the tag before OP_CHECKMULTISIG, so the same keys sign the same way with no key derivation. This is how per-account deposit addresses are derived (tag = AccountTag(accountURI)); every deposit address is full m-of-n custody.

func VaultAddress

func VaultAddress(redeemScript []byte, net *chaincfg.Params) (btcutil.Address, error)

VaultAddress derives the P2WSH bech32 address for a redeem script: the witness program is SHA256(redeemScript).

Types

type AssetResolver added in v0.5.0

type AssetResolver struct{}

func NewAssetResolver added in v0.5.0

func NewAssetResolver() AssetResolver

func (AssetResolver) AssetDecimals added in v0.5.0

func (AssetResolver) AssetDecimals(ctx context.Context, assetAddress string) (uint8, error)

func (AssetResolver) ValidateAssetAddress added in v0.5.0

func (AssetResolver) ValidateAssetAddress(_ context.Context, assetAddress string) error

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a concrete bitcoind JSON-RPC client implementing RPC. Wallet-scoped calls (ListUnspent) route to /wallet/<wallet>; the rest hit the node root. It carries only the read + broadcast surface the adapters need — wallet provisioning (createwallet, mining, funding) is deliberately out of scope.

func NewClient

func NewClient(url, user, pass, wallet string, opts ...Option) *Client

NewClient builds a bitcoind RPC client at url with basic-auth user/pass. wallet is the wallet name wallet-scoped RPCs route to (may be empty if the node has a single default wallet loaded).

func (*Client) EstimateSmartFeeSatPerVByte

func (c *Client) EstimateSmartFeeSatPerVByte(ctx context.Context, confTarget int, fallbackRate int64) (int64, error)

EstimateSmartFeeSatPerVByte returns the node's fee estimate for confTarget blocks, falling back to fallbackRate when the node cannot estimate (e.g. on regtest).

func (*Client) GetBlockCount

func (c *Client) GetBlockCount(ctx context.Context) (int64, error)

GetBlockCount returns the height of the most-work fully-validated chain.

func (*Client) GetBlockHash

func (c *Client) GetBlockHash(ctx context.Context, height int64) (string, error)

GetBlockHash returns the block hash at the given height.

func (*Client) GetBlockTxids

func (c *Client) GetBlockTxids(ctx context.Context, blockHash string) ([]string, error)

GetBlockTxids returns the txids in the block (verbosity 1).

func (*Client) GetRawTransaction

func (c *Client) GetRawTransaction(ctx context.Context, txid string) (*RawTx, error)

GetRawTransaction returns the decoded transaction (verbose=true). Requires the node to find the tx: txindex=1, or the tx unspent/in mempool.

func (*Client) GetTxOut

func (c *Client) GetTxOut(ctx context.Context, txid string, vout uint32, includeMempool bool) (*TxOut, error)

GetTxOut returns the unspent output txid:vout, or nil if it is spent/unknown.

func (*Client) ListUnspent

func (c *Client) ListUnspent(ctx context.Context, minConf int, addrs []string) ([]Unspent, error)

ListUnspent returns the vault UTXOs at addrs with at least minConf confirmations.

func (*Client) SendRawTransaction

func (c *Client) SendRawTransaction(ctx context.Context, hexTx string) (string, error)

SendRawTransaction broadcasts hexTx and returns its txid.

type Config

type Config struct {
	ConfirmationDepth uint64 // min confirmations for a vault UTXO to be spendable
	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
}

Config carries the per-chain tunables the finalizer needs.

type ConsolidationFinalizer added in v0.2.0

type ConsolidationFinalizer struct {
	// contains filtered or unexported fields
}

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.

func NewConsolidationFinalizer added in v0.2.0

func NewConsolidationFinalizer(net *chaincfg.Params, rpc RPC, signer sign.Signer, store VaultStore, cfg Config, assets blockchain.AssetResolver, accountURIs ...string) (*ConsolidationFinalizer, error)

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 (*ConsolidationFinalizer) Due added in v0.2.0

func (f *ConsolidationFinalizer) Due(ctx context.Context, target int, feeCeilingSatVb int64) (bool, error)

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 (*ConsolidationFinalizer) OwnedUTXOCount added in v0.2.0

func (f *ConsolidationFinalizer) OwnedUTXOCount(ctx context.Context) (int, error)

OwnedUTXOCount reports the number of currently-spendable owned UTXOs. The consolidation trigger uses it to decide whether a fold is warranted.

func (*ConsolidationFinalizer) Pack added in v0.2.0

func (f *ConsolidationFinalizer) Pack(ctx context.Context, consolidationID [32]byte) ([]byte, error)

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 (*ConsolidationFinalizer) Sign added in v0.2.0

func (f *ConsolidationFinalizer) Sign(ctx context.Context, packed []byte) ([]byte, error)

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 (*ConsolidationFinalizer) Submit added in v0.2.0

func (f *ConsolidationFinalizer) Submit(ctx context.Context, packed []byte, shares [][]byte) (string, error)

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 (*ConsolidationFinalizer) Validate added in v0.2.0

func (f *ConsolidationFinalizer) Validate(ctx context.Context, consolidationID [32]byte, packed []byte) error

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.

type Depositor

type Depositor struct {
	// contains filtered or unexported fields
}

Depositor funds a per-account deposit address from the depositor's own P2WPKH wallet (the key the supplied sign.Signer holds). It implements core.VaultDepositor. The deposit address is derived from the vault's pubkeys + threshold (the same address the withdrawal finalizer can later spend).

func NewDepositor

func NewDepositor(net *chaincfg.Params, rpc RPC, signer sign.Signer, vaultPubkeys [][]byte, threshold int, cfg Config, assets blockchain.AssetResolver) (*Depositor, error)

NewDepositor builds the BTC depositor. signer is the depositor's secp256k1 key; vaultPubkeys + threshold define the vault whose per-account deposit addresses funds are sent to.

func (*Depositor) DepositorAddress

func (d *Depositor) DepositorAddress() string

DepositorAddress returns the depositor's own P2WPKH funding address.

func (*Depositor) SubmitDeposit

func (d *Depositor) SubmitDeposit(ctx context.Context, assetAddress string, amount decimal.Decimal, dest core.DepositDestination) (string, error)

SubmitDeposit sends amount from the depositor's wallet to the per-account deposit address for dest.Account. assetAddress must be "" for native 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 (*Depositor) VerifyDeposit

func (d *Depositor) VerifyDeposit(ctx context.Context, txID string, minConf uint64) (core.DepositStatus, error)

VerifyDeposit reports the on-chain status of the deposit txID. Requires the node to resolve the tx (txindex=1, or the tx unspent / in the mempool). A tx the node has never seen — or one reorged out and dropped — reads as DepositAbsent; a mempool tx (0 confs) is DepositPending until it is mined with at least max(1, minConf) confirmations (a deposit is only Confirmed once on chain, consistent with the other chains).

type Option

type Option func(*Client)

Option configures a Client.

func WithHTTPClient

func WithHTTPClient(h *http.Client) Option

WithHTTPClient overrides the default *http.Client (30s timeout).

type RPC

type RPC interface {
	ListUnspent(ctx context.Context, minConf int, addrs []string) ([]Unspent, error)
	GetTxOut(ctx context.Context, txid string, vout uint32, includeMempool bool) (*TxOut, error)
	SendRawTransaction(ctx context.Context, hexTx string) (string, error)
	EstimateSmartFeeSatPerVByte(ctx context.Context, confTarget int, fallbackRate int64) (int64, error)

	// For VerifyExecution: scan recent blocks for the OP_RETURN <withdrawalID>.
	GetBlockCount(ctx context.Context) (int64, error)
	GetBlockHash(ctx context.Context, height int64) (string, error)
	GetBlockTxids(ctx context.Context, blockHash string) ([]string, error)
	GetRawTransaction(ctx context.Context, txid string) (*RawTx, error)
}

RPC is the bitcoind RPC surface the adapters depend on. It is supplied by the caller (mirroring how the EVM adapters take a caller-supplied *ethclient.Client), so the SDK carries no JSON-RPC client of its own. The block/raw-tx methods back the withdrawal-execution scan.

type RPCError

type RPCError struct {
	Code    int
	Message string
}

RPCError is a typed bitcoind JSON-RPC error response. The Code lets callers branch on outcome (e.g. already-in-chain) without string matching.

func (*RPCError) Error

func (e *RPCError) Error() string

type RawTx

type RawTx struct {
	TxID          string
	Confirmations int64
	Vouts         []RawVout
}

RawTx is a decoded transaction as reported by GetRawTransaction.

type RawVout

type RawVout struct {
	ValueSats       int64
	ScriptPubKeyHex string
}

RawVout is one output of a RawTx, with its scriptPubKey hex.

type RotationFinalizer

type RotationFinalizer struct {
	// contains filtered or unexported fields
}

RotationFinalizer rotates a BTC P2WSH vault by sweeping every old-vault UTXO into the vault derived from the new signer set. It implements core.SignerRotationFinalizer. The signing/merge/UTXO machinery is the withdrawal path (it is mechanically a withdrawal whose inputs are all old-vault UTXOs and whose single output is the new vault); the extra pieces are the sweep build and the post-confirmation pivot via the VaultStore.

func NewRotationFinalizer

func NewRotationFinalizer(net *chaincfg.Params, rpc RPC, signer sign.Signer, store VaultStore, cfg Config, assets blockchain.AssetResolver, accountURIs ...string) (*RotationFinalizer, error)

NewRotationFinalizer builds the BTC rotation finalizer. signer is this node's vault key; store supplies the current vault and receives the pivot. accountURIs are the per-account deposit accounts whose tagged-address UTXOs must be included in the sweep (the base vault is always swept) — undeclared accounts' UTXOs would be stranded under the old vault.

func (*RotationFinalizer) Pack

func (f *RotationFinalizer) Pack(ctx context.Context, opID [32]byte, newSigners []string, newThreshold int) ([]byte, error)

Pack lists every current-vault UTXO and builds the unsigned sweep: all of them as inputs, output 0 paying the new vault the total minus fee, and a final zero-value OP_RETURN carrying opID so an external watcher can attribute the landed sweep to this rotation (and pivot the vault).

func (*RotationFinalizer) Sign

func (f *RotationFinalizer) Sign(ctx context.Context, packed []byte) ([]byte, error)

Sign produces this node's per-input signatures over the sweep, delegating to the current-vault signing machinery.

func (*RotationFinalizer) Submit

func (f *RotationFinalizer) Submit(ctx context.Context, packed []byte, shares [][]byte) (string, error)

Submit assembles the witnesses from the collected shares and broadcasts the sweep. Idempotent by the UTXO model: if the sweep already landed, its inputs are spent and the rebroadcast is rejected as already-known/missing-inputs, in which case the original txID is returned.

func (*RotationFinalizer) Validate

func (f *RotationFinalizer) Validate(ctx context.Context, opID [32]byte, packed []byte, newSigners []string, newThreshold int) error

Validate re-derives the new vault and asserts the packed sweep pays exactly it from output 0, carries the OP_RETURN(opID) marker as its final output, consumes only current-vault UTXOs, and keeps the implied fee within the ceiling.

func (*RotationFinalizer) VerifyRotation

func (f *RotationFinalizer) VerifyRotation(ctx context.Context, newSigners []string, newThreshold int) (string, bool, error)

VerifyRotation reports whether the sweep landed — the new vault holds at least one confirmed UTXO — and, when so, pivots the store to the new vault. Binary; the sweep txID is not recovered here, so an empty txID is returned with done=true. Note: a vault with nothing to sweep cannot be observed as rotated.

type SigShare

type SigShare struct {
	PubKey string   `json:"pubkey"`
	Sigs   []string `json:"sigs"`
}

SigShare is one signer's contribution: a DER+sighash signature per input, in input order, plus the signer's 33-byte compressed pubkey (hex).

type TxOut

type TxOut struct {
	AmountSats    int64
	ScriptPubKey  string
	Confirmations int64
}

TxOut is a single output as reported by GetTxOut.

type UTXO

type UTXO struct {
	TxID   chainhash.Hash
	Vout   uint32
	Amount int64 // satoshis
}

UTXO is a spendable vault output.

func SelectUTXOs

func SelectUTXOs(available []UTXO, amount int64, satPerVByte int64, numFixedOutputs, maxInputs int) (selected []UTXO, feeSats int64, err error)

SelectUTXOs deterministically chooses inputs to cover amount plus the fee the resulting transaction will pay. UTXOs are sorted by (amount desc, txid, vout) 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.

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.

type Unspent

type Unspent struct {
	TxID          string
	Vout          uint32
	AmountSats    int64
	Confirmations int64
	ScriptPubKey  string
}

Unspent is a vault UTXO as reported by ListUnspent.

type VaultStore

type VaultStore interface {
	Current(ctx context.Context) (pubkeys [][]byte, threshold int, err error)
	Pivot(ctx context.Context, pubkeys [][]byte, threshold int) error
}

VaultStore is the seam that lets BTC rotation fit the in-place SignerRotationFinalizer interface. A P2WSH vault's address is a function of its signer set, so rotation is a sweep into a newly-derived vault, after which the daemon must pivot to that vault. Current supplies the active vault (the source of UTXOs to sweep and the set that authorizes the sweep); Pivot adopts the new vault once the sweep confirms.

Pivot must be idempotent: it is called by every node from VerifyRotation when the sweep is observed, and re-adopting the already-current vault is a no-op.

type WithdrawalFinalizer

type WithdrawalFinalizer struct {
	// contains filtered or unexported fields
}

WithdrawalFinalizer is the Bitcoin m-of-n P2WSH vault withdrawal path. It owns this node's signer (one of the vault keys); the quorum's shares are merged off-mesh by the caller. It implements core.VaultWithdrawalFinalizer.

func NewWithdrawalFinalizer

func NewWithdrawalFinalizer(net *chaincfg.Params, rpc RPC, signer sign.Signer, pubkeys [][]byte, threshold int, cfg Config, assets blockchain.AssetResolver) (*WithdrawalFinalizer, error)

NewWithdrawalFinalizer builds the vault finalizer. pubkeys are the providers' 33-byte compressed keys; signer is this node's identity and its public key must be one of pubkeys.

func (*WithdrawalFinalizer) Pack

func (f *WithdrawalFinalizer) Pack(ctx context.Context, op *core.WithdrawalOp, withdrawalID [32]byte, deadline int64) ([]byte, error)

Pack selects vault UTXOs, sizes the fee, and builds the canonical unsigned transaction (recipient, optional change, OP_RETURN <withdrawalID>).

deadline is accepted for interface symmetry but deliberately ignored: a Bitcoin transaction has no consensus-level expiry, so the digest carries no time bound. BTC's authorization lifetime is instead governed by the UTXO set (the inputs stop existing once spent) and, for the re-credit path, by a receipt-gated Expired ceremony that requires no local signature share ever existed — see the adapter's AuthorizationDead.

func (*WithdrawalFinalizer) RegisterDepositAccounts

func (f *WithdrawalFinalizer) RegisterDepositAccounts(accountURIs ...string) error

RegisterDepositAccounts adds the tagged deposit addresses for the given account URIs to the spendable set, so withdrawals can select and sign UTXOs that landed at per-account deposit addresses.

func (*WithdrawalFinalizer) Sign

func (f *WithdrawalFinalizer) Sign(ctx context.Context, packed []byte) ([]byte, error)

Sign produces this node's signature over every input of the packed tx, each under its own witness script. Returns a JSON SigShare.

func (*WithdrawalFinalizer) Submit

func (f *WithdrawalFinalizer) Submit(ctx context.Context, packed []byte, shares [][]byte) (string, error)

Submit assembles the witnesses from the collected shares and broadcasts the signed tx, returning its hash. Idempotent on an already-known/spent reply.

func (*WithdrawalFinalizer) Validate

func (f *WithdrawalFinalizer) Validate(ctx context.Context, packed []byte, op *core.WithdrawalOp, withdrawalID [32]byte, deadline int64) error

Validate re-derives the trust-bound shape from the op and asserts the packed tx matches: output 0 pays the exact recipient/amount, the final output is OP_RETURN <withdrawalID>, any middle output is change to the vault, every input is a confirmed vault UTXO, and the implied fee is within the ceiling.

func (*WithdrawalFinalizer) VerifyExecution

func (f *WithdrawalFinalizer) VerifyExecution(ctx context.Context, withdrawalID [32]byte) (string, bool, error)

VerifyExecution scans the most recent blocks for a tx carrying OP_RETURN <withdrawalID>. Returns the txID + true on a hit. Bounded by executionScanBlocks; a withdrawal older than that window reads as not-found.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL