From 5a24ceef4598a1c9b424d189fe5f7710752de35d Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Mon, 9 Mar 2026 20:38:53 -0700 Subject: [PATCH] feat: redesign TransactionIntent as high-level business intents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace low-level call intents (Transfer, Bond, Unbond, AddProxy, etc.) with business-level intents (Payment, Consolidate, Stake, Unstake, Claim, FillNonce). The crate now handles intent-to-call composition internally, including automatic batching for multi-call operations. Composition logic: - Payment → transfer or transferKeepAlive - Consolidate → transferAll - Stake with proxyAddress → batchAll(bond, addProxy) - Stake without proxyAddress → bondExtra (top-up) - Unstake with stopStaking → batchAll(removeProxy, chill, unbond) - Unstake without stopStaking → unbond - Claim → withdrawUnbonded - FillNonce → zero-value transferKeepAlive to self Old call-level types are now internal CallIntent (not public). Includes bondExtra encoding (previously missing). 47 tests pass (18 new composition + deserialization tests). Ticket: BTC-3120 --- packages/wasm-dot/js/builder.ts | 69 ++-- packages/wasm-dot/js/types.ts | 110 +++--- packages/wasm-dot/src/builder/calls.rs | 97 ++--- packages/wasm-dot/src/builder/mod.rs | 17 +- packages/wasm-dot/src/builder/types.rs | 502 +++++++++++++++++++++---- packages/wasm-dot/src/wasm/builder.rs | 52 +-- packages/wasm-dot/test/builder.ts | 225 ++++++----- 7 files changed, 683 insertions(+), 389 deletions(-) diff --git a/packages/wasm-dot/js/builder.ts b/packages/wasm-dot/js/builder.ts index 0541dc36..8a40ea4f 100644 --- a/packages/wasm-dot/js/builder.ts +++ b/packages/wasm-dot/js/builder.ts @@ -1,8 +1,9 @@ /** - * Transaction building from high-level intents. + * Transaction building from high-level business intents. * * Provides the `buildTransaction()` function for building DOT transactions. - * Follows wallet-platform pattern: buildTransaction(intent, context) + * The crate handles intent composition internally (e.g., stake with proxy + * automatically produces a batchAll of bond + addProxy). */ import { BuilderNamespace } from "./wasm/wasm_dot.js"; @@ -10,64 +11,36 @@ import { DotTransaction } from "./transaction.js"; import type { TransactionIntent, BuildContext } from "./types.js"; /** - * Build a DOT transaction from an intent and context. + * Build a DOT transaction from a business-level intent and context. * - * This function takes a declarative TransactionIntent and BuildContext, - * producing a Transaction object that can be inspected, signed, and serialized. + * The intent describes *what* to do (payment, stake, etc.) and the context + * provides *how* to build it (sender, nonce, material, validity). + * Multi-call intents are batched automatically. * - * The returned transaction is unsigned - signatures should be added via - * `addSignature()` before serializing with `toBytes()` and broadcasting. - * - * @param intent - What to do (transfer, stake, etc.) - * @param context - How to build it (sender, nonce, material, validity, referenceBlock) - * @returns A Transaction object that can be inspected, signed, and serialized + * @param intent - Business intent (payment, stake, unstake, claim, etc.) + * @param context - Build context (sender, nonce, material, validity, referenceBlock) + * @returns An unsigned DotTransaction ready for signing * @throws Error if the intent cannot be built (e.g., invalid addresses) * * @example * ```typescript * import { buildTransaction } from '@bitgo/wasm-dot'; * - * // Build a simple DOT transfer + * // Payment * const tx = buildTransaction( - * { type: 'transfer', to: '5FHneW46...', amount: 1000000000000n, keepAlive: true }, - * { - * sender: '5EGoFA95omzemRssELLDjVenNZ68aXyUeqtKQScXSEBvVJkr', - * nonce: 5, - * material: { - * genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3', - * chainName: 'Polkadot', - * specName: 'polkadot', - * specVersion: 9150, - * txVersion: 9 - * }, - * validity: { firstValid: 1000, maxDuration: 2400 }, - * referenceBlock: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3' - * } + * { type: 'payment', to: '5FHneW46...', amount: 1000000000000n }, + * context * ); * - * // Inspect the transaction - * console.log(tx.nonce); - * - * // Get the signable payload for signing - * const payload = tx.signablePayload(); - * - * // Add signature and serialize - * tx.addSignature(signerPubkey, signature); - * const txBytes = tx.toBytes(); - * ``` + * // New stake (produces batchAll of bond + addProxy) + * const stakeTx = buildTransaction( + * { type: 'stake', amount: 5000000000000n, proxyAddress: '5Grwva...' }, + * context + * ); * - * @example - * ```typescript - * // Build with batch (multiple operations) - * const tx = buildTransaction( - * { - * type: 'batch', - * calls: [ - * { type: 'transfer', to: recipient, amount: 1000000000000n }, - * { type: 'stake', amount: 5000000000000n, payee: { type: 'staked' } } - * ], - * atomic: true - * }, + * // Full unstake (produces batchAll of removeProxy + chill + unbond) + * const unstakeTx = buildTransaction( + * { type: 'unstake', amount: 5000000000000n, stopStaking: true, proxyAddress: '5Grwva...' }, * context * ); * ``` diff --git a/packages/wasm-dot/js/types.ts b/packages/wasm-dot/js/types.ts index 0c940dcc..83738b1d 100644 --- a/packages/wasm-dot/js/types.ts +++ b/packages/wasm-dot/js/types.ts @@ -1,9 +1,12 @@ /** * TypeScript type definitions for wasm-dot * - * Follows wallet-platform pattern: buildTransaction(intent, context) - * - intent: what to do (transfer, stake, etc.) - single operation + * buildTransaction(intent, context) + * - intent: business-level intent (payment, stake, unstake, etc.) * - context: how to build it (sender, nonce, material, validity) + * + * The crate handles intent composition internally. For example, a stake + * intent with a proxy address produces a batchAll(bond, addProxy) extrinsic. */ // ============================================================================= @@ -54,9 +57,9 @@ export interface ParseContext { material: Material; /** Sender address (optional, helps with decoding) */ sender?: string; - /** Reference block hash (not in extrinsic bytes — pass-through for consumers) */ + /** Reference block hash (not in extrinsic bytes, pass-through for consumers) */ referenceBlock?: string; - /** Block number when transaction becomes valid (not in extrinsic bytes — pass-through for consumers) */ + /** Block number when transaction becomes valid (not in extrinsic bytes, pass-through for consumers) */ blockNumber?: number; } @@ -65,9 +68,7 @@ export interface ParseContext { // ============================================================================= /** - * Build context - contains all non-intent data needed to build a transaction - * - * Matches wallet-platform's material + nonce + validity pattern. + * Build context: contains all non-intent data needed to build a transaction. */ export interface BuildContext { /** Sender address (SS58 encoded) */ @@ -89,46 +90,54 @@ export interface BuildContext { // ============================================================================= /** - * Transaction intent - a single operation to perform + * Business-level transaction intent. * - * Discriminated union using the `type` field. - * For multiple operations, use the `batch` intent type. + * Discriminated union using the `type` field. The crate handles composing + * these into the correct Polkadot extrinsic calls, including automatic + * batching when multiple calls are needed (e.g., stake with proxy). */ export type TransactionIntent = - | TransferIntent - | TransferAllIntent + | PaymentIntent + | ConsolidateIntent | StakeIntent | UnstakeIntent - | WithdrawUnbondedIntent - | ChillIntent - | AddProxyIntent - | RemoveProxyIntent - | BatchIntent; - -export interface TransferIntent { - type: "transfer"; + | ClaimIntent + | FillNonceIntent; + +/** Transfer DOT to a recipient */ +export interface PaymentIntent { + type: "payment"; /** Recipient address (SS58) */ to: string; /** Amount in planck */ amount: bigint; - /** Use transferKeepAlive (default: true) */ + /** Use transferKeepAlive to prevent reaping (default: true) */ keepAlive?: boolean; } -export interface TransferAllIntent { - type: "transferAll"; +/** Sweep all DOT to a recipient (transferAll) */ +export interface ConsolidateIntent { + type: "consolidate"; /** Recipient address (SS58) */ to: string; - /** Keep account alive after transfer */ + /** Keep sender account alive after transfer (default: true) */ keepAlive?: boolean; } +/** + * Stake DOT. + * + * - With `proxyAddress`: new stake, produces batchAll(bond, addProxy) + * - Without `proxyAddress`: top-up, produces bondExtra + */ export interface StakeIntent { type: "stake"; /** Amount to stake in planck */ amount: bigint; - /** Where to send staking rewards */ + /** Reward destination (default: Staked / compound) */ payee?: StakePayee; + /** Proxy address for new stake. Absent means top-up (bondExtra). */ + proxyAddress?: string; } export type StakePayee = @@ -137,48 +146,33 @@ export type StakePayee = | { type: "controller" } | { type: "account"; address: string }; +/** + * Unstake DOT. + * + * - `stopStaking=true` + `proxyAddress`: full unstake, produces + * batchAll(removeProxy, chill, unbond) + * - `stopStaking=false`: partial unstake, produces unbond + */ export interface UnstakeIntent { type: "unstake"; /** Amount to unstake in planck */ amount: bigint; + /** Full unstake (remove proxy + chill) or partial (just unbond). Default: false */ + stopStaking?: boolean; + /** Proxy address to remove (required when stopStaking=true) */ + proxyAddress?: string; } -export interface WithdrawUnbondedIntent { - type: "withdrawUnbonded"; - /** Number of slashing spans (usually 0) */ +/** Claim (withdraw unbonded) DOT after the unbonding period */ +export interface ClaimIntent { + type: "claim"; + /** Number of slashing spans (default: 0) */ slashingSpans?: number; } -export interface ChillIntent { - type: "chill"; -} - -export interface AddProxyIntent { - type: "addProxy"; - /** Delegate address (SS58) */ - delegate: string; - /** Proxy type (Any, NonTransfer, Staking, etc.) */ - proxyType: string; - /** Delay in blocks */ - delay?: number; -} - -export interface RemoveProxyIntent { - type: "removeProxy"; - /** Delegate address (SS58) */ - delegate: string; - /** Proxy type */ - proxyType: string; - /** Delay in blocks */ - delay?: number; -} - -export interface BatchIntent { - type: "batch"; - /** List of intents to execute */ - calls: TransactionIntent[]; - /** Use batchAll (atomic) instead of batch (default: true) */ - atomic?: boolean; +/** Zero-value self-transfer to advance the account nonce */ +export interface FillNonceIntent { + type: "fillNonce"; } // ============================================================================= diff --git a/packages/wasm-dot/src/builder/calls.rs b/packages/wasm-dot/src/builder/calls.rs index 1f0c728e..3e438fc7 100644 --- a/packages/wasm-dot/src/builder/calls.rs +++ b/packages/wasm-dot/src/builder/calls.rs @@ -1,9 +1,12 @@ //! Call encoding using subxt dynamic API //! -//! Clean, readable call building - similar to txwrapper-polkadot's methods.balances.transferKeepAlive() +//! Two entry points: +//! - `encode_intent()`: public — accepts a business-level `TransactionIntent`, +//! composes it into calls, and encodes (batching if needed) +//! - `encode_call()`: internal — encodes a single `CallIntent` to call data bytes use crate::address::decode_ss58; -use crate::builder::types::{StakePayee, TransactionIntent}; +use crate::builder::types::{intent_to_calls, CallIntent, StakePayee, TransactionIntent}; use crate::error::WasmDotError; use subxt_core::{ ext::scale_value::{Composite, Value}, @@ -11,13 +14,30 @@ use subxt_core::{ tx::payload::{dynamic, Payload}, }; -/// Encode a transaction intent to call data bytes -pub fn encode_call( +/// Encode a business-level intent to call data bytes. +/// +/// Handles composition: single-call intents are encoded directly, +/// multi-call intents (e.g., stake with proxy) are wrapped in batchAll. +pub fn encode_intent( intent: &TransactionIntent, + sender: &str, metadata: &Metadata, ) -> Result, WasmDotError> { - let payload = match intent { - TransactionIntent::Transfer { + let calls = intent_to_calls(intent, sender)?; + + match calls.len() { + 0 => Err(WasmDotError::InvalidInput( + "Intent produced no calls".to_string(), + )), + 1 => encode_call(&calls[0], metadata), + _ => encode_batch(&calls, metadata), + } +} + +/// Encode a single call-level intent to call data bytes. +fn encode_call(call: &CallIntent, metadata: &Metadata) -> Result, WasmDotError> { + let payload = match call { + CallIntent::Transfer { to, amount, keep_alive, @@ -29,26 +49,24 @@ pub fn encode_call( }; balances(method, to, *amount)? } - TransactionIntent::TransferAll { to, keep_alive } => transfer_all(to, *keep_alive)?, - TransactionIntent::Stake { amount, payee } => staking_bond(*amount, payee)?, - TransactionIntent::Unstake { amount } => staking_unbond(*amount), - TransactionIntent::WithdrawUnbonded { slashing_spans } => { + CallIntent::TransferAll { to, keep_alive } => transfer_all(to, *keep_alive)?, + CallIntent::Bond { amount, payee } => staking_bond(*amount, payee)?, + CallIntent::BondExtra { amount } => staking_bond_extra(*amount), + CallIntent::Unbond { amount } => staking_unbond(*amount), + CallIntent::WithdrawUnbonded { slashing_spans } => { staking_withdraw_unbonded(*slashing_spans) } - TransactionIntent::Chill => staking_chill(), - TransactionIntent::AddProxy { + CallIntent::Chill => staking_chill(), + CallIntent::AddProxy { delegate, proxy_type, delay, } => proxy_add(delegate, proxy_type, *delay)?, - TransactionIntent::RemoveProxy { + CallIntent::RemoveProxy { delegate, proxy_type, delay, } => proxy_remove(delegate, proxy_type, *delay)?, - TransactionIntent::Batch { calls, atomic } => { - return encode_batch(calls, *atomic, metadata); - } }; payload @@ -116,6 +134,14 @@ fn staking_bond( )) } +fn staking_bond_extra(amount: u64) -> subxt_core::tx::payload::DynamicPayload { + dynamic( + "Staking", + "bond_extra", + named([("max_additional", Value::u128(amount as u128))]), + ) +} + fn staking_unbond(amount: u64) -> subxt_core::tx::payload::DynamicPayload { dynamic( "Staking", @@ -179,44 +205,21 @@ fn proxy_remove( // Utility pallet (batch) // ============================================================================= -/// Encode a batch - like txwrapper's methods.utility.batch({ calls }) -fn encode_batch( - intents: &[TransactionIntent], - atomic: bool, - metadata: &Metadata, -) -> Result, WasmDotError> { +/// Encode multiple calls as a batchAll (atomic batch). +fn encode_batch(calls: &[CallIntent], metadata: &Metadata) -> Result, WasmDotError> { use parity_scale_codec::{Compact, Encode}; - if intents.is_empty() { - return Err(WasmDotError::InvalidInput( - "Batch cannot be empty".to_string(), - )); - } - - // Reject nested batches - if intents - .iter() - .any(|i| matches!(i, TransactionIntent::Batch { .. })) - { - return Err(WasmDotError::InvalidInput( - "Nested batch not supported".to_string(), - )); - } - - // Encode each call (same as txwrapper's unsigned.method) - let calls: Result, _> = intents + let encoded_calls: Result, _> = calls .iter() - .map(|intent| encode_call(intent, metadata)) + .map(|call| encode_call(call, metadata)) .collect(); - let calls = calls?; + let encoded_calls = encoded_calls?; - // Build batch: [pallet][method][calls...] - let method = if atomic { "batch_all" } else { "batch" }; - let (pallet_idx, call_idx) = get_call_index(metadata, "Utility", method)?; + let (pallet_idx, call_idx) = get_call_index(metadata, "Utility", "batch_all")?; let mut result = vec![pallet_idx, call_idx]; - Compact(calls.len() as u32).encode_to(&mut result); - for call in calls { + Compact(encoded_calls.len() as u32).encode_to(&mut result); + for call in encoded_calls { result.extend(call); } Ok(result) diff --git a/packages/wasm-dot/src/builder/mod.rs b/packages/wasm-dot/src/builder/mod.rs index 55cf58c6..784d1515 100644 --- a/packages/wasm-dot/src/builder/mod.rs +++ b/packages/wasm-dot/src/builder/mod.rs @@ -1,7 +1,8 @@ //! Transaction building from intents //! -//! Build DOT transactions from high-level intent descriptions. -//! Follows wallet-platform pattern: buildTransaction(intent, context) +//! Build DOT transactions from high-level business intent descriptions. +//! Accepts intents like Payment, Stake, Unstake (not low-level calls) +//! and handles composition into the correct extrinsic calls. mod calls; pub mod types; @@ -9,14 +10,16 @@ pub mod types; use crate::error::WasmDotError; use crate::transaction::{encode_era, Transaction}; use crate::types::{Era, Validity}; -use calls::encode_call; +use calls::encode_intent; use parity_scale_codec::{Compact, Encode}; use subxt_core::metadata::Metadata; use types::{BuildContext, TransactionIntent}; -/// Build a transaction from an intent and context +/// Build a transaction from a business-level intent and context. /// -/// This is the main entry point, matching wallet-platform's pattern. +/// The intent describes *what* to do (payment, stake, etc.) and the context +/// provides *how* to build it (sender, nonce, material, validity). +/// Multi-call intents (e.g., stake with proxy) are batched automatically. pub fn build_transaction( intent: TransactionIntent, context: BuildContext, @@ -24,8 +27,8 @@ pub fn build_transaction( // Decode metadata once let metadata = decode_metadata(&context.material.metadata)?; - // Build call data using metadata - let call_data = encode_call(&intent, &metadata)?; + // Compose intent into calls and encode (batching if needed) + let call_data = encode_intent(&intent, &context.sender, &metadata)?; // Calculate era from validity let era = compute_era(&context.validity); diff --git a/packages/wasm-dot/src/builder/types.rs b/packages/wasm-dot/src/builder/types.rs index 43456b1d..0ccd35d3 100644 --- a/packages/wasm-dot/src/builder/types.rs +++ b/packages/wasm-dot/src/builder/types.rs @@ -1,101 +1,233 @@ //! Intent types for transaction building //! -//! Matches wallet-platform pattern: buildTransaction(intent, context) -//! - intent: what to do (transfer, stake, etc.) - single operation -//! - context: how to build it (sender, nonce, material, validity) +//! Two-layer design: +//! - `TransactionIntent`: public, business-level intents (payment, stake, unstake, etc.) +//! - `CallIntent`: internal, call-level intents (transfer, bond, addProxy, etc.) +//! +//! The composition function `intent_to_calls()` converts business intents into +//! one or more call intents, handling batch composition automatically. +use crate::error::WasmDotError; use crate::types::{Material, Validity}; use serde::{Deserialize, Serialize}; -/// Transaction intent - what to do +// ============================================================================= +// Public API: Business-level intents +// ============================================================================= + +/// High-level business intent for transaction building. /// -/// Single operation (transfer, stake, etc.). For multiple ops, use Batch. -/// Matches wallet-platform's DOTPaymentIntent, DOTStakingIntent, etc. +/// These intents represent what the caller wants to do (payment, stake, etc.). +/// The crate handles composing them into the correct Polkadot extrinsic calls, +/// including batching when multiple calls are needed (e.g., bond + addProxy). #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum TransactionIntent { - /// Transfer DOT to recipient - Transfer { + /// Transfer DOT to a recipient + Payment { /// Recipient address (SS58) to: String, - /// Amount in planck (accepts JS BigInt natively via serde_wasm_bindgen) + /// Amount in planck amount: u64, - /// Use transferKeepAlive (default: true) - #[serde(default = "default_keep_alive", rename = "keepAlive")] + /// Use transferKeepAlive to prevent reaping (default: true) + #[serde(default = "default_true", rename = "keepAlive")] keep_alive: bool, }, - /// Transfer all DOT to recipient - TransferAll { + + /// Sweep all DOT to a recipient (transferAll) + Consolidate { /// Recipient address (SS58) to: String, - /// Keep account alive after transfer - #[serde(default, rename = "keepAlive")] + /// Keep sender account alive after transfer (default: true) + #[serde(default = "default_true", rename = "keepAlive")] keep_alive: bool, }, - /// Stake (bond) DOT + + /// Stake DOT. + /// + /// - With `proxy_address`: new stake → batchAll(bond, addProxy) + /// - Without `proxy_address`: top-up → bondExtra Stake { - /// Amount to stake in planck (accepts JS BigInt natively) + /// Amount to stake in planck amount: u64, - /// Where to send staking rewards + /// Reward destination (default: Staked / compound) #[serde(default)] payee: StakePayee, + /// Proxy address for new stake. Absent means top-up (bondExtra). + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "proxyAddress" + )] + proxy_address: Option, }, - /// Unstake (unbond) DOT + + /// Unstake DOT. + /// + /// - `stop_staking=true`: full unstake → batchAll(removeProxy, chill, unbond) + /// - `stop_staking=false`: partial unstake → unbond Unstake { - /// Amount to unstake in planck (accepts JS BigInt natively) + /// Amount to unstake in planck amount: u64, + /// Full unstake (remove proxy + chill) or partial (just unbond) + #[serde(default, rename = "stopStaking")] + stop_staking: bool, + /// Proxy address to remove (required when stopStaking=true) + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "proxyAddress" + )] + proxy_address: Option, }, - /// Withdraw unbonded DOT - WithdrawUnbonded { - /// Number of slashing spans (usually 0) + + /// Claim (withdraw unbonded) DOT after the unbonding period + Claim { + /// Number of slashing spans (default: 0) #[serde(default, rename = "slashingSpans")] slashing_spans: u32, }, - /// Stop nominating/validating + + /// Zero-value self-transfer to advance the account nonce. + /// + /// The sender address comes from `BuildContext.sender`. + FillNonce, +} + +// ============================================================================= +// Internal: Call-level intents +// ============================================================================= + +/// Low-level call intent representing a single Polkadot extrinsic call. +/// +/// These are internal building blocks used by `intent_to_calls()` and the +/// call encoder. Not part of the public API. +#[derive(Debug, Clone)] +pub(crate) enum CallIntent { + Transfer { + to: String, + amount: u64, + keep_alive: bool, + }, + TransferAll { + to: String, + keep_alive: bool, + }, + Bond { + amount: u64, + payee: StakePayee, + }, + BondExtra { + amount: u64, + }, + Unbond { + amount: u64, + }, + WithdrawUnbonded { + slashing_spans: u32, + }, Chill, - /// Add a proxy account AddProxy { - /// Delegate address (SS58) delegate: String, - /// Proxy type (Any, NonTransfer, Staking, etc.) - #[serde(rename = "proxyType")] proxy_type: String, - /// Delay in blocks - #[serde(default)] delay: u32, }, - /// Remove a proxy account RemoveProxy { - /// Delegate address (SS58) delegate: String, - /// Proxy type - #[serde(rename = "proxyType")] proxy_type: String, - /// Delay in blocks - #[serde(default)] delay: u32, }, - /// Batch multiple intents atomically - Batch { - /// List of intents to execute - calls: Vec, - /// Use batchAll (atomic) instead of batch - #[serde(default = "default_atomic")] - atomic: bool, - }, } -fn default_keep_alive() -> bool { - true -} +// ============================================================================= +// Composition: business intent → call sequence +// ============================================================================= -fn default_atomic() -> bool { - true +/// Convert a business-level intent into a sequence of call-level intents. +/// +/// Returns one call for simple operations, multiple for batched operations +/// (e.g., stake with proxy → bond + addProxy). +pub(crate) fn intent_to_calls( + intent: &TransactionIntent, + sender: &str, +) -> Result, WasmDotError> { + match intent { + TransactionIntent::Payment { + to, + amount, + keep_alive, + } => Ok(vec![CallIntent::Transfer { + to: to.clone(), + amount: *amount, + keep_alive: *keep_alive, + }]), + + TransactionIntent::Consolidate { to, keep_alive } => Ok(vec![CallIntent::TransferAll { + to: to.clone(), + keep_alive: *keep_alive, + }]), + + TransactionIntent::Stake { + amount, + payee, + proxy_address, + } => match proxy_address { + Some(proxy) => Ok(vec![ + CallIntent::Bond { + amount: *amount, + payee: payee.clone(), + }, + CallIntent::AddProxy { + delegate: proxy.clone(), + proxy_type: "Staking".to_string(), + delay: 0, + }, + ]), + None => Ok(vec![CallIntent::BondExtra { amount: *amount }]), + }, + + TransactionIntent::Unstake { + amount, + stop_staking, + proxy_address, + } => { + if *stop_staking { + let proxy = proxy_address.as_ref().ok_or_else(|| { + WasmDotError::InvalidInput( + "Unstake with stopStaking=true requires proxyAddress".to_string(), + ) + })?; + Ok(vec![ + CallIntent::RemoveProxy { + delegate: proxy.clone(), + proxy_type: "Staking".to_string(), + delay: 0, + }, + CallIntent::Chill, + CallIntent::Unbond { amount: *amount }, + ]) + } else { + Ok(vec![CallIntent::Unbond { amount: *amount }]) + } + } + + TransactionIntent::Claim { slashing_spans } => Ok(vec![CallIntent::WithdrawUnbonded { + slashing_spans: *slashing_spans, + }]), + + TransactionIntent::FillNonce => Ok(vec![CallIntent::Transfer { + to: sender.to_string(), + amount: 0, + keep_alive: true, + }]), + } } -/// Build context - how to build the transaction -/// -/// Matches wallet-platform's material + nonce + validity pattern +// ============================================================================= +// Shared types +// ============================================================================= + +/// Build context: how to build the transaction (sender, nonce, material, etc.) #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct BuildContext { @@ -103,7 +235,7 @@ pub struct BuildContext { pub sender: String, /// Account nonce pub nonce: u32, - /// Optional tip amount (in planck, accepts JS BigInt natively) + /// Optional tip amount (in planck) #[serde(default)] pub tip: u64, /// Chain material metadata @@ -132,67 +264,158 @@ pub enum StakePayee { }, } +fn default_true() -> bool { + true +} + +// ============================================================================= +// Tests +// ============================================================================= + #[cfg(test)] mod tests { use super::*; + // ---- Deserialization tests ---- + #[test] - fn test_deserialize_transfer_intent() { + fn test_deserialize_payment_intent() { let json = r#"{ - "type": "transfer", + "type": "payment", "to": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", "amount": 1000000000000 }"#; - let intent: TransactionIntent = serde_json::from_str(json).unwrap(); match intent { - TransactionIntent::Transfer { + TransactionIntent::Payment { amount, keep_alive, .. } => { assert_eq!(amount, 1_000_000_000_000); assert!(keep_alive); // default } - _ => panic!("Expected Transfer"), + _ => panic!("Expected Payment"), } } #[test] - fn test_deserialize_stake_intent() { + fn test_deserialize_consolidate_intent() { + let json = r#"{ + "type": "consolidate", + "to": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + "keepAlive": false + }"#; + let intent: TransactionIntent = serde_json::from_str(json).unwrap(); + match intent { + TransactionIntent::Consolidate { keep_alive, .. } => { + assert!(!keep_alive); + } + _ => panic!("Expected Consolidate"), + } + } + + #[test] + fn test_deserialize_stake_new() { let json = r#"{ "type": "stake", "amount": 5000000000000, - "payee": { "type": "staked" } + "payee": { "type": "staked" }, + "proxyAddress": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" }"#; - let intent: TransactionIntent = serde_json::from_str(json).unwrap(); match intent { - TransactionIntent::Stake { amount, .. } => { + TransactionIntent::Stake { + amount, + proxy_address, + .. + } => { assert_eq!(amount, 5_000_000_000_000); + assert!(proxy_address.is_some()); } _ => panic!("Expected Stake"), } } #[test] - fn test_deserialize_batch_intent() { + fn test_deserialize_stake_topup() { let json = r#"{ - "type": "batch", - "calls": [ - { "type": "transfer", "to": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", "amount": 1000 }, - { "type": "chill" } - ] + "type": "stake", + "amount": 2000000000000 }"#; + let intent: TransactionIntent = serde_json::from_str(json).unwrap(); + match intent { + TransactionIntent::Stake { + amount, + proxy_address, + .. + } => { + assert_eq!(amount, 2_000_000_000_000); + assert!(proxy_address.is_none()); + } + _ => panic!("Expected Stake"), + } + } + #[test] + fn test_deserialize_unstake_full() { + let json = r#"{ + "type": "unstake", + "amount": 1000000000000, + "stopStaking": true, + "proxyAddress": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + }"#; let intent: TransactionIntent = serde_json::from_str(json).unwrap(); match intent { - TransactionIntent::Batch { calls, atomic } => { - assert_eq!(calls.len(), 2); - assert!(atomic); // default + TransactionIntent::Unstake { + stop_staking, + proxy_address, + .. + } => { + assert!(stop_staking); + assert!(proxy_address.is_some()); } - _ => panic!("Expected Batch"), + _ => panic!("Expected Unstake"), } } + #[test] + fn test_deserialize_unstake_partial() { + let json = r#"{ + "type": "unstake", + "amount": 500000000000 + }"#; + let intent: TransactionIntent = serde_json::from_str(json).unwrap(); + match intent { + TransactionIntent::Unstake { + amount, + stop_staking, + .. + } => { + assert_eq!(amount, 500_000_000_000); + assert!(!stop_staking); // default + } + _ => panic!("Expected Unstake"), + } + } + + #[test] + fn test_deserialize_claim() { + let json = r#"{ "type": "claim" }"#; + let intent: TransactionIntent = serde_json::from_str(json).unwrap(); + match intent { + TransactionIntent::Claim { slashing_spans } => { + assert_eq!(slashing_spans, 0); // default + } + _ => panic!("Expected Claim"), + } + } + + #[test] + fn test_deserialize_fill_nonce() { + let json = r#"{ "type": "fillNonce" }"#; + let intent: TransactionIntent = serde_json::from_str(json).unwrap(); + assert!(matches!(intent, TransactionIntent::FillNonce)); + } + #[test] fn test_deserialize_context() { let json = r#"{ @@ -213,7 +436,6 @@ mod tests { }, "referenceBlock": "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3" }"#; - let ctx: BuildContext = serde_json::from_str(json).unwrap(); assert_eq!( ctx.sender, @@ -221,4 +443,138 @@ mod tests { ); assert_eq!(ctx.nonce, 5); } + + // ---- Composition tests ---- + + const SENDER: &str = "5EGoFA95omzemRssELLDjVenNZ68aXyUeqtKQScXSEBvVJkr"; + const PROXY: &str = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + + #[test] + fn test_payment_composes_to_transfer() { + let intent = TransactionIntent::Payment { + to: PROXY.to_string(), + amount: 1_000_000_000_000, + keep_alive: true, + }; + let calls = intent_to_calls(&intent, SENDER).unwrap(); + assert_eq!(calls.len(), 1); + assert!(matches!( + calls[0], + CallIntent::Transfer { + keep_alive: true, + .. + } + )); + } + + #[test] + fn test_consolidate_composes_to_transfer_all() { + let intent = TransactionIntent::Consolidate { + to: PROXY.to_string(), + keep_alive: false, + }; + let calls = intent_to_calls(&intent, SENDER).unwrap(); + assert_eq!(calls.len(), 1); + assert!(matches!( + calls[0], + CallIntent::TransferAll { + keep_alive: false, + .. + } + )); + } + + #[test] + fn test_stake_new_composes_to_bond_and_add_proxy() { + let intent = TransactionIntent::Stake { + amount: 1_000_000_000_000, + payee: StakePayee::Staked, + proxy_address: Some(PROXY.to_string()), + }; + let calls = intent_to_calls(&intent, SENDER).unwrap(); + assert_eq!(calls.len(), 2); + assert!(matches!(calls[0], CallIntent::Bond { .. })); + assert!(matches!(calls[1], CallIntent::AddProxy { .. })); + } + + #[test] + fn test_stake_topup_composes_to_bond_extra() { + let intent = TransactionIntent::Stake { + amount: 1_000_000_000_000, + payee: StakePayee::Staked, + proxy_address: None, + }; + let calls = intent_to_calls(&intent, SENDER).unwrap(); + assert_eq!(calls.len(), 1); + assert!(matches!(calls[0], CallIntent::BondExtra { .. })); + } + + #[test] + fn test_unstake_full_composes_to_remove_proxy_chill_unbond() { + let intent = TransactionIntent::Unstake { + amount: 1_000_000_000_000, + stop_staking: true, + proxy_address: Some(PROXY.to_string()), + }; + let calls = intent_to_calls(&intent, SENDER).unwrap(); + assert_eq!(calls.len(), 3); + // Order matters: removeProxy, chill, unbond + assert!(matches!(calls[0], CallIntent::RemoveProxy { .. })); + assert!(matches!(calls[1], CallIntent::Chill)); + assert!(matches!(calls[2], CallIntent::Unbond { .. })); + } + + #[test] + fn test_unstake_partial_composes_to_unbond() { + let intent = TransactionIntent::Unstake { + amount: 500_000_000_000, + stop_staking: false, + proxy_address: None, + }; + let calls = intent_to_calls(&intent, SENDER).unwrap(); + assert_eq!(calls.len(), 1); + assert!(matches!(calls[0], CallIntent::Unbond { .. })); + } + + #[test] + fn test_unstake_full_without_proxy_errors() { + let intent = TransactionIntent::Unstake { + amount: 1_000_000_000_000, + stop_staking: true, + proxy_address: None, + }; + let result = intent_to_calls(&intent, SENDER); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("requires proxyAddress")); + } + + #[test] + fn test_claim_composes_to_withdraw_unbonded() { + let intent = TransactionIntent::Claim { slashing_spans: 0 }; + let calls = intent_to_calls(&intent, SENDER).unwrap(); + assert_eq!(calls.len(), 1); + assert!(matches!(calls[0], CallIntent::WithdrawUnbonded { .. })); + } + + #[test] + fn test_fill_nonce_composes_to_zero_self_transfer() { + let intent = TransactionIntent::FillNonce; + let calls = intent_to_calls(&intent, SENDER).unwrap(); + assert_eq!(calls.len(), 1); + match &calls[0] { + CallIntent::Transfer { + to, + amount, + keep_alive, + } => { + assert_eq!(to, SENDER); + assert_eq!(*amount, 0); + assert!(*keep_alive); + } + _ => panic!("Expected Transfer"), + } + } } diff --git a/packages/wasm-dot/src/wasm/builder.rs b/packages/wasm-dot/src/wasm/builder.rs index 16fff4c9..809be36b 100644 --- a/packages/wasm-dot/src/wasm/builder.rs +++ b/packages/wasm-dot/src/wasm/builder.rs @@ -16,52 +16,22 @@ pub struct BuilderNamespace; #[wasm_bindgen] impl BuilderNamespace { - /// Build a transaction from an intent and context + /// Build a transaction from a business-level intent and context. /// - /// Follows wallet-platform pattern: buildTransaction(intent, context) - /// - intent: what to do (transfer, stake, etc.) + /// - intent: what to do (payment, stake, unstake, etc.) /// - context: how to build it (sender, nonce, material, validity) /// - /// # Arguments - /// * `intent` - What to do (JSON object with type field) - /// * `context` - Build context (sender, nonce, material, validity, referenceBlock) - /// - /// # Returns - /// WasmTransaction ready for signing - /// - /// # Example Intent (Transfer) - /// ```json - /// { "type": "transfer", "to": "5FHneW46...", "amount": "1000000000000", "keepAlive": true } - /// ``` - /// - /// # Example Context - /// ```json - /// { - /// "sender": "5EGoFA95omzemRssELLDjVenNZ68aXyUeqtKQScXSEBvVJkr", - /// "nonce": 5, - /// "tip": "0", - /// "material": { - /// "genesisHash": "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3", - /// "chainName": "Polkadot", - /// "specName": "polkadot", - /// "specVersion": 9150, - /// "txVersion": 9 - /// }, - /// "validity": { "firstValid": 1000, "maxDuration": 2400 }, - /// "referenceBlock": "0x91b171bb..." - /// } - /// ``` + /// Multi-call intents (e.g., new stake with proxy) are batched automatically. /// /// # Intent Types - /// - `transfer`: Transfer DOT (to, amount, keepAlive) - /// - `transferAll`: Transfer all DOT (to, keepAlive) - /// - `stake`: Bond DOT (amount, payee) - /// - `unstake`: Unbond DOT (amount) - /// - `withdrawUnbonded`: Withdraw unbonded (slashingSpans) - /// - `chill`: Stop nominating - /// - `addProxy`: Add proxy (delegate, proxyType, delay) - /// - `removeProxy`: Remove proxy (delegate, proxyType, delay) - /// - `batch`: Multiple calls (calls, atomic) + /// - `payment`: Transfer DOT (to, amount, keepAlive?) + /// - `consolidate`: Sweep all DOT (to, keepAlive?) + /// - `stake`: Bond DOT — with proxyAddress = new stake (bond+addProxy), + /// without = top-up (bondExtra) + /// - `unstake`: Unbond DOT — stopStaking + proxyAddress = full + /// (removeProxy+chill+unbond), otherwise partial (unbond only) + /// - `claim`: Withdraw unbonded (slashingSpans?) + /// - `fillNonce`: Zero-value self-transfer to advance nonce #[wasm_bindgen(js_name = buildTransaction)] pub fn build_transaction_wasm( intent: JsValue, diff --git a/packages/wasm-dot/test/builder.ts b/packages/wasm-dot/test/builder.ts index 4d12c796..34aa1ad2 100644 --- a/packages/wasm-dot/test/builder.ts +++ b/packages/wasm-dot/test/builder.ts @@ -36,10 +36,10 @@ describe("buildTransaction", () => { referenceBlock: REFERENCE_BLOCK, }); - describe("transfer", () => { - it("should build a DOT transfer transaction", () => { + describe("payment", () => { + it("should build a payment transaction (transferKeepAlive)", () => { const intent: TransactionIntent = { - type: "transfer", + type: "payment", to: RECIPIENT, amount: 1000000000000n, }; @@ -49,9 +49,9 @@ describe("buildTransaction", () => { assert.strictEqual(tx.nonce, 0); }); - it("should build a transfer with custom nonce", () => { + it("should build a payment with custom nonce", () => { const intent: TransactionIntent = { - type: "transfer", + type: "payment", to: RECIPIENT, amount: 1000000000000n, }; @@ -61,31 +61,70 @@ describe("buildTransaction", () => { assert.strictEqual(tx.nonce, 5); }); - it("should build transferAll", () => { + it("should build a payment with keepAlive=false (transferAllowDeath)", () => { const intent: TransactionIntent = { - type: "transferAll", + type: "payment", to: RECIPIENT, + amount: 1000000000000n, keepAlive: false, }; + const tx = buildTransaction(intent, testContext(0)); + assert.ok(tx); + }); + }); + + describe("consolidate", () => { + it("should build a consolidate transaction (transferAll)", () => { + const intent: TransactionIntent = { + type: "consolidate", + to: RECIPIENT, + }; + const tx = buildTransaction(intent, testContext(1)); assert.ok(tx); }); + + it("should build consolidate with keepAlive=false", () => { + const intent: TransactionIntent = { + type: "consolidate", + to: RECIPIENT, + keepAlive: false, + }; + + const tx = buildTransaction(intent, testContext(0)); + assert.ok(tx); + }); }); - describe("staking operations", () => { - it("should build a stake (bond) transaction", () => { + describe("staking", () => { + it("should build a stake top-up (bondExtra)", () => { + const intent: TransactionIntent = { + type: "stake", + amount: 10000000000000n, + }; + + const tx = buildTransaction(intent, testContext(0)); + assert.ok(tx); + }); + + it("should build a new stake with proxy (batchAll: bond + addProxy)", () => { const intent: TransactionIntent = { type: "stake", amount: 10000000000000n, payee: { type: "stash" }, + proxyAddress: RECIPIENT, }; const tx = buildTransaction(intent, testContext(0)); assert.ok(tx); + // Should be a batchAll since it produces bond + addProxy + const callData = toHex(tx.callData); + // Utility.batch_all pallet index varies by runtime, but compact length should be 0x08 (2 calls) + assert.ok(callData.length > 10, "Batch call data should be non-trivial"); }); - it("should build an unstake (unbond) transaction", () => { + it("should build a partial unstake (unbond)", () => { const intent: TransactionIntent = { type: "unstake", amount: 5000000000000n, @@ -95,148 +134,104 @@ describe("buildTransaction", () => { assert.ok(tx); }); - it("should build a chill transaction", () => { - const intent: TransactionIntent = { type: "chill" }; + it("should build a full unstake with proxy (batchAll: removeProxy + chill + unbond)", () => { + const intent: TransactionIntent = { + type: "unstake", + amount: 5000000000000n, + stopStaking: true, + proxyAddress: RECIPIENT, + }; const tx = buildTransaction(intent, testContext(2)); assert.ok(tx); + // Should be a batchAll with 3 calls + const callData = toHex(tx.callData); + assert.ok(callData.length > 20, "Full unstake batch should be non-trivial"); }); + }); - it("should build a withdrawUnbonded transaction", () => { + describe("claim", () => { + it("should build a claim transaction (withdrawUnbonded)", () => { const intent: TransactionIntent = { - type: "withdrawUnbonded", - slashingSpans: 0, + type: "claim", }; const tx = buildTransaction(intent, testContext(3)); assert.ok(tx); }); - }); - describe("batch operations", () => { - it("should build a batched transaction with transfer + stake", () => { + it("should build a claim with custom slashingSpans", () => { const intent: TransactionIntent = { - type: "batch", - calls: [ - { type: "transfer", to: RECIPIENT, amount: 1000000000000n }, - { type: "stake", amount: 5000000000000n, payee: { type: "staked" } }, - ], - atomic: true, + type: "claim", + slashingSpans: 5, }; - const tx = buildTransaction(intent, testContext(10)); + const tx = buildTransaction(intent, testContext(0)); assert.ok(tx); }); + }); - it("should build non-atomic batch", () => { + describe("fillNonce", () => { + it("should build a fillNonce transaction (zero self-transfer)", () => { const intent: TransactionIntent = { - type: "batch", - calls: [{ type: "transfer", to: RECIPIENT, amount: 1000000000000n }, { type: "chill" }], - atomic: false, + type: "fillNonce", }; - const tx = buildTransaction(intent, testContext(0)); + const tx = buildTransaction(intent, testContext(42)); assert.ok(tx); + assert.strictEqual(tx.nonce, 42); }); + }); - it("should encode batch calls correctly - inner calls match standalone encoding", () => { - // Build a standalone transfer - const transferIntent: TransactionIntent = { - type: "transfer", - to: RECIPIENT, - amount: 1000000000000n, + describe("batch composition", () => { + it("new stake call data should differ from top-up (bond+addProxy vs bondExtra)", () => { + const topUp: TransactionIntent = { + type: "stake", + amount: 10000000000000n, }; - const standaloneTx = buildTransaction(transferIntent, testContext(0)); - const standaloneCallData = toHex(standaloneTx.callData); - - // Build a batch with the same transfer - const batchIntent: TransactionIntent = { - type: "batch", - calls: [transferIntent], - atomic: true, + const newStake: TransactionIntent = { + type: "stake", + amount: 10000000000000n, + proxyAddress: RECIPIENT, }; - const batchTx = buildTransaction(batchIntent, testContext(0)); - const batchCallData = toHex(batchTx.callData); - // Batch structure: [pallet_idx][call_idx][compact_len][call1...] - // The inner call should appear in the batch call data after the header - assert.ok( - batchCallData.includes(standaloneCallData), - `Batch call data should contain the standalone call data.\nBatch: ${batchCallData}\nStandalone: ${standaloneCallData}`, - ); + const topUpTx = buildTransaction(topUp, testContext(0)); + const newStakeTx = buildTransaction(newStake, testContext(0)); - // Verify batch has correct length prefix (1 call = 0x04 in compact encoding) - // Format: pallet(1) + method(1) + compact_len + calls - // For single call batch, compact(1) = 0x04 - const compactLen = batchCallData.slice(4, 6); // bytes 2-3 (after pallet+method) - assert.strictEqual(compactLen, "04", "Compact length for 1 call should be 0x04"); - - // Verify the call appears right after the header - const callsStart = batchCallData.slice(6); // after pallet + method + compact_len - assert.strictEqual( - callsStart, - standaloneCallData, - "Call data should match exactly after batch header", + // They should produce different call data (bondExtra vs batchAll(bond, addProxy)) + assert.notStrictEqual( + toHex(topUpTx.callData), + toHex(newStakeTx.callData), + "Top-up and new stake should produce different call data", ); }); - it("should encode batch with 2 calls correctly", () => { - // Build standalone calls - const transfer: TransactionIntent = { - type: "transfer", - to: RECIPIENT, - amount: 1000000000000n, + it("partial unstake call data should differ from full unstake", () => { + const partial: TransactionIntent = { + type: "unstake", + amount: 5000000000000n, }; - const chill: TransactionIntent = { type: "chill" }; - - const transferTx = buildTransaction(transfer, testContext(0)); - const chillTx = buildTransaction(chill, testContext(0)); - - // Build batch - const batchIntent: TransactionIntent = { - type: "batch", - calls: [transfer, chill], - atomic: false, + const full: TransactionIntent = { + type: "unstake", + amount: 5000000000000n, + stopStaking: true, + proxyAddress: RECIPIENT, }; - const batchTx = buildTransaction(batchIntent, testContext(0)); - const batchCallData = toHex(batchTx.callData); - // Verify compact length = 0x08 (2 calls) - const compactLen = batchCallData.slice(4, 6); - assert.strictEqual(compactLen, "08", "Compact length for 2 calls should be 0x08"); + const partialTx = buildTransaction(partial, testContext(0)); + const fullTx = buildTransaction(full, testContext(0)); - // Verify both calls are in the batch - assert.ok( - batchCallData.includes(toHex(transferTx.callData)), - "Batch should contain transfer call", + assert.notStrictEqual( + toHex(partialTx.callData), + toHex(fullTx.callData), + "Partial and full unstake should produce different call data", ); - assert.ok(batchCallData.includes(toHex(chillTx.callData)), "Batch should contain chill call"); - }); - }); - - describe("proxy operations", () => { - it("should build addProxy transaction", () => { - const intent: TransactionIntent = { - type: "addProxy", - delegate: RECIPIENT, - proxyType: "Any", - delay: 0, - }; - - const tx = buildTransaction(intent, testContext(0)); - assert.ok(tx); - }); - - it("should build removeProxy transaction", () => { - const intent: TransactionIntent = { - type: "removeProxy", - delegate: RECIPIENT, - proxyType: "Staking", - delay: 0, - }; - const tx = buildTransaction(intent, testContext(1)); - assert.ok(tx); + // Full unstake should have larger call data (3 calls vs 1) + assert.ok( + fullTx.callData.length > partialTx.callData.length, + "Full unstake (3 calls) should be larger than partial (1 call)", + ); }); }); });