Expand description
sidestr-wallet — a wallet for sidestr sidechains, in Rust: the coin
set for a script, the reference coin selection, taproot key-path spends
and peg-out burns signed behind a signer port, the parent-side peg-in
transaction shape, and delivery as a POST /tx body or a kind-23500
event.
A wallet needs a chain id and a relay, and nothing of the producer’s
(SPEC 11). It learns the chain from a mirror’s chain.json, held to the
signer’s announced tip; asks a producer /coins/<script hex> for what
its script owns, or folds the block file itself with
sidestr_core::State and gets the same list; builds a transaction
Bitcoin’s rules accept, with the fee at the document’s minFeeRate; and
hands it over as POST /tx or as an event on a relay whose key is
anyone’s, because the transaction authorises itself. A producer includes
what validates. A wallet with nothing publishes a kind-23501 request and
a faucet may answer it.
This crate is a port of siding, the reference implementation by
Melvin Carvalho (https://github.com/sidestr/spec, AGPL-3.0), ported from
commit 2de40bdac4cba01be0864156a553d8287c22e279 and brought to SPEC 0.0.4
(@sidestr/spec 0.0.6) at fa86dac83d47b8f70195132e91e9dc083e1d9228 (lib/txsign.mjs) — siding/lib/spend.mjs,
lib/address.mjs, the construction halves of lib/parent.mjs and
lib/checkpoint.mjs, and the send and faucet commands of
bin/siding.mjs — and carries the same licence, AGPL-3.0-only. SPEC.md
in that repository is the design; section numbers below are its. Where a
function ports a siding function its documentation names it. Consensus
types, the markers, addresses and the document come from
sidestr_core and are not duplicated here.
§What is here
| module | what | SPEC | ported from |
|---|---|---|---|
coins | the coin set for a script: /coins JSON or a state fold; maturity | 11 | siding/lib/chain.mjs coins, spend.mjs |
select | largest-first selection to the amount plus a fee bound | 11 | spend.mjs buildSpend |
spend | a key-path spend to an address or script, fee at minFeeRate, signed, as hex | 11 | spend.mjs buildSpend, resolveTo; siding send |
burn | a peg-out: OP_RETURN pegout:<parent script hex>, at least pegoutMin | 7 | spend.mjs (--pegout) |
pegin | the parent side: peg output + pegin:<chain id>:<script> marker; the peg-out payment and checkpoint shapes; scanning | 6, 7, 11 | parent.mjs, checkpoint.mjs |
deliver | POST /tx, /coins, /tip, /chain.json as data; kind 23500 / 23501 templates; HTTP behind feature client | 11 | spend.mjs deliver, bin/siding.mjs routes |
key | the SpendSigner port, a plain key, pubkey → 5120 script → bech32m, ADR-2101 spend-key derivation | 3 | sign.mjs, address.mjs |
policy | the SpendPolicy hook every builder consults; Permissive | — | (ADR-2100) |
§A payment, end to end
Against a chain held in memory — the same calls work against a
producer’s /coins and /tip (see deliver).
use bitcoin::secp256k1::SecretKey;
use sidestr_core::block::{challenge_for, pubkey_of};
use sidestr_core::document::{ChainDocument, Peg};
use sidestr_core::state::{NextBlock, State};
use sidestr_wallet::burn::{build_burn, BurnRequest};
use sidestr_wallet::spend::{build_spend, SpendRequest};
use sidestr_wallet::{coins, key, Error, Permissive, PlainKey, SpendSigner};
// the chain's signer seals blocks; the wallet's spend key is a role key (ADR-2101)
let signer = SecretKey::from_slice(&[7u8; 32]).unwrap();
let root = SecretKey::from_slice(&[0x11u8; 32]).unwrap();
let wallet = PlainKey::new(key::derive_spend_key(&root, &"0".repeat(64), 0).unwrap());
// a throwaway document whose genesis pegs 0.01 BTC to the wallet's script (SPEC 5)
let json = format!(r#"{{"id":"sidestr:example","name":"example","parent":"tbtc4","challenge":"{}",
"powLimit":"7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","addressPrefix":"ex",
"genesisTime":1790000000,"signer":"{}","pegs":[]}}"#, challenge_for(&pubkey_of(&signer)).to_hex_string(), pubkey_of(&signer));
let mut doc = ChainDocument::from_json(&json).unwrap();
doc.pegs.push(Peg { txid: "a".repeat(64), vout: 0, amount: 1_000_000, script: wallet.script().to_hex_string(), extra: Default::default() });
let mut chain = State::with_key(doc.clone(), &signer).unwrap();
// genesis coins are coinbase outputs: mature at 100 (a wallet sees this as `coinbase: true`)
let listed = coins::from_state(&chain, &wallet.script());
assert!(listed[0].coinbase && !listed[0].is_mature(chain.height()));
for i in 1..=100 { chain.produce(&signer, &NextBlock { time: 1790000000 + i, claims: vec![] }, None).unwrap(); }
// pay someone: an address under the chain's prefix, fee at minFeeRate, signed through the port
let you = key::address_for(&PlainKey::new(SecretKey::from_slice(&[8u8; 32]).unwrap()).pubkey(), "ex").unwrap();
let req = SpendRequest { chain: &doc, coins: &listed, tip_height: chain.height(), to: &you, amount: 250_000, fee: None };
let paid = build_spend(&req, &wallet, &Permissive).unwrap();
let ok = chain.submit(paid.tx.clone()).unwrap(); // the producer's mempool policy, and the signature
assert_eq!((ok.txid, ok.fee), (paid.txid, paid.fee));
// once mined, the change is a coin; burn some of it to a parent address: the peg holders owe it on tbtc4 (SPEC 7)
chain.produce(&signer, &NextBlock { time: 1790000200, claims: vec![] }, None).unwrap();
let coins_now = coins::from_state(&chain, &wallet.script());
assert_eq!(coins_now[0].value, paid.change);
let burn = build_burn(&BurnRequest { chain: &doc, coins: &coins_now, tip_height: chain.height(), to: "tb1pvts4e2zcrujj9zey3kadyfgh2xs93v8va8ae9ldhukpxy2n3848qyqurhc", amount: 10_000, fee: None }, &wallet, &Permissive).unwrap();
assert!(chain.submit(burn.tx.clone()).is_ok());
// what the wallet refuses before signing
assert!(matches!(build_spend(&SpendRequest { amount: 5_000_000, ..req }, &wallet, &Permissive), Err(Error::Insufficient { .. })));
assert!(matches!(build_spend(&SpendRequest { amount: 100, ..req }, &wallet, &Permissive), Err(Error::Dust { .. })));§Conventions that matter
- Keys stay behind
SpendSigner. A builder computes the key-path sighash the chain’s family requires (BIP 341 beside stock Bitcoin, Knots’ unified beside BLAKE2b) and asks the port to sign it; it never holds a secret.PlainKeyis the in-memory implementation; a signer that holds a derived role key and permits named operations only (ADR-2101) fits the same trait.key::derive_spend_keyis the derivation; the identity key never spends. - The wallet builds and signs; it does not decide. Every builder
consults a
SpendPolicywith the chain, kind, script, amount, fee and input count before signing.Permissivesays yes; the authority gate (ADR-2100) is the caller’s implementation. - The fee is
minFeeRate × vsize, exactly, sized with the 65-byte witness the signer will produce, unless the caller fixes one — and a fixed fee under the rate is refused here rather than by the producer. - No I/O by default.
deliverreturns URLs, bodies and event templates; featureclientadds the HTTP calls overureq. Nothing here publishes to a relay: the event’s signature issidestr-nostr’s. - Amounts are sats,
u64; txids in markers are display-order hex, as the markers carry them; structural txids arebitcoin::Txid.
§Where this port departs from siding
- The signature carries its hash type explicitly. As
txsign.mjsdoes since 0.0.3:0x01beside stock Bitcoin,0x21beside a BLAKE2b parent, so every witness is 65 bytes and the fee is sized for that. The message is the reference’s (tests/oracle.rschecks each engine verifies the other’s signature); the signature itself differs, sincePlainKeysigns with zero auxiliary randomness and siding with fresh randomness, so byte equality of whole transactions is not a goal. - Dust is refused. siding will pay 1 sat to a taproot script and
return 1 sat of change; this crate refuses an amount under the script’s
dust threshold (
spend::dust_threshold, 330 sats for taproot) and leaves change under it to the fee instead of minting an unspendable coin. A burn’sOP_RETURNhas no dust floor;pegoutMingoverns it. - A fixed fee is checked against
minFeeRatebefore signing; siding lets the producer refuse it. - Zero BIP 340 auxiliary randomness in
PlainKey, assidestr-coreseals blocks: a spend is a pure function of its inputs and the key. - The EVM deposit branch is not carried (
--evm; ADR-2096 excludes theevmrule), nor are assets (SPEC 12, reserved).
Re-exports§
pub use burn::build_burn;pub use burn::BurnRequest;pub use coins::Coin;pub use error::Error;pub use error::Result;pub use key::PlainKey;pub use key::SpendSigner;pub use pegin::build_pegin;pub use pegin::PegIn;pub use policy::Permissive;pub use policy::SpendPolicy;pub use spend::build_spend;pub use spend::Spend;pub use spend::SpendRequest;
Modules§
- asset
- Issued assets (SPEC 12): issue one, and move it, keeping the
assetsrule assidestr_core::assets::AssetViewreads it. - burn
- A peg-out (SPEC 7): a sidechain transaction paying a burn output,
OP_RETURN pegout:<parent output script hex>with a value of at leastpegoutMin. The value leaves the supply; the peg holders owe it to that script on the parent. A port of thepegoutbranch ofsiding/lib/spend.mjs buildSpend(siding send --pegout). - coins
- The coin set for a script (SPEC 11): what a producer’s
/coins/<script hex>returns, or the same list folded from asidestr-corestate, and the maturity filter both wallets apply before choosing. - compose
- A spend with a laid-out body: named outputs first, then
OP_RETURNrecords (SPEC 12.1), then change. The general form behindcrate::asset: an asset transfer is outputs that carry, atally:record that says what they carry, and inputs that must be spent because they carry it. - deliver
- Delivery (SPEC 11): a transaction reaches a producer by
POST /tx, body the hex, or as a kind 23500 event on a relay, content the hex, taggedchain= chain id; a wallet with nothing publishes a kind 23501 event, content an address, and a faucet may answer. A port ofsiding/lib/spend.mjs deliverand the producer’s routes insiding/bin/siding.mjs(/coins/<script>,/tip,/chain.json,POST /tx). - error
- The one error type every fallible function in this crate returns.
- external
- Spends signed somewhere else: a browser extension behind
window.nostr.sidestr.signTransaction(specproposals/browser-signer.md). - key
- Keys behind a port: the
SpendSignerevery builder signs through, a plain-key implementation for tests and a CLI, the pubkey → script → address chain, and the ADR-2101 spend-key derivation. - pegin
- The parent side (SPEC 6, 7, 11): the peg-in transaction shape a wallet
builds on the parent, and the shapes of the peg holders’ peg-out payment
and the producer’s checkpoint, all with rust-bitcoin for the parent
network the chain document names. A port of the transaction and marker
construction of
siding/lib/parent.mjs(scanPegins,payPegout) andsiding/lib/checkpoint.mjs(sendCheckpoint), not of their RPC client: siding hands Bitcoin Core asendcall with[{address: btc}, {data: hex}], andPegIn::core_send_outputsis that argument. - policy
- The policy hook: the wallet builds and signs, it does not decide.
- select
- Coin selection, as the reference does it (
siding/lib/spend.mjs buildSpend): largest first, until the picked coins cover the amount plus a fee bound. - spend
- A key-path taproot spend to an address or script, with the fee sized at
the chain’s
minFeeRate, signed through theSpendSignerport and encoded as the hex a producer’sPOST /txtakes (SPEC 11). A port ofsiding/lib/spend.mjs buildSpendandresolveTo, and of the shapesiding sendprints.