diff --git a/CHANGELOG.md b/CHANGELOG.md index 592b44f..a5a4825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Notable changes to `@quantus-network/wasm`. Release notes with pull-request links are on [GitHub Releases](https://github.com/Quantus-Network/quantus-wasm/releases). +## Unreleased + +### Added + +- `signTransferAll` and `signTransferAllFromMnemonic`: build and sign `balances.transfer_all`, moving the whole transferable balance to the recipient. `keepAlive` is required: `true` leaves the existential deposit so the sender survives (the sweep for deposit addresses), `false` also moves the existential deposit and lets the sender be reaped. + ## 1.0.0 - 2026-10-09 ### Breaking diff --git a/README.md b/README.md index 121efb9..9f5bfde 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This package is **compiled to WebAssembly from the Quantus chain's own crypto cr - `account(seed, opts?)` → ML-DSA keypair → Poseidon `AccountId32` → SS58 address (prefix `189`). - `signTransfer(seed, params)` → a signed **v4 extrinsic**, ready for `author_submitExtrinsic`. +- `signTransferAll(seed, params)` → sweep the whole transferable balance (`balances.transfer_all`). - `signCall(seed, call, params)` → sign **any** call (build it with polkadot.js, sign it here). - BIP39 mnemonic helpers using the canonical Quantus HD path. - Every function takes an optional `scheme`: `"ml-dsa-87"` (default) or `"ml-dsa-65"`. @@ -56,7 +57,7 @@ const extrinsicHex = Two properties of the chain interact badly if a signer is careless, and this package's defaults are chosen around them: -- **Reaping resets the nonce.** When an account's free balance drops below the existential deposit (0.001 QTC) the account is deleted, nonce included. The next deposit recreates it with nonce 0. Sign transfers with `keepAlive: true` (`balances.transfer_keep_alive`, which fails instead of reaping the sender) unless emptying the account is the intent; `keepAlive: false` is `transfer_allow_death`. The field has no default, so the choice is always explicit. +- **Reaping resets the nonce.** When an account's free balance drops below the existential deposit (0.001 QTC) the account is deleted, nonce included. The next deposit recreates it with nonce 0. Sign transfers with `keepAlive: true` (`balances.transfer_keep_alive`, which fails instead of reaping the sender) unless emptying the account is the intent; `keepAlive: false` is `transfer_allow_death`. To sweep a deposit address, use `signTransferAll` with `keepAlive: true`: it moves the transferable balance minus the existential deposit, so the address survives and its nonce keeps counting. The field has no default, so the choice is always explicit. - **An immortal extrinsic never expires.** If the sender was reaped and re-funded, an immortal extrinsic signed earlier with nonce 0 is valid again, bytes unchanged, and anyone who saw it can resubmit it. Always sign **mortal** extrinsics: pass `period` (64 to 256 blocks is typical) plus the current best block's `blockNumber` and `blockHash`. Mortality bounds the replay window; keeping the account alive closes it. - **The block hash must come from the node.** Quantus hashes blocks with Poseidon. A hash computed client-side by a Blake2 codec, such as polkadot.js's `header.hash`, is a different value, and an extrinsic anchored to it fails signature verification (`BadProof`). Use `chain_getBlockHash` (`api.rpc.chain.getBlockHash()`), then `chain_getHeader(hash)` for the block number. - **Hedged signatures keep extrinsic hashes unique.** Without hedging, ML-DSA is deterministic: re-signing the same call with the same nonce after a reap would produce byte-identical extrinsics, and indexers that key on the extrinsic hash would see a duplicate. Every signature from this package mixes in 32 bytes of fresh platform randomness, so no two signed extrinsics are ever byte-identical. Note that hedging does not protect against replay of an *existing* extrinsic; only mortality and keep-alive do. @@ -126,6 +127,21 @@ Notes: - **Immortal** requires an explicit `period: 0`; omitting `period` also signs immortal but logs a one-time warning. See [Mortality, reaping and replay](#mortality-reaping-and-replay). - Hashes accept either `0x`-hex strings or raw `Uint8Array`. Amounts accept `bigint` (recommended), decimal strings, or safe integers. +### `signTransferAll(seed: Uint8Array, params: TransferAllParams): Uint8Array` + +Builds and signs a `balances.transfer_all`, which moves the sender's whole transferable balance to `recipient`. The amount is decided on chain at execution time, so there is no `amount` field. + +```ts +interface TransferAllParams { + scheme?: Scheme; // signing key scheme; default "ml-dsa-87" + recipient: string | Uint8Array; + keepAlive: boolean; // required: true leaves the existential deposit (sender survives); false also moves it and lets the sender be reaped + // ...plus the same chain context fields as TransferParams (nonce, period, blockNumber, blockHash, genesisHash, specVersion, transactionVersion, tip) +} +``` + +`keepAlive: true` is the sweep for exchange deposit addresses: the address keeps 0.001 QTC, stays alive, and its nonce keeps counting. `keepAlive: false` also moves the existential deposit and allows the sender to be reaped. Reaping is not guaranteed: only the reducible balance moves, and locks, reserves or other references to the account keep it alive. + ### `signCall(seed: Uint8Array, call: Call, params: CallParams): Uint8Array` Signs an **already-encoded `RuntimeCall`**, returning the SCALE-encoded v4 extrinsic. This is the generic primitive behind `signTransfer`: encode the call with polkadot.js (whose codec handles the call fine — only the 7219-byte Dilithium *signature* exceeds its limits), then sign it here. @@ -202,6 +218,10 @@ When `scheme` is given in both the options and the params, the options win: they Same as `signTransfer`, but keyed from a mnemonic at the given HD indices. +### `signTransferAllFromMnemonic(mnemonic, params, opts?): Uint8Array` + +Same as `signTransferAll`, but keyed from a mnemonic at the given HD indices. + ### `signCallFromMnemonic(mnemonic, call, params, opts?): Uint8Array` Same as `signCall`, but keyed from a mnemonic at the given HD indices. diff --git a/examples/usage.js b/examples/usage.js index a4f0cc0..36e7e74 100644 --- a/examples/usage.js +++ b/examples/usage.js @@ -17,6 +17,7 @@ const { account, signTransfer, + signTransferAll, signCall, accountFromMnemonic, signTransferFromMnemonic, @@ -87,6 +88,17 @@ const allowDeathXt = signTransfer(seed, { }); console.log("extrinsic:", preview(allowDeathXt, 6)); +console.log("\n== signTransferAll(seed, params) — sweep, keepAlive: true (transfer_all) =="); +// Moves the transferable balance minus the existential deposit, so the sender survives and +// its nonce keeps counting: the right sweep for a deposit address. +const sweepXt = signTransferAll(seed, { + recipient: hd0.address, + keepAlive: true, + nonce: 0, + ...ctx, +}); +console.log("extrinsic:", preview(sweepXt, 6)); + console.log("\n== signTransfer(seed, params) — assets transfer (assetId) =="); // The assets pallet is not currently on mainnet; this only encodes the call. const assetXt = signTransfer(seed, { diff --git a/js/index.ts b/js/index.ts index 89c54f5..5df490e 100644 --- a/js/index.ts +++ b/js/index.ts @@ -102,6 +102,20 @@ export interface TransferParams extends CallParams { keepAlive: boolean; } +/** `balances.transfer_all`: move the whole transferable balance to `recipient`. */ +export interface TransferAllParams extends CallParams { + recipient: Recipient; + /** + * Required, no default. `true` leaves the existential deposit behind so the + * sender account survives (the right choice for sweeping a deposit address: + * its nonce keeps counting). `false` also moves the existential deposit and + * lets the sender be reaped (nonce back to 0). Reaping is not guaranteed: + * only the reducible balance moves, and locks or reserves keep the account + * alive. + */ + keepAlive: boolean; +} + let warnedImmortal = false; function warnImmortalOnce(): void { @@ -266,6 +280,34 @@ export function signTransferFromMnemonic( ); } +/** + * Sign a `balances.transfer_all` (sweep the whole transferable balance), + * returning the SCALE-encoded v4 extrinsic ready for `author_submitExtrinsic`. + */ +export function signTransferAll(seed: Uint8Array, params: TransferAllParams): Uint8Array { + return wasm.signTransferAll(asSeed(seed), encodeTransferAll(params)); +} + +/** + * Sign a `balances.transfer_all` from a BIP39 mnemonic using the Quantus HD path + * `m/44'/189189'/'/'/'`. + */ +export function signTransferAllFromMnemonic( + mnemonic: string, + params: TransferAllParams, + opts: MnemonicOptions = {} +): Uint8Array { + const scheme = opts.scheme ?? params.scheme; + return wasm.signTransferAllFromMnemonic( + mnemonic, + encodeTransferAll(params, scheme), + opts.account ?? 0, + opts.change ?? 0, + opts.addressIndex ?? defaultAddressIndex(scheme), + opts.passphrase + ); +} + /** * Sign an arbitrary, already-encoded `RuntimeCall` (e.g. from polkadot.js * `api.tx..(...).method.toU8a()` or `.toHex()`), returning the @@ -321,17 +363,33 @@ function encodeContext(params: CallParams, scheme?: Scheme): Record { - if (typeof params.keepAlive !== "boolean") { - throw new TypeError( - "keepAlive is required: true builds balances.transfer_keep_alive, false builds balances.transfer_allow_death" - ); +function requireKeepAlive(value: unknown, meaning: string): boolean { + if (typeof value !== "boolean") { + throw new TypeError(`keepAlive is required: ${meaning}`); } + return value; +} + +function encodeTransfer(params: TransferParams, scheme?: Scheme): Record { return { recipient: toRecipient(params.recipient), amount: toDecimal(params.amount, "amount"), assetId: params.assetId, - keepAlive: params.keepAlive, + keepAlive: requireKeepAlive( + params.keepAlive, + "true builds balances.transfer_keep_alive, false builds balances.transfer_allow_death" + ), + ...encodeContext(params, scheme), + }; +} + +function encodeTransferAll(params: TransferAllParams, scheme?: Scheme): Record { + return { + recipient: toRecipient(params.recipient), + keepAlive: requireKeepAlive( + params.keepAlive, + "true leaves the existential deposit so the sender survives, false lets the sender be reaped" + ), ...encodeContext(params, scheme), }; } diff --git a/src/ext.rs b/src/ext.rs index 8026c62..4308e61 100644 --- a/src/ext.rs +++ b/src/ext.rs @@ -39,6 +39,7 @@ const PAYLOAD_HASH_THRESHOLD: usize = 256; const BALANCES_PALLET: u8 = 2; const BALANCES_TRANSFER_ALLOW_DEATH: u8 = 0; const BALANCES_TRANSFER_KEEP_ALIVE: u8 = 3; +const BALANCES_TRANSFER_ALL: u8 = 4; // The assets pallet is not currently on mainnet; index 17 is kept vacant there. const ASSETS_PALLET: u8 = 17; const ASSETS_TRANSFER: u8 = 8; @@ -221,6 +222,17 @@ pub struct TransferParams { pub ctx: SignContext, } +/// Parameters for `balances.transfer_all`: move the whole transferable balance +/// to `recipient`. `keep_alive: true` leaves the existential deposit behind so +/// the sender survives; `false` also moves it and lets the sender be reaped +/// (nonce back to 0), though locks or reserves can keep the account alive. +/// Deliberately no default. +pub struct TransferAllParams { + pub recipient: AccountId32, + pub keep_alive: bool, + pub ctx: SignContext, +} + /// Quantus account id = Poseidon hash of the public key (both schemes). fn account_id_from_public(public_key: &[u8]) -> AccountId32 { AccountId32::new(hash_bytes(public_key)) @@ -324,6 +336,23 @@ pub fn sign_transfer_with_keypair(keypair: &Keypair, p: &TransferParams) -> Resu sign_call_with_keypair(keypair, &encode_call(p), &p.ctx) } +/// Build a signed, SCALE-encoded v4 `balances.transfer_all` extrinsic. +pub fn sign_transfer_all( + seed: &[u8], + scheme: Scheme, + p: &TransferAllParams, +) -> Result, Error> { + sign_transfer_all_with_keypair(&Keypair::from_seed(seed, scheme)?, p) +} + +/// [`sign_transfer_all`] with an already-derived keypair. +pub fn sign_transfer_all_with_keypair( + keypair: &Keypair, + p: &TransferAllParams, +) -> Result, Error> { + sign_call_with_keypair(keypair, &encode_transfer_all_call(p), &p.ctx) +} + fn sign_payload( keypair: &Keypair, payload: &[u8], @@ -342,27 +371,38 @@ fn encode_address(account: &AccountId32, out: &mut Vec) { out.extend_from_slice(account.as_ref()); } +/// `balances.(MultiAddress::Id(recipient), ..)` prefix; the caller appends +/// the remaining arguments. +fn balances_call(call_index: u8, recipient: &AccountId32) -> Vec { + let mut call = alloc::vec![BALANCES_PALLET, call_index]; + encode_address(recipient, &mut call); + call +} + fn encode_call(p: &TransferParams) -> Vec { - let mut call = Vec::new(); - match p.asset_id { - None => { - call.push(BALANCES_PALLET); - call.push(if p.keep_alive { + let mut call = match p.asset_id { + None => balances_call( + if p.keep_alive { BALANCES_TRANSFER_KEEP_ALIVE } else { BALANCES_TRANSFER_ALLOW_DEATH - }); - encode_address(&p.recipient, &mut call); - Compact(p.amount).encode_to(&mut call); - } + }, + &p.recipient, + ), Some(asset_id) => { - call.push(ASSETS_PALLET); - call.push(ASSETS_TRANSFER); + let mut call = alloc::vec![ASSETS_PALLET, ASSETS_TRANSFER]; Compact(asset_id).encode_to(&mut call); encode_address(&p.recipient, &mut call); - Compact(p.amount).encode_to(&mut call); + call } - } + }; + Compact(p.amount).encode_to(&mut call); + call +} + +fn encode_transfer_all_call(p: &TransferAllParams) -> Vec { + let mut call = balances_call(BALANCES_TRANSFER_ALL, &p.recipient); + call.push(p.keep_alive as u8); call } @@ -520,6 +560,14 @@ mod tests { } } + fn sample_all_params(keep_alive: bool) -> TransferAllParams { + TransferAllParams { + recipient: AccountId32::new([2u8; 32]), + keep_alive, + ctx: sample_ctx(), + } + } + fn sig_len(scheme: Scheme) -> usize { match scheme { Scheme::MlDsa87 => ml_dsa_87::SIGNBYTES, @@ -784,6 +832,27 @@ mod tests { assert_eq!((asset[0], asset[1]), (ASSETS_PALLET, ASSETS_TRANSFER)); } + #[test] + fn transfer_all_call_and_extrinsic() { + let seed = [0u8; 32]; + for keep_alive in [true, false] { + let p = sample_all_params(keep_alive); + let call = encode_transfer_all_call(&p); + let mut expected = alloc::vec![BALANCES_PALLET, BALANCES_TRANSFER_ALL, 0u8]; + expected.extend_from_slice(&[2u8; 32]); + expected.push(keep_alive as u8); + assert_eq!(call, expected); + for scheme in SCHEMES { + let via_all = sign_transfer_all(&seed, scheme, &p).unwrap(); + let via_call = sign_call(&seed, scheme, &call, &p.ctx).unwrap(); + assert_eq!( + without_signature(&via_all, scheme), + without_signature(&via_call, scheme) + ); + } + } + } + #[test] fn sign_call_matches_sign_transfer() { // The generic call signer and the transfer convenience must produce the diff --git a/src/lib.rs b/src/lib.rs index db80410..a0290fc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,10 @@ //! WASM bindings for Quantus account derivation and ML-DSA extrinsic signing. //! -//! Exposes two entry points to JavaScript/TypeScript: +//! Entry points exposed to JavaScript/TypeScript: //! - [`account`]: 32-byte seed -> ML-DSA keypair, Poseidon `AccountId32`, SS58 address. //! - [`signTransfer`]: 32-byte seed + transfer params -> signed v4 extrinsic bytes. +//! - [`signTransferAll`]: same for `balances.transfer_all`. +//! - [`signCall`]: sign any pre-encoded `RuntimeCall`. //! //! Every entry point takes an optional `scheme` (`"ml-dsa-87"`, the default, or //! `"ml-dsa-65"`) selecting the signing key type. All signatures are hedged with @@ -125,6 +127,18 @@ struct JsTransferParams { ctx: JsSignContext, } +/// `balances.transfer_all` parameters: recipient, keep-alive choice, context. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct JsTransferAllParams { + recipient: String, + /// `true` leaves the existential deposit behind; `false` lets the sender + /// be reaped. Required. + keep_alive: bool, + #[serde(flatten)] + ctx: JsSignContext, +} + /// Sign a balances/assets transfer, returning the SCALE-encoded v4 extrinsic. #[wasm_bindgen(js_name = signTransfer)] pub fn sign_transfer(seed: &[u8], params: JsValue) -> Result, JsError> { @@ -132,6 +146,13 @@ pub fn sign_transfer(seed: &[u8], params: JsValue) -> Result, JsError> { ext::sign_transfer(seed, scheme, ¶ms).map_err(to_js_error) } +/// Sign a `balances.transfer_all`, returning the SCALE-encoded v4 extrinsic. +#[wasm_bindgen(js_name = signTransferAll)] +pub fn sign_transfer_all(seed: &[u8], params: JsValue) -> Result, JsError> { + let (params, scheme) = build_transfer_all_params(params)?; + ext::sign_transfer_all(seed, scheme, ¶ms).map_err(to_js_error) +} + /// Sign an already-encoded `RuntimeCall` (e.g. polkadot.js `tx.method.toU8a()`), /// returning the SCALE-encoded v4 extrinsic. #[wasm_bindgen(js_name = signCall)] @@ -151,12 +172,15 @@ pub(crate) fn parse_scheme(name: Option<&str>) -> Result { } } +fn from_js(value: JsValue) -> Result { + serde_wasm_bindgen::from_value(value) + .map_err(|e| JsError::new(&alloc::format!("invalid params: {e}"))) +} + pub(crate) fn build_transfer_params( params: JsValue, ) -> Result<(ext::TransferParams, Scheme), JsError> { - let p: JsTransferParams = serde_wasm_bindgen::from_value(params) - .map_err(|e| JsError::new(&alloc::format!("invalid params: {e}")))?; - + let p: JsTransferParams = from_js(params)?; let (ctx, scheme) = build_sign_context(&p.ctx)?; Ok(( ext::TransferParams { @@ -170,11 +194,25 @@ pub(crate) fn build_transfer_params( )) } +pub(crate) fn build_transfer_all_params( + params: JsValue, +) -> Result<(ext::TransferAllParams, Scheme), JsError> { + let p: JsTransferAllParams = from_js(params)?; + let (ctx, scheme) = build_sign_context(&p.ctx)?; + Ok(( + ext::TransferAllParams { + recipient: parse_account_id(&p.recipient)?, + keep_alive: p.keep_alive, + ctx, + }, + scheme, + )) +} + pub(crate) fn build_sign_context_from_value( context: JsValue, ) -> Result<(ext::SignContext, Scheme), JsError> { - let c: JsSignContext = serde_wasm_bindgen::from_value(context) - .map_err(|e| JsError::new(&alloc::format!("invalid params: {e}")))?; + let c: JsSignContext = from_js(context)?; build_sign_context(&c) } diff --git a/src/mnemonic.rs b/src/mnemonic.rs index 349e911..1b11501 100644 --- a/src/mnemonic.rs +++ b/src/mnemonic.rs @@ -68,6 +68,22 @@ pub fn sign_transfer_from_mnemonic( ext::sign_transfer_with_keypair(&keypair, ¶ms).map_err(crate::to_js_error) } +/// Sign a `balances.transfer_all` from a mnemonic at the given HD indices. +#[wasm_bindgen(js_name = signTransferAllFromMnemonic)] +pub fn sign_transfer_all_from_mnemonic( + mnemonic: &str, + params: JsValue, + account: u32, + change: u32, + address_index: u32, + passphrase: Option, +) -> Result, JsError> { + let (params, scheme) = crate::build_transfer_all_params(params)?; + let keypair = + keypair_from_mnemonic(mnemonic, passphrase, account, change, address_index, scheme)?; + ext::sign_transfer_all_with_keypair(&keypair, ¶ms).map_err(crate::to_js_error) +} + /// Sign an already-encoded `RuntimeCall` from a mnemonic at the given HD indices. #[wasm_bindgen(js_name = signCallFromMnemonic)] pub fn sign_call_from_mnemonic( diff --git a/test/smoke.test.js b/test/smoke.test.js index da23ef3..8e02cd6 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -3,9 +3,11 @@ const assert = require("node:assert/strict"); const { account, signTransfer, + signTransferAll, signCall, accountFromMnemonic, signTransferFromMnemonic, + signTransferAllFromMnemonic, signCallFromMnemonic, mnemonicToSeed, } = require("../dist/index.js"); @@ -39,6 +41,9 @@ const callBytes = (xt, callLen) => hex(xt.subarray(xt.length - callLen)); // pallet 2, call 3, 0x00 (Id), 32-byte account, compact(1000)=0xa10f. const KEEP_ALIVE_CALL = "0x020300" + CRYSTAL_ALICE_ACCOUNT_ID + "a10f"; const ALLOW_DEATH_CALL = "0x020000" + CRYSTAL_ALICE_ACCOUNT_ID + "a10f"; +// balances.transfer_all(MultiAddress::Id(crystal_alice), keep_alive): pallet 2, call 4, then a bool. +const TRANSFER_ALL_CALL = (keepAlive) => + "020400" + CRYSTAL_ALICE_ACCOUNT_ID + (keepAlive ? "01" : "00"); test("account matches the crystal_alice golden vector", () => { const a = account(CRYSTAL_ALICE_SEED); @@ -173,6 +178,34 @@ test("keepAlive is required: true builds transfer_keep_alive, false transfer_all ); }); +test("signTransferAll builds transfer_all with the explicit keep_alive flag", () => { + const params = { + recipient: CRYSTAL_ALICE_ADDRESS, + nonce: 0, + period: 0, + genesisHash: "0x" + "00".repeat(32), + specVersion: 100, + transactionVersion: 1, + }; + for (const keepAlive of [true, false]) { + const xt = signTransferAll(CRYSTAL_ALICE_SEED, { ...params, keepAlive }); + assert.equal(xt.length, 7297 - 1); // one bool instead of compact(1000) + assert.equal(callBytes(xt, 36), TRANSFER_ALL_CALL(keepAlive)); + const viaCall = signCall(CRYSTAL_ALICE_SEED, "0x" + TRANSFER_ALL_CALL(keepAlive), params); + assert.deepEqual(envelope(xt), envelope(viaCall)); + } + assert.throws(() => signTransferAll(CRYSTAL_ALICE_SEED, params), /keepAlive is required/); + assert.throws( + () => signTransferAllFromMnemonic(MNEMONIC, { ...params, keepAlive: "true" }), + /keepAlive is required/ + ); + + const fromMnemonic = signTransferAllFromMnemonic(MNEMONIC, { ...params, keepAlive: true }, { scheme: ML_DSA_65 }); + assert.equal(fromMnemonic[2 + 1 + 33], 0x01); + assert.deepEqual(fromMnemonic.subarray(4, 36), accountFromMnemonic(MNEMONIC, { scheme: ML_DSA_65 }).accountId); + assert.equal(callBytes(fromMnemonic, 36), TRANSFER_ALL_CALL(true)); +}); + test("signCall matches signTransfer for the equivalent encoded call", () => { // Mirrors what polkadot.js `tx.method.toHex()` would produce for this call. const ctx = {