Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions examples/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
const {
account,
signTransfer,
signTransferAll,
signCall,
accountFromMnemonic,
signTransferFromMnemonic,
Expand Down Expand Up @@ -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, {
Expand Down
70 changes: 64 additions & 6 deletions js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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'/<account>'/<change>'/<addressIndex>'`.
*/
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.<pallet>.<method>(...).method.toU8a()` or `.toHex()`), returning the
Expand Down Expand Up @@ -321,17 +363,33 @@ function encodeContext(params: CallParams, scheme?: Scheme): Record<string, unkn
};
}

function encodeTransfer(params: TransferParams, scheme?: Scheme): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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),
};
}
95 changes: 82 additions & 13 deletions src/ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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<Vec<u8>, 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<Vec<u8>, Error> {
sign_call_with_keypair(keypair, &encode_transfer_all_call(p), &p.ctx)
}

fn sign_payload(
keypair: &Keypair,
payload: &[u8],
Expand All @@ -342,27 +371,38 @@ fn encode_address(account: &AccountId32, out: &mut Vec<u8>) {
out.extend_from_slice(account.as_ref());
}

/// `balances.<call>(MultiAddress::Id(recipient), ..)` prefix; the caller appends
/// the remaining arguments.
fn balances_call(call_index: u8, recipient: &AccountId32) -> Vec<u8> {
let mut call = alloc::vec![BALANCES_PALLET, call_index];
encode_address(recipient, &mut call);
call
}

fn encode_call(p: &TransferParams) -> Vec<u8> {
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<u8> {
let mut call = balances_call(BALANCES_TRANSFER_ALL, &p.recipient);
call.push(p.keep_alive as u8);
call
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading