diff --git a/CLAUDE.md b/CLAUDE.md index 43e4cc0f1..3ab1b7a1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,8 +80,9 @@ through wasm. - **`btc-blocking` cannot target wasm32** and says so with a `compile_error!`. Use `btc-async` for anything that must run in a browser. - Sequentia-specific modules exported from `lwk_wollet`: `btc`, `adaptor`, `openamp`, `ark`, - `seqdex_htlc`, `seqdex_swap`, `seqob_covenant`, `sequentia_delegation`, `coinjoin`, plus - `sequentia_stake_script` and the any-asset fee builder. `SEQUENTIA.md` documents each. `pos` is + `seqdex_htlc`, `seqdex_swap`, `seqob_covenant`, `sequentia_delegation`, + `sequentia_stake_records`, `coinjoin`, plus `sequentia_stake_script` and the any-asset fee + builder. `SEQUENTIA.md` documents each. `pos` is upstream's point-of-sale module (feature `prices`), not proof of stake. ## Cross-language contracts diff --git a/README.md b/README.md index 102089e30..1f3084f72 100644 --- a/README.md +++ b/README.md @@ -40,16 +40,24 @@ on top of the upstream history, listed precisely in [SEQUENTIA.md](SEQUENTIA.md) primitives (`bump_fee_of`, `replace_tx_of`, `cpfp_of`) work with any-asset fees. - **Staking**: `sequentia_stake_script` / `TxBuilder::add_stake_output` build the CSV-locked bonding output used to stake Sequence tokens for block production - (the only place the Sequence token is special). + (the only place the Sequence token is special), and `sequentia_stake_records` + takes a stake out in two steps (`build_unbond_tx`, then + `build_unbond_claim_tx` once the unbonding depth has passed). Every spend of + a stake record carries the signature the chain wants at the next block + (`StakeRecordSigning`): from the chain's `pos_records_v2_height` + (`pos_records_v2_height()` carries it per network) it commits to the amount + spent. - **SeqDEX primitives**: the same-chain atomic-swap `SeqdexSwapRequest` builder and the Sequentia-leg HTLC (redeem script, claim, refund) for cross-chain BTC-to-asset swaps, byte-compatible with the SeqDEX daemon. - **SeqOB covenant orders**: `seqob_covenant` assembles the raw FILL and REFUND transactions for a resting passive-CLOB covenant order (a taproot script-path input with no signature, plus the taker's own key-path funding inputs). -- **Staking-pool delegation**: `TxBuilder::add_delegation_output` creates a - delegation record and `sequentia_delegation` spends one (leave a pool, or - re-point to another signer in the same transaction). +- **Staking-pool delegation**: `sequentia_delegation` creates a delegation + record from a coin of the staking key (`TxBuilder::add_record_authorization` + pays it, `build_delegation_create_tx` spends it into the record, both mined + together) and spends one (leave a pool, or re-point to another signer in the + same transaction). - **CoinJoin**: `coinjoin::sign_coinjoin_inputs` signs the wallet's own P2WPKH inputs of a coordinator-built seqcj round transaction. - **OpenAMP restricted assets** (feature `openamp`): AID derivation, the tagged @@ -113,10 +121,10 @@ Sequentia example in `lwk_simplicity`. | Crate | What it is | |---|---| -| `lwk_wollet` | The watch-only wallet core (CT descriptors, scanning, balances, PSET create/finalize). Sequentia additions: explicit-output handling, any-asset fees + RBF/CPFP rescue, staking output, SeqDEX swap/HTLC builders, SeqOB covenant fill/refund, staking-pool delegation, CoinJoin input signing, the OpenAMP client (feature `openamp`), adaptor signatures (feature `adaptor`), the Arca leaf module (feature `ark`, `src/ark/`), and the whole Bitcoin parent-chain module (`src/btc/`). | +| `lwk_wollet` | The watch-only wallet core (CT descriptors, scanning, balances, PSET create/finalize). Sequentia additions: explicit-output handling, any-asset fees + RBF/CPFP rescue, staking output and two-step unbonding, SeqDEX swap/HTLC builders, SeqOB covenant fill/refund, staking-pool delegation, CoinJoin input signing, the OpenAMP client (feature `openamp`), adaptor signatures (feature `adaptor`), the Arca leaf module (feature `ark`, `src/ark/`), and the whole Bitcoin parent-chain module (`src/btc/`). | | `lwk_common` | Shared types. Sequentia addition: `Network::sequentia_testnet()` and Sequentia address parameters. | | `lwk_signer` | Software signer (BIP39 mnemonic to PSET signatures); signs Sequentia PSETs as-is. Sequentia additions: `sign_tapscript`, a script-path signature at any leaf version over the Elements signature hash, for covenant leaves whose signature opcodes check the wallet's key, under `SIGHASH_DEFAULT` or `SIGHASH_ALL` unless the caller names another type; `sign_csfs`, a message signature for `OP_CHECKSIGFROMSTACK` over Arca's three messages, which rebuilds the digest from the message's fields, refuses a mismatch or another chain than the wallet's, and caps what a rebind leaves to whoever broadcasts. Each has a `describe` for the approval screen. | -| `lwk_wasm` | WebAssembly bindings (wasm-bindgen). Sequentia additions: `Network.sequentiaTestnet()`, `BtcWallet`, the `xchain*` HTLC helpers, SeqDEX bindings, `buildCovenantFillTx` / `buildCovenantRefundTx`, delegation (`buildDelegationSpendTx`, `findDelegationRecords`), `coinjoinSignInputs` / `coinjoinUnblindOutputs`, the `Openamp` client and enclave helpers, `adaptor*`, `Signer.signTapscript` / `tapscriptSighash` / `tapscriptDescribe`, `Signer.signCsfs` / `csfsDigest` / `csfsDescribe`, Arca leaves (`ArkVerifier`, `ArkStore`, `arkParseRecord`, `Signer.arkLeafKey`), staking and any-asset-fee bindings. | +| `lwk_wasm` | WebAssembly bindings (wasm-bindgen). Sequentia additions: `Network.sequentiaTestnet()`, `BtcWallet`, the `xchain*` HTLC helpers, SeqDEX bindings, `buildCovenantFillTx` / `buildCovenantRefundTx`, delegation (`buildDelegationCreateTx`, `buildDelegationSpendTx`, `findDelegationRecords`), unbonding (`buildUnbondTx`, `buildUnbondClaimTx`), `coinjoinSignInputs` / `coinjoinUnblindOutputs`, the `Openamp` client and enclave helpers, `adaptor*`, `Signer.signTapscript` / `tapscriptSighash` / `tapscriptDescribe`, `Signer.signCsfs` / `csfsDigest` / `csfsDescribe`, Arca leaves (`ArkVerifier`, `ArkStore`, `arkParseRecord`, `Signer.arkLeafKey`), staking and any-asset-fee bindings. | | `lwk_bindings` | UniFFI bindings (Python, Kotlin, Swift, C#, Go, C++). Upstream API surface (no Sequentia network exposed yet); only the `Contract::from_parts` call changed. | | `lwk_cli` / `lwk_app` / `lwk_rpc_model` / `lwk_tiny_jrpc` | JSON-RPC wallet server and CLI client. Upstream apart from `lwk_app`'s `Contract::from_parts` call: no `sequentia` network selector yet (networks: liquid, liquid-testnet, regtest). | | `lwk_jade`, `lwk_ledger`, `lwk_hwi` | Hardware-signer support (upstream; not wired to Sequentia flows). | @@ -170,9 +178,10 @@ lwk_wollet = { features = ["btc-async"] } # wasm / async apps `Network.sequentiaTestnet()`, `Network.isSequentia()`, the dual-chain `BtcWallet`, the `xchain*` helpers for cross-chain swaps, `TxBuilder.feeAsset()` / `addStakeOutput()` / `addExplicitRecipient()` / -`addDelegationOutput()`, `Signer.stakerPublicKey()`, `buildCovenantFillTx()`, -`buildDelegationSpendTx()`, `coinjoinSignInputs()`, the `Openamp` client, and -the `adaptor*` functions. +`addRecordAuthorization()`, `Signer.stakerPublicKey()`, `buildCovenantFillTx()`, +`buildDelegationCreateTx()` / `buildDelegationSpendTx()`, `buildUnbondTx()` / +`buildUnbondClaimTx()`, `coinjoinSignInputs()`, the `Openamp` client, and the +`adaptor*` functions. ```sh cd lwk_wasm @@ -192,6 +201,7 @@ Standard Rust workspace (toolchain pinned in `rust-toolchain.toml`): cargo build # whole workspace cargo test -p lwk_wollet --lib --features btc-blocking,btc-async btc # dual-chain tests cargo test -p lwk_wollet --lib --features sequentia seqdex # SeqDEX builders +cargo test -p lwk_wollet --lib --features sequentia sequentia_ # staking records ``` Notes: @@ -201,8 +211,14 @@ Notes: failing upstream fixture tests. The workspace enables the vendored `elements` crate's `sequentia` feature globally, which changes the transaction and header wire format, so upstream Liquid test vectors no longer deserialize. - The Sequentia-specific test modules (`btc`, `seqdex_htlc`, `seqdex_swap`) all - pass; use the filtered commands above. + The Sequentia-specific test modules (`btc`, `seqdex_htlc`, `seqdex_swap`, + `sequentia_delegation`, `sequentia_stake_records`) all pass; use the filtered + commands above. +- `lwk_wollet/tests/sequentia_stake_records.rs` has a proof-of-stake + `sequentiad` on a regtest chain confirm every staking transaction the kit + builds, and refuse each wrong one in the mempool and in a block: + `SEQUENTIAD_EXEC=/path/to/sequentiad cargo test -p lwk_wollet --features + sequentia --test sequentia_stake_records`. - Upstream integration tests (`lwk_wollet/tests/e2e.rs` and friends) need a local Elements/Liquid test environment (see `lwk_test_util` and `lwk_containers`); they exercise upstream Liquid behavior, not Sequentia. diff --git a/SEQUENTIA.md b/SEQUENTIA.md index 7e89b9f01..2cef331b8 100644 --- a/SEQUENTIA.md +++ b/SEQUENTIA.md @@ -228,15 +228,43 @@ Changes by file: `sequentia_stake_script()` and `TxBuilder::add_stake_output(staker_pubkey, csv, satoshi)` build the CSV-locked bonding output used to stake for block production. - - Staking pools: `TxBuilder::add_delegation_output()` pays the bare + - Staking pools: `TxBuilder::add_record_authorization(pubkey, satoshi)` + pays the staking key's `P2WPKH`, the coin that authorises a delegation + record (below). `TxBuilder::add_delegation_output()` pays the bare delegation-record script (`"SEQDEL" OP_DROP OP_DROP - OP_CHECKSIG`) that lends the wallet's stake weight to a pool signer. -- `src/sequentia_delegation.rs` (feature `sequentia`): spends a delegation - record, which no descriptor matches. `build_delegation_spend_tx()` either - reclaims the record (leave the pool) or re-points it to another signer in - the same transaction (consensus allows one live record per controller, so - the two steps must not be separate transactions). Also + OP_CHECKSIG`) straight from wallet coins, which the network accepts only + below `pos_hardening_height` (on the testnet the same height as its + `pos_records_v2_height`; block 1 on every other chain). +- `src/sequentia_delegation.rs` (feature `sequentia`): creates and spends + delegation records, which no descriptor matches. A record must be created + by a transaction spending a coin of its controller, so + `build_delegation_create_tx()` funds it from such a coin and nothing else: + the wallet pays the staking key's `P2WPKH` first + (`add_record_authorization`), and the two transactions are mined together. + `build_delegation_spend_tx()` either reclaims the record (leave the pool) or + re-points it to another signer in the same transaction (consensus allows one + live record per controller, so the two steps must not be separate + transactions; the spent record authorises the new one). Also `sequentia_delegation_script()` and `parse_delegation_script()`. +- `src/sequentia_stake_records.rs` (feature `sequentia`): stake record spends + (staking, unbonding, delegation and payout outputs) and two-step unbonding. + `StakeRecordSigning` names the signature a spend needs in the block after + the wallet's tip: the legacy hash below the chain's `pos_records_v2_height`, + and from it the segwit-v0 hash over the record script, committing to the + amount, in a scriptSig of one minimal low-S push. `pos_records_v2_height()` + is `SEQUENTIA_TESTNET_POS_RECORDS_V2_HEIGHT` for + `Network::sequentia_testnet()` (matched on its whole definition, genesis + hash included) and 1 on every other chain; no node RPC reports it. + `sign_stake_record_input()` signs any record spend that way. + `build_unbond_tx()` moves staking outputs into an unbonding output of the + same key (`sequentia_unbond_script()`, `"SEQUNBOND" OP_DROP + OP_CHECKSIG`), its fee capped at 1% of the stake (`unbond_fee_cap()`); + `build_unbond_claim_tx()` sends unbonding outputs to an address once the + unbonding depth has passed. `build_record_create_tx()` creates any record + from a `P2WPKH` coin of its key; the kit announces no payout policy of its + own (that belongs to the node wallet), but its tests create and withdraw one + this way. Also `parse_stake_script()`, `key_coin_script()` and + `find_key_coins()`. - `src/seqob_covenant.rs` (feature `sequentia`): the raw-Elements FILL and REFUND transaction assemblers for a resting SeqOB passive-CLOB covenant order (`build_covenant_fill_tx()`, `build_covenant_refund_tx()`). The @@ -406,6 +434,18 @@ Changes by file: CLTV timelocks are liveness only. - `examples/sequentia_sync.rs`: end-to-end watch-only sync against the live explorer (`cargo run -p lwk_wollet --example sequentia_sync`). +- `tests/sequentia_stake_records.rs`: every stake record transaction the kit + builds, confirmed in a block a proof-of-stake `sequentiad` produces on an + `elementsregtest` chain, under the node's default relay policy: a bond, a + delegation created with the controller's coin, a re-point, a reclaim, a + payout announcement and its withdrawal, and an unbond in two steps; then a + chain crossing `pos_records_v2_height` at block 12, signed the legacy way + below it and the second-generation way at it. Each wrong case (a record from + wallet coins alone, a spend signed for the other generation, a claim before + the unbonding depth) is refused by the mempool and by a block + (`testproposedblock` on the node's own next block with the transaction + added), which accepts the right one. It needs `SEQUENTIAD_EXEC`, and runs + `lwk_wasm/tests/node/stake_records.js` when the node package is linked. - `examples/rescue_test.rs`: live functional test of bump/replace/CPFP against the testnet (needs a funded wallet). @@ -429,7 +469,8 @@ The fork is not published to npm; consumers build `pkg/` with `wasm-pack`. - `src/seqdex_htlc.rs`: `generateSwapSecret`, `htlcKeypair`, `buildSeqHtlcRedeemScript`, `buildSeqHtlcClaimTx`, `buildSeqHtlcRefundTx`. - `src/tx_builder.rs`: `feeAsset()` (any-asset fees), `addExplicitRecipient()`, - `addStakeOutput()`, `sequentiaStakeScript()`, `addDelegationOutput()`. + `addStakeOutput()`, `sequentiaStakeScript()`, `addRecordAuthorization()`, + `addDelegationOutput()`. - `src/wollet.rs`: explicit-UTXO and rescue (bump/replace/CPFP) bindings. - `src/network.rs`: `Network.regtestWithGenesis(policyAsset, genesisHash)`, a regtest network for a chain started with its own parameters, whose genesis @@ -535,7 +576,15 @@ The fork is not published to npm; consumers build `pkg/` with `wasm-pack`. - `src/seqob_covenant.rs`: `buildCovenantFillTx`, `buildCovenantRefundTx`, `covenantMakerAddress`, `covenantMakerDescriptor`, `scriptToAddress`. - `src/sequentia_delegation.rs`: `sequentiaDelegationScript`, - `parseDelegationScript`, `findDelegationRecords`, `buildDelegationSpendTx`. + `parseDelegationScript`, `findDelegationRecords`, `buildDelegationCreateTx`, + `buildDelegationSpendTx`, `stakeRecordSigning`. The builders take the chain + tip (`tipHeight`, defaulting to `locktime`) and sign for the block after it. +- `src/sequentia_stake_records.rs`: `buildUnbondTx` and `buildUnbondClaimTx` + (the two steps of unbonding), `sequentiaUnbondScript`, `unbondFeeCap`. +- `tests/node/stake_records.js`: the wasm half of + `lwk_wollet/tests/sequentia_stake_records.rs`: every recipe the test's + confirmed transactions came from, through the bindings, must give the same + transaction byte for byte. - `src/coinjoin.rs`: `coinjoinSignInputs`, `coinjoinUnblindOutputs`. - `src/openamp.rs`: the `Openamp` client class (`registerUser`, `getUser`, `enclaveAddress`, `assetInfo`, `createTransfer`, `completeTransfer`) and @@ -556,7 +605,7 @@ purposes (BIP44 `44'`, BIP49 `49'`, BIP84 `84'`, BIP86 `86'`, AMP2 `87'`): | Path | Key | |---|---| -| `m/2/0` | The staking key: stake bonding, staking-pool delegation, and messages signed as the staker | +| `m/2/0` | The staking key: stake bonding and unbonding, staking-pool delegation (with the `P2WPKH` coin that authorises a record), and messages signed as the staker | | `m/3/0` | The SeqDEX HTLC key (`htlcKeypair`), and the cross-chain swap's Sequentia claim key in its legacy relative mode; the cross-chain swap's own keys are at `m/84'/1'/0'/2/0` (BTC refund), `/3/0` (Sequentia claim) and `/4/0` (BTC claim) | | `m/5/0` | The OpenAMP enclave key, which signs raw 32-byte digests (`openampSignSighash`) | | `m/6'/'/c1'/c2'/c3'/c4'` | Arca leaf keys, one per leaf, from the leaf's owner nonce (`ark::keys`) | @@ -570,9 +619,9 @@ shares a path with one that signs anything else. - Sequentia is transparent by default; confidentiality is opt-in. Docs and code never assume blinded-by-default (that is the Liquid model). - The Sequence token (SEQ; tSEQ on testnet) is the policy asset but has no - privilege anywhere in the kit except the staking output builder; fees are - payable in any accepted asset and fee rates are denominated in the chosen - asset's own units per vByte. + privilege anywhere in the kit except staking (bonding, unbonding and + delegation records); fees are payable in any accepted asset and fee rates + are denominated in the chosen asset's own units per vByte. - One redeemScript source for cross-chain HTLCs: the Bitcoin leg wraps the same builder the Sequentia leg uses, so the legs cannot drift (this is why the `btc` feature implies `sequentia`). diff --git a/lwk_wasm/README.md b/lwk_wasm/README.md index 31ca8dad9..a5646c73b 100644 --- a/lwk_wasm/README.md +++ b/lwk_wasm/README.md @@ -7,8 +7,9 @@ > and `Network.isSequentia()`, the Bitcoin testnet4 `BtcWallet` (dual-chain > wallets), the `xchain*` helpers (cross-chain BTC-to-asset HTLC swaps), SeqDEX > bindings (`SwapRequest`, `buildSeqHtlc*`), `TxBuilder.feeAsset()` (any-asset -> fees), `addStakeOutput()` and `Signer.stakerPublicKey()` (staking), -> `addDelegationOutput()` / `buildDelegationSpendTx()` (staking pools), +> fees), `addStakeOutput()`, `buildUnbondTx()` / `buildUnbondClaimTx()` and +> `Signer.stakerPublicKey()` (staking), `addRecordAuthorization()` / +> `buildDelegationCreateTx()` / `buildDelegationSpendTx()` (staking pools), > `buildCovenantFillTx()` / `buildCovenantRefundTx()` (SeqOB covenant orders), > `coinjoinSignInputs()` / `coinjoinUnblindOutputs()` (CoinJoin), the `Openamp` > client and `adaptor*` functions. See @@ -155,6 +156,23 @@ SEQUENTIAD_EXEC=/path/to/sequentiad cargo test -p lwk_wollet \ --no-default-features --features ark --test ark_regtest -- --nocapture ``` +### Stake records on regtest + +`lwk_wollet/tests/sequentia_stake_records.rs` has a proof-of-stake +`sequentiad` confirm every stake record transaction the kit builds (a bond, a +delegation created with the staking key's coin, a re-point, a reclaim, an +unbond in two steps) on both sides of the chain's `pos_records_v2_height`, and +then runs `tests/node/stake_records.js`, which builds each of them again +through the bindings from the recipe a wallet would pass and must get the same +transaction byte for byte. The wasm half needs the node package above, linked +or installed as `lwk_node` in `tests/node/node_modules`, and is skipped, with a +line saying so, without it: + +```shell +SEQUENTIAD_EXEC=/path/to/sequentiad cargo test -p lwk_wollet \ + --features sequentia --test sequentia_stake_records -- --nocapture +``` + ### Arca signers on regtest `tests/node/scripts/arca_regtest.py` spends the Arca reference scripts on a diff --git a/lwk_wasm/src/lib.rs b/lwk_wasm/src/lib.rs index 5c1b551e7..84e96f9c8 100644 --- a/lwk_wasm/src/lib.rs +++ b/lwk_wasm/src/lib.rs @@ -52,6 +52,8 @@ mod seqob_covenant; // SEQUENTIA staking pools: join, move between, and (the half a descriptor wallet // cannot do alone) leave one. mod sequentia_delegation; +// SEQUENTIA unbonding in two steps, signed with the staking key. +mod sequentia_stake_records; // SEQUENTIA staking rewards: which coins a staker was PAID, and which of them // to convert. Shared by every light wallet so the two decisions cannot drift. mod staking_rewards; @@ -156,8 +158,11 @@ pub use seqdex_htlc::{ }; pub use seqob_covenant::{build_covenant_fill_tx_js, script_to_address}; pub use sequentia_delegation::{ - build_delegation_spend_tx_js, find_delegation_records_js, parse_delegation_script_js, - sequentia_delegation_script_js, + build_delegation_create_tx_js, build_delegation_spend_tx_js, find_delegation_records_js, + parse_delegation_script_js, sequentia_delegation_script_js, stake_record_signing_js, +}; +pub use sequentia_stake_records::{ + build_unbond_claim_tx_js, build_unbond_tx_js, sequentia_unbond_script_js, unbond_fee_cap_js, }; pub use staking_rewards::{ attribute_staking_rewards_js, decide_reward_conversion_js, plan_reward_batches_js, diff --git a/lwk_wasm/src/sequentia_delegation.rs b/lwk_wasm/src/sequentia_delegation.rs index bbe68a3df..0f305370f 100644 --- a/lwk_wasm/src/sequentia_delegation.rs +++ b/lwk_wasm/src/sequentia_delegation.rs @@ -1,13 +1,23 @@ //! SEQUENTIA staking pools, for the browser and mobile wallets: join a pool, //! move between pools, and leave one. //! -//! Creating a delegation record is an ordinary payment to a bare script, which -//! the PSET builder already handles (`TxBuilder.addDelegationOutput`). Spending -//! one is not: a bare pre-segwit script matches no descriptor, so the wallet's -//! own signer will not touch it. `buildDelegationSpendTx` is that missing half, -//! and shipping the first without it would let a wallet join a pool it could -//! never leave, turning the property that makes delegation safe into a promise -//! the wallet cannot keep. +//! Joining takes two transactions, mined together, because the network accepts +//! a delegation record only from a transaction that spends a coin of its +//! controller (the wallet's staking key, m/2/0), and the wallet's descriptor +//! coins are not that key's: +//! +//! 1. `TxBuilder.addRecordAuthorization(stakerPublicKey, recordAtoms + feeAtoms)`, +//! an ordinary wallet payment to the staking key's `P2WPKH`, signed and +//! broadcast the usual way; +//! 2. `buildDelegationCreateTx`, the record, funded by that coin and nothing +//! else; broadcast it straight after, unconfirmed parent and all. +//! +//! Spending a record (`buildDelegationSpendTx`) moves to another pool or +//! leaves. A bare script matches no descriptor, so the wallet's own signer will +//! not touch either the record or the staking key's coin; this module signs +//! both. Its signature is the one the chain wants at the next block, chosen +//! from the chain tip (`tipHeight`): stake record spends commit to the amount +//! from `pos_records_v2_height`, 163,000 on the testnet. //! //! Note what is deliberately NOT here: announcing a payout policy, which is what //! turns a staker into a pool anyone should join. That binds every block a key @@ -17,9 +27,11 @@ use lwk_wollet::bitcoin::bip32; use lwk_wollet::elements::hex::{FromHex, ToHex}; use lwk_wollet::sequentia_delegation::{ - build_delegation_spend_tx, delegation_pubkey_from_hex, delegation_txid_from_hex, - sequentia_delegation_script, DelegationSpendPlan, + build_delegation_create_tx, build_delegation_spend_tx, delegation_pubkey_from_hex, + delegation_txid_from_hex, sequentia_delegation_script, DelegationCreatePlan, + DelegationSpendPlan, }; +use lwk_wollet::sequentia_stake_records::{find_key_coins, StakeRecordSigning}; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -83,8 +95,9 @@ pub fn find_delegation_records_js(tx_hex: &str, controller: &str) -> Result::from_hex(tx_hex.trim()) .map_err(|e| Error::Generic(format!("invalid transaction hex: {e}")))?; - let tx: lwk_wollet::elements::Transaction = lwk_wollet::elements::encode::deserialize(&bytes) - .map_err(|e| Error::Generic(format!("could not decode the transaction: {e}")))?; + let tx: lwk_wollet::elements::Transaction = + lwk_wollet::elements::encode::deserialize(&bytes) + .map_err(|e| Error::Generic(format!("could not decode the transaction: {e}")))?; let mut found = Vec::new(); for (vout, out) in tx.output.iter().enumerate() { @@ -148,6 +161,15 @@ struct DelegationSpendRecipeJson { fee_atoms: String, /// nLockTime, normally the current tip. locktime: u32, + /// The chain tip the spend is built against, which decides its signature. + /// Defaults to `locktime`, which wallets set to the tip. + #[serde(default)] + tip_height: Option, + /// The height the chain signs record spends the second-generation way + /// from. Defaults to the network's (163,000 on the testnet, 1 elsewhere); + /// set it only for a custom chain started with `-posrecordsv2height`. + #[serde(default)] + records_v2_height: Option, } #[derive(Serialize)] @@ -161,18 +183,18 @@ struct BuiltDelegationTx { /// True when this re-points rather than reclaims, so the caller can say the /// right thing without re-deriving it. repointed: bool, + /// The signature it carries: `"legacy"` or `"segwitV0"`. + signing: &'static str, } -/// Build and sign the spend of a delegation record. Returns -/// `{ rawHex, txid, outValue, repointed }`. -#[wasm_bindgen(js_name = buildDelegationSpendTx)] -pub fn build_delegation_spend_tx_js(recipe: JsValue, network: &Network) -> Result { - let r: DelegationSpendRecipeJson = serde_wasm_bindgen::from_value(recipe)?; - - let signer = crate::Signer::new(&crate::Mnemonic::new(&r.mnemonic)?, network)?; - // The controller is this wallet's staking key, m/2/0, the same key - // Signer.stakerPublicKey() hands out and the same one the stake itself is - // registered to. Only it can spend the record. +/// The staking key's secret, m/2/0: the key `Signer.stakerPublicKey()` hands +/// out, the controller of the wallet's delegation and the key its stake is +/// bonded to. +pub(crate) fn staker_secret( + mnemonic: &str, + network: &Network, +) -> Result { + let signer = crate::Signer::new(&crate::Mnemonic::new(mnemonic)?, network)?; let path = bip32::DerivationPath::from(vec![ bip32::ChildNumber::Normal { index: 2 }, bip32::ChildNumber::Normal { index: 0 }, @@ -181,9 +203,54 @@ pub fn build_delegation_spend_tx_js(recipe: JsValue, network: &Network) -> Resul .inner .derive_xprv(&path) .map_err(|e| Error::Generic(format!("derive staking key: {e}")))?; - let controller_secret = - lwk_wollet::elements::secp256k1_zkp::SecretKey::from_slice(&xprv.private_key.secret_bytes()) - .map_err(|e| Error::Generic(format!("invalid staking key: {e}")))?; + lwk_wollet::elements::secp256k1_zkp::SecretKey::from_slice(&xprv.private_key.secret_bytes()) + .map_err(|e| Error::Generic(format!("invalid staking key: {e}"))) +} + +/// The signature a record spend built against `tip_height` (or, without one, +/// `locktime`, which wallets set to the tip) needs in the next block. +pub(crate) fn record_signing( + network: &Network, + tip_height: Option, + locktime: u32, + records_v2_height: Option, +) -> Result { + let tip = match tip_height { + Some(t) => t, + None if locktime > 0 => locktime, + None => { + return Err(Error::Generic( + "the chain tip is needed to choose the signature (tipHeight); it changes at the network's fork height" + .into(), + )) + } + }; + let v2 = + records_v2_height.unwrap_or_else(|| lwk_wollet::pos_records_v2_height(&network.into())); + Ok(StakeRecordSigning::for_next_block(tip, v2)) +} + +pub(crate) fn signing_name(signing: StakeRecordSigning) -> &'static str { + match signing { + StakeRecordSigning::Legacy => "legacy", + StakeRecordSigning::SegwitV0 => "segwitV0", + } +} + +pub(crate) fn atoms(value: &str, what: &str) -> Result { + value + .trim() + .parse() + .map_err(|_| Error::Generic(format!("{what} must be an integer number of atoms"))) +} + +/// Build and sign the spend of a delegation record. Returns +/// `{ rawHex, txid, outValue, repointed, signing }`. +#[wasm_bindgen(js_name = buildDelegationSpendTx)] +pub fn build_delegation_spend_tx_js(recipe: JsValue, network: &Network) -> Result { + let r: DelegationSpendRecipeJson = serde_wasm_bindgen::from_value(recipe)?; + let controller_secret = staker_secret(&r.mnemonic, network)?; + let signing = record_signing(network, r.tip_height, r.locktime, r.records_v2_height)?; let current_signer = delegation_pubkey_from_hex(&r.current_signer, "current signer")?; let rotate_to = match r.rotate_to.as_deref() { @@ -195,13 +262,7 @@ pub fn build_delegation_spend_tx_js(recipe: JsValue, network: &Network) -> Resul // require the destination rather than inventing one. let reclaim_spk = match (&rotate_to, r.reclaim_address.as_deref()) { (Some(_), _) => lwk_wollet::elements::Script::new(), - (None, Some(a)) if !a.trim().is_empty() => { - let addr: lwk_wollet::elements::Address = a - .trim() - .parse() - .map_err(|e| Error::Generic(format!("invalid reclaim address: {e}")))?; - addr.script_pubkey() - } + (None, Some(a)) if !a.trim().is_empty() => address_script(a, "reclaim")?, (None, _) => { return Err(Error::Generic( "reclaiming a delegation needs a reclaimAddress to send its coins to".into(), @@ -212,26 +273,19 @@ pub fn build_delegation_spend_tx_js(recipe: JsValue, network: &Network) -> Resul let plan = DelegationSpendPlan { record_txid: delegation_txid_from_hex(&r.record_txid)?, record_vout: r.record_vout, - record_value: r - .record_value - .trim() - .parse() - .map_err(|_| Error::Generic("recordValue must be an integer number of atoms".into()))?, + record_value: atoms(&r.record_value, "recordValue")?, asset: network.policy_asset().into(), current_signer, controller_secret, rotate_to: rotate_to.clone(), reclaim_spk, - fee_atoms: r - .fee_atoms - .trim() - .parse() - .map_err(|_| Error::Generic("feeAtoms must be an integer number of atoms".into()))?, + fee_atoms: atoms(&r.fee_atoms, "feeAtoms")?, // Elements' default dust relay fee over the ~200-byte spend-and-output // pair a record produces. Refusing here beats a broadcast rejection the // wallet would have to explain after the fact. dust_floor: 1_000, locktime: r.locktime, + signing, }; let out_value = plan.record_value.saturating_sub(plan.fee_atoms); let (raw_hex, txid) = build_delegation_spend_tx(&plan)?; @@ -240,5 +294,140 @@ pub fn build_delegation_spend_tx_js(recipe: JsValue, network: &Network) -> Resul txid: txid.to_string(), out_value: out_value.to_string(), repointed: rotate_to.is_some(), + signing: signing_name(signing), + })?) +} + +pub(crate) fn address_script( + address: &str, + what: &str, +) -> Result { + let addr: lwk_wollet::elements::Address = address + .trim() + .parse() + .map_err(|e| Error::Generic(format!("invalid {what} address: {e}")))?; + Ok(addr.script_pubkey()) +} + +/// Everything needed to create a delegation record from a coin of the +/// wallet's staking key, as JSON from the wallet. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DelegationCreateRecipeJson { + mnemonic: String, + /// The transaction holding the staking key's coin: normally the payment + /// `TxBuilder.addRecordAuthorization` built, signed and finalized. + coin_tx_hex: String, + /// Which output of it; by default the first explicit Sequence token + /// output paying the staking key's `P2WPKH`. + #[serde(default)] + coin_vout: Option, + /// The pool's signer key, 33-byte hex. + signer: String, + /// The record's own value in atoms, as a string. + record_value: String, + /// Where anything the coin holds beyond the record and the fee goes. + /// Required only when something is left over. + #[serde(default)] + change_address: Option, + /// Network fee in atoms, taken out of the coin. + fee_atoms: String, + /// nLockTime, normally the current tip. + locktime: u32, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BuiltDelegationCreateTx { + raw_hex: String, + txid: String, + /// The record's value, atoms, as a string. + record_value: String, + /// What went back to `changeAddress`, atoms, as a string ("0" for none). + change_value: String, +} + +/// Build and sign the transaction that creates a delegation record, funded by +/// a coin of the wallet's staking key and nothing else. Returns +/// `{ rawHex, txid, recordValue, changeValue }`. +/// +/// Broadcast it right after the transaction holding the coin; it may spend +/// that coin unconfirmed, and the two are mined together. +#[wasm_bindgen(js_name = buildDelegationCreateTx)] +pub fn build_delegation_create_tx_js(recipe: JsValue, network: &Network) -> Result { + let r: DelegationCreateRecipeJson = serde_wasm_bindgen::from_value(recipe)?; + let controller_secret = staker_secret(&r.mnemonic, network)?; + let controller = lwk_wollet::elements::secp256k1_zkp::PublicKey::from_secret_key( + &lwk_wollet::elements::secp256k1_zkp::Secp256k1::new(), + &controller_secret, + ) + .serialize() + .to_vec(); + let asset: lwk_wollet::elements::AssetId = network.policy_asset().into(); + let bytes = Vec::::from_hex(r.coin_tx_hex.trim()) + .map_err(|e| Error::Generic(format!("invalid transaction hex: {e}")))?; + let coin_tx: lwk_wollet::elements::Transaction = + lwk_wollet::elements::encode::deserialize(&bytes) + .map_err(|e| Error::Generic(format!("could not decode the transaction: {e}")))?; + let coins = find_key_coins(&coin_tx, &controller, asset); + let (coin_vout, coin_value) = match r.coin_vout { + Some(v) => *coins.iter().find(|(n, _)| *n == v).ok_or_else(|| { + Error::Generic(format!( + "output {v} of that transaction is not an explicit Sequence token coin of the staking key" + )) + })?, + None => *coins.first().ok_or_else(|| { + Error::Generic( + "that transaction pays the staking key nothing; build it with TxBuilder.addRecordAuthorization" + .into(), + ) + })?, + }; + let record_value = atoms(&r.record_value, "recordValue")?; + let fee_atoms = atoms(&r.fee_atoms, "feeAtoms")?; + let change = coin_value.saturating_sub(record_value.saturating_add(fee_atoms)); + let change_spk = match r.change_address.as_deref() { + Some(a) if !a.trim().is_empty() => address_script(a, "change")?, + _ if change > 0 => { + return Err(Error::Generic(format!( + "the coin holds {change} atoms more than the record and its fee; give a changeAddress for them" + ))) + } + _ => lwk_wollet::elements::Script::new(), + }; + let plan = DelegationCreatePlan { + coin_txid: coin_tx.txid(), + coin_vout, + coin_value, + asset, + controller_secret, + signer: delegation_pubkey_from_hex(&r.signer, "signer")?, + record_value, + change_spk, + fee_atoms, + dust_floor: 1_000, + locktime: r.locktime, + }; + let (raw_hex, txid) = build_delegation_create_tx(&plan)?; + Ok(serde_wasm_bindgen::to_value(&BuiltDelegationCreateTx { + raw_hex, + txid: txid.to_string(), + record_value: record_value.to_string(), + change_value: change.to_string(), })?) } + +/// The signature a stake record spend built against `tipHeight` needs in the +/// next block: `"legacy"` or `"segwitV0"`. `recordsV2Height` overrides the +/// network's fork height (163,000 on the testnet, 1 elsewhere) for a custom +/// chain started with `-posrecordsv2height`. +#[wasm_bindgen(js_name = stakeRecordSigning)] +pub fn stake_record_signing_js( + network: &Network, + tip_height: u32, + records_v2_height: Option, +) -> String { + let v2 = + records_v2_height.unwrap_or_else(|| lwk_wollet::pos_records_v2_height(&network.into())); + signing_name(StakeRecordSigning::for_next_block(tip_height, v2)).to_string() +} diff --git a/lwk_wasm/src/sequentia_stake_records.rs b/lwk_wasm/src/sequentia_stake_records.rs new file mode 100644 index 000000000..303b54318 --- /dev/null +++ b/lwk_wasm/src/sequentia_stake_records.rs @@ -0,0 +1,195 @@ +//! SEQUENTIA unbonding, for the browser and mobile wallets: a stake bonded to +//! the wallet's staking key (m/2/0, `Signer.stakerPublicKey()`) leaves in two +//! steps. +//! +//! 1. `buildUnbondTx` spends the staking outputs into one unbonding output of +//! the same key, paying a fee of at most 1% of the stake out of it. The +//! stake stops counting when this confirms. Each staking output can be +//! spent once its own relative lock (the `csv` it was bonded with) has +//! passed since it confirmed. +//! 2. `buildUnbondClaimTx` sends the unbonding output to an address once the +//! parent chain (Bitcoin) has advanced the unbonding depth (2,016 blocks on +//! the testnet) past the anchor of the block that confirmed step 1. The node +//! refuses an earlier claim with `bad-unbond-premature`. +//! +//! Both are bare-script spends the wallet's PSET signer will not touch, signed +//! here with the staking key for the next block (`tipHeight`). + +use lwk_wollet::elements::hex::{FromHex, ToHex}; +use lwk_wollet::sequentia_delegation::{delegation_pubkey_from_hex, delegation_txid_from_hex}; +use lwk_wollet::sequentia_stake_records::{ + build_unbond_claim_tx, build_unbond_tx, sequentia_unbond_script, unbond_fee_cap, StakeOutput, + UnbondClaimPlan, UnbondPlan, UnbondingOutput, +}; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; + +use crate::sequentia_delegation::{ + address_script, atoms, record_signing, signing_name, staker_secret, +}; +use crate::{Error, Network}; + +/// The canonical unbonding output script for a 33-byte hex staker key, as hex. +/// Cross-checked byte-for-byte against the node's `BuildUnbondScript`. +#[wasm_bindgen(js_name = sequentiaUnbondScript)] +pub fn sequentia_unbond_script_js(staker_pubkey: &str) -> Result { + let pk = delegation_pubkey_from_hex(staker_pubkey, "staker")?; + Ok(sequentia_unbond_script(&pk).as_bytes().to_hex()) +} + +/// The most unbonding may pay in fees out of a stake of `staked_atoms` (a +/// decimal string): 1%, rounded as the node rounds it. Returns a string. +#[wasm_bindgen(js_name = unbondFeeCap)] +pub fn unbond_fee_cap_js(staked_atoms: &str) -> Result { + Ok(unbond_fee_cap(atoms(staked_atoms, "stakedAtoms")?).to_string()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct StakeJson { + txid: String, + vout: u32, + /// Atoms, as a string. + value: String, + /// The staking output's script, hex, as found on chain. + script: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnbondRecipeJson { + mnemonic: String, + /// The staking outputs to unbond, all bonded to the wallet's staking key. + stakes: Vec, + /// Network fee in atoms, taken out of the stake (at most `unbondFeeCap`). + fee_atoms: String, + /// nLockTime, normally the current tip. + locktime: u32, + /// The chain tip the spend is built against; defaults to `locktime`. + #[serde(default)] + tip_height: Option, + /// Overrides the network's fork height for a custom chain. + #[serde(default)] + records_v2_height: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BuiltUnbondTx { + raw_hex: String, + txid: String, + /// What the unbonding output (vout 0) holds, atoms, as a string. + unbonding_value: String, + /// The signature it carries: `"legacy"` or `"segwitV0"`. + signing: &'static str, +} + +/// Unbonding, step 1: spend the staking outputs into an unbonding output of +/// the same key (vout 0). Returns `{ rawHex, txid, unbondingValue, signing }`. +#[wasm_bindgen(js_name = buildUnbondTx)] +pub fn build_unbond_tx_js(recipe: JsValue, network: &Network) -> Result { + let r: UnbondRecipeJson = serde_wasm_bindgen::from_value(recipe)?; + let signing = record_signing(network, r.tip_height, r.locktime, r.records_v2_height)?; + let mut stakes = Vec::with_capacity(r.stakes.len()); + for s in &r.stakes { + stakes.push(StakeOutput { + txid: delegation_txid_from_hex(&s.txid)?, + vout: s.vout, + value: atoms(&s.value, "stake value")?, + script_pubkey: lwk_wollet::elements::Script::from( + Vec::::from_hex(s.script.trim()) + .map_err(|e| Error::Generic(format!("invalid staking script hex: {e}")))?, + ), + }); + } + let total: u64 = stakes.iter().map(|s| s.value).sum(); + let plan = UnbondPlan { + stakes, + asset: network.policy_asset().into(), + staker_secret: staker_secret(&r.mnemonic, network)?, + fee_atoms: atoms(&r.fee_atoms, "feeAtoms")?, + locktime: r.locktime, + signing, + }; + let (raw_hex, txid) = build_unbond_tx(&plan)?; + Ok(serde_wasm_bindgen::to_value(&BuiltUnbondTx { + raw_hex, + txid: txid.to_string(), + unbonding_value: total.saturating_sub(plan.fee_atoms).to_string(), + signing: signing_name(signing), + })?) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnbondingJson { + txid: String, + vout: u32, + /// Atoms, as a string. + value: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnbondClaimRecipeJson { + mnemonic: String, + /// The unbonding outputs to claim, all of the wallet's staking key. + unbonding: Vec, + /// Where the coins go: an unblinded address of the wallet. + address: String, + /// Network fee in atoms, taken out of the coins claimed. + fee_atoms: String, + /// nLockTime, normally the current tip. + locktime: u32, + /// The chain tip the spend is built against; defaults to `locktime`. + #[serde(default)] + tip_height: Option, + /// Overrides the network's fork height for a custom chain. + #[serde(default)] + records_v2_height: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BuiltClaimTx { + raw_hex: String, + txid: String, + /// What arrives at the address, atoms, as a string. + out_value: String, + /// The signature it carries: `"legacy"` or `"segwitV0"`. + signing: &'static str, +} + +/// Unbonding, step 2: send the unbonding outputs to `address`. Returns +/// `{ rawHex, txid, outValue, signing }`. +#[wasm_bindgen(js_name = buildUnbondClaimTx)] +pub fn build_unbond_claim_tx_js(recipe: JsValue, network: &Network) -> Result { + let r: UnbondClaimRecipeJson = serde_wasm_bindgen::from_value(recipe)?; + let signing = record_signing(network, r.tip_height, r.locktime, r.records_v2_height)?; + let mut unbonding = Vec::with_capacity(r.unbonding.len()); + for u in &r.unbonding { + unbonding.push(UnbondingOutput { + txid: delegation_txid_from_hex(&u.txid)?, + vout: u.vout, + value: atoms(&u.value, "unbonding value")?, + }); + } + let total: u64 = unbonding.iter().map(|u| u.value).sum(); + let plan = UnbondClaimPlan { + unbonding, + asset: network.policy_asset().into(), + staker_secret: staker_secret(&r.mnemonic, network)?, + destination: address_script(&r.address, "destination")?, + fee_atoms: atoms(&r.fee_atoms, "feeAtoms")?, + dust_floor: 1_000, + locktime: r.locktime, + signing, + }; + let (raw_hex, txid) = build_unbond_claim_tx(&plan)?; + Ok(serde_wasm_bindgen::to_value(&BuiltClaimTx { + raw_hex, + txid: txid.to_string(), + out_value: total.saturating_sub(plan.fee_atoms).to_string(), + signing: signing_name(signing), + })?) +} diff --git a/lwk_wasm/src/tx_builder.rs b/lwk_wasm/src/tx_builder.rs index e98ff1c67..b86126bde 100644 --- a/lwk_wasm/src/tx_builder.rs +++ b/lwk_wasm/src/tx_builder.rs @@ -170,6 +170,12 @@ impl TxBuilder { /// the old record and create the new one in one transaction (consensus /// permits at most one live record per controller); use /// `buildDelegationSpendTx` with `rotateTo` for that, and for leaving. + /// + /// The network accepts a record only from a transaction that spends a coin + /// of its controller (from `pos_hardening_height`: 163,000 on the testnet, + /// block 1 on every other chain), which a wallet's own coins are not. Create + /// the record with `addRecordAuthorization` and `buildDelegationCreateTx` + /// instead; this output alone is valid only below that height. #[wasm_bindgen(js_name = addDelegationOutput)] pub fn add_delegation_output( self, @@ -195,6 +201,16 @@ impl TxBuilder { .into()) } + /// Pay `satoshi` of the Sequence token (SEQ), unblinded, to the `P2WPKH` of + /// `pubkey` (33-byte hex, normally `Signer.stakerPublicKey()`): the coin of + /// the staking key that `buildDelegationCreateTx` spends to create a + /// delegation record. Pay the record's value plus that transaction's fee. + #[wasm_bindgen(js_name = addRecordAuthorization)] + pub fn add_record_authorization(self, pubkey: &str, satoshi: u64) -> Result { + let key = lwk_wollet::sequentia_delegation::delegation_pubkey_from_hex(pubkey, "key")?; + Ok(self.inner.add_record_authorization(&key, satoshi).into()) + } + /// Issue an asset /// /// There will be `asset_sats` units of this asset that will be received by diff --git a/lwk_wasm/tests/node/stake_records.js b/lwk_wasm/tests/node/stake_records.js new file mode 100644 index 000000000..63e98102c --- /dev/null +++ b/lwk_wasm/tests/node/stake_records.js @@ -0,0 +1,54 @@ +// The wasm half of lwk_wollet/tests/sequentia_stake_records.rs. That test +// builds each stake record transaction natively, has a sequentiad node confirm +// it in a block, and writes a fixture holding, for each, the recipe a wallet +// hands the bindings and the transaction the node confirmed. Here every recipe +// goes through the bindings and must give that transaction byte for byte, so +// what the node accepted is what the browser wallets build. +// +// Run by that test as `node stake_records.js `. + +const assert = require('assert'); +const fs = require('fs'); +const lwk = require('lwk_node'); + +const fx = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const network = lwk.Network.regtestWithGenesis(new lwk.AssetId(fx.policy_asset), fx.genesis_hash); + +// The staking key the bindings derive is the one the native wallet used. +const signer = new lwk.Signer(new lwk.Mnemonic(fx.mnemonic), network); +assert.strictEqual(signer.stakerPublicKey(), fx.staker); +assert.strictEqual(lwk.sequentiaUnbondScript(fx.staker), fx.unbond_script); + +// The signature follows the fork height: legacy below it, segwit-v0 from it. +for (const c of fx.signing) { + assert.strictEqual(lwk.stakeRecordSigning(network, c.tip, c.v2_height), c.expect, JSON.stringify(c)); +} +// The testnet's own height, with no override: a spend built on a tip of +// 162,998 enters block 162,999 and signs the legacy way; from a tip of +// 162,999 it enters block 163,000 and signs the second-generation way. +const testnet = lwk.Network.sequentiaTestnet(); +assert.strictEqual(lwk.stakeRecordSigning(testnet, 162998), 'legacy'); +assert.strictEqual(lwk.stakeRecordSigning(testnet, 162999), 'segwitV0'); + +const build = { + buildDelegationCreateTx: lwk.buildDelegationCreateTx, + buildDelegationSpendTx: lwk.buildDelegationSpendTx, + buildUnbondTx: lwk.buildUnbondTx, + buildUnbondClaimTx: lwk.buildUnbondClaimTx, +}; + +let n = 0; +for (const c of fx.cases) { + const built = build[c.fn]({ mnemonic: fx.mnemonic, ...c.recipe }, network); + assert.strictEqual(built.rawHex, c.raw_hex, `${c.what}: the bindings built another transaction`); + assert.strictEqual(built.txid, c.txid, c.what); + if (c.signing !== undefined) assert.strictEqual(built.signing, c.signing, c.what); + console.log(`stake_records: ${c.fn} (${c.what}) gives ${built.txid}`); + n++; +} + +// What the bindings refuse rather than build. +assert.throws(() => lwk.buildDelegationSpendTx({ + mnemonic: fx.mnemonic, ...fx.cases.find(c => c.fn === 'buildDelegationSpendTx').recipe, locktime: 0, +}, network), /chain tip is needed/); +console.log(`stake_records: ${n} transactions built by the bindings, each the one the node confirmed`); diff --git a/lwk_wollet/Cargo.toml b/lwk_wollet/Cargo.toml index 57cf5ce32..35d3c3428 100644 --- a/lwk_wollet/Cargo.toml +++ b/lwk_wollet/Cargo.toml @@ -183,6 +183,11 @@ name = "ark_regtest" path = "tests/ark_regtest.rs" required-features = ["ark"] +[[test]] +name = "sequentia_stake_records" +path = "tests/sequentia_stake_records.rs" +required-features = ["sequentia"] + [[example]] name = "list_transactions" diff --git a/lwk_wollet/src/lib.rs b/lwk_wollet/src/lib.rs index 2a8c85b05..33c9db9d5 100644 --- a/lwk_wollet/src/lib.rs +++ b/lwk_wollet/src/lib.rs @@ -126,6 +126,11 @@ mod seqob_covenant; // wallet cannot do on its own) spend one to re-point or leave a pool. #[cfg(feature = "sequentia")] pub mod sequentia_delegation; +// SEQUENTIA stake records: which signature a spend of a staking, unbonding, +// delegation or payout output carries at a given height, and unbonding in two +// steps. +#[cfg(feature = "sequentia")] +pub mod sequentia_stake_records; // SEQUENTIA staking rewards: which coins a staker was PAID, and which of them to // convert. Pure; the light wallets share this rather than each inventing one. #[cfg(feature = "sequentia")] @@ -180,8 +185,15 @@ pub use crate::wollet::DirectoryIdHash; pub use crate::tx_builder::{BuiltTx, TxBuilder, WolletTxBuilder}; #[cfg(feature = "sequentia")] pub use crate::sequentia_delegation::{ - build_delegation_spend_tx, parse_delegation_script, sequentia_delegation_script, - DelegationSpendPlan, + build_delegation_create_tx, build_delegation_spend_tx, parse_delegation_script, + sequentia_delegation_script, DelegationCreatePlan, DelegationSpendPlan, +}; +#[cfg(feature = "sequentia")] +pub use crate::sequentia_stake_records::{ + build_record_create_tx, build_unbond_claim_tx, build_unbond_tx, parse_stake_script, + parse_unbond_script, pos_records_v2_height, sequentia_unbond_script, sign_stake_record_input, + stake_record_sighash, ParsedStakeScript, RecordCreatePlan, StakeOutput, StakeRecordSigning, + UnbondClaimPlan, UnbondPlan, UnbondingOutput, SEQUENTIA_TESTNET_POS_RECORDS_V2_HEIGHT, }; #[cfg(feature = "sequentia")] pub use crate::staking_rewards::{ diff --git a/lwk_wollet/src/sequentia_delegation.rs b/lwk_wollet/src/sequentia_delegation.rs index dbc32890c..42af2b222 100644 --- a/lwk_wollet/src/sequentia_delegation.rs +++ b/lwk_wollet/src/sequentia_delegation.rs @@ -12,15 +12,33 @@ //! touched by any of this, and the signer never appears in the staking output's //! spending condition, so a pool can never spend a delegator's stake. //! -//! Creating the record is an ordinary payment to a bare script, which the PSET -//! builder handles ([`crate::TxBuilder::add_delegation_output`]). SPENDING it is -//! not: a bare pre-segwit script matches no descriptor, so the wallet's signer -//! will not touch it. That is what this module is for, and why it exists at all -//! - without it a light wallet could join a pool but never leave, which would -//! turn the one property that makes delegation safe (exit is unilateral and -//! immediate) into a promise the wallet could not keep. +//! A bare script matches no descriptor, so the wallet's PSET signer will neither +//! spend a record nor spend the coin of the staking key that authorises one. +//! This module builds and signs both. Without it a light wallet could not +//! leave a pool, which would turn the one property that makes delegation safe +//! (exit is unilateral and immediate) into a promise the wallet could not keep. //! -//! Two spends, one shape: +//! # Creating a record +//! +//! A delegation record must be created by a transaction that spends a coin of +//! its controller, so that nobody can lend weight in another key's name. A +//! record naming the controller as its own signer is refused, and so is a +//! record spent and re-created identically in one block. The wallet's own coins +//! belong to its descriptor keys, not to the staking key, so creating a first +//! record takes two transactions, mined together: +//! +//! 1. an ordinary payment from the wallet to the controller's `P2WPKH` +//! ([`crate::TxBuilder::add_record_authorization`]); +//! 2. the record, funded by that coin and nothing else +//! ([`build_delegation_create_tx`]), which may spend it unconfirmed. +//! +//! A wallet that already holds a coin of the controller (a split pool pays its +//! delegators to that key's `P2WPKH`) passes that coin to the second step and +//! skips the first. +//! +//! # Spending a record +//! +//! Two spends, one shape ([`build_delegation_spend_tx`]): //! //! * **reclaim** - spend the record back to the wallet. The delegation ends at //! that confirmation. @@ -29,31 +47,31 @@ //! most one unspent record per controller, so a reclaim and a fresh //! delegation broadcast as two loose transactions could be mined in the order //! that leaves two live records, which invalidates the block carrying the -//! second. One transaction cannot be mis-ordered against itself. +//! second. One transaction cannot be mis-ordered against itself. The record +//! it spends is the controller's, which is what authorises the new one. //! //! The record pays its own fee out of its own value, so neither spend needs to //! select a wallet coin, which is what keeps this independent of the PSET path. +//! Its signature is the one the chain wants at the next block +//! ([`crate::sequentia_stake_records`]). -use elements::bitcoin::hashes::Hash as _; -use elements::hashes::hash160; -use elements::secp256k1_zkp::{Message, Secp256k1, SecretKey}; -use elements::{ - confidential, opcodes, AssetId, EcdsaSighashType, LockTime, OutPoint, Script, Sequence, - Transaction, TxIn, TxInWitness, TxOut, TxOutWitness, Txid, -}; use elements::encode::serialize_hex; use elements::hex::FromHex; -use elements::sighash::SighashCache; +use elements::secp256k1_zkp::{Secp256k1, SecretKey}; +use elements::{ + confidential, opcodes, AssetId, LockTime, OutPoint, Script, Sequence, Transaction, TxIn, + TxInWitness, TxOut, TxOutWitness, Txid, +}; use std::str::FromStr; use crate::error::Error; +use crate::sequentia_stake_records::{ + key_coin_script, sign_stake_record_input, RecordCreatePlan, StakeRecordSigning, +}; /// The record's marker, byte for byte the node's `DELEGATION_MARKER`. const DELEGATION_MARKER: &[u8; 6] = b"SEQDEL"; -/// SIGHASH_ALL, as the byte appended to a DER signature. -const SIGHASH_ALL_BYTE: u8 = 0x01; - /// The canonical Sequentia delegation-record script, a byte-for-byte mirror of /// the node's `BuildDelegationScript`: /// `<"SEQDEL"> OP_DROP OP_DROP OP_CHECKSIG`. @@ -103,6 +121,9 @@ pub struct DelegationSpendPlan { pub dust_floor: u64, /// nLockTime, normally the current tip (anti-fee-sniping). pub locktime: u32, + /// The signature the next block wants + /// ([`StakeRecordSigning::for_next_block`]). + pub signing: StakeRecordSigning, } /// Build and sign the spend of a delegation record. Returns `(raw_hex, txid)`. @@ -132,7 +153,8 @@ pub fn build_delegation_spend_tx(plan: &DelegationSpendPlan) -> Result<(String, // otherwise the signature cannot satisfy it. Catch a wrong derivation here, // with a message that says so, rather than broadcasting an unspendable // transaction and watching it be rejected. - let controller_pk = elements::secp256k1_zkp::PublicKey::from_secret_key(&secp, &plan.controller_secret); + let controller_pk = + elements::secp256k1_zkp::PublicKey::from_secret_key(&secp, &plan.controller_secret); let controller_bytes = controller_pk.serialize().to_vec(); let record_script = sequentia_delegation_script(&controller_bytes, &plan.current_signer); @@ -156,7 +178,8 @@ pub fn build_delegation_spend_tx(plan: &DelegationSpendPlan) -> Result<(String, Some(new_signer) => { if new_signer.as_slice() == plan.current_signer.as_slice() { return Err(Error::Generic( - "re-pointing to the signer the record already names would change nothing".into(), + "re-pointing to the signer the record already names would change nothing" + .into(), )); } if new_signer.as_slice() == controller_bytes.as_slice() { @@ -178,23 +201,86 @@ pub fn build_delegation_spend_tx(plan: &DelegationSpendPlan) -> Result<(String, }); tx.output.push(TxOut::new_fee(plan.fee_atoms, plan.asset)); - // A bare (pre-segwit) script is satisfied by a legacy signature over the - // script itself, and the scriptSig is nothing but that signature push - - // the same shape the node's own reclaim builds. - let sighash = { - let cache = SighashCache::new(&tx); - cache.legacy_sighash(0, &record_script, EcdsaSighashType::All) - }; - let message = Message::from_digest(sighash.to_byte_array()); - let signature = secp.sign_ecdsa(&message, &plan.controller_secret); - let mut der = signature.serialize_der().to_vec(); - der.push(SIGHASH_ALL_BYTE); - tx.input[0].script_sig = elements::script::Builder::new().push_slice(&der).into_script(); + // The scriptSig is nothing but the signature push, over the hash the next + // block wants - the same shape the node's own reclaim builds. + sign_stake_record_input( + &mut tx, + 0, + &record_script, + confidential::Value::Explicit(plan.record_value), + &plan.controller_secret, + plan.signing, + )?; let txid = tx.txid(); Ok((serialize_hex(&tx), txid)) } +/// Everything needed to create a delegation record from a coin of its +/// controller. +#[derive(Debug, Clone)] +pub struct DelegationCreatePlan { + /// The controller's coin: an explicit Sequence token output paying the + /// controller's `P2WPKH` ([`crate::sequentia_stake_records::key_coin_script`]). + pub coin_txid: Txid, + /// Its index in that transaction. + pub coin_vout: u32, + /// Its explicit value. + pub coin_value: u64, + /// The Sequence token's asset id; the coin and the record hold it. + pub asset: AssetId, + /// The controller's secret key, which alone can spend the coin and, + /// later, the record. + pub controller_secret: SecretKey, + /// The signer (pool) the record lends the controller's weight to. + pub signer: Vec, + /// The record's own value, recoverable when it is reclaimed. + pub record_value: u64, + /// Where whatever the coin holds beyond the record and the fee goes. Unused + /// when nothing is left over. + pub change_spk: Script, + /// Network fee, taken out of the coin. + pub fee_atoms: u64, + /// The dust floor the record and any change must clear to relay. 0 + /// disables it. + pub dust_floor: u64, + /// nLockTime, normally the current tip. + pub locktime: u32, +} + +/// Build and sign the transaction that creates a delegation record, funded by +/// a coin of its controller and nothing else. Returns `(raw_hex, txid)`. +/// +/// Spending the controller's coin is what authorises the record +/// ([`crate::sequentia_stake_records::build_record_create_tx`]); the +/// transaction may spend it unconfirmed. +pub fn build_delegation_create_tx(plan: &DelegationCreatePlan) -> Result<(String, Txid), Error> { + let secp = Secp256k1::new(); + let controller = + elements::secp256k1_zkp::PublicKey::from_secret_key(&secp, &plan.controller_secret) + .serialize() + .to_vec(); + if plan.signer.as_slice() == controller.as_slice() { + return Err(Error::Generic( + "delegating to the controller itself is what already happens with no record at all; the network refuses it" + .into(), + )); + } + crate::sequentia_stake_records::build_record_create_tx(&RecordCreatePlan { + coin_txid: plan.coin_txid, + coin_vout: plan.coin_vout, + coin_value: plan.coin_value, + asset: plan.asset, + key_secret: plan.controller_secret, + record_script: sequentia_delegation_script(&controller, &plan.signer), + record_value: plan.record_value, + change_spk: plan.change_spk.clone(), + fee_atoms: plan.fee_atoms, + dust_floor: plan.dust_floor, + locktime: plan.locktime, + }) +} + /// Parse a 33-byte compressed secp256k1 public key from hex, rejecting anything /// that is not one. Every pubkey crossing this boundary comes from a pool /// listing or a user paste, so it is checked rather than trusted. @@ -240,14 +326,11 @@ pub fn parse_delegation_script(script: &Script) -> Option<(Vec, Vec)> { Some((controller, signer)) } -/// Hash160 of a compressed public key, for the p2wpkh reclaim destination a -/// light wallet uses when it takes its delegation back. +/// The `P2WPKH` script of a compressed public key: the reclaim destination a +/// light wallet may use when it takes its delegation back, and the kind of +/// coin of the controller a record's creation spends. pub fn p2wpkh_script_pubkey(compressed_pubkey: &[u8]) -> Script { - let pkh = hash160::Hash::hash(compressed_pubkey).to_byte_array(); - elements::script::Builder::new() - .push_int(0) - .push_slice(&pkh) - .into_script() + key_coin_script(compressed_pubkey) } /// Parse a txid from hex. @@ -258,6 +341,11 @@ pub fn delegation_txid_from_hex(hex: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use elements::bitcoin::hashes::Hash as _; + use elements::hashes::hash160; + use elements::secp256k1_zkp::Message; + use elements::sighash::SighashCache; + use elements::EcdsaSighashType; fn key(byte: u8) -> (SecretKey, Vec) { let secp = Secp256k1::new(); @@ -277,10 +365,18 @@ mod tests { assert_eq!(&bytes[1..7], DELEGATION_MARKER); assert_eq!(bytes[7], opcodes::all::OP_DROP.into_u8()); assert_eq!(bytes[8], 33); - assert_eq!(&bytes[9..42], signer.as_slice(), "the signer is pushed FIRST"); + assert_eq!( + &bytes[9..42], + signer.as_slice(), + "the signer is pushed FIRST" + ); assert_eq!(bytes[42], opcodes::all::OP_DROP.into_u8()); assert_eq!(bytes[43], 33); - assert_eq!(&bytes[44..77], controller.as_slice(), "the controller is what OP_CHECKSIG tests"); + assert_eq!( + &bytes[44..77], + controller.as_slice(), + "the controller is what OP_CHECKSIG tests" + ); assert_eq!(bytes[77], opcodes::all::OP_CHECKSIG.into_u8()); assert_eq!(bytes.len(), 78); } @@ -359,6 +455,7 @@ mod tests { fee_atoms: 1_000, dust_floor: 1_000, locktime: 500, + signing: StakeRecordSigning::SegwitV0, }, controller, ) @@ -368,14 +465,21 @@ mod tests { fn reclaim_spends_the_record_to_the_wallet() { let (p, controller) = plan(None); let (raw, _txid) = build_delegation_spend_tx(&p).unwrap(); - let tx: Transaction = elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); assert_eq!(tx.input.len(), 1); assert_eq!(tx.output.len(), 2, "destination + the explicit fee output"); - assert_eq!(tx.output[0].script_pubkey, p2wpkh_script_pubkey(&controller)); + assert_eq!( + tx.output[0].script_pubkey, + p2wpkh_script_pubkey(&controller) + ); assert_eq!(tx.output[0].value, confidential::Value::Explicit(99_000)); assert!(tx.output[1].is_fee()); assert_eq!(tx.output[1].value, confidential::Value::Explicit(1_000)); - assert!(!tx.input[0].script_sig.is_empty(), "the record must be signed"); + assert!( + !tx.input[0].script_sig.is_empty(), + "the record must be signed" + ); } #[test] @@ -383,12 +487,14 @@ mod tests { let (_, new_signer) = key(11); let (p, controller) = plan(Some(new_signer.clone())); let (raw, _) = build_delegation_spend_tx(&p).unwrap(); - let tx: Transaction = elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); // The whole point: the old record is consumed and the new one created by // the SAME transaction, so no block can ever hold two for one controller. assert_eq!(tx.input.len(), 1); assert_eq!(tx.input[0].previous_output.vout, 1); - let (c, s) = parse_delegation_script(&tx.output[0].script_pubkey).expect("output 0 is a record"); + let (c, s) = + parse_delegation_script(&tx.output[0].script_pubkey).expect("output 0 is a record"); assert_eq!(c, controller); assert_eq!(s, new_signer); } @@ -412,7 +518,10 @@ mod tests { let e = build_delegation_spend_tx(&p).unwrap_err().to_string(); assert!(e.contains("relay"), "unexpected error: {e}"); // The stake is never involved in any of this, and the message says so. - assert!(e.contains("stake was never at risk"), "unexpected error: {e}"); + assert!( + e.contains("stake was never at risk"), + "unexpected error: {e}" + ); } #[test] @@ -422,47 +531,232 @@ mod tests { rotate_to: Some(p.current_signer.clone()), ..p.clone() }; - assert!(build_delegation_spend_tx(&same).is_err(), "re-pointing to the same signer"); + assert!( + build_delegation_spend_tx(&same).is_err(), + "re-pointing to the same signer" + ); let secp = Secp256k1::new(); - let controller = elements::secp256k1_zkp::PublicKey::from_secret_key(&secp, &p.controller_secret) - .serialize() - .to_vec(); + let controller = + elements::secp256k1_zkp::PublicKey::from_secret_key(&secp, &p.controller_secret) + .serialize() + .to_vec(); let to_self = DelegationSpendPlan { rotate_to: Some(controller), ..p.clone() }; - assert!(build_delegation_spend_tx(&to_self).is_err(), "re-pointing at yourself"); + assert!( + build_delegation_spend_tx(&to_self).is_err(), + "re-pointing at yourself" + ); } #[test] fn signature_satisfies_the_record_script() { // The signature must verify against the exact script the record commits - // to. This is the check that catches a wrong sighash or a mixed-up - // controller/signer order, which would otherwise only show up as a - // rejected broadcast. + // to, under the hash the chain wants. This is the check that catches a + // wrong sighash or a mixed-up controller/signer order, which would + // otherwise only show up as a rejected broadcast. + for signing in [StakeRecordSigning::Legacy, StakeRecordSigning::SegwitV0] { + let (mut p, controller) = plan(None); + p.signing = signing; + let (raw, _) = build_delegation_spend_tx(&p).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + let script = sequentia_delegation_script(&controller, &p.current_signer); + let sighash = crate::sequentia_stake_records::stake_record_sighash( + &tx, + 0, + &script, + confidential::Value::Explicit(p.record_value), + signing, + ) + .unwrap(); + crate::sequentia_stake_records::tests::verify_single_push( + &tx, + 0, + &sighash, + &controller, + ); + } + } + + #[test] + fn the_two_signatures_differ_and_only_the_second_commits_to_the_amount() { let (p, controller) = plan(None); let (raw, _) = build_delegation_spend_tx(&p).unwrap(); - let tx: Transaction = elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); let script = sequentia_delegation_script(&controller, &p.current_signer); - let sighash = SighashCache::new(&tx).legacy_sighash(0, &script, EcdsaSighashType::All); - - // Pull the signature back out of the scriptSig and verify it. - let sig_push = tx.input[0] - .script_sig - .instructions() - .next() + let hash = |value: u64, signing| { + crate::sequentia_stake_records::stake_record_sighash( + &tx, + 0, + &script, + confidential::Value::Explicit(value), + signing, + ) .unwrap() - .unwrap() - .push_bytes() - .unwrap() - .to_vec(); - assert_eq!(*sig_push.last().unwrap(), SIGHASH_ALL_BYTE); - let sig = elements::secp256k1_zkp::ecdsa::Signature::from_der(&sig_push[..sig_push.len() - 1]).unwrap(); - let secp = Secp256k1::new(); - let pk = elements::secp256k1_zkp::PublicKey::from_slice(&controller).unwrap(); - secp.verify_ecdsa(&Message::from_digest(sighash.to_byte_array()), &sig, &pk) - .expect("the scriptSig must satisfy the record it spends"); + }; + let v2 = StakeRecordSigning::SegwitV0; + let legacy = StakeRecordSigning::Legacy; + assert_ne!(hash(p.record_value, v2), hash(p.record_value, legacy)); + assert_ne!( + hash(p.record_value, v2), + hash(p.record_value + 1, v2), + "the amount is committed" + ); + assert_eq!( + hash(p.record_value, legacy), + hash(p.record_value + 1, legacy), + "the legacy hash ignores it" + ); + } + + #[test] + fn matches_the_nodes_signed_vectors() { + // The reclaim of `plan(None)`, built and signed by the node's own test + // framework (test/functional/test_framework/script.py, + // `PosRecordSignatureHash`, with RFC 6979 low-S signing) for each + // generation of stake records. The kit must produce the same hash and + // the same transaction byte for byte: a hash that differs in one + // committed field still yields a well-formed signature, which the + // chain refuses only at broadcast. + let vectors = [ + ( + StakeRecordSigning::Legacy, + "4dac157ced649ca73c2363270ac2c66c2ecbb542e7f7bcdde404d5af31c9c2f0", + "02000000000109090909090909090909090909090909090909090909090909090909090909090100000049483045022100a954f962ea9741d1d5fbae4ba639aed176b83226f0238307e749be77377125b402204b54732a976c4382effed5b928254e720346828a2b0af315825e92ee7307dbad01fdffffff020103030303030303030303030303030303030303030303030303030303030303030100000000000182b800160014a3c6b1ee4a49d9f2af3b3802974744fba924164a0103030303030303030303030303030303030303030303030303030303030303030100000000000003e80000f4010000", + ), + ( + StakeRecordSigning::SegwitV0, + "0c3d13a49395473dfc335bbae3cb4d429c3f2d6addbe6b5a4fd9cd7844b36b75", + "020000000001090909090909090909090909090909090909090909090909090909090909090901000000484730440220723cb39f64b3a4192cacbf0d77dfd79686675458c2dbfcda5646525e9a63c4b102205e41a12d8e7d5a71fe1d69b16da0a7d7e43376c49636d8ce745cdc9f952004c701fdffffff020103030303030303030303030303030303030303030303030303030303030303030100000000000182b800160014a3c6b1ee4a49d9f2af3b3802974744fba924164a0103030303030303030303030303030303030303030303030303030303030303030100000000000003e80000f4010000", + ), + ]; + for (signing, sighash_hex, raw_hex) in vectors { + let (mut p, controller) = plan(None); + p.signing = signing; + let (raw, _) = build_delegation_spend_tx(&p).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + let sighash = crate::sequentia_stake_records::stake_record_sighash( + &tx, + 0, + &sequentia_delegation_script(&controller, &p.current_signer), + confidential::Value::Explicit(p.record_value), + signing, + ) + .unwrap(); + assert_eq!( + elements::hex::ToHex::to_hex(sighash.as_slice()), + sighash_hex, + "{signing:?}" + ); + assert_eq!(raw, raw_hex, "{signing:?}"); + } + } + + fn create_plan() -> (DelegationCreatePlan, Vec) { + let (controller_sk, controller) = key(7); + let (_, signer) = key(8); + ( + DelegationCreatePlan { + coin_txid: Txid::from_slice(&[10u8; 32]).unwrap(), + coin_vout: 2, + coin_value: 101_000, + asset: AssetId::from_slice(&[3u8; 32]).unwrap(), + controller_secret: controller_sk, + signer, + record_value: 100_000, + change_spk: p2wpkh_script_pubkey(&controller), + fee_atoms: 1_000, + dust_floor: 1_000, + locktime: 7, + }, + controller, + ) + } + + #[test] + fn create_spends_the_controllers_coin_into_the_record() { + let (p, controller) = create_plan(); + let (raw, _) = build_delegation_create_tx(&p).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + assert_eq!(tx.input.len(), 1, "the controller's coin and nothing else"); + assert_eq!(tx.input[0].previous_output, OutPoint::new(p.coin_txid, 2)); + assert_eq!( + tx.output.len(), + 2, + "the record and the fee: nothing left over" + ); + let (c, s) = parse_delegation_script(&tx.output[0].script_pubkey).unwrap(); + assert_eq!(c, controller); + assert_eq!(s, p.signer); + assert_eq!(tx.output[0].value, confidential::Value::Explicit(100_000)); + assert!(tx.output[1].is_fee()); + + // The witness is a P2WPKH spend of the controller's key. + let wit = &tx.input[0].witness.script_witness; + assert_eq!(wit.len(), 2); + assert_eq!(wit[1], controller); + assert!(tx.input[0].script_sig.is_empty()); + let pkh = hash160::Hash::hash(&controller).to_byte_array(); + let code = elements::script::Builder::new() + .push_opcode(opcodes::all::OP_DUP) + .push_opcode(opcodes::all::OP_HASH160) + .push_slice(&pkh) + .push_opcode(opcodes::all::OP_EQUALVERIFY) + .push_opcode(opcodes::all::OP_CHECKSIG) + .into_script(); + let sighash = SighashCache::new(&tx).segwitv0_sighash( + 0, + &code, + confidential::Value::Explicit(p.coin_value), + EcdsaSighashType::All, + ); + let sig = elements::secp256k1_zkp::ecdsa::Signature::from_der(&wit[0][..wit[0].len() - 1]) + .unwrap(); + Secp256k1::new() + .verify_ecdsa( + &Message::from_digest(sighash.to_byte_array()), + &sig, + &elements::secp256k1_zkp::PublicKey::from_slice(&controller).unwrap(), + ) + .expect("the controller's coin is signed by the controller"); + } + + #[test] + fn create_returns_change_and_refuses_what_the_network_would() { + let (mut p, controller) = create_plan(); + p.coin_value = 150_000; + let (raw, _) = build_delegation_create_tx(&p).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + assert_eq!(tx.output.len(), 3); + assert_eq!( + tx.output[1].script_pubkey, + p2wpkh_script_pubkey(&controller) + ); + assert_eq!(tx.output[1].value, confidential::Value::Explicit(49_000)); + + p.coin_value = 101_500; // 500 atoms of change, below the floor + assert!(build_delegation_create_tx(&p) + .unwrap_err() + .to_string() + .contains("change")); + p.coin_value = 100_500; // short of record + fee + assert!(build_delegation_create_tx(&p) + .unwrap_err() + .to_string() + .contains("does not cover")); + let (mut p, controller) = create_plan(); + p.signer = controller; // bad-delegation-self + assert!(build_delegation_create_tx(&p) + .unwrap_err() + .to_string() + .contains("controller itself")); } #[test] @@ -471,7 +765,10 @@ mod tests { let hex = elements::hex::ToHex::to_hex(pk.as_slice()); assert!(delegation_pubkey_from_hex(&hex, "signer").is_ok()); assert!(delegation_pubkey_from_hex("not hex", "signer").is_err()); - assert!(delegation_pubkey_from_hex("02ab", "signer").is_err(), "too short"); + assert!( + delegation_pubkey_from_hex("02ab", "signer").is_err(), + "too short" + ); // Right length, wrong prefix: an uncompressed-style lead byte. assert!(delegation_pubkey_from_hex(&format!("04{}", &hex[2..]), "signer").is_err()); } diff --git a/lwk_wollet/src/sequentia_stake_records.rs b/lwk_wollet/src/sequentia_stake_records.rs new file mode 100644 index 000000000..37a65c834 --- /dev/null +++ b/lwk_wollet/src/sequentia_stake_records.rs @@ -0,0 +1,1076 @@ +//! SEQUENTIA stake records, from a light wallet: which signature a spend of one +//! carries, how it is signed, and the two steps that take a stake out. +//! +//! Four bare scripts hold a staker's standing on chain, and each ends in +//! ` OP_CHECKSIG`, so only that key can spend it: +//! +//! * the staking output, [`crate::sequentia_stake_script`]; +//! * the unbonding output, [`sequentia_unbond_script`]; +//! * the delegation record, [`crate::sequentia_delegation_script`]; +//! * the payout record, which the node wallet announces (`announcepayout`). +//! +//! A bare script matches no descriptor, so the wallet's PSET signer never signs +//! a spend of one. The builders here and in [`crate::sequentia_delegation`] do. +//! +//! # Which signature a record spend carries +//! +//! The chain's `pos_records_v2_height` decides, block by block: +//! +//! * below it, the legacy signature hash over the record script, which commits +//! to no amount; +//! * from it, the segwit-v0 signature hash with the record script as the script +//! code and the amount spent committed, and a scriptSig with exactly one valid +//! encoding: one minimal push of a low-S signature. +//! +//! A spend signed for one side is invalid on the other. The wallet therefore +//! signs for the block it expects the spend to enter, the one after its tip +//! ([`StakeRecordSigning::for_next_block`]). A spend signed just below the +//! height and still unconfirmed when the chain reaches it is evicted from the +//! mempool and has to be built again. +//! +//! The height is fixed per chain and no node RPC reports it, so the kit carries +//! it ([`pos_records_v2_height`]): 163,000 on the testnet, block 1 on every +//! other chain. A custom chain started with `-posrecordsv2height` set to +//! something else needs that value passed to +//! [`StakeRecordSigning::for_next_block`] directly. +//! +//! # Unbonding, in two steps +//! +//! A staking output may only be spent into another staking output or into an +//! unbonding output of the same key, losing at most a capped fee on the way +//! ([`build_unbond_tx`]). The unbonding output carries no stake weight. Its +//! coins can be sent to an address ([`build_unbond_claim_tx`]) once the parent +//! chain (Bitcoin) has advanced the unbonding depth past the anchor of the block +//! that created it: 2,016 Bitcoin blocks on the testnet, counted in Sequentia +//! blocks on a chain without anchoring. A claim sent earlier is refused with +//! `bad-unbond-premature`. + +use elements::bitcoin::hashes::Hash as _; +use elements::encode::serialize_hex; +use elements::hashes::hash160; +use elements::opcodes::all::{ + OP_CHECKSIG, OP_CLTV, OP_CSV, OP_DROP, OP_DUP, OP_EQUALVERIFY, OP_HASH160, OP_PUSHNUM_1, + OP_PUSHNUM_16, +}; +use elements::script::{Builder, Instruction}; +use elements::secp256k1_zkp::{Message, PublicKey, Secp256k1, SecretKey}; +use elements::sighash::SighashCache; +use elements::{ + confidential, AssetId, EcdsaSighashType, LockTime, OutPoint, Script, Sequence, Transaction, + TxIn, TxInWitness, TxOut, TxOutWitness, Txid, +}; + +use crate::error::Error; + +/// The height from which the Sequentia testnet enforces the second generation +/// of stake records (`pos_records_v2_height` in the node's chain parameters). +pub const SEQUENTIA_TESTNET_POS_RECORDS_V2_HEIGHT: u32 = 163_000; + +/// The share of a stake, in thousandths, that unbonding may spend on its fee +/// (the node's `POS_UNBOND_MAX_FEE_PERMILLE`). +pub const UNBOND_MAX_FEE_PERMILLE: u64 = 10; + +/// The unbonding output's marker, byte for byte the node's `UNBOND_MARKER`. +const UNBOND_MARKER: &[u8; 9] = b"SEQUNBOND"; + +/// SIGHASH_ALL, as the byte appended to a DER signature. +const SIGHASH_ALL_BYTE: u8 = 0x01; + +/// BIP68: the relative lock counts 512-second units rather than blocks. +const SEQUENCE_LOCKTIME_TYPE_FLAG: u32 = 1 << 22; +/// BIP68: the bits of a relative lock that hold its value. +const SEQUENCE_LOCKTIME_MASK: u32 = 0x0000_ffff; + +/// The first block height at which `network` signs stake record spends the +/// second-generation way: 163,000 on the Sequentia testnet, 1 on every other +/// chain. +/// +/// The testnet is recognised by its whole definition, genesis hash included, +/// so a chain started afresh under the same name falls to the rule every new +/// chain has from its first block. +pub fn pos_records_v2_height(network: &lwk_common::Network) -> u32 { + if *network == lwk_common::Network::sequentia_testnet() { + SEQUENTIA_TESTNET_POS_RECORDS_V2_HEIGHT + } else { + 1 + } +} + +/// The signature a spend of a stake record carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StakeRecordSigning { + /// Below `pos_records_v2_height`: the legacy signature hash over the + /// record script, which commits to no amount. + Legacy, + /// From `pos_records_v2_height`: the segwit-v0 signature hash with the + /// record script as the script code and the amount spent committed, in a + /// canonical scriptSig. + SegwitV0, +} + +impl StakeRecordSigning { + /// The signature a spend needs to be valid in the block at `block_height`. + /// A `records_v2_height` of 0 means the chain never switches. + pub fn at_height(block_height: u32, records_v2_height: u32) -> Self { + if records_v2_height > 0 && block_height >= records_v2_height { + StakeRecordSigning::SegwitV0 + } else { + StakeRecordSigning::Legacy + } + } + + /// The signature a spend needs to enter the block after `tip_height`, which + /// is the block a spend built now is meant for. + pub fn for_next_block(tip_height: u32, records_v2_height: u32) -> Self { + Self::at_height(tip_height.saturating_add(1), records_v2_height) + } + + /// [`Self::for_next_block`] with the height `network` switches at. + pub fn for_network(network: &lwk_common::Network, tip_height: u32) -> Self { + Self::for_next_block(tip_height, pos_records_v2_height(network)) + } +} + +/// The hash a spend of `record_script` at `input_index` signs, under `signing`. +/// `value` is the spent output's value, which only the second generation +/// commits to. +pub fn stake_record_sighash( + tx: &Transaction, + input_index: usize, + record_script: &Script, + value: confidential::Value, + signing: StakeRecordSigning, +) -> Result<[u8; 32], Error> { + if input_index >= tx.input.len() { + return Err(Error::Generic(format!( + "input {input_index} is not in a transaction of {} inputs", + tx.input.len() + ))); + } + let mut cache = SighashCache::new(tx); + let sighash = match signing { + StakeRecordSigning::Legacy => { + cache.legacy_sighash(input_index, record_script, EcdsaSighashType::All) + } + StakeRecordSigning::SegwitV0 => { + cache.segwitv0_sighash(input_index, record_script, value, EcdsaSighashType::All) + } + }; + Ok(sighash.to_byte_array()) +} + +/// Sign the spend of a stake record at `input_index` with `secret`, setting +/// the input's scriptSig to the one push the record takes. +/// +/// The signature is deterministic (RFC 6979) and low-S, and the push is +/// minimal, which is the one encoding the second generation accepts and is +/// equally valid under the legacy rule. Sign after every input and output is in +/// place: the signature commits to all of them. +pub fn sign_stake_record_input( + tx: &mut Transaction, + input_index: usize, + record_script: &Script, + value: confidential::Value, + secret: &SecretKey, + signing: StakeRecordSigning, +) -> Result<(), Error> { + let sighash = stake_record_sighash(tx, input_index, record_script, value, signing)?; + let secp = Secp256k1::signing_only(); + let signature = secp.sign_ecdsa(&Message::from_digest(sighash), secret); + let mut der = signature.serialize_der().to_vec(); + der.push(SIGHASH_ALL_BYTE); + let input = &mut tx.input[input_index]; + input.script_sig = Builder::new().push_slice(&der).into_script(); + // A record is spent with no witness at all. + input.witness.script_witness.clear(); + Ok(()) +} + +/// The canonical unbonding output for `staker_pubkey`, a byte-for-byte mirror +/// of the node's `BuildUnbondScript`: +/// `<"SEQUNBOND"> OP_DROP OP_CHECKSIG`. +pub fn sequentia_unbond_script(staker_pubkey: &[u8]) -> Script { + Builder::new() + .push_slice(UNBOND_MARKER) + .push_opcode(OP_DROP) + .push_slice(staker_pubkey) + .push_opcode(OP_CHECKSIG) + .into_script() +} + +/// The staker key of an unbonding output, or `None` if `script` is not one. +pub fn parse_unbond_script(script: &Script) -> Option> { + let mut ins = script.instructions(); + if ins.next()?.ok()?.push_bytes()? != UNBOND_MARKER.as_slice() { + return None; + } + if ins.next()?.ok()?.op()? != OP_DROP { + return None; + } + let pubkey = ins.next()?.ok()?.push_bytes()?.to_vec(); + if ins.next()?.ok()?.op()? != OP_CHECKSIG || ins.next().is_some() { + return None; + } + if pubkey.len() != 33 || PublicKey::from_slice(&pubkey).is_err() { + return None; + } + Some(pubkey) +} + +/// A staking output's script, read back. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedStakeScript { + /// The BIP68 relative lock a spend's input must carry as its sequence. + pub csv: u32, + /// The staker key, 33 bytes compressed. + pub staker_pubkey: Vec, + /// Whether the output registers a committee BLS key (data only; it does + /// not change how the output is spent). + pub registers_bls_key: bool, + /// The absolute vesting lock, when the stake has one. + pub vesting_locktime: Option, +} + +/// Read a staking output's script: ` OP_CHECKSEQUENCEVERIFY OP_DROP`, +/// optionally ` OP_DROP OP_DROP`, optionally +/// ` OP_CHECKLOCKTIMEVERIFY OP_DROP`, then ` OP_CHECKSIG`. +/// +/// Only the node's canonical encoding is recognised (minimal numbers, a +/// non-zero relative lock with no stray bits, a compressed key), which is what +/// the node counts as stake. +pub fn parse_stake_script(script: &Script) -> Option { + let mut ins = script.instructions().peekable(); + let csv = script_number(&ins.next()?.ok()?)?; + if csv < 1 || csv > u32::MAX as i64 { + return None; + } + let csv = csv as u32; + if csv & !(SEQUENCE_LOCKTIME_TYPE_FLAG | SEQUENCE_LOCKTIME_MASK) != 0 + || csv & SEQUENCE_LOCKTIME_MASK == 0 + { + return None; + } + if ins.next()?.ok()?.op()? != OP_CSV || ins.next()?.ok()?.op()? != OP_DROP { + return None; + } + let mut push = ins.next()?.ok()?.push_bytes()?.to_vec(); + let mut bls = None; + if push.len() == 48 { + if ins.next()?.ok()?.op()? != OP_DROP { + return None; + } + let pop = ins.next()?.ok()?.push_bytes()?.to_vec(); + if pop.len() != 96 || ins.next()?.ok()?.op()? != OP_DROP { + return None; + } + bls = Some((push, pop)); + push = ins.next()?.ok()?.push_bytes()?.to_vec(); + } + // A vesting lock is told from the key by what follows it, as in the node. + let mut vesting = None; + if let Some(Ok(Instruction::Op(op))) = ins.peek() { + if *op == OP_CLTV { + ins.next(); + let value = script_number(&Instruction::PushBytes(&push))?; + if value <= 0 || value > u32::MAX as i64 { + return None; + } + vesting = Some(value as u32); + if ins.next()?.ok()?.op()? != OP_DROP { + return None; + } + push = ins.next()?.ok()?.push_bytes()?.to_vec(); + } + } + if ins.next()?.ok()?.op()? != OP_CHECKSIG || ins.next().is_some() { + return None; + } + if push.len() != 33 || PublicKey::from_slice(&push).is_err() { + return None; + } + // Rebuild it canonically: anything the builder would not have written, a + // non-minimal number above all, is not a stake the node counts. + let mut b = Builder::new() + .push_int(csv as i64) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP); + if let Some((key, pop)) = &bls { + b = b + .push_slice(key) + .push_opcode(OP_DROP) + .push_slice(pop) + .push_opcode(OP_DROP); + } + if let Some(lock) = vesting { + b = b + .push_int(lock as i64) + .push_opcode(OP_CLTV) + .push_opcode(OP_DROP); + } + let canonical = b.push_slice(&push).push_opcode(OP_CHECKSIG).into_script(); + if canonical != *script { + return None; + } + Some(ParsedStakeScript { + csv, + staker_pubkey: push, + registers_bls_key: bls.is_some(), + vesting_locktime: vesting, + }) +} + +/// A script number: `OP_1`..`OP_16` or a push of up to five bytes. Minimality +/// is checked by the caller's canonical rebuild. +fn script_number(ins: &Instruction<'_>) -> Option { + match ins { + Instruction::Op(op) + if op.into_u8() >= OP_PUSHNUM_1.into_u8() + && op.into_u8() <= OP_PUSHNUM_16.into_u8() => + { + Some((op.into_u8() - OP_PUSHNUM_1.into_u8() + 1) as i64) + } + Instruction::PushBytes(data) if !data.is_empty() && data.len() <= 5 => { + let mut value: i64 = 0; + for (i, byte) in data.iter().enumerate() { + value |= (*byte as i64) << (8 * i); + } + if data[data.len() - 1] & 0x80 != 0 { + return None; // negative: never a lock + } + Some(value) + } + _ => None, + } +} + +/// The most a stake may pay in fees out of itself on its way to unbonding: 1% +/// of the staked total, rounded the way the node rounds it. +pub fn unbond_fee_cap(staked_total: u64) -> u64 { + staked_total / 1000 * UNBOND_MAX_FEE_PERMILLE +} + +/// One staking output of the wallet's staker key. +#[derive(Debug, Clone)] +pub struct StakeOutput { + /// The transaction that created it. + pub txid: Txid, + /// Its index there. + pub vout: u32, + /// Its explicit value, in atoms of the Sequence token. + pub value: u64, + /// Its script, as found on chain. It names the relative lock the spend's + /// sequence must carry. + pub script_pubkey: Script, +} + +/// Everything needed to move stake into an unbonding output. +#[derive(Debug, Clone)] +pub struct UnbondPlan { + /// The staking outputs to unbond, all of the staker key below, each past + /// its relative lock. + pub stakes: Vec, + /// The Sequence token's asset id. + pub asset: AssetId, + /// The staker key's secret. + pub staker_secret: SecretKey, + /// The network fee, taken out of the stake; at most + /// [`unbond_fee_cap`] of the staked total. + pub fee_atoms: u64, + /// nLockTime, normally the current tip. + pub locktime: u32, + /// The signature the next block wants ([`StakeRecordSigning::for_next_block`]). + pub signing: StakeRecordSigning, +} + +/// Build and sign the first step of unbonding: every staking output in `plan` +/// spent into one unbonding output of the same key, less the fee. Returns +/// `(raw_hex, txid)`. +/// +/// The stake stops counting when this confirms; the coins wait in the +/// unbonding output until [`build_unbond_claim_tx`] can send them on. +pub fn build_unbond_tx(plan: &UnbondPlan) -> Result<(String, Txid), Error> { + if plan.stakes.is_empty() { + return Err(Error::Generic("no staking outputs to unbond".into())); + } + let secp = Secp256k1::signing_only(); + let staker = PublicKey::from_secret_key(&secp, &plan.staker_secret) + .serialize() + .to_vec(); + + let mut total: u64 = 0; + let mut inputs = Vec::with_capacity(plan.stakes.len()); + for stake in &plan.stakes { + let parsed = parse_stake_script(&stake.script_pubkey).ok_or_else(|| { + Error::Generic(format!( + "{}:{} is not a staking output", + stake.txid, stake.vout + )) + })?; + if parsed.staker_pubkey != staker { + return Err(Error::Generic(format!( + "{}:{} is staked to another key; this wallet's staker key cannot unbond it", + stake.txid, stake.vout + ))); + } + if parsed.vesting_locktime.is_some() { + return Err(Error::Generic(format!( + "{}:{} carries a vesting lock; unbond it from the node wallet (withdrawstake)", + stake.txid, stake.vout + ))); + } + total = total + .checked_add(stake.value) + .ok_or_else(|| Error::Generic("staked total overflows".into()))?; + inputs.push(TxIn { + previous_output: OutPoint::new(stake.txid, stake.vout), + is_pegin: false, + script_sig: Script::new(), + // BIP68: the input must carry the script's own relative lock. + sequence: Sequence::from_consensus(parsed.csv), + asset_issuance: Default::default(), + witness: TxInWitness::default(), + }); + } + + let cap = unbond_fee_cap(total); + if plan.fee_atoms > cap { + return Err(Error::Generic(format!( + "a {} atom fee is more than unbonding may take out of the stake: {} atoms, 1% of the {} staked", + plan.fee_atoms, cap, total + ))); + } + if plan.fee_atoms >= total { + return Err(Error::Generic(format!( + "the stake ({total} atoms) does not cover the {} atom fee", + plan.fee_atoms + ))); + } + + let mut tx = Transaction { + version: 2, + lock_time: LockTime::from_consensus(plan.locktime), + input: inputs, + output: vec![ + explicit_output( + plan.asset, + total - plan.fee_atoms, + sequentia_unbond_script(&staker), + ), + TxOut::new_fee(plan.fee_atoms, plan.asset), + ], + }; + for (i, stake) in plan.stakes.iter().enumerate() { + sign_stake_record_input( + &mut tx, + i, + &stake.script_pubkey, + confidential::Value::Explicit(stake.value), + &plan.staker_secret, + plan.signing, + )?; + } + let txid = tx.txid(); + Ok((serialize_hex(&tx), txid)) +} + +/// One unbonding output of the wallet's staker key. +#[derive(Debug, Clone)] +pub struct UnbondingOutput { + /// The transaction that created it (the first step of unbonding). + pub txid: Txid, + /// Its index there. + pub vout: u32, + /// Its explicit value, in atoms of the Sequence token. + pub value: u64, +} + +/// Everything needed to send unbonded coins to an address. +#[derive(Debug, Clone)] +pub struct UnbondClaimPlan { + /// The unbonding outputs to claim, all of the staker key below, each past + /// the unbonding depth. + pub unbonding: Vec, + /// The Sequence token's asset id. + pub asset: AssetId, + /// The staker key's secret. + pub staker_secret: SecretKey, + /// Where the coins go. + pub destination: Script, + /// The network fee, taken out of the coins claimed. + pub fee_atoms: u64, + /// The dust floor the claimed output must clear to relay. 0 disables it. + pub dust_floor: u64, + /// nLockTime, normally the current tip. + pub locktime: u32, + /// The signature the next block wants ([`StakeRecordSigning::for_next_block`]). + pub signing: StakeRecordSigning, +} + +/// Build and sign the second step of unbonding: every unbonding output in +/// `plan` sent to `destination`, less the fee. Returns `(raw_hex, txid)`. +/// +/// Valid only once the parent chain has advanced the unbonding depth past the +/// anchor of the block that created each output; the node refuses an earlier +/// claim with `bad-unbond-premature`. +pub fn build_unbond_claim_tx(plan: &UnbondClaimPlan) -> Result<(String, Txid), Error> { + if plan.unbonding.is_empty() { + return Err(Error::Generic("no unbonding outputs to claim".into())); + } + let secp = Secp256k1::signing_only(); + let staker = PublicKey::from_secret_key(&secp, &plan.staker_secret) + .serialize() + .to_vec(); + let script = sequentia_unbond_script(&staker); + + let mut total: u64 = 0; + for u in &plan.unbonding { + total = total + .checked_add(u.value) + .ok_or_else(|| Error::Generic("unbonded total overflows".into()))?; + } + if plan.fee_atoms >= total { + return Err(Error::Generic(format!( + "the unbonded coins ({total} atoms) do not cover the {} atom fee", + plan.fee_atoms + ))); + } + let out_value = total - plan.fee_atoms; + if out_value < plan.dust_floor { + return Err(Error::Generic(format!( + "this would leave {out_value} atoms, below the {} the network will relay", + plan.dust_floor + ))); + } + + let mut tx = Transaction { + version: 2, + lock_time: LockTime::from_consensus(plan.locktime), + input: plan + .unbonding + .iter() + .map(|u| TxIn { + previous_output: OutPoint::new(u.txid, u.vout), + is_pegin: false, + script_sig: Script::new(), + // No relative lock: the depth is judged from the anchors. + // Replaceable, so a fee that turns out too low can be bumped. + sequence: Sequence::from_consensus(0xffff_fffd), + asset_issuance: Default::default(), + witness: TxInWitness::default(), + }) + .collect(), + output: vec![ + explicit_output(plan.asset, out_value, plan.destination.clone()), + TxOut::new_fee(plan.fee_atoms, plan.asset), + ], + }; + for (i, u) in plan.unbonding.iter().enumerate() { + sign_stake_record_input( + &mut tx, + i, + &script, + confidential::Value::Explicit(u.value), + &plan.staker_secret, + plan.signing, + )?; + } + let txid = tx.txid(); + Ok((serialize_hex(&tx), txid)) +} + +/// Everything needed to create a delegation or payout record from a coin of +/// its key. +#[derive(Debug, Clone)] +pub struct RecordCreatePlan { + /// The key's coin: an explicit Sequence token output paying the key's + /// `P2WPKH` ([`key_coin_script`]). + pub coin_txid: Txid, + /// Its index in that transaction. + pub coin_vout: u32, + /// Its explicit value. + pub coin_value: u64, + /// The Sequence token's asset id; the coin and the record hold it. + pub asset: AssetId, + /// The key's secret: a delegation record's controller, a payout record's + /// signer. + pub key_secret: SecretKey, + /// The record to create. It must name the key; the node refuses one that + /// does not (`bad-delegation-unauthorized`, `bad-payout-unauthorized`). + pub record_script: Script, + /// The record's own value, recoverable when it is spent. + pub record_value: u64, + /// Where whatever the coin holds beyond the record and the fee goes. + /// Unused when nothing is left over. + pub change_spk: Script, + /// Network fee, taken out of the coin. + pub fee_atoms: u64, + /// The dust floor the record and any change must clear to relay. 0 + /// disables it. + pub dust_floor: u64, + /// nLockTime, normally the current tip. + pub locktime: u32, +} + +/// Build and sign the transaction that creates a record, funded by a coin of +/// its key and nothing else. Returns `(raw_hex, txid)`. +/// +/// Spending a coin only the key can spend is what authorises the record. The +/// coin is a key-path `P2WPKH` spend, signed the same way in either generation +/// of stake records; the transaction may spend it unconfirmed, so the payment +/// that created it ([`crate::TxBuilder::add_record_authorization`]) and the +/// record are mined together. +pub fn build_record_create_tx(plan: &RecordCreatePlan) -> Result<(String, Txid), Error> { + let secp = Secp256k1::signing_only(); + let key = PublicKey::from_secret_key(&secp, &plan.key_secret) + .serialize() + .to_vec(); + let spent = plan + .record_value + .checked_add(plan.fee_atoms) + .ok_or_else(|| Error::Generic("record value and fee overflow".into()))?; + if spent > plan.coin_value { + return Err(Error::Generic(format!( + "the key's coin holds {} atoms, which does not cover a {} atom record and its {} atom fee", + plan.coin_value, plan.record_value, plan.fee_atoms + ))); + } + if plan.record_value < plan.dust_floor { + return Err(Error::Generic(format!( + "a {} atom record is below the {} the network will relay", + plan.record_value, plan.dust_floor + ))); + } + let change = plan.coin_value - spent; + if change > 0 && change < plan.dust_floor { + return Err(Error::Generic(format!( + "this would leave {change} atoms of change, below the {} the network will relay; put them into the record or the fee", + plan.dust_floor + ))); + } + + let mut output = vec![explicit_output( + plan.asset, + plan.record_value, + plan.record_script.clone(), + )]; + if change > 0 { + output.push(explicit_output(plan.asset, change, plan.change_spk.clone())); + } + output.push(TxOut::new_fee(plan.fee_atoms, plan.asset)); + let mut tx = Transaction { + version: 2, + lock_time: LockTime::from_consensus(plan.locktime), + input: vec![TxIn { + previous_output: OutPoint::new(plan.coin_txid, plan.coin_vout), + is_pegin: false, + script_sig: Script::new(), + sequence: Sequence::from_consensus(0xffff_fffd), + asset_issuance: Default::default(), + witness: TxInWitness::default(), + }], + output, + }; + + // P2WPKH: the segwit-v0 hash over the P2PKH script code of the key hash, + // committing to the coin's value; witness . + let pkh = hash160::Hash::hash(&key).to_byte_array(); + let script_code = Builder::new() + .push_opcode(OP_DUP) + .push_opcode(OP_HASH160) + .push_slice(&pkh) + .push_opcode(OP_EQUALVERIFY) + .push_opcode(OP_CHECKSIG) + .into_script(); + let sighash = SighashCache::new(&tx).segwitv0_sighash( + 0, + &script_code, + confidential::Value::Explicit(plan.coin_value), + EcdsaSighashType::All, + ); + let signature = secp.sign_ecdsa( + &Message::from_digest(sighash.to_byte_array()), + &plan.key_secret, + ); + let mut der = signature.serialize_der().to_vec(); + der.push(SIGHASH_ALL_BYTE); + tx.input[0].witness.script_witness = vec![der, key]; + + let txid = tx.txid(); + Ok((serialize_hex(&tx), txid)) +} + +/// The `P2WPKH` script of a compressed key: the kind of coin of the key a +/// delegation record's transaction spends to show the key authorised it. +pub fn key_coin_script(compressed_pubkey: &[u8]) -> Script { + let pkh = hash160::Hash::hash(compressed_pubkey).to_byte_array(); + Builder::new().push_int(0).push_slice(&pkh).into_script() +} + +/// The explicit `asset` outputs of `tx` paying [`key_coin_script`] of +/// `compressed_pubkey`, as `(vout, value)`. +pub fn find_key_coins( + tx: &Transaction, + compressed_pubkey: &[u8], + asset: AssetId, +) -> Vec<(u32, u64)> { + let script = key_coin_script(compressed_pubkey); + tx.output + .iter() + .enumerate() + .filter(|(_, out)| out.script_pubkey == script) + .filter_map(|(vout, out)| match (out.asset, out.value) { + (confidential::Asset::Explicit(a), confidential::Value::Explicit(v)) if a == asset => { + Some((vout as u32, v)) + } + _ => None, + }) + .collect() +} + +pub(crate) fn explicit_output(asset: AssetId, value: u64, script_pubkey: Script) -> TxOut { + TxOut { + asset: confidential::Asset::Explicit(asset), + value: confidential::Value::Explicit(value), + nonce: confidential::Nonce::Null, + script_pubkey, + witness: TxOutWitness::default(), + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use elements::hex::{FromHex, ToHex}; + + fn key(byte: u8) -> (SecretKey, Vec) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[byte; 32]).unwrap(); + let pk = PublicKey::from_secret_key(&secp, &sk); + (sk, pk.serialize().to_vec()) + } + + #[test] + fn signing_follows_the_height_of_the_next_block() { + let fork = SEQUENTIA_TESTNET_POS_RECORDS_V2_HEIGHT; + // The tip below the fork's parent signs legacy; the fork's parent is the + // tip from which the next block is the first second-generation one. + assert_eq!( + StakeRecordSigning::for_next_block(fork - 2, fork), + StakeRecordSigning::Legacy + ); + assert_eq!( + StakeRecordSigning::for_next_block(fork - 1, fork), + StakeRecordSigning::SegwitV0 + ); + assert_eq!( + StakeRecordSigning::for_next_block(fork, fork), + StakeRecordSigning::SegwitV0 + ); + // 0 is "never", as in the node. + assert_eq!( + StakeRecordSigning::for_next_block(u32::MAX, 0), + StakeRecordSigning::Legacy + ); + // A fresh chain has it from block 1, so from its genesis tip. + assert_eq!( + StakeRecordSigning::for_next_block(0, 1), + StakeRecordSigning::SegwitV0 + ); + } + + #[test] + fn the_fork_height_is_per_chain() { + let testnet = lwk_common::Network::sequentia_testnet(); + assert_eq!(pos_records_v2_height(&testnet), 163_000); + assert_eq!( + StakeRecordSigning::for_network(&testnet, 162_998), + StakeRecordSigning::Legacy + ); + assert_eq!( + StakeRecordSigning::for_network(&testnet, 162_999), + StakeRecordSigning::SegwitV0 + ); + let regtest = lwk_common::Network::default_regtest(); + assert_eq!(pos_records_v2_height(®test), 1); + assert_eq!( + StakeRecordSigning::for_network(®test, 0), + StakeRecordSigning::SegwitV0 + ); + } + + #[test] + fn unbond_script_matches_the_nodes_layout() { + let (_, pk) = key(1); + let script = sequentia_unbond_script(&pk); + let expected = + format!("09{}75 21{}ac", UNBOND_MARKER.to_hex(), pk.to_hex()).replace(' ', ""); + assert_eq!(script.as_bytes().to_hex(), expected); + assert_eq!(parse_unbond_script(&script), Some(pk.clone())); + assert!(parse_unbond_script(&crate::sequentia_stake_script(&pk, 5)).is_none()); + } + + #[test] + fn stake_scripts_parse_back() { + let (_, pk) = key(2); + for csv in [ + 1u32, + 5, + 16, + 17, + 127, + 128, + 255, + 43_200, + 65_535, + SEQUENCE_LOCKTIME_TYPE_FLAG | 7, + ] { + let parsed = parse_stake_script(&crate::sequentia_stake_script(&pk, csv)) + .unwrap_or_else(|| panic!("csv {csv} parses")); + assert_eq!(parsed.csv, csv); + assert_eq!(parsed.staker_pubkey, pk); + assert!(!parsed.registers_bls_key); + assert_eq!(parsed.vesting_locktime, None); + } + // With a committee registration and a vesting lock, as the node builds it. + let full = Builder::new() + .push_int(10) + .push_opcode(OP_CSV) + .push_opcode(OP_DROP) + .push_slice(&[7u8; 48]) + .push_opcode(OP_DROP) + .push_slice(&[8u8; 96]) + .push_opcode(OP_DROP) + .push_int(200_000) + .push_opcode(OP_CLTV) + .push_opcode(OP_DROP) + .push_slice(&pk) + .push_opcode(OP_CHECKSIG) + .into_script(); + let parsed = parse_stake_script(&full).unwrap(); + assert_eq!(parsed.csv, 10); + assert!(parsed.registers_bls_key); + assert_eq!(parsed.vesting_locktime, Some(200_000)); + } + + #[test] + fn non_canonical_stake_scripts_are_not_stake() { + let (_, pk) = key(3); + // csv 5 pushed as a one-byte push instead of OP_5. + let non_minimal = + Script::from(Vec::::from_hex(&format!("0105b27521{}ac", pk.to_hex())).unwrap()); + assert!(parse_stake_script(&non_minimal).is_none()); + // The disable flag is a stray bit. + assert!(parse_stake_script(&crate::sequentia_stake_script(&pk, (1 << 31) | 5)).is_none()); + // A zero relative lock. + assert!(parse_stake_script(&crate::sequentia_stake_script( + &pk, + SEQUENCE_LOCKTIME_TYPE_FLAG + )) + .is_none()); + assert!(parse_stake_script(&sequentia_unbond_script(&pk)).is_none()); + } + + fn stake(value: u64, csv: u32, pk: &[u8]) -> StakeOutput { + StakeOutput { + txid: Txid::from_slice(&[4u8; 32]).unwrap(), + vout: 0, + value, + script_pubkey: crate::sequentia_stake_script(pk, csv), + } + } + + #[test] + fn unbond_moves_the_stake_to_its_own_unbonding_output() { + let (sk, pk) = key(5); + let plan = UnbondPlan { + stakes: vec![stake(4_000_000_000_000, 43_200, &pk)], + asset: AssetId::from_slice(&[3u8; 32]).unwrap(), + staker_secret: sk, + fee_atoms: 1_000, + locktime: 160_000, + signing: StakeRecordSigning::SegwitV0, + }; + let (raw, _) = build_unbond_tx(&plan).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + assert_eq!(tx.version, 2); + assert_eq!( + tx.input[0].sequence.to_consensus_u32(), + 43_200, + "the input carries the script's lock" + ); + assert_eq!(tx.output[0].script_pubkey, sequentia_unbond_script(&pk)); + assert_eq!( + tx.output[0].value, + confidential::Value::Explicit(4_000_000_000_000 - 1_000) + ); + assert!(tx.output[1].is_fee()); + // The signature is one push and satisfies the second-generation hash. + let sighash = stake_record_sighash( + &tx, + 0, + &plan.stakes[0].script_pubkey, + confidential::Value::Explicit(plan.stakes[0].value), + StakeRecordSigning::SegwitV0, + ) + .unwrap(); + verify_single_push(&tx, 0, &sighash, &pk); + } + + #[test] + fn unbond_matches_the_nodes_signed_vector() { + // Built and signed by the node's own test framework + // (`PosRecordSignatureHash`, second generation, RFC 6979 low-S): the + // kit must produce the same stake script, hash and transaction. + let (sk, pk) = key(5); + let script = crate::sequentia_stake_script(&pk, 43_200); + assert_eq!( + script.as_bytes().to_hex(), + "03c0a800b275210362c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7ac" + ); + let plan = UnbondPlan { + stakes: vec![stake(4_000_000_000_000, 43_200, &pk)], + asset: AssetId::from_slice(&[3u8; 32]).unwrap(), + staker_secret: sk, + fee_atoms: 1_000, + locktime: 160_000, + signing: StakeRecordSigning::SegwitV0, + }; + let (raw, _) = build_unbond_tx(&plan).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + let sighash = stake_record_sighash( + &tx, + 0, + &script, + confidential::Value::Explicit(4_000_000_000_000), + StakeRecordSigning::SegwitV0, + ) + .unwrap(); + assert_eq!( + sighash.to_hex(), + "881d9a78e3a5631bf22c2593359a6dfb5807e3468cc1f517054cbcccb7f4667a" + ); + assert_eq!( + raw, + "0200000000010404040404040404040404040404040404040404040404040404040404040404000000004847304402207e24ad7a9754c17e3d3384ed67f803352595d7268918c08d7eaca3b9a41cd38b022061e52ba1a3aac59d8700f8c87940dae5bc950a0af40b4a5cfcfc348d3ed056a401c0a800000201030303030303030303030303030303030303030303030303030303030303030301000003a352943c18002e09534551554e424f4e4475210362c0a046dacce86ddd0343c6d3c7c79c2208ba0d9c9cf24a6d046d21d21f90f7ac0103030303030303030303030303030303030303030303030303030303030303030100000000000003e8000000710200" + ); + } + + #[test] + fn unbond_refuses_a_fee_above_the_cap_and_another_key() { + let (sk, pk) = key(6); + let (_, other) = key(7); + let mut plan = UnbondPlan { + stakes: vec![stake(100_000, 5, &pk)], + asset: AssetId::from_slice(&[3u8; 32]).unwrap(), + staker_secret: sk, + fee_atoms: 1_001, + locktime: 0, + signing: StakeRecordSigning::SegwitV0, + }; + assert_eq!(unbond_fee_cap(100_000), 1_000); + let e = build_unbond_tx(&plan).unwrap_err().to_string(); + assert!(e.contains("1%"), "unexpected error: {e}"); + plan.fee_atoms = 1_000; + assert!(build_unbond_tx(&plan).is_ok()); + plan.stakes = vec![stake(100_000, 5, &other)]; + assert!(build_unbond_tx(&plan) + .unwrap_err() + .to_string() + .contains("another key")); + } + + #[test] + fn claim_spends_the_unbonding_outputs_to_the_destination() { + let (sk, pk) = key(8); + let dest = key_coin_script(&pk); + let plan = UnbondClaimPlan { + unbonding: vec![ + UnbondingOutput { + txid: Txid::from_slice(&[5u8; 32]).unwrap(), + vout: 0, + value: 70_000, + }, + UnbondingOutput { + txid: Txid::from_slice(&[6u8; 32]).unwrap(), + vout: 0, + value: 30_000, + }, + ], + asset: AssetId::from_slice(&[3u8; 32]).unwrap(), + staker_secret: sk, + destination: dest.clone(), + fee_atoms: 500, + dust_floor: 1_000, + locktime: 9, + signing: StakeRecordSigning::Legacy, + }; + let (raw, _) = build_unbond_claim_tx(&plan).unwrap(); + let tx: Transaction = + elements::encode::deserialize(&Vec::::from_hex(&raw).unwrap()).unwrap(); + assert_eq!(tx.input.len(), 2); + assert_eq!(tx.output[0].script_pubkey, dest); + assert_eq!(tx.output[0].value, confidential::Value::Explicit(99_500)); + for i in 0..2 { + let sighash = stake_record_sighash( + &tx, + i, + &sequentia_unbond_script(&pk), + confidential::Value::Explicit(plan.unbonding[i].value), + StakeRecordSigning::Legacy, + ) + .unwrap(); + verify_single_push(&tx, i, &sighash, &pk); + } + } + + #[test] + fn find_key_coins_reads_explicit_payments_to_the_key() { + let (_, pk) = key(9); + let asset = AssetId::from_slice(&[3u8; 32]).unwrap(); + let other = AssetId::from_slice(&[4u8; 32]).unwrap(); + let tx = Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![ + explicit_output(asset, 5, Script::new()), + explicit_output(asset, 2_000, key_coin_script(&pk)), + explicit_output(other, 3_000, key_coin_script(&pk)), + ], + }; + assert_eq!(find_key_coins(&tx, &pk, asset), vec![(1, 2_000)]); + } + + /// The scriptSig is exactly one minimal push of a low-S DER signature with + /// SIGHASH_ALL, verifying against `sighash` under `pk`. + pub(crate) fn verify_single_push(tx: &Transaction, i: usize, sighash: &[u8; 32], pk: &[u8]) { + let mut ins = tx.input[i].script_sig.instructions_minimal(); + let push = ins.next().unwrap().unwrap().push_bytes().unwrap().to_vec(); + assert!(ins.next().is_none(), "one push and nothing else"); + assert_eq!( + tx.input[i].script_sig.len(), + push.len() + 1, + "a direct push" + ); + assert_eq!(*push.last().unwrap(), SIGHASH_ALL_BYTE); + let mut sig = + elements::secp256k1_zkp::ecdsa::Signature::from_der(&push[..push.len() - 1]).unwrap(); + let normalized = { + let mut s = sig; + s.normalize_s(); + s + }; + assert_eq!(sig, normalized, "low-S"); + sig.normalize_s(); + Secp256k1::verification_only() + .verify_ecdsa( + &Message::from_digest(*sighash), + &sig, + &PublicKey::from_slice(pk).unwrap(), + ) + .expect("the signature satisfies the record"); + assert!(tx.input[i].witness.script_witness.is_empty()); + } +} diff --git a/lwk_wollet/src/tx_builder.rs b/lwk_wollet/src/tx_builder.rs index d0d11bb59..fec25c61d 100644 --- a/lwk_wollet/src/tx_builder.rs +++ b/lwk_wollet/src/tx_builder.rs @@ -349,6 +349,14 @@ impl TxBuilder { /// the old record and create the new one in the same transaction, because /// consensus permits at most one unspent record per controller; use /// [`crate::build_delegation_spend_tx`] for that. + /// + /// The network accepts a record only from a transaction that spends a coin + /// of its controller (from `pos_hardening_height`: 163,000 on the testnet, + /// block 1 on every other chain), and the wallet's descriptor coins are not + /// the staking key's. A wallet creates the record with + /// [`Self::add_record_authorization`] and + /// [`crate::build_delegation_create_tx`] instead; this output alone is valid + /// only below that height. #[cfg(feature = "sequentia")] pub fn add_delegation_output( mut self, @@ -369,6 +377,24 @@ impl TxBuilder { self } + /// Pay `satoshi` of the Sequence token (SEQ), unblinded, to the `P2WPKH` of + /// `pubkey` (33-byte compressed): a coin of that key, which the transaction + /// creating a delegation record spends to show the key authorised it + /// ([`crate::build_delegation_create_tx`]). Pay the record's value plus + /// the fee of that transaction, and spend the coin straight away; the two + /// transactions are mined together. + #[cfg(feature = "sequentia")] + pub fn add_record_authorization(mut self, pubkey: &[u8], satoshi: u64) -> Self { + let asset = *self.network().policy_asset(); + self.recipients.push(Recipient { + satoshi, + script_pubkey: crate::sequentia_stake_records::key_coin_script(pubkey), + blinding_pubkey: None, + asset, + }); + self + } + /// Fee rate in sats/kvb /// Multiply sats/vb value by 1000 i.e. 1.0 sat/byte = 1000.0 sat/kvb pub fn fee_rate(mut self, fee_rate: Option) -> Self { diff --git a/lwk_wollet/tests/sequentia_stake_records.rs b/lwk_wollet/tests/sequentia_stake_records.rs new file mode 100644 index 000000000..5320e4480 --- /dev/null +++ b/lwk_wollet/tests/sequentia_stake_records.rs @@ -0,0 +1,1370 @@ +//! Stake record transactions built and signed by the kit, confirmed in blocks +//! a real `sequentiad` produces. +//! +//! Each test starts a proof-of-stake `sequentiad` on a fresh `elementsregtest` +//! chain, funds a kit wallet from the genesis output, then restarts the node on +//! its default relay policy, under which every kit transaction must relay. +//! +//! `records_from_block_one`, on a chain with the second generation of stake +//! records and the record hardening in force from block 1 (every new chain): +//! +//! - a bond (the wallet's PSET path), its script equal to the node's +//! `getstakescript`; +//! - a delegation created with a coin of its controller: the wallet pays the +//! staking key's `P2WPKH`, and the record's transaction spends it, both mined +//! in one block; the record funded by wallet coins alone is refused +//! (`bad-delegation-unauthorized`); +//! - a re-point and a reclaim of that record; +//! - a payout announcement by the staking key, created the same way, and its +//! withdrawal while still inside its notice; +//! - an unbond in two steps: the stake into its unbonding output, and, once the +//! unbonding depth has passed, the unbonding output to the wallet (refused +//! before it with `bad-unbond-premature`). +//! +//! Every record spend is signed for the next block, which on this chain is the +//! second generation. Each wrong case (the record from wallet coins alone, the +//! same spend signed the legacy way, the claim before the depth) is refused by +//! the node's mempool and by a block, with the reason it names: the block the +//! node produces next, taken back, with the transaction added, judged by +//! `testproposedblock`; the right transaction is valid in that same block. +//! +//! `records_across_the_fork_height`, on a chain switching at height 12: below +//! it a record created from wallet coins alone is valid and a re-point is +//! signed the legacy way (the second-generation signature refused); at it the +//! reclaim is signed the second-generation way (the legacy one refused). +//! +//! Each test then runs `lwk_wasm/tests/node/stake_records.js`, which builds +//! every confirmed transaction again through the wasm bindings, from the recipe +//! a browser wallet would pass, and must get it byte for byte. That half needs +//! `node` and the wasm package built for node.js, linked as `lwk_node` in +//! `lwk_wasm/tests/node/node_modules` (`lwk_wasm/README.md`); without the +//! package it is skipped with a line saying so. +//! +//! Needs `SEQUENTIAD_EXEC` (or `ELEMENTSD_EXEC`) pointing at a `sequentiad`: +//! +//! ```text +//! SEQUENTIAD_EXEC=/path/to/sequentiad \ +//! cargo test -p lwk_wollet --features sequentia --test sequentia_stake_records -- --nocapture +//! ``` +//! +//! Without it the tests fail and say what they need: a test that cannot run +//! must not pass. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::str::FromStr; +use std::time::{Duration, Instant}; + +use elements::bitcoin::bip32::{ChildNumber, DerivationPath}; +use elements::confidential::{Asset, Nonce, Value}; +use elements::encode::{deserialize, serialize}; +use elements::hashes::{sha256d, Hash as _}; +use elements::hex::{FromHex, ToHex}; +use elements::secp256k1_zkp::{PublicKey, Secp256k1, SecretKey}; +use elements::{ + AssetId, BlockExtData, BlockHash, BlockHeader, LockTime, OutPoint, Script, Sequence, + Transaction, TxIn, TxInWitness, TxMerkleNode, TxOut, TxOutWitness, Txid, +}; +use lwk_common::{DescriptorBlindingKey, ElementsParamsBuilder, Network, Signer as _, Singlesig}; +use lwk_signer::SwSigner; +use lwk_wollet::clients::LastUnused; +use lwk_wollet::sequentia_stake_records::find_key_coins; +use lwk_wollet::{ + build_delegation_create_tx, build_delegation_spend_tx, build_record_create_tx, + build_unbond_claim_tx, build_unbond_tx, pos_records_v2_height, sequentia_stake_script, + sign_stake_record_input, Chain, DelegationCreatePlan, DelegationSpendPlan, DownloadTxResult, + RecordCreatePlan, StakeOutput, StakeRecordSigning, TxBuilder, UnbondClaimPlan, UnbondPlan, + UnbondingOutput, Update, Wollet, WolletBuilder, WolletDescriptor, +}; +use serde_json::{json, Value as Json}; + +const COIN: u64 = 100_000_000; +/// Network fee of every raw record transaction, in atoms: over 5 atoms a vbyte. +const FEE: u64 = 2_000; +/// A record's own value. +const RECORD: u64 = 100_000; +/// The wallet's PSET fee rate, atoms per 1000 vbytes. +const FEE_RATE: f32 = 2_000.0; +/// The chain's unbonding period (blocks), hence the bond's relative lock. +const UNBONDING: u32 = 5; +/// Blocks an unbonding output waits before it may be claimed. +const UNBOND_DEPTH: u32 = 3; +/// Blocks between a payout announcement and its activation, at least. +const NOTICE: u32 = 3; +/// A published test mnemonic: the wallet holds nothing outside these tests. +const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +/// One `sequentiad` on a fresh proof-of-stake `elementsregtest` chain, +/// stopped and its data deleted on drop. +struct Node { + exe: PathBuf, + child: Child, + port: u16, + dir: PathBuf, + chain_args: Vec, +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn spawn(exe: &Path, dir: &Path, port: u16, args: &[String]) -> Child { + Command::new(exe) + .args([ + "-chain=elementsregtest".to_string(), + format!("-datadir={}", dir.display()), + format!("-rpcport={port}"), + format!("-port={}", free_port()), + "-listen=0".into(), + "-server".into(), + "-printtoconsole=0".into(), + "-rpcuser=u".into(), + "-rpcpassword=p".into(), + "-disablewallet".into(), + "-txindex=1".into(), + // Scripts checked one by one, so a refusal names its script error. + "-par=1".into(), + "-persistmempool=0".into(), + ]) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap() +} + +impl Node { + /// Start on a fresh chain whose consensus options are `chain_args`, with + /// non-standard transactions accepted so the genesis output can be spent. + fn start(name: &str, chain_args: Vec) -> Node { + let exe = std::env::var("SEQUENTIAD_EXEC") + .or_else(|_| std::env::var("ELEMENTSD_EXEC")) + .expect("sequentia_stake_records needs SEQUENTIAD_EXEC set to a sequentiad binary"); + let exe = PathBuf::from(exe); + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let port = free_port(); + let mut args = chain_args.clone(); + args.push("-acceptnonstdtxn=1".into()); + let child = spawn(&exe, &dir, port, &args); + let node = Node { + exe, + child, + port, + dir, + chain_args, + }; + node.wait_ready(); + node + } + + /// Restart on the same chain under the default relay policy. + fn restart_with_default_policy(&mut self) { + self.stop(); + self.port = free_port(); + self.child = spawn(&self.exe, &self.dir, self.port, &self.chain_args); + self.wait_ready(); + } + + fn wait_ready(&self) { + let t0 = Instant::now(); + while self.try_rpc("getblockcount", json!([])).is_err() { + assert!( + t0.elapsed() < Duration::from_secs(60), + "sequentiad did not answer" + ); + std::thread::sleep(Duration::from_millis(200)); + } + } + + fn stop(&mut self) { + let _ = self.try_rpc("stop", json!([])); + let t0 = Instant::now(); + while t0.elapsed() < Duration::from_secs(60) { + if let Ok(Some(_)) = self.child.try_wait() { + return; + } + std::thread::sleep(Duration::from_millis(200)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } + + fn try_rpc(&self, method: &str, params: Json) -> Result { + let body = + json!({"jsonrpc": "1.0", "id": 1, "method": method, "params": params}).to_string(); + let mut s = TcpStream::connect(("127.0.0.1", self.port)).map_err(|e| e.to_string())?; + // "u:p", the test node's own credentials. + let req = format!( + "POST / HTTP/1.0\r\nHost: 127.0.0.1\r\nAuthorization: Basic dTpw\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + s.write_all(req.as_bytes()).map_err(|e| e.to_string())?; + let mut resp = String::new(); + s.read_to_string(&mut resp).map_err(|e| e.to_string())?; + let body = resp.split("\r\n\r\n").nth(1).ok_or("no body")?; + let v: Json = serde_json::from_str(body).map_err(|e| format!("{e}: {resp}"))?; + if !v["error"].is_null() { + return Err(v["error"].to_string()); + } + Ok(v["result"].clone()) + } + + fn rpc(&self, method: &str, params: Json) -> Json { + self.try_rpc(method, params) + .unwrap_or_else(|e| panic!("{method}: {e}")) + } + + fn tip(&self) -> u32 { + self.rpc("getblockcount", json!([])).as_u64().unwrap() as u32 + } + + /// Produce one block as the chain's staker, from the mempool. Returns the + /// ids of the transactions it carries. + fn produce(&self, staker_wif: &str) -> Vec { + let r = self.rpc("generateposblock", json!([staker_wif])); + let hash = BlockHash::from_str(r["hash"].as_str().unwrap()).unwrap(); + self.rpc("getblock", json!([hash.to_string(), 1]))["tx"] + .as_array() + .unwrap() + .iter() + .map(|t| Txid::from_str(t.as_str().unwrap()).unwrap()) + .collect() + } + + fn send(&self, tx_hex: &str) -> Txid { + Txid::from_str( + self.rpc("sendrawtransaction", json!([tx_hex])) + .as_str() + .unwrap(), + ) + .unwrap() + } + + /// The reason the mempool refuses `tx_hex`; panics if it would accept it. + fn refusal(&self, tx_hex: &str) -> String { + let r = &self.rpc("testmempoolaccept", json!([[tx_hex]]))[0]; + assert_eq!( + r["allowed"], + json!(false), + "the node accepts what it should refuse: {r}" + ); + r["reject-reason"].as_str().unwrap().to_string() + } + + /// What a block makes of each variant: the block this node produces next, + /// taken back (`invalidateblock`), with the variant's transactions added + /// and its merkle root recomputed, judged by `testproposedblock`. That runs + /// every check a block must pass except the leader's signature over its + /// hash, the one thing adding a transaction breaks. Every variant is judged + /// at the same height; the node then returns to its block, so the chain + /// advances by one. + /// + /// The relay policy's verdict can hide a consensus flaw; this one cannot. + fn block_verdicts( + &self, + staker_wif: &str, + variants: &[Vec], + ) -> Vec> { + let r = self.rpc("generateposblock", json!([staker_wif])); + let hash = r["hash"].as_str().unwrap().to_string(); + let block = + Vec::::from_hex(self.rpc("getblock", json!([hash, 0])).as_str().unwrap()).unwrap(); + // The header as the node serialises it: this chain has no Bitcoin + // anchor, so it is spliced as bytes rather than decoded. + let header = Vec::::from_hex( + self.rpc("getblockheader", json!([hash, false])) + .as_str() + .unwrap(), + ) + .unwrap(); + assert_eq!(&block[..header.len()], header.as_slice()); + let txs: Vec = deserialize(&block[header.len()..]).unwrap(); + assert_eq!(txs.len(), 1, "the block carries only its coinbase"); + // The merkle root follows the version and the previous block's hash. + assert_eq!( + &header[36..68], + merkle_root(&txs).to_byte_array().as_slice() + ); + self.rpc("invalidateblock", json!([hash])); + let verdicts = variants + .iter() + .map(|extra| { + let mut all = txs.clone(); + all.extend(extra.iter().cloned()); + commit_witnesses(&mut all); + let mut proposal = header.clone(); + proposal[36..68].copy_from_slice(&merkle_root(&all).to_byte_array()); + proposal.extend(serialize(&all)); + self.try_rpc("testproposedblock", json!([proposal.to_hex()])) + .map(|_| ()) + }) + .collect(); + self.rpc("reconsiderblock", json!([hash])); + assert_eq!(self.rpc("getbestblockhash", json!([])), json!(hash)); + verdicts + } +} + +impl Drop for Node { + fn drop(&mut self) { + self.stop(); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// The script error of a record spend signed the legacy way where the second +/// generation is in force: the record is checked against the segwit-v0 hash, +/// a mismatching signature must be empty (NULLFAIL), and the record rules +/// enforce that in a block as well as in the mempool. +const WRONG_HASH: &str = "(Signature must be zero for failed CHECK(MULTI)SIG operation)"; +/// The script error of a record spend signed the second-generation way below +/// the fork height: the legacy check fails, and a block (which does not +/// enforce NULLFAIL on legacy scripts) sees a false result. +const FALSE_RESULT: &str = + "(Script evaluated without error but finished with a false/empty top stack element)"; + +/// `bad` is refused by the node's relay policy and by a block, for the reasons +/// given, and `good` (the same thing done right, possibly several +/// transactions) is valid in that same block. Prints the reasons. +fn refused_everywhere( + node: &Node, + producer: &str, + bad: &Transaction, + good: &[&Transaction], + what: &str, + policy: &str, + block: &str, +) { + let by_policy = node.refusal(&hex_of(bad)); + let mut variants = vec![vec![bad.clone()]]; + if !good.is_empty() { + variants.push(good.iter().map(|t| (*t).clone()).collect()); + } + let verdicts = node.block_verdicts(producer, &variants); + let by_block = verdicts[0] + .clone() + .expect_err("a block carrying it must be invalid"); + println!("{what}: mempool \"{by_policy}\"; block {by_block}"); + assert!( + by_policy.contains(policy), + "{what}: mempool said {by_policy}" + ); + assert!(by_block.contains(block), "{what}: block said {by_block}"); + if let Some(v) = verdicts.get(1) { + v.clone() + .expect("the same block carrying the right transaction is valid"); + } +} + +/// A record spend signed for the wrong generation is refused by the mempool +/// and by a block, which accepts the same spend signed for the right one. +/// `in_block` is the script error the block reports. +/// +/// The node labels every script failure `mempool-script-verify-flag-failed` +/// when the flags include any standard-only bit, which block flags do (CLTV, +/// CSV, witness), so the label is the same in both places; the verdict on +/// the block is what makes the refusal a consensus one. +fn wrong_signature_refused( + node: &Node, + producer: &str, + bad_hex: &str, + good_hex: &str, + what: &str, + in_block: &str, +) { + refused_everywhere( + node, + producer, + &decode(bad_hex), + &[&decode(good_hex)], + what, + &format!("script-verify-flag-failed {WRONG_HASH}"), + &format!("script-verify-flag-failed {in_block}"), + ); +} + +/// Rewrite the coinbase's witness commitment for the block's transactions, as +/// the node computes it in Elements mode: a fast merkle root over each +/// transaction's witness-only hash, hashed with the coinbase's witness nonce. +fn commit_witnesses(txs: &mut [Transaction]) { + fn hash(v: &T) -> [u8; 32] { + sha256d::Hash::hash(&serialize(v)).to_byte_array() + } + fn root(leaves: &[[u8; 32]]) -> [u8; 32] { + if leaves.is_empty() { + return [0; 32]; + } + elements::fast_merkle_root(leaves).to_byte_array() + } + let witness_only = |tx: &Transaction| { + let ins: Vec<[u8; 32]> = tx + .input + .iter() + .map(|i| { + let w = if i.is_coinbase() { + TxInWitness::default() + } else { + i.witness.clone() + }; + root(&[ + hash(&w.amount_rangeproof), + hash(&w.inflation_keys_rangeproof), + hash(&w.script_witness), + hash(&w.pegin_witness), + ]) + }) + .collect(); + let outs: Vec<[u8; 32]> = tx + .output + .iter() + .map(|o| { + root(&[ + hash(&o.witness.surjection_proof), + hash(&o.witness.rangeproof), + ]) + }) + .collect(); + root(&[root(&ins), root(&outs)]) + }; + let leaves: Vec<[u8; 32]> = txs.iter().map(witness_only).collect(); + let mut committed = root(&leaves).to_vec(); + committed.extend_from_slice(&txs[0].input[0].witness.script_witness[0]); + let commitment = sha256d::Hash::hash(&committed).to_byte_array(); + let index = txs[0] + .output + .iter() + .rposition(|o| { + let b = o.script_pubkey.as_bytes(); + b.len() >= 38 && b[..6] == [0x6a, 0x24, 0xaa, 0x21, 0xa9, 0xed] + }) + .expect("the coinbase commits to the witnesses"); + let mut script = txs[0].output[index].script_pubkey.to_bytes(); + script[6..38].copy_from_slice(&commitment); + txs[0].output[index].script_pubkey = Script::from(script); +} + +/// A block's merkle root: the Bitcoin tree over its transaction ids. +fn merkle_root(txs: &[Transaction]) -> TxMerkleNode { + let mut level: Vec<[u8; 32]> = txs.iter().map(|t| t.txid().to_byte_array()).collect(); + while level.len() > 1 { + if level.len() % 2 == 1 { + level.push(*level.last().unwrap()); + } + level = level + .chunks(2) + .map(|pair| { + let mut both = pair[0].to_vec(); + both.extend_from_slice(&pair[1]); + sha256d::Hash::hash(&both).to_byte_array() + }) + .collect(); + } + TxMerkleNode::from_byte_array(level[0]) +} + +fn explicit(asset: AssetId, value: u64, spk: Script) -> TxOut { + TxOut { + asset: Asset::Explicit(asset), + value: Value::Explicit(value), + nonce: Nonce::Null, + script_pubkey: spk, + witness: TxOutWitness::default(), + } +} + +fn hex_of(tx: &Transaction) -> String { + serialize(tx).to_hex() +} + +fn decode(raw: &str) -> Transaction { + deserialize(&Vec::::from_hex(raw).unwrap()).unwrap() +} + +fn random_key() -> (SecretKey, Vec) { + let mut bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut bytes); + let sk = SecretKey::from_slice(&bytes).unwrap(); + let pk = PublicKey::from_secret_key(&Secp256k1::new(), &sk); + (sk, pk.serialize().to_vec()) +} + +fn wif(sk: &SecretKey) -> String { + let inner = elements::bitcoin::secp256k1::SecretKey::from_slice(&sk.secret_bytes()).unwrap(); + elements::bitcoin::PrivateKey::new(inner, elements::bitcoin::NetworkKind::Test).to_wif() +} + +/// The chain's consensus options: one staker, short periods, and whatever +/// `extra` sets (the fork heights). +fn chain_args(producer_pub: &[u8], extra: &[&str]) -> Vec { + let mut args: Vec = [ + "-con_pos=1", + "-posvrf=1", + "-posslotinterval=1", + "-signblockscript=51", + "-initialfreecoins=2100000000000000", + "-anyonecanspendaremine=0", + "-con_blocksubsidy=0", + "-con_connect_genesis_outputs=1", + "-validatepegin=0", + "-con_default_blinded_addresses=0", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + args.push(format!("-posunbonding={UNBONDING}")); + args.push(format!("-posunbonddepth={UNBOND_DEPTH}")); + args.push(format!("-pospayoutnotice={NOTICE}")); + args.push(format!("-staker={}:{}", producer_pub.to_hex(), COIN)); + args.extend(extra.iter().map(|s| s.to_string())); + args +} + +/// A kit wallet and its staking key, on the node's chain. +struct Kit { + network: Network, + signer: SwSigner, + wollet: Wollet, + staker_secret: SecretKey, + staker: Vec, +} + +impl Kit { + fn new(node: &Node) -> Kit { + let asset = AssetId::from_str( + node.rpc("getsidechaininfo", json!([]))["pegged_asset"] + .as_str() + .unwrap(), + ) + .unwrap(); + let genesis = + BlockHash::from_str(node.rpc("getblockhash", json!([0])).as_str().unwrap()).unwrap(); + let network = Network::CustomElements( + ElementsParamsBuilder::new() + .with_policy_asset(asset) + .with_genesis_hash(genesis) + .build() + .unwrap(), + ); + let signer = SwSigner::new(MNEMONIC, false).unwrap(); + let desc = + lwk_common::singlesig_desc(&signer, Singlesig::Wpkh, DescriptorBlindingKey::Slip77) + .unwrap(); + let mut wollet = WolletBuilder::new(network, WolletDescriptor::from_str(&desc).unwrap()) + .build() + .unwrap(); + register_scripts(&mut wollet); + // The staking key, m/2/0, as every wallet on the kit derives it. + let xprv = signer + .derive_xprv(&DerivationPath::from_str("m/2/0").unwrap()) + .unwrap(); + let staker_secret = SecretKey::from_slice(&xprv.private_key.secret_bytes()).unwrap(); + let staker = PublicKey::from_secret_key(&Secp256k1::new(), &staker_secret) + .serialize() + .to_vec(); + Kit { + network, + signer, + wollet, + staker_secret, + staker, + } + } + + fn asset(&self) -> AssetId { + *self.network.policy_asset() + } + + fn address_spk(&self, index: u32) -> Script { + self.wollet + .address(Some(index)) + .unwrap() + .address() + .script_pubkey() + } + + /// Build, sign and finalize a wallet transaction on the PSET path. + fn pset_tx(&self, builder: TxBuilder) -> Transaction { + let mut pset = builder + .fee_rate(Some(FEE_RATE)) + .finish(&self.wollet) + .unwrap(); + self.signer.sign(&mut pset).unwrap(); + self.wollet.finalize(&mut pset).unwrap() + } + + fn applied(&mut self, tx: &Transaction) { + self.wollet.apply_transaction(tx.clone()).unwrap(); + } + + /// The signature the node wants for a spend entering its next block. + fn signing(&self, node: &Node, records_v2_height: u32) -> StakeRecordSigning { + StakeRecordSigning::for_next_block(node.tip(), records_v2_height) + } +} + +/// Teach the wallet its first scripts, which a scan of a blockchain backend +/// would; the test then hands it each transaction it makes or receives +/// (`Wollet::apply_transaction`), which recognises only scripts it knows. +fn register_scripts(wollet: &mut Wollet) { + let mut scripts = vec![]; + for i in 0..20u32 { + let n = ChildNumber::from_normal_idx(i).unwrap(); + let external = wollet.address(Some(i)).unwrap().address().script_pubkey(); + let internal = wollet.change(Some(i)).unwrap().address().script_pubkey(); + scripts.push((Chain::External, n, external, None)); + scripts.push((Chain::Internal, n, internal, None)); + } + let update = Update { + version: 4, + wollet_status: wollet.status(), + new_txs: DownloadTxResult::default(), + txid_height_new: vec![], + txid_height_delete: vec![], + timestamps: vec![], + scripts_with_blinding_pubkey: scripts, + // The wallet's own placeholder, which leaves its tip alone. + tip: BlockHeader { + version: 0, + prev_blockhash: BlockHash::all_zeros(), + merkle_root: TxMerkleNode::all_zeros(), + time: 0, + height: 0, + ext: BlockExtData::default(), + bitcoin_anchor: Some((0, BlockHash::all_zeros())), + }, + unspent: vec![], + last_unused: LastUnused::default(), + }; + wollet.apply_update(update).unwrap(); +} + +/// Fund the kit wallet from the genesis output, then put the node on its +/// default relay policy. Returns the funding transaction. +fn fund(node: &mut Node, kit: &mut Kit, producer_wif: &str) -> Transaction { + node.produce(producer_wif); + let genesis = node.rpc("getblock", json!([node.rpc("getblockhash", json!([0])), 2])); + let (txid, vout, value) = genesis["tx"] + .as_array() + .unwrap() + .iter() + .flat_map(|tx| { + tx["vout"].as_array().unwrap().iter().map(move |o| { + ( + tx["txid"].as_str().unwrap().to_string(), + o["n"].as_u64().unwrap() as u32, + o["scriptPubKey"]["hex"].as_str().unwrap().to_string(), + (o["value"].as_f64().unwrap_or(0.0) * COIN as f64).round() as u64, + ) + }) + }) + .find(|(_, _, spk, v)| spk == "51" && *v > 0) + .map(|(t, n, _, v)| (Txid::from_str(&t).unwrap(), n, v)) + .expect("an OP_TRUE genesis output"); + let asset = kit.asset(); + let fee = 100_000; + let to_wallet = 2 * 200 * COIN; + let tx = Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new(txid, vout), + is_pegin: false, + script_sig: Script::new(), + sequence: Sequence::from_consensus(0xffff_fffe), + asset_issuance: Default::default(), + witness: TxInWitness::default(), + }], + output: vec![ + explicit(asset, 200 * COIN, kit.address_spk(0)), + explicit(asset, 200 * COIN, kit.address_spk(1)), + explicit(asset, value - to_wallet - fee, Script::from(vec![0x51])), + TxOut::new_fee(fee, asset), + ], + }; + let id = node.send(&hex_of(&tx)); + assert!(node.produce(producer_wif).contains(&id)); + kit.applied(&tx); + + node.restart_with_default_policy(); + // The remainder at OP_TRUE is now unspendable under relay policy: from here + // on, what the node accepts is what the testnet would relay. + let spend_rest = Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new(id, 2), + is_pegin: false, + script_sig: Script::new(), + sequence: Sequence::from_consensus(0xffff_fffe), + asset_issuance: Default::default(), + witness: TxInWitness::default(), + }], + output: vec![ + explicit(asset, COIN, kit.address_spk(2)), + explicit( + asset, + value - to_wallet - 2 * fee - COIN, + kit.address_spk(3), + ), + TxOut::new_fee(fee, asset), + ], + }; + assert_eq!( + node.refusal(&hex_of(&spend_rest)), + "bad-txns-nonstandard-inputs" + ); + tx +} + +/// One transaction for the wasm half: what a wallet hands the binding `fun` +/// (`recipe`, the mnemonic added there) and the transaction the node confirmed. +fn wasm_case(what: &str, fun: &str, mut recipe: Json, raw: &str, signing: Option<&str>) -> Json { + recipe["locktime"] = json!(decode(raw).lock_time.to_consensus_u32()); + let mut case = json!({ + "what": what, + "fn": fun, + "recipe": recipe, + "raw_hex": raw, + "txid": decode(raw).txid().to_string(), + }); + if let Some(signing) = signing { + case["signing"] = json!(signing); + } + case +} + +/// The wasm half: `lwk_wasm/tests/node/stake_records.js` builds every case +/// through the bindings and must get the transaction the node confirmed. It +/// needs `node` and the wasm package built for node.js, linked as `lwk_node` +/// in `lwk_wasm/tests/node/node_modules` (`lwk_wasm/README.md`); without them +/// this half does not run, and says so. +fn wasm_half(name: &str, node: &Node, kit: &Kit, cases: Vec, signing: Vec) { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../lwk_wasm/tests/node"); + if !dir.join("node_modules/lwk_node").exists() { + println!( + "WASM HALF NOT RUN: no lwk_node package in {}/node_modules", + dir.display() + ); + return; + } + let fixture = json!({ + "policy_asset": kit.asset().to_string(), + "genesis_hash": node.rpc("getblockhash", json!([0])), + "mnemonic": MNEMONIC, + "staker": kit.staker.to_hex(), + "unbond_script": lwk_wollet::sequentia_unbond_script(&kit.staker).as_bytes().to_hex(), + "signing": signing, + "cases": cases, + }); + let path = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("{name}.json")); + std::fs::write(&path, serde_json::to_string_pretty(&fixture).unwrap()).unwrap(); + let out = Command::new("node") + .arg(dir.join("stake_records.js")) + .arg(&path) + .current_dir(&dir) + .output() + .expect("node runs"); + let _ = std::fs::remove_file(&path); + println!("{}", String::from_utf8_lossy(&out.stdout)); + eprintln!("{}", String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success(), "stake_records.js failed"); +} + +/// The unblinded address string of the wallet's receive address `index`. +fn address_string(kit: &Kit, index: u32) -> String { + kit.wollet + .address(Some(index)) + .unwrap() + .address() + .to_unconfidential() + .to_string() +} + +#[test] +fn records_from_block_one() { + let (producer_sk, producer_pub) = random_key(); + let producer = wif(&producer_sk); + // The fork heights are left at the custom chain's default: block 1. + let mut node = Node::start("stake_records_block_one", chain_args(&producer_pub, &[])); + let mut kit = Kit::new(&node); + let v2_height = pos_records_v2_height(&kit.network); + assert_eq!(v2_height, 1); + fund(&mut node, &mut kit, &producer); + let asset = kit.asset(); + let staker = kit.staker.clone(); + let staker_hex = staker.to_hex(); + let mut wasm = vec![]; + + // --- Bond ------------------------------------------------------------- + let stake_script = sequentia_stake_script(&staker, UNBONDING); + assert_eq!( + stake_script.as_bytes().to_hex(), + node.rpc("getstakescript", json!([staker_hex, UNBONDING]))["script"] + .as_str() + .unwrap(), + "the kit's staking script is the node's" + ); + let bond = + kit.pset_tx(TxBuilder::new(kit.network).add_stake_output(&staker, UNBONDING, 50 * COIN)); + let bond_id = node.send(&hex_of(&bond)); + assert!(node.produce(&producer).contains(&bond_id)); + let bond_height = node.tip(); + kit.applied(&bond); + let bond_vout = bond + .output + .iter() + .position(|o| o.script_pubkey == stake_script) + .unwrap() as u32; + assert_eq!( + node.rpc("getstakerinfo", json!([false, true]))[&staker_hex], + json!(50 * COIN) + ); + println!("bond {bond_id} at {bond_height}"); + + // --- Delegation, created with a coin of the controller ----------------- + let (_, pool_p) = random_key(); + let (_, pool_q) = random_key(); + + // The wallet's coins alone no longer authorise a record. + let unauthorised = + kit.pset_tx(TxBuilder::new(kit.network).add_delegation_output(&staker, &pool_p, RECORD)); + + let pay = + kit.pset_tx(TxBuilder::new(kit.network).add_record_authorization(&staker, RECORD + FEE)); + let coins = find_key_coins(&pay, &staker, asset); + assert_eq!(coins.len(), 1); + let (coin_vout, coin_value) = coins[0]; + let (create_raw, create_id) = build_delegation_create_tx(&DelegationCreatePlan { + coin_txid: pay.txid(), + coin_vout, + coin_value, + asset, + controller_secret: kit.staker_secret, + signer: pool_p.clone(), + record_value: RECORD, + change_spk: kit.address_spk(4), + fee_atoms: FEE, + dust_floor: 1_000, + locktime: node.tip(), + }) + .unwrap(); + refused_everywhere( + &node, + &producer, + &unauthorised, + &[&pay, &decode(&create_raw)], + "record funded by wallet coins alone", + "bad-delegation-unauthorized", + "bad-delegation-unauthorized", + ); + let pay_id = node.send(&hex_of(&pay)); + assert_eq!(node.send(&create_raw), create_id); + wasm.push(wasm_case( + "delegation created with the controller's coin", + "buildDelegationCreateTx", + json!({ + "coinTxHex": hex_of(&pay), + "signer": pool_p.to_hex(), + "recordValue": RECORD.to_string(), + "feeAtoms": FEE.to_string(), + }), + &create_raw, + None, + )); + let block = node.produce(&producer); + assert!( + block.contains(&pay_id) && block.contains(&create_id), + "mined together" + ); + kit.applied(&pay); + assert_eq!( + node.rpc("getdelegationinfo", json!([]))[&staker_hex], + json!(pool_p.to_hex()) + ); + println!( + "authorising payment {pay_id} and delegation {create_id} in block {}", + node.tip() + ); + + // --- Re-point --------------------------------------------------------- + let signing = kit.signing(&node, v2_height); + assert_eq!(signing, StakeRecordSigning::SegwitV0); + let repoint = |signing| DelegationSpendPlan { + record_txid: create_id, + record_vout: 0, + record_value: RECORD, + asset, + current_signer: pool_p.clone(), + controller_secret: kit.staker_secret, + rotate_to: Some(pool_q.clone()), + reclaim_spk: Script::new(), + fee_atoms: FEE, + dust_floor: 1_000, + locktime: node.tip(), + signing, + }; + let (legacy_raw, _) = build_delegation_spend_tx(&repoint(StakeRecordSigning::Legacy)).unwrap(); + let (repoint_raw, repoint_id) = build_delegation_spend_tx(&repoint(signing)).unwrap(); + wrong_signature_refused( + &node, + &producer, + &legacy_raw, + &repoint_raw, + "re-point signed the legacy way", + WRONG_HASH, + ); + node.send(&repoint_raw); + assert!(node.produce(&producer).contains(&repoint_id)); + wasm.push(wasm_case( + "re-point", + "buildDelegationSpendTx", + json!({ + "recordTxid": create_id.to_string(), + "recordVout": 0, + "recordValue": RECORD.to_string(), + "currentSigner": pool_p.to_hex(), + "rotateTo": pool_q.to_hex(), + "feeAtoms": FEE.to_string(), + }), + &repoint_raw, + Some("segwitV0"), + )); + assert_eq!( + node.rpc("getdelegationinfo", json!([]))[&staker_hex], + json!(pool_q.to_hex()) + ); + println!("re-point {repoint_id} in block {}", node.tip()); + + // --- Reclaim ---------------------------------------------------------- + let reclaim = |signing| DelegationSpendPlan { + record_txid: repoint_id, + record_vout: 0, + record_value: RECORD - FEE, + asset, + current_signer: pool_q.clone(), + controller_secret: kit.staker_secret, + rotate_to: None, + reclaim_spk: kit.address_spk(5), + fee_atoms: FEE, + dust_floor: 1_000, + locktime: node.tip(), + signing, + }; + let (legacy_raw, _) = build_delegation_spend_tx(&reclaim(StakeRecordSigning::Legacy)).unwrap(); + let (reclaim_raw, reclaim_id) = + build_delegation_spend_tx(&reclaim(kit.signing(&node, v2_height))).unwrap(); + wrong_signature_refused( + &node, + &producer, + &legacy_raw, + &reclaim_raw, + "reclaim signed the legacy way", + WRONG_HASH, + ); + node.send(&reclaim_raw); + assert!(node.produce(&producer).contains(&reclaim_id)); + wasm.push(wasm_case( + "reclaim", + "buildDelegationSpendTx", + json!({ + "recordTxid": repoint_id.to_string(), + "recordVout": 0, + "recordValue": (RECORD - FEE).to_string(), + "currentSigner": pool_q.to_hex(), + "reclaimAddress": address_string(&kit, 5), + "feeAtoms": FEE.to_string(), + }), + &reclaim_raw, + Some("segwitV0"), + )); + assert!(node + .rpc("getdelegationinfo", json!([])) + .get(&staker_hex) + .is_none()); + kit.applied(&decode(&reclaim_raw)); + println!("reclaim {reclaim_id} in block {}", node.tip()); + + // --- Payout announcement, and its withdrawal inside the notice --------- + let activation = node.tip() + NOTICE + 10; + let payout_script = Script::from( + Vec::::from_hex( + node.rpc( + "getpayoutscript", + json!([ + staker_hex, + activation, + "direct", + kit.address_spk(6).as_bytes().to_hex() + ]), + )["script"] + .as_str() + .unwrap(), + ) + .unwrap(), + ); + let pay = + kit.pset_tx(TxBuilder::new(kit.network).add_record_authorization(&staker, RECORD + FEE)); + let (coin_vout, coin_value) = find_key_coins(&pay, &staker, asset)[0]; + let (announce_raw, announce_id) = build_record_create_tx(&RecordCreatePlan { + coin_txid: pay.txid(), + coin_vout, + coin_value, + asset, + key_secret: kit.staker_secret, + record_script: payout_script.clone(), + record_value: RECORD, + change_spk: kit.address_spk(7), + fee_atoms: FEE, + dust_floor: 1_000, + locktime: node.tip(), + }) + .unwrap(); + let pay_id = node.send(&hex_of(&pay)); + node.send(&announce_raw); + let block = node.produce(&producer); + assert!( + block.contains(&pay_id) && block.contains(&announce_id), + "mined together" + ); + kit.applied(&pay); + let policies = node.rpc("getpayoutinfo", json!([staker_hex])); + assert_eq!(policies[&staker_hex][0]["activation"], json!(activation)); + assert_eq!(policies[&staker_hex][0]["in_force"], json!(false)); + println!( + "authorising payment {pay_id} and payout announcement {announce_id} in block {}", + node.tip() + ); + + let withdraw = |signing| { + let mut tx = Transaction { + version: 2, + lock_time: LockTime::from_consensus(node.tip()), + input: vec![TxIn { + previous_output: OutPoint::new(announce_id, 0), + is_pegin: false, + script_sig: Script::new(), + sequence: Sequence::from_consensus(0xffff_fffd), + asset_issuance: Default::default(), + witness: TxInWitness::default(), + }], + output: vec![ + explicit(asset, RECORD - FEE, kit.address_spk(8)), + TxOut::new_fee(FEE, asset), + ], + }; + sign_stake_record_input( + &mut tx, + 0, + &payout_script, + Value::Explicit(RECORD), + &kit.staker_secret, + signing, + ) + .unwrap(); + tx + }; + let withdrawal = withdraw(kit.signing(&node, v2_height)); + wrong_signature_refused( + &node, + &producer, + &hex_of(&withdraw(StakeRecordSigning::Legacy)), + &hex_of(&withdrawal), + "payout withdrawal signed the legacy way", + WRONG_HASH, + ); + let withdrawal_id = node.send(&hex_of(&withdrawal)); + assert!(node.produce(&producer).contains(&withdrawal_id)); + assert!(node + .rpc("getpayoutinfo", json!([staker_hex])) + .get(&staker_hex) + .is_none()); + kit.applied(&withdrawal); + println!( + "payout record withdrawn by {withdrawal_id} in block {}", + node.tip() + ); + + // --- Unbond, step 1 --------------------------------------------------- + // The bond's relative lock: spendable in the block UNBONDING after it. + while node.tip() + 1 < bond_height + UNBONDING { + node.produce(&producer); + } + let unbond = |signing| UnbondPlan { + stakes: vec![StakeOutput { + txid: bond_id, + vout: bond_vout, + value: 50 * COIN, + script_pubkey: stake_script.clone(), + }], + asset, + staker_secret: kit.staker_secret, + fee_atoms: FEE, + locktime: node.tip(), + signing, + }; + let (legacy_raw, _) = build_unbond_tx(&unbond(StakeRecordSigning::Legacy)).unwrap(); + let (unbond_raw, unbond_id) = build_unbond_tx(&unbond(kit.signing(&node, v2_height))).unwrap(); + wrong_signature_refused( + &node, + &producer, + &legacy_raw, + &unbond_raw, + "unbond signed the legacy way", + WRONG_HASH, + ); + node.send(&unbond_raw); + assert!(node.produce(&producer).contains(&unbond_id)); + wasm.push(wasm_case( + "unbond, step 1", + "buildUnbondTx", + json!({ + "stakes": [{ + "txid": bond_id.to_string(), + "vout": bond_vout, + "value": (50 * COIN).to_string(), + "script": stake_script.as_bytes().to_hex(), + }], + "feeAtoms": FEE.to_string(), + }), + &unbond_raw, + Some("segwitV0"), + )); + let unbond_height = node.tip(); + assert!(node + .rpc("getstakerinfo", json!([false, true])) + .get(&staker_hex) + .is_none()); + println!("unbond {unbond_id} in block {unbond_height}"); + + // --- Unbond, step 2 --------------------------------------------------- + let claim = |signing| UnbondClaimPlan { + unbonding: vec![UnbondingOutput { + txid: unbond_id, + vout: 0, + value: 50 * COIN - FEE, + }], + asset, + staker_secret: kit.staker_secret, + destination: kit.address_spk(9), + fee_atoms: FEE, + dust_floor: 1_000, + locktime: node.tip(), + signing, + }; + let (early_raw, _) = build_unbond_claim_tx(&claim(kit.signing(&node, v2_height))).unwrap(); + refused_everywhere( + &node, + &producer, + &decode(&early_raw), + &[], + "claim before the unbonding depth", + "bad-unbond-premature", + "bad-unbond-premature", + ); + while node.tip() + 1 < unbond_height + UNBOND_DEPTH { + node.produce(&producer); + } + let (legacy_raw, _) = build_unbond_claim_tx(&claim(StakeRecordSigning::Legacy)).unwrap(); + let (claim_raw, claim_id) = + build_unbond_claim_tx(&claim(kit.signing(&node, v2_height))).unwrap(); + wrong_signature_refused( + &node, + &producer, + &legacy_raw, + &claim_raw, + "claim signed the legacy way", + WRONG_HASH, + ); + node.send(&claim_raw); + assert!(node.produce(&producer).contains(&claim_id)); + wasm.push(wasm_case( + "unbond, step 2", + "buildUnbondClaimTx", + json!({ + "unbonding": [{ + "txid": unbond_id.to_string(), + "vout": 0, + "value": (50 * COIN - FEE).to_string(), + }], + "address": address_string(&kit, 9), + "feeAtoms": FEE.to_string(), + }), + &claim_raw, + Some("segwitV0"), + )); + kit.applied(&decode(&claim_raw)); + println!("claim {claim_id} in block {}", node.tip()); + + // The claimed coins are the wallet's. + let claimed = kit.wollet.utxos().unwrap().into_iter().any(|u| { + u.outpoint == OutPoint::new(claim_id, 0) && u.unblinded.value == 50 * COIN - 2 * FEE + }); + assert!(claimed, "the wallet holds the claimed coins"); + + let signing = vec![ + json!({"tip": 0, "v2_height": null, "expect": "segwitV0"}), + json!({"tip": 162_998, "v2_height": 163_000, "expect": "legacy"}), + json!({"tip": 162_999, "v2_height": 163_000, "expect": "segwitV0"}), + ]; + wasm_half("stake_records_block_one", &node, &kit, wasm, signing); +} + +#[test] +fn records_across_the_fork_height() { + const FORK: u32 = 12; + let (producer_sk, producer_pub) = random_key(); + let producer = wif(&producer_sk); + let heights = [ + format!("-posrecordsv2height={FORK}"), + format!("-poshardeningheight={FORK}"), + ]; + let extra: Vec<&str> = heights.iter().map(String::as_str).collect(); + let mut node = Node::start("stake_records_fork", chain_args(&producer_pub, &extra)); + let mut kit = Kit::new(&node); + fund(&mut node, &mut kit, &producer); + let asset = kit.asset(); + let staker = kit.staker.clone(); + let staker_hex = staker.to_hex(); + let (_, pool_p) = random_key(); + let (_, pool_q) = random_key(); + let mut wasm = vec![]; + + // Below the height a record from wallet coins alone is still valid. + let create = + kit.pset_tx(TxBuilder::new(kit.network).add_delegation_output(&staker, &pool_p, RECORD)); + let create_id = node.send(&hex_of(&create)); + assert!(node.produce(&producer).contains(&create_id)); + kit.applied(&create); + let record_vout = create + .output + .iter() + .position(|o| lwk_wollet::parse_delegation_script(&o.script_pubkey).is_some()) + .unwrap() as u32; + assert!(node.tip() + 1 < FORK); + + // ...and its re-point is signed the legacy way. + let signing = StakeRecordSigning::for_next_block(node.tip(), FORK); + assert_eq!(signing, StakeRecordSigning::Legacy); + let repoint = |signing| DelegationSpendPlan { + record_txid: create_id, + record_vout, + record_value: RECORD, + asset, + current_signer: pool_p.clone(), + controller_secret: kit.staker_secret, + rotate_to: Some(pool_q.clone()), + reclaim_spk: Script::new(), + fee_atoms: FEE, + dust_floor: 1_000, + locktime: node.tip(), + signing, + }; + let (v2_raw, _) = build_delegation_spend_tx(&repoint(StakeRecordSigning::SegwitV0)).unwrap(); + let (repoint_raw, repoint_id) = build_delegation_spend_tx(&repoint(signing)).unwrap(); + wrong_signature_refused( + &node, + &producer, + &v2_raw, + &repoint_raw, + "re-point below the height, signed the second-generation way", + FALSE_RESULT, + ); + node.send(&repoint_raw); + assert!(node.produce(&producer).contains(&repoint_id)); + wasm.push(wasm_case( + "re-point below the fork height", + "buildDelegationSpendTx", + json!({ + "recordTxid": create_id.to_string(), + "recordVout": record_vout, + "recordValue": RECORD.to_string(), + "currentSigner": pool_p.to_hex(), + "rotateTo": pool_q.to_hex(), + "feeAtoms": FEE.to_string(), + "recordsV2Height": FORK, + }), + &repoint_raw, + Some("legacy"), + )); + println!( + "legacy-signed re-point {repoint_id} in block {}", + node.tip() + ); + assert_eq!( + node.rpc("getdelegationinfo", json!([]))[&staker_hex], + json!(pool_q.to_hex()) + ); + + // At the height the reclaim is signed the second-generation way. + while node.tip() + 1 < FORK { + node.produce(&producer); + } + let signing = StakeRecordSigning::for_next_block(node.tip(), FORK); + assert_eq!(signing, StakeRecordSigning::SegwitV0); + let reclaim = |signing| DelegationSpendPlan { + record_txid: repoint_id, + record_vout: 0, + record_value: RECORD - FEE, + asset, + current_signer: pool_q.clone(), + controller_secret: kit.staker_secret, + rotate_to: None, + reclaim_spk: kit.address_spk(5), + fee_atoms: FEE, + dust_floor: 1_000, + locktime: node.tip(), + signing, + }; + let (legacy_raw, _) = build_delegation_spend_tx(&reclaim(StakeRecordSigning::Legacy)).unwrap(); + let (reclaim_raw, reclaim_id) = build_delegation_spend_tx(&reclaim(signing)).unwrap(); + // Judged in the block at the fork height itself. + assert_eq!(node.tip() + 1, FORK); + wrong_signature_refused( + &node, + &producer, + &legacy_raw, + &reclaim_raw, + "reclaim at the height, signed the legacy way", + WRONG_HASH, + ); + node.send(&reclaim_raw); + assert!(node.produce(&producer).contains(&reclaim_id)); + wasm.push(wasm_case( + "reclaim at the fork height", + "buildDelegationSpendTx", + json!({ + "recordTxid": repoint_id.to_string(), + "recordVout": 0, + "recordValue": (RECORD - FEE).to_string(), + "currentSigner": pool_q.to_hex(), + "reclaimAddress": address_string(&kit, 5), + "feeAtoms": FEE.to_string(), + "recordsV2Height": FORK, + }), + &reclaim_raw, + Some("segwitV0"), + )); + assert!(node + .rpc("getdelegationinfo", json!([])) + .get(&staker_hex) + .is_none()); + println!( + "second-generation reclaim {reclaim_id} in block {}", + node.tip() + ); + let signing = vec![ + json!({"tip": FORK - 2, "v2_height": FORK, "expect": "legacy"}), + json!({"tip": FORK - 1, "v2_height": FORK, "expect": "segwitV0"}), + ]; + wasm_half("stake_records_fork", &node, &kit, wasm, signing); +}