From 2c9df93d169545a84f709ebd74e3ea3e7e006368 Mon Sep 17 00:00:00 2001 From: GracedEternalKingCabbageMan <151803062+GracedEternalKingCabbageMan@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:02:35 +0200 Subject: [PATCH] wollet: sign stake records for the chain's height Sequentia Core 25.2.1 changes stake records at pos_records_v2_height (163,000 on the testnet, block 1 on every new chain): a spend of a staking, unbonding, delegation or payout output signs the segwit-v0 hash over the record script, committing to the amount, in a canonical scriptSig, and the legacy signature is refused. From the same height a delegation record must be created by a transaction spending a coin of its controller. The kit signed every record spend the legacy way and created records from wallet coins alone, so every wallet on it would stop being able to leave or join a pool at the fork. - sequentia_stake_records: StakeRecordSigning chooses the signature for the block after the wallet's tip; pos_records_v2_height() carries the fork height per network, since no node RPC reports it, and binds the testnet's to its whole definition, genesis hash included. - Delegation spends sign accordingly (DelegationSpendPlan.signing). Records are created from a P2WPKH coin of the controller that the wallet pays first (TxBuilder::add_record_authorization, then build_delegation_create_tx); both are mined together. - Unbonding in two steps (build_unbond_tx, build_unbond_claim_tx), which no wallet on the kit could do at all: a bonded stake had no way out but the node wallet. - lwk_wasm exposes the same: buildDelegationCreateTx, addRecordAuthorization, buildUnbondTx, buildUnbondClaimTx, stakeRecordSigning; buildDelegationSpendTx takes tipHeight (defaulting to locktime, as wallets already pass) and refuses to guess without it. Pinned vectors are derived with the node's own test framework (PosRecordSignatureHash, RFC 6979), and must match byte for byte. tests/sequentia_stake_records.rs has a proof-of-stake sequentiad confirm a bond, a delegation, a re-point, a reclaim, a payout announcement and withdrawal and both unbonding steps under default relay policy, on both sides of the fork height; each wrong case is refused by the mempool and by a block that accepts the right one, and the wasm bindings must rebuild every confirmed transaction exactly. --- CLAUDE.md | 5 +- README.md | 38 +- SEQUENTIA.md | 75 +- lwk_wasm/README.md | 22 +- lwk_wasm/src/lib.rs | 9 +- lwk_wasm/src/sequentia_delegation.rs | 271 +++- lwk_wasm/src/sequentia_stake_records.rs | 195 +++ lwk_wasm/src/tx_builder.rs | 16 + lwk_wasm/tests/node/stake_records.js | 54 + lwk_wollet/Cargo.toml | 5 + lwk_wollet/src/lib.rs | 16 +- lwk_wollet/src/sequentia_delegation.rs | 449 +++++- lwk_wollet/src/sequentia_stake_records.rs | 1076 +++++++++++++++ lwk_wollet/src/tx_builder.rs | 26 + lwk_wollet/tests/sequentia_stake_records.rs | 1370 +++++++++++++++++++ 15 files changed, 3478 insertions(+), 149 deletions(-) create mode 100644 lwk_wasm/src/sequentia_stake_records.rs create mode 100644 lwk_wasm/tests/node/stake_records.js create mode 100644 lwk_wollet/src/sequentia_stake_records.rs create mode 100644 lwk_wollet/tests/sequentia_stake_records.rs 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); +}