diff --git a/.gitignore b/.gitignore index 0ad8a2576..1aaee98f9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,13 @@ dist/ target/ Cargo.lock .surfpool + +# Anchor +.anchor/ +test-ledger/ +programs/mpp-channel-keypair.json + +# Local development files +CLAUDE.md +docs/ +scripts/ diff --git a/Anchor.toml b/Anchor.toml new file mode 100644 index 000000000..076e8c2cf --- /dev/null +++ b/Anchor.toml @@ -0,0 +1,23 @@ +[toolchain] + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +mpp_channel = "21fLdahqKtVAt4V2JLwVrRb7tuqPADjjPVCU9bK3MFPQ" + +[programs.devnet] +mpp_channel = "21fLdahqKtVAt4V2JLwVrRb7tuqPADjjPVCU9bK3MFPQ" + +[registry] +url = "https://api.apr.dev" + +[provider] +cluster = "localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "pnpm exec vitest run --config typescript/vitest.config.anchor.ts" + +[hooks] diff --git a/README.md b/README.md index 737046a6e..c974b29e4 100644 --- a/README.md +++ b/README.md @@ -214,12 +214,19 @@ just ts-fmt # Format and lint just ts-build # Build just ts-test # Unit tests (charge + session, no network) just ts-test-integration # Integration tests (requires Surfpool) -# Rust + +# Rust client SDK cd rust && cargo build +# Anchor program (programs/mpp-channel) +# Prerequisites: Rust stable toolchain, Anchor CLI >=1.0.0-rc.2, solana-test-validator on PATH +just anchor-build # Compile the on-chain program +just anchor-test # Localnet integration tests (starts/stops validator automatically) + # Everything -just build # Build both -just test # Test both +just build # Build TypeScript, Rust, and Anchor +just test # Unit tests (TypeScript + Rust, no network) +just test-all # All tests including integration and Anchor localnet just pre-commit # Full pre-commit checks ``` diff --git a/justfile b/justfile index 1d1dfcd36..095800823 100644 --- a/justfile +++ b/justfile @@ -51,16 +51,26 @@ rs-fmt: rs-lint: cd rust && cargo clippy -- -D warnings +# ── Anchor ── + +# Build Anchor program (programs/mpp-channel) +anchor-build: + anchor build --no-idl + +# Run Anchor localnet tests (starts solana-test-validator automatically) +anchor-test: + anchor test + # ── Orchestration ── # Build everything -build: ts-build rs-build +build: ts-build rs-build anchor-build # Run all unit tests test: ts-test rs-test # Run all tests including integration -test-all: ts-test ts-test-integration rs-test +test-all: ts-test ts-test-integration rs-test anchor-test # Format everything fmt: ts-fmt rs-fmt diff --git a/programs/mpp-channel/Cargo.toml b/programs/mpp-channel/Cargo.toml new file mode 100644 index 000000000..66739dfb2 --- /dev/null +++ b/programs/mpp-channel/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "mpp-channel" +version = "0.1.0" +edition = "2021" +description = "MPP Solana payment channel escrow program" + +[lib] +crate-type = ["cdylib", "lib"] + +[dependencies] +anchor-lang = "1.0.0-rc.2" +anchor-spl = "1.0.0-rc.2" +solana-instructions-sysvar = "3" +solana-sdk-ids = "3" + +[dev-dependencies] + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] diff --git a/programs/mpp-channel/src/ed25519.rs b/programs/mpp-channel/src/ed25519.rs new file mode 100644 index 000000000..019c2bf8b --- /dev/null +++ b/programs/mpp-channel/src/ed25519.rs @@ -0,0 +1,93 @@ +use anchor_lang::prelude::*; + +use crate::errors::MppChannelError; + +// Ed25519 instruction data layout offsets (for a single signature). +// See: https://docs.solanalabs.com/runtime/programs#ed25519-program +// +// Header: +// [0..2] num_signatures (u16 LE) = 1 +// [2..4] padding (u16 LE) = 0 +// +// Per-signature descriptor (16 bytes): +// [4..6] signature_offset (u16 LE) +// [6..8] signature_instruction_index (u16 LE) = 0xFFFF (same instruction) +// [8..10] public_key_offset (u16 LE) +// [10..12] public_key_instruction_index (u16 LE) = 0xFFFF +// [12..14] message_data_offset (u16 LE) +// [14..16] message_data_size (u16 LE) +// [16..18] message_instruction_index (u16 LE) = 0xFFFF +// +// Padding: +// Data (for inline, all in same instruction): +// [16..80] signature (64 bytes) +// [80..112] public_key (32 bytes) +// [112..] message (variable) + +const HEADER_SIZE: usize = 2; // num_signatures u16 +const DESCRIPTOR_SIZE: usize = 14; // 7 x u16 fields + +const SIGNATURE_SIZE: usize = 64; +const PUBKEY_SIZE: usize = 32; + +const DATA_START: usize = HEADER_SIZE + DESCRIPTOR_SIZE; // 16 +const SIGNATURE_OFFSET: usize = DATA_START; +const PUBKEY_OFFSET: usize = SIGNATURE_OFFSET + SIGNATURE_SIZE; +const MESSAGE_OFFSET: usize = PUBKEY_OFFSET + PUBKEY_SIZE; + +/// Validate that a specific instruction in the transaction is an Ed25519 +/// precompile verification of the expected public key and message. +/// +/// The Ed25519 precompile itself verifies the cryptographic signature. +/// This function verifies that the precompile was asked to check the +/// correct inputs (the payer's public key and the binary voucher bytes). +/// +/// If this validation is wrong or missing, anyone could submit a settle/close +/// transaction with an Ed25519 instruction that verifies a different key or +/// message, effectively bypassing signature authorization. +pub fn validate_ed25519_instruction( + instructions_sysvar: &AccountInfo, + expected_signer: &Pubkey, + expected_message: &[u8], + ed25519_instruction_index: u8, +) -> Result<()> { + // Load the instruction at the given index from the instructions sysvar. + let instruction = solana_instructions_sysvar::load_instruction_at_checked( + ed25519_instruction_index as usize, + instructions_sysvar, + ) + .map_err(|_| MppChannelError::MissingEd25519Instruction)?; + + // Verify the instruction targets the Ed25519 precompile program. + if instruction.program_id != solana_sdk_ids::ed25519_program::ID { + return Err(MppChannelError::InvalidEd25519Program.into()); + } + + let data = &instruction.data; + + // Minimum size: header + descriptor + padding + signature + pubkey + at least 1 byte message + let min_size = MESSAGE_OFFSET + 1; + if data.len() < min_size { + return Err(MppChannelError::MissingEd25519Instruction.into()); + } + + // Verify num_signatures == 1 (we only support single-signature verification). + let num_signatures = u16::from_le_bytes([data[0], data[1]]); + if num_signatures != 1 { + return Err(MppChannelError::MissingEd25519Instruction.into()); + } + + // Extract the public key from the instruction data and compare. + let pubkey_bytes = &data[PUBKEY_OFFSET..PUBKEY_OFFSET + PUBKEY_SIZE]; + if pubkey_bytes != expected_signer.as_ref() { + return Err(MppChannelError::InvalidEd25519PublicKey.into()); + } + + // Extract the message from the instruction data and compare. + let message_bytes = &data[MESSAGE_OFFSET..]; + if message_bytes != expected_message { + return Err(MppChannelError::InvalidEd25519Message.into()); + } + + Ok(()) +} diff --git a/programs/mpp-channel/src/errors.rs b/programs/mpp-channel/src/errors.rs new file mode 100644 index 000000000..37664db82 --- /dev/null +++ b/programs/mpp-channel/src/errors.rs @@ -0,0 +1,35 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum MppChannelError { + #[msg("Channel is not open (finalized)")] + ChannelNotOpen, + #[msg("Channel is already finalized")] + ChannelFinalized, + #[msg("Cumulative amount must exceed current settled amount")] + AmountNotGreaterThanSettled, + #[msg("Cumulative amount exceeds deposit")] + AmountExceedsDeposit, + #[msg("Missing Ed25519 verify instruction")] + MissingEd25519Instruction, + #[msg("Ed25519 instruction targets wrong program")] + InvalidEd25519Program, + #[msg("Ed25519 instruction verifies wrong public key")] + InvalidEd25519PublicKey, + #[msg("Ed25519 instruction verifies wrong message")] + InvalidEd25519Message, + #[msg("Unauthorized: caller is not the payer")] + UnauthorizedPayer, + #[msg("Unauthorized: caller is not the payee")] + UnauthorizedPayee, + #[msg("Deposit amount must be greater than zero")] + ZeroDeposit, + #[msg("Arithmetic overflow")] + ArithmeticOverflow, + #[msg("Close has not been requested")] + CloseNotRequested, + #[msg("Grace period has not expired yet")] + GracePeriodNotExpired, + #[msg("Close has already been requested")] + CloseAlreadyRequested, +} diff --git a/programs/mpp-channel/src/events.rs b/programs/mpp-channel/src/events.rs new file mode 100644 index 000000000..64452db93 --- /dev/null +++ b/programs/mpp-channel/src/events.rs @@ -0,0 +1,45 @@ +use anchor_lang::prelude::*; + +#[event] +pub struct ChannelOpened { + pub channel: Pubkey, + pub payer: Pubkey, + pub payee: Pubkey, + pub token: Pubkey, + pub authorized_signer: Pubkey, + pub deposit: u64, + pub grace_period_seconds: u64, +} + +#[event] +pub struct ChannelSettled { + pub channel: Pubkey, + pub delta: u64, + pub cumulative_settled: u64, +} + +#[event] +pub struct ChannelClosed { + pub channel: Pubkey, + pub final_settled: u64, + pub refund: u64, +} + +#[event] +pub struct CloseRequested { + pub channel: Pubkey, + pub requested_at: i64, +} + +#[event] +pub struct TopUpCompleted { + pub channel: Pubkey, + pub additional: u64, + pub new_deposit: u64, +} + +#[event] +pub struct Withdrawn { + pub channel: Pubkey, + pub refund: u64, +} diff --git a/programs/mpp-channel/src/instructions/close.rs b/programs/mpp-channel/src/instructions/close.rs new file mode 100644 index 000000000..42104a37d --- /dev/null +++ b/programs/mpp-channel/src/instructions/close.rs @@ -0,0 +1,159 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; + +use crate::jcs_voucher::{parse_jcs_voucher_message, verify_channel_id}; +use crate::ed25519::validate_ed25519_instruction; +use crate::errors::MppChannelError; +use crate::events::ChannelClosed; +use crate::state::*; + +use super::settle::SettleArgs; + +#[derive(Accounts)] +pub struct Close<'info> { + pub payee: Signer<'info>, + + #[account( + mut, + has_one = payee @ MppChannelError::UnauthorizedPayee, + has_one = token @ MppChannelError::UnauthorizedPayee, + )] + pub channel: Account<'info, PaymentChannel>, + + #[account(address = channel.token)] + pub token: InterfaceAccount<'info, Mint>, + + #[account( + mut, + token::mint = token, + token::authority = channel, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + token::mint = token, + token::authority = payee, + )] + pub payee_token_account: InterfaceAccount<'info, TokenAccount>, + + /// The payer's token account to receive the refund. + #[account( + mut, + token::mint = token, + constraint = payer_token_account.owner == channel.payer @ MppChannelError::UnauthorizedPayer, + )] + pub payer_token_account: InterfaceAccount<'info, TokenAccount>, + + /// CHECK: Validated by address constraint. + #[account(address = solana_sdk_ids::sysvar::instructions::ID)] + pub instructions_sysvar: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handler(ctx: Context, args: SettleArgs) -> Result<()> { + let channel = &ctx.accounts.channel; + + require!(!channel.finalized, MppChannelError::ChannelFinalized); + + let final_settled = if !args.voucher_message.is_empty() { + // Voucher provided: verify signature and settle final delta. + require!( + args.cumulative_amount > channel.settled, + MppChannelError::AmountNotGreaterThanSettled + ); + + require!( + args.cumulative_amount <= channel.deposit, + MppChannelError::AmountExceedsDeposit + ); + + validate_ed25519_instruction( + &ctx.accounts.instructions_sysvar, + &channel.authorized_signer, + &args.voucher_message, + args.ed25519_instruction_index, + )?; + + let parsed = parse_jcs_voucher_message(&args.voucher_message) + .ok_or(MppChannelError::InvalidEd25519Message)?; + + require!( + verify_channel_id(&parsed.channel_id_bytes, &channel.key()), + MppChannelError::InvalidEd25519Message + ); + + require!( + parsed.cumulative_amount == args.cumulative_amount, + MppChannelError::InvalidEd25519Message + ); + + args.cumulative_amount + } else { + // No voucher: cooperative refund-only close. Use current settled amount. + channel.settled + }; + + let delta = final_settled + .checked_sub(channel.settled) + .ok_or(MppChannelError::ArithmeticOverflow)?; + let refund = channel + .deposit + .checked_sub(final_settled) + .ok_or(MppChannelError::ArithmeticOverflow)?; + + let salt_bytes = channel.salt.to_le_bytes(); + let seeds: &[&[u8]] = &[ + CHANNEL_SEED, + channel.payer.as_ref(), + channel.payee.as_ref(), + channel.token.as_ref(), + &salt_bytes, + channel.authorized_signer.as_ref(), + &[channel.bump], + ]; + let signer_seeds = &[seeds]; + + if delta > 0 { + let transfer_accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), + mint: ctx.accounts.token.to_account_info(), + to: ctx.accounts.payee_token_account.to_account_info(), + authority: ctx.accounts.channel.to_account_info(), + }; + let transfer_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.key(), + transfer_accounts, + signer_seeds, + ); + token_interface::transfer_checked(transfer_ctx, delta, ctx.accounts.token.decimals)?; + } + + if refund > 0 { + let transfer_accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), + mint: ctx.accounts.token.to_account_info(), + to: ctx.accounts.payer_token_account.to_account_info(), + authority: ctx.accounts.channel.to_account_info(), + }; + let transfer_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.key(), + transfer_accounts, + signer_seeds, + ); + token_interface::transfer_checked(transfer_ctx, refund, ctx.accounts.token.decimals)?; + } + + let channel = &mut ctx.accounts.channel; + channel.settled = final_settled; + channel.finalized = true; + + emit!(ChannelClosed { + channel: channel.key(), + final_settled, + refund, + }); + + Ok(()) +} diff --git a/programs/mpp-channel/src/instructions/mod.rs b/programs/mpp-channel/src/instructions/mod.rs new file mode 100644 index 000000000..9b908bb7c --- /dev/null +++ b/programs/mpp-channel/src/instructions/mod.rs @@ -0,0 +1,6 @@ +pub mod close; +pub mod open; +pub mod request_close; +pub mod settle; +pub mod top_up; +pub mod withdraw; diff --git a/programs/mpp-channel/src/instructions/open.rs b/programs/mpp-channel/src/instructions/open.rs new file mode 100644 index 000000000..f779536a0 --- /dev/null +++ b/programs/mpp-channel/src/instructions/open.rs @@ -0,0 +1,101 @@ +use anchor_lang::prelude::*; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; + +use crate::errors::MppChannelError; +use crate::events::ChannelOpened; +use crate::state::*; + +#[derive(Accounts)] +#[instruction(salt: u64, _deposit: u64, _grace_period_seconds: u64, authorized_signer: Pubkey)] +pub struct Open<'info> { + #[account(mut)] + pub payer: Signer<'info>, + + /// CHECK: Any valid pubkey can be a payee. + pub payee: UncheckedAccount<'info>, + + pub mint: InterfaceAccount<'info, Mint>, + + #[account( + init, + payer = payer, + space = PaymentChannel::SIZE, + seeds = [ + CHANNEL_SEED, + payer.key().as_ref(), + payee.key().as_ref(), + mint.key().as_ref(), + &salt.to_le_bytes(), + authorized_signer.as_ref(), + ], + bump, + )] + pub channel: Account<'info, PaymentChannel>, + + #[account( + mut, + token::mint = mint, + token::authority = payer, + )] + pub payer_token_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + init, + payer = payer, + associated_token::mint = mint, + associated_token::authority = channel, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, + pub associated_token_program: Program<'info, AssociatedToken>, + pub system_program: Program<'info, System>, +} + +pub fn handler( + ctx: Context, + salt: u64, + deposit: u64, + grace_period_seconds: u64, + authorized_signer: Pubkey, +) -> Result<()> { + require!(deposit > 0, MppChannelError::ZeroDeposit); + + let channel = &mut ctx.accounts.channel; + channel.payer = ctx.accounts.payer.key(); + channel.payee = ctx.accounts.payee.key(); + channel.token = ctx.accounts.mint.key(); + channel.authorized_signer = authorized_signer; + channel.deposit = deposit; + channel.settled = 0; + channel.close_requested_at = 0; + channel.grace_period_seconds = grace_period_seconds; + channel.finalized = false; + channel.salt = salt; + channel.bump = ctx.bumps.channel; + + let transfer_accounts = TransferChecked { + from: ctx.accounts.payer_token_account.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.vault.to_account_info(), + authority: ctx.accounts.payer.to_account_info(), + }; + let transfer_ctx = CpiContext::new( + ctx.accounts.token_program.key(), + transfer_accounts, + ); + token_interface::transfer_checked(transfer_ctx, deposit, ctx.accounts.mint.decimals)?; + + emit!(ChannelOpened { + channel: channel.key(), + payer: channel.payer, + payee: channel.payee, + token: channel.token, + authorized_signer: channel.authorized_signer, + deposit, + grace_period_seconds, + }); + + Ok(()) +} diff --git a/programs/mpp-channel/src/instructions/request_close.rs b/programs/mpp-channel/src/instructions/request_close.rs new file mode 100644 index 000000000..a4b8a6635 --- /dev/null +++ b/programs/mpp-channel/src/instructions/request_close.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::*; + +use crate::errors::MppChannelError; +use crate::events::CloseRequested; +use crate::state::*; + +#[derive(Accounts)] +pub struct RequestClose<'info> { + pub payer: Signer<'info>, + + #[account( + mut, + has_one = payer @ MppChannelError::UnauthorizedPayer, + )] + pub channel: Account<'info, PaymentChannel>, +} + +pub fn handler(ctx: Context) -> Result<()> { + let channel = &mut ctx.accounts.channel; + + require!(!channel.finalized, MppChannelError::ChannelFinalized); + require!( + channel.close_requested_at == 0, + MppChannelError::CloseAlreadyRequested + ); + + let clock = Clock::get()?; + channel.close_requested_at = clock.unix_timestamp; + + emit!(CloseRequested { + channel: channel.key(), + requested_at: channel.close_requested_at, + }); + + Ok(()) +} diff --git a/programs/mpp-channel/src/instructions/settle.rs b/programs/mpp-channel/src/instructions/settle.rs new file mode 100644 index 000000000..1045a5c30 --- /dev/null +++ b/programs/mpp-channel/src/instructions/settle.rs @@ -0,0 +1,133 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; + +use crate::jcs_voucher::{parse_jcs_voucher_message, verify_channel_id}; +use crate::ed25519::validate_ed25519_instruction; +use crate::errors::MppChannelError; +use crate::events::ChannelSettled; +use crate::state::*; + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct SettleArgs { + pub cumulative_amount: u64, + pub voucher_message: Vec, + pub ed25519_instruction_index: u8, +} + +#[derive(Accounts)] +pub struct Settle<'info> { + pub payee: Signer<'info>, + + #[account( + mut, + has_one = payee @ MppChannelError::UnauthorizedPayee, + has_one = token @ MppChannelError::UnauthorizedPayee, + )] + pub channel: Account<'info, PaymentChannel>, + + #[account(address = channel.token)] + pub token: InterfaceAccount<'info, Mint>, + + #[account( + mut, + token::mint = token, + token::authority = channel, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + token::mint = token, + token::authority = payee, + )] + pub payee_token_account: InterfaceAccount<'info, TokenAccount>, + + /// CHECK: Validated by address constraint. + #[account(address = solana_sdk_ids::sysvar::instructions::ID)] + pub instructions_sysvar: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handler(ctx: Context, args: SettleArgs) -> Result<()> { + let channel = &ctx.accounts.channel; + + require!(!channel.finalized, MppChannelError::ChannelFinalized); + + require!( + args.cumulative_amount > channel.settled, + MppChannelError::AmountNotGreaterThanSettled + ); + + require!( + args.cumulative_amount <= channel.deposit, + MppChannelError::AmountExceedsDeposit + ); + + // Validate the Ed25519 instruction verified the voucher message + // with the channel's authorized signer. + validate_ed25519_instruction( + &ctx.accounts.instructions_sysvar, + &channel.authorized_signer, + &args.voucher_message, + args.ed25519_instruction_index, + )?; + + // Parse the JCS voucher message and verify channelId and cumulativeAmount. + let parsed = parse_jcs_voucher_message(&args.voucher_message) + .ok_or(MppChannelError::InvalidEd25519Message)?; + + require!( + verify_channel_id(&parsed.channel_id_bytes, &channel.key()), + MppChannelError::InvalidEd25519Message + ); + + require!( + parsed.cumulative_amount == args.cumulative_amount, + MppChannelError::InvalidEd25519Message + ); + + // Transfer delta from vault to payee. + let delta = args + .cumulative_amount + .checked_sub(channel.settled) + .ok_or(MppChannelError::ArithmeticOverflow)?; + + if delta > 0 { + let salt_bytes = channel.salt.to_le_bytes(); + let seeds: &[&[u8]] = &[ + CHANNEL_SEED, + channel.payer.as_ref(), + channel.payee.as_ref(), + channel.token.as_ref(), + &salt_bytes, + channel.authorized_signer.as_ref(), + &[channel.bump], + ]; + let signer_seeds = &[seeds]; + + let transfer_accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), + mint: ctx.accounts.token.to_account_info(), + to: ctx.accounts.payee_token_account.to_account_info(), + authority: ctx.accounts.channel.to_account_info(), + }; + let transfer_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.key(), + transfer_accounts, + signer_seeds, + ); + token_interface::transfer_checked(transfer_ctx, delta, ctx.accounts.token.decimals)?; + } + + let channel = &mut ctx.accounts.channel; + channel.settled = args.cumulative_amount; + + emit!(ChannelSettled { + channel: channel.key(), + delta, + cumulative_settled: channel.settled, + }); + + Ok(()) +} diff --git a/programs/mpp-channel/src/instructions/top_up.rs b/programs/mpp-channel/src/instructions/top_up.rs new file mode 100644 index 000000000..88d729096 --- /dev/null +++ b/programs/mpp-channel/src/instructions/top_up.rs @@ -0,0 +1,71 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; + +use crate::errors::MppChannelError; +use crate::events::TopUpCompleted; +use crate::state::*; + +#[derive(Accounts)] +pub struct TopUp<'info> { + pub payer: Signer<'info>, + + #[account( + mut, + has_one = payer @ MppChannelError::UnauthorizedPayer, + has_one = token @ MppChannelError::UnauthorizedPayer, + )] + pub channel: Account<'info, PaymentChannel>, + + #[account(address = channel.token)] + pub token: InterfaceAccount<'info, Mint>, + + #[account( + mut, + token::mint = token, + token::authority = channel, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + token::mint = token, + token::authority = payer, + )] + pub payer_token_account: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handler(ctx: Context, amount: u64) -> Result<()> { + let channel = &ctx.accounts.channel; + + require!(!channel.finalized, MppChannelError::ChannelFinalized); + require!(amount > 0, MppChannelError::ZeroDeposit); + + let transfer_accounts = TransferChecked { + from: ctx.accounts.payer_token_account.to_account_info(), + mint: ctx.accounts.token.to_account_info(), + to: ctx.accounts.vault.to_account_info(), + authority: ctx.accounts.payer.to_account_info(), + }; + let transfer_ctx = CpiContext::new( + ctx.accounts.token_program.key(), + transfer_accounts, + ); + token_interface::transfer_checked(transfer_ctx, amount, ctx.accounts.token.decimals)?; + + let channel = &mut ctx.accounts.channel; + channel.deposit = channel + .deposit + .checked_add(amount) + .ok_or(MppChannelError::ArithmeticOverflow)?; + channel.close_requested_at = 0; + + emit!(TopUpCompleted { + channel: channel.key(), + additional: amount, + new_deposit: channel.deposit, + }); + + Ok(()) +} diff --git a/programs/mpp-channel/src/instructions/withdraw.rs b/programs/mpp-channel/src/instructions/withdraw.rs new file mode 100644 index 000000000..6f14a7ebd --- /dev/null +++ b/programs/mpp-channel/src/instructions/withdraw.rs @@ -0,0 +1,100 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{self, Mint, TokenAccount, TokenInterface, TransferChecked}; + +use crate::errors::MppChannelError; +use crate::events::Withdrawn; +use crate::state::*; + +#[derive(Accounts)] +pub struct Withdraw<'info> { + pub payer: Signer<'info>, + + #[account( + mut, + has_one = payer @ MppChannelError::UnauthorizedPayer, + has_one = token @ MppChannelError::UnauthorizedPayer, + )] + pub channel: Account<'info, PaymentChannel>, + + #[account(address = channel.token)] + pub token: InterfaceAccount<'info, Mint>, + + #[account( + mut, + token::mint = token, + token::authority = channel, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + token::mint = token, + token::authority = payer, + )] + pub payer_token_account: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handler(ctx: Context) -> Result<()> { + let channel = &ctx.accounts.channel; + + require!(!channel.finalized, MppChannelError::ChannelFinalized); + require!( + channel.close_requested_at > 0, + MppChannelError::CloseNotRequested + ); + + let clock = Clock::get()?; + let grace_end = channel + .close_requested_at + .checked_add(channel.grace_period_seconds as i64) + .ok_or(MppChannelError::ArithmeticOverflow)?; + + require!( + clock.unix_timestamp >= grace_end, + MppChannelError::GracePeriodNotExpired + ); + + let refund = channel + .deposit + .checked_sub(channel.settled) + .ok_or(MppChannelError::ArithmeticOverflow)?; + + if refund > 0 { + let salt_bytes = channel.salt.to_le_bytes(); + let seeds: &[&[u8]] = &[ + CHANNEL_SEED, + channel.payer.as_ref(), + channel.payee.as_ref(), + channel.token.as_ref(), + &salt_bytes, + channel.authorized_signer.as_ref(), + &[channel.bump], + ]; + let signer_seeds = &[seeds]; + + let transfer_accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), + mint: ctx.accounts.token.to_account_info(), + to: ctx.accounts.payer_token_account.to_account_info(), + authority: ctx.accounts.channel.to_account_info(), + }; + let transfer_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.key(), + transfer_accounts, + signer_seeds, + ); + token_interface::transfer_checked(transfer_ctx, refund, ctx.accounts.token.decimals)?; + } + + let channel = &mut ctx.accounts.channel; + channel.finalized = true; + + emit!(Withdrawn { + channel: channel.key(), + refund, + }); + + Ok(()) +} diff --git a/programs/mpp-channel/src/jcs_voucher.rs b/programs/mpp-channel/src/jcs_voucher.rs new file mode 100644 index 000000000..3630b670f --- /dev/null +++ b/programs/mpp-channel/src/jcs_voucher.rs @@ -0,0 +1,116 @@ +use anchor_lang::prelude::*; + +/// Parse a JCS-serialized voucher message to extract channelId and cumulativeAmount. +/// +/// The voucher message is JCS-canonicalized JSON with sorted keys: +/// {"channelId":"","cumulativeAmount":""} +/// or with expiresAt: +/// {"channelId":"","cumulativeAmount":"","expiresAt":""} +/// +/// The on-chain program receives these raw bytes (which were signed by the +/// client) and validates that the channelId matches the channel PDA and the +/// cumulativeAmount matches the settle instruction's argument. +pub struct ParsedVoucherMessage { + pub channel_id_bytes: Vec, + pub cumulative_amount: u64, +} + +/// Parse a JCS voucher message and extract channelId (as raw base58 bytes) +/// and cumulativeAmount (as u64). +/// +/// Returns None if the message doesn't match the expected JCS format. +pub fn parse_jcs_voucher_message(message: &[u8]) -> Option { + // Expected format: {"channelId":"...","cumulativeAmount":"..."} + // or {"channelId":"...","cumulativeAmount":"...","expiresAt":"..."} + let prefix = b"{\"channelId\":\""; + if !message.starts_with(prefix) { + return None; + } + + let after_prefix = &message[prefix.len()..]; + + // Find end of channelId value + let channel_id_end = find_byte(after_prefix, b'"')?; + let channel_id_bytes = after_prefix[..channel_id_end].to_vec(); + + // Skip to cumulativeAmount + let after_channel_id = &after_prefix[channel_id_end..]; + let cumulative_prefix = b"\",\"cumulativeAmount\":\""; + if !after_channel_id.starts_with(cumulative_prefix) { + return None; + } + + let after_cumulative_prefix = &after_channel_id[cumulative_prefix.len()..]; + + // Find end of cumulativeAmount value + let amount_end = find_byte(after_cumulative_prefix, b'"')?; + let amount_str = core::str::from_utf8(&after_cumulative_prefix[..amount_end]).ok()?; + let cumulative_amount: u64 = amount_str.parse().ok()?; + + Some(ParsedVoucherMessage { + channel_id_bytes, + cumulative_amount, + }) +} + +/// Verify that the parsed channelId (base58 string) decodes to the expected pubkey. +pub fn verify_channel_id(channel_id_bytes: &[u8], expected: &Pubkey) -> bool { + let channel_id_str = match core::str::from_utf8(channel_id_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + + // Use Pubkey's FromStr implementation which handles base58 decoding. + match channel_id_str.parse::() { + Ok(decoded) => decoded == *expected, + Err(_) => false, + } +} + +fn find_byte(data: &[u8], byte: u8) -> Option { + data.iter().position(|&b| b == byte) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_voucher_without_expires() { + let message = br#"{"channelId":"11111111111111111111111111111112","cumulativeAmount":"1000000"}"#; + let parsed = parse_jcs_voucher_message(message).unwrap(); + assert_eq!(parsed.cumulative_amount, 1_000_000); + assert_eq!( + core::str::from_utf8(&parsed.channel_id_bytes).unwrap(), + "11111111111111111111111111111112" + ); + } + + #[test] + fn parse_voucher_with_expires() { + let message = br#"{"channelId":"11111111111111111111111111111112","cumulativeAmount":"500","expiresAt":"2026-01-01T00:00:00Z"}"#; + let parsed = parse_jcs_voucher_message(message).unwrap(); + assert_eq!(parsed.cumulative_amount, 500); + } + + #[test] + fn reject_invalid_prefix() { + let message = br#"{"invalid":"data"}"#; + assert!(parse_jcs_voucher_message(message).is_none()); + } + + #[test] + fn verify_channel_id_match() { + let pubkey = Pubkey::default(); + let encoded = pubkey.to_string(); + assert!(verify_channel_id(encoded.as_bytes(), &pubkey)); + } + + #[test] + fn verify_channel_id_mismatch() { + let pubkey1 = Pubkey::new_unique(); + let pubkey2 = Pubkey::new_unique(); + let encoded = pubkey1.to_string(); + assert!(!verify_channel_id(encoded.as_bytes(), &pubkey2)); + } +} diff --git a/programs/mpp-channel/src/lib.rs b/programs/mpp-channel/src/lib.rs new file mode 100644 index 000000000..236c52628 --- /dev/null +++ b/programs/mpp-channel/src/lib.rs @@ -0,0 +1,52 @@ +use anchor_lang::prelude::*; + +pub mod jcs_voucher; +pub mod ed25519; +pub mod errors; +pub mod events; +pub mod instructions; +pub mod state; + +pub use instructions::close::*; +pub use instructions::open::*; +pub use instructions::request_close::*; +pub use instructions::settle::*; +pub use instructions::top_up::*; +pub use instructions::withdraw::*; + +declare_id!("21fLdahqKtVAt4V2JLwVrRb7tuqPADjjPVCU9bK3MFPQ"); + +#[program] +pub mod mpp_channel { + use super::*; + + pub fn open( + ctx: Context, + salt: u64, + deposit: u64, + grace_period_seconds: u64, + authorized_signer: Pubkey, + ) -> Result<()> { + crate::instructions::open::handler(ctx, salt, deposit, grace_period_seconds, authorized_signer) + } + + pub fn settle(ctx: Context, args: SettleArgs) -> Result<()> { + crate::instructions::settle::handler(ctx, args) + } + + pub fn close(ctx: Context, args: SettleArgs) -> Result<()> { + crate::instructions::close::handler(ctx, args) + } + + pub fn top_up(ctx: Context, amount: u64) -> Result<()> { + crate::instructions::top_up::handler(ctx, amount) + } + + pub fn request_close(ctx: Context) -> Result<()> { + crate::instructions::request_close::handler(ctx) + } + + pub fn withdraw(ctx: Context) -> Result<()> { + crate::instructions::withdraw::handler(ctx) + } +} diff --git a/programs/mpp-channel/src/state.rs b/programs/mpp-channel/src/state.rs new file mode 100644 index 000000000..220f23e3d --- /dev/null +++ b/programs/mpp-channel/src/state.rs @@ -0,0 +1,49 @@ +use anchor_lang::prelude::*; + +pub const CHANNEL_SEED: &[u8] = b"mpp-channel"; +pub const CHANNEL_VAULT_SEED: &[u8] = b"mpp-channel-vault"; + +/// On-chain payment channel account. +/// +/// Tracks escrowed funds between a payer and payee. The payer deposits +/// SPL tokens at open time. The payee can settle partial amounts using +/// signed vouchers. Either party can close the channel: the payee via +/// cooperative close, the payer via requestClose + grace period + withdraw. +#[account] +pub struct PaymentChannel { + /// The wallet that deposited funds into this channel. + pub payer: Pubkey, + /// The wallet authorized to settle and close the channel. + pub payee: Pubkey, + /// The SPL token mint. Native SOL is not currently supported — all channels + /// use SPL tokens via transfer_checked and ATA-backed vaults. + /// TODO: add a separate native SOL path (wrapping or system-program transfer). + pub token: Pubkey, + /// The key permitted to sign vouchers. Equals payer unless delegated. + pub authorized_signer: Pubkey, + /// Total amount deposited (in token base units). + pub deposit: u64, + /// Cumulative amount already transferred to payee via settle/close. + pub settled: u64, + /// Unix timestamp when forced close was requested (0 if none). + pub close_requested_at: i64, + /// Grace period in seconds before payer can withdraw after requestClose. + pub grace_period_seconds: u64, + /// Whether the channel has been finalized (closed). + pub finalized: bool, + /// Client-chosen salt used as a PDA seed. Allows the same payer/payee + /// pair to open multiple channels. + pub salt: u64, + /// PDA bump seed. + pub bump: u8, +} + +impl PaymentChannel { + /// Account size: 8-byte discriminator + all fields. + /// 32 + 32 + 32 + 32 + 8 + 8 + 8 + 8 + 1 + 8 + 1 = 170 + pub const SIZE: usize = 8 + 170; + + pub fn is_open(&self) -> bool { + !self.finalized + } +} diff --git a/rust/src/client/solana/charge.rs b/rust/src/client/solana/charge.rs index dfedee393..2d2f29a61 100644 --- a/rust/src/client/solana/charge.rs +++ b/rust/src/client/solana/charge.rs @@ -285,6 +285,7 @@ fn build_sol_instructions( Ok(()) } +#[allow(clippy::too_many_arguments)] fn build_spl_instructions( instructions: &mut Vec, signer_pubkey: &Pubkey, diff --git a/typescript/packages/mpp/package.json b/typescript/packages/mpp/package.json index c038181e4..9f2c5158d 100644 --- a/typescript/packages/mpp/package.json +++ b/typescript/packages/mpp/package.json @@ -20,6 +20,10 @@ "./client": { "types": "./dist/client/index.d.ts", "default": "./dist/client/index.js" + }, + "./anchor": { + "types": "./dist/anchor/index.d.ts", + "default": "./dist/anchor/index.js" } }, "files": [ diff --git a/typescript/packages/mpp/src/Methods.ts b/typescript/packages/mpp/src/Methods.ts index d25464f4e..cf5d4031b 100644 --- a/typescript/packages/mpp/src/Methods.ts +++ b/typescript/packages/mpp/src/Methods.ts @@ -69,28 +69,12 @@ export const charge = Method.from({ }); const voucherSchema = z.object({ - /** Chain identifier, for example `solana:mainnet-beta`. */ - chainId: z.string(), - /** Channel identifier bound to this voucher. */ + /** Channel PDA address. Cryptographically commits to payer, payee, token, and program via derivation. */ channelId: z.string(), - /** Channel program address expected by server verifier. */ - channelProgram: z.string(), - /** Monotonic cumulative authorized amount. */ + /** Monotonic cumulative authorized amount in token base units. */ cumulativeAmount: z.string(), /** Optional voucher expiration timestamp in ISO-8601 format. */ expiresAt: z.optional(z.string()), - /** Meter name for priced usage tracking. */ - meter: z.string(), - /** Wallet payer bound to this voucher. */ - payer: z.string(), - /** Channel recipient service wallet. */ - recipient: z.string(), - /** Monotonic sequence number for replay protection. */ - sequence: z.number(), - /** Server-provided nonce for challenge binding. */ - serverNonce: z.string(), - /** Meter units associated with this authorization update. */ - units: z.string(), }); const signedVoucherSchema = z.object({ @@ -108,10 +92,10 @@ const signedVoucherSchema = z.object({ * * Supports four credential actions: * - * - **open**: opens a payment channel, records deposit, and anchors setup tx. - * - **update**: submits a new monotonic voucher for cumulative usage. - * - **topup**: increases channel escrow using a separate topup transaction. - * - **close**: final voucher update used to finalize channel settlement. + * - **open**: opens a payment channel via a partially-signed transaction (pull mode). + * - **voucher**: submits a new monotonic voucher authorizing cumulative spend. + * - **topUp**: increases channel escrow via a partially-signed transaction. + * - **close**: cooperative channel close with optional final voucher. */ export const session = Method.from({ intent: 'session', @@ -122,118 +106,89 @@ export const session = Method.from({ z.object({ /** Session lifecycle action. */ action: z.literal('open'), - /** Authorization mode selected by the authorizer implementation. */ - authorizationMode: z.string(), - /** Optional advertised authorizer capabilities for client hints. */ - capabilities: z.optional( - z.object({ - /** Allowed action subset advertised by the authorizer. */ - allowedActions: z.optional(z.array(z.string())), - /** Maximum cumulative authorized amount for this channel. */ - maxCumulativeAmount: z.optional(z.string()), - }), - ), - /** Unique channel identifier generated by the client authorizer. */ + /** Voucher signer policy for delegated signing. */ + authorizationPolicy: z.optional(z.record(z.string(), z.unknown())), + /** Implementation-specific extensions. */ + capabilities: z.optional(z.record(z.string(), z.unknown())), + /** Channel PDA address (derived from payer, payee, token, salt, authorizedSigner, channelProgram). */ channelId: z.string(), /** Initial escrow amount committed for this channel. */ depositAmount: z.string(), - /** Optional voucher expiration timestamp in ISO-8601 format. */ + /** Session expiration (ISO 8601). */ expiresAt: z.optional(z.string()), - /** On-chain transaction reference proving open/setup step. */ - openTx: z.string(), /** Wallet payer for this channel (base58 public key). */ payer: z.string(), - /** Signed session voucher payload for open action. */ + /** Base64-encoded partially-signed transaction (pull mode). Server co-signs and broadcasts. */ + transaction: z.string(), + /** Signed session voucher for the initial authorization. */ voucher: signedVoucherSchema, }), z.object({ /** Session lifecycle action. */ - action: z.literal('update'), - /** Existing channel identifier targeted by this update. */ + action: z.literal('voucher'), + /** Channel PDA address targeted by this voucher. */ channelId: z.string(), - /** Signed session voucher payload for usage update. */ + /** Signed session voucher authorizing cumulative spend. */ voucher: signedVoucherSchema, }), z.object({ /** Session lifecycle action. */ - action: z.literal('topup'), + action: z.literal('topUp'), /** Additional escrow amount to add to channel deposit. */ additionalAmount: z.string(), - /** Existing channel identifier targeted by this topup. */ + /** Channel PDA address targeted by this top-up. */ channelId: z.string(), - /** On-chain transaction reference proving topup execution. */ - topupTx: z.string(), + /** Base64-encoded partially-signed transaction (pull mode). Server co-signs and broadcasts. */ + transaction: z.string(), }), z.object({ /** Session lifecycle action. */ action: z.literal('close'), - /** Existing channel identifier targeted by this close. */ + /** Channel PDA address targeted by this close. */ channelId: z.string(), - /** Optional on-chain settlement transaction reference for this close. */ - closeTx: z.optional(z.string()), - /** Signed final voucher payload for close action. */ - voucher: signedVoucherSchema, + /** Signed final voucher. Optional if highest amount is already settled on-chain. */ + voucher: z.optional(signedVoucherSchema), }), ]), }, request: z.object({ - asset: z.object({ - /** Token decimals for amount normalization. */ - decimals: z.number(), - /** Asset kind: native SOL or SPL token. */ - kind: z.string(), - /** SPL mint address when kind is `spl`. */ - mint: z.optional(z.string()), - /** Optional ticker/symbol used for display. */ - symbol: z.optional(z.string()), + /** Price per unit in token base units. */ + amount: z.string(), + /** Currency identifier: "sol" for native SOL, or SPL mint address. */ + currency: z.string(), + /** Human-readable description of the resource or service. */ + description: z.optional(z.string()), + /** Merchant's reference for reconciliation. */ + externalId: z.optional(z.string()), + methodDetails: z.object({ + /** Existing channel PDA to resume (if reconnecting to an open channel). */ + channelId: z.optional(z.string()), + + /** Channel program address for voucher verification. */ + channelProgram: z.string(), + /** Token decimals (required for SPL tokens). */ + decimals: z.optional(z.number()), + /** If true, server pays transaction fees. Client must use feePayerKey. */ + feePayer: z.optional(z.boolean()), + /** Server's base58-encoded public key for fee payment. Present when feePayer is true. */ + feePayerKey: z.optional(z.string()), + /** Grace period in seconds for forced close (recommended 900). */ + gracePeriodSeconds: z.optional(z.number()), + /** Minimum voucher delta the server will accept. */ + minVoucherDelta: z.optional(z.string()), + /** Solana network: mainnet-beta, devnet, or localnet. */ + network: z.optional(z.string()), + /** Token program address (TOKEN_PROGRAM or TOKEN_2022_PROGRAM). */ + tokenProgram: z.optional(z.string()), + /** Suggested time-to-live for the session in seconds. */ + ttlSeconds: z.optional(z.number()), }), - /** Channel program address used to verify vouchers and actions. */ - channelProgram: z.string(), - /** Solana network name, for example mainnet-beta, devnet, localnet. */ - network: z.optional(z.string()), - /** Optional pricing contract used to derive debit increments. */ - pricing: z.optional( - z.object({ - /** Price per unit in asset base units. */ - amountPerUnit: z.string(), - /** Meter identifier for usage accounting. */ - meter: z.string(), - /** Optional minimum debit to apply per request. */ - minDebit: z.optional(z.string()), - /** Logical unit name charged by the service. */ - unit: z.string(), - }), - ), - /** Service recipient wallet that receives settlement funds. */ + /** Base58-encoded recipient (payee) public key. */ recipient: z.string(), - /** Optional server hints for default session behavior. */ - sessionDefaults: z.optional( - z.object({ - /** Optional close behavior hint for client UX. */ - closeBehavior: z.optional(z.string()), - /** Optional settlement cadence policy hints. */ - settleInterval: z.optional( - z.object({ - kind: z.string(), - minIncrement: z.optional(z.string()), - seconds: z.optional(z.number()), - }), - ), - /** Suggested channel deposit to use on auto-open. */ - suggestedDeposit: z.optional(z.string()), - /** Suggested time-to-live for channel/session in seconds. */ - ttlSeconds: z.optional(z.number()), - }), - ), - /** Optional server-side verifier policy hints. */ - verifier: z.optional( - z.object({ - /** Supported authorization modes for this endpoint. */ - acceptAuthorizationModes: z.optional(z.array(z.string())), - /** Maximum allowable client/server clock skew in seconds. */ - maxClockSkewSeconds: z.optional(z.number()), - }), - ), + /** Suggested initial channel deposit in token base units. */ + suggestedDeposit: z.optional(z.string()), + /** Unit type for pricing (e.g., "request", "token", "byte"). */ + unitType: z.optional(z.string()), }), }, }); diff --git a/typescript/packages/mpp/src/__tests__/anchor-channel.test.ts b/typescript/packages/mpp/src/__tests__/anchor-channel.test.ts new file mode 100644 index 000000000..e5f3e29f9 --- /dev/null +++ b/typescript/packages/mpp/src/__tests__/anchor-channel.test.ts @@ -0,0 +1,611 @@ +/** + * Anchor integration tests for the mpp-channel program. + * + * Runs against solana-test-validator with the program loaded. + * Requires: `anchor build --no-idl` to have been run first. + * + * Run: `pnpm exec vitest run --config vitest.config.anchor.ts` + */ +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { + address, + generateKeyPairSigner, + createTransactionMessage, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + appendTransactionMessageInstructions, + compileTransaction, + partiallySignTransaction, + getBase64EncodedWireTransaction, + getBase58Encoder, + type Address, + type KeyPairSigner, + type Instruction, +} from '@solana/kit'; +import { getCreateAccountInstruction } from '@solana-program/system'; +import { + getInitializeMintInstruction, + getMintToInstruction, + getCreateAssociatedTokenIdempotentInstruction, + findAssociatedTokenPda, + TOKEN_PROGRAM_ADDRESS, + ASSOCIATED_TOKEN_PROGRAM_ADDRESS, +} from '@solana-program/token'; + +import { + deriveChannelPda, + deriveVaultPda, + buildOpenInstruction, + buildSettleInstructions, + buildTopUpInstruction, + buildRequestCloseInstruction, + buildWithdrawInstruction, + buildCloseInstructions, +} from '../anchor/MppChannelClient.js'; +import { serializeVoucher, signVoucher } from '../session/Voucher.js'; + +const PROGRAM_ID = address('21fLdahqKtVAt4V2JLwVrRb7tuqPADjjPVCU9bK3MFPQ'); +const PROGRAM_SO_PATH = new URL('../../../../../programs/mpp-channel/target/deploy/mpp_channel.so', import.meta.url) + .pathname; +const RPC_URL = 'http://127.0.0.1:8899'; +const GRACE_PERIOD = 2n; // 2 seconds for testing +const MINT_DECIMALS = 6; + +let validatorProcess: ChildProcess | null = null; +let payer: KeyPairSigner; +let payee: KeyPairSigner; +let mint: Address; +let payerAta: Address; +let payeeAta: Address; +const base58Encoder = getBase58Encoder(); + +// ---- RPC helpers ---- + +async function rpcCall(method: string, params: unknown[] = []): Promise { + const response = await fetch(RPC_URL, { + body: JSON.stringify({ id: 1, jsonrpc: '2.0', method, params }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const data = (await response.json()) as { error?: { message: string }; result?: unknown }; + if (data.error) throw new Error(`RPC ${method}: ${data.error.message}`); + return data.result; +} + +async function airdrop(recipient: Address, lamports: number): Promise { + const signature = (await rpcCall('requestAirdrop', [recipient, lamports])) as string; + await waitForSignature(signature); +} + +async function waitForSignature(signature: string): Promise { + for (let i = 0; i < 60; i++) { + const result = (await rpcCall('getSignatureStatuses', [[signature]])) as { + value: ({ confirmationStatus: string; err: unknown } | null)[]; + }; + const status = result.value[0]; + if (status?.confirmationStatus === 'confirmed' || status?.confirmationStatus === 'finalized') { + if (status.err) throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`); + return; + } + await new Promise(r => setTimeout(r, 500)); + } + throw new Error('Confirmation timeout'); +} + +async function getBlockhash(): Promise<{ blockhash: string; lastValidBlockHeight: bigint }> { + const result = (await rpcCall('getLatestBlockhash', [{ commitment: 'confirmed' }])) as { + value: { blockhash: string; lastValidBlockHeight: number }; + }; + return { blockhash: result.value.blockhash, lastValidBlockHeight: BigInt(result.value.lastValidBlockHeight) }; +} + +async function sendTx(instructions: Instruction[], signers: KeyPairSigner[]): Promise { + const { blockhash, lastValidBlockHeight } = await getBlockhash(); + const feePayer = signers[0]; + + const msg = appendTransactionMessageInstructions( + instructions, + setTransactionMessageLifetimeUsingBlockhash( + { blockhash: blockhash as any, lastValidBlockHeight }, + setTransactionMessageFeePayer(feePayer.address, createTransactionMessage({ version: 0 })), + ), + ); + + // Compile the message to a transaction, then sign with all provided key pairs. + const compiled = compileTransaction(msg as any); + const keyPairs = await Promise.all(signers.map(s => s.keyPair)); + const signed = await partiallySignTransaction(keyPairs as any, compiled); + const base64Tx = getBase64EncodedWireTransaction(signed as any); + + const signature = (await rpcCall('sendTransaction', [ + base64Tx, + { encoding: 'base64', skipPreflight: true }, + ])) as string; + await waitForSignature(signature); + return signature; +} + +async function getTokenBalance(tokenAccount: Address): Promise { + const result = (await rpcCall('getTokenAccountBalance', [tokenAccount, { commitment: 'confirmed' }])) as { + value: { amount: string }; + }; + return BigInt(result.value.amount); +} + +async function getAccountData(accountAddress: Address): Promise { + const result = (await rpcCall('getAccountInfo', [ + accountAddress, + { encoding: 'base64', commitment: 'confirmed' }, + ])) as { + value: { data: [string, string] } | null; + }; + if (!result?.value) return null; + const base64Data = result.value.data[0]; + return Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); +} + +// ---- Validator lifecycle ---- + +async function startValidator(): Promise { + const repoRoot = new URL('../../../../../', import.meta.url).pathname; + + validatorProcess = spawn( + 'solana-test-validator', + ['--bpf-program', PROGRAM_ID, PROGRAM_SO_PATH, '--reset', '--quiet'], + { + stdio: ['ignore', 'pipe', 'pipe'], + cwd: repoRoot, + }, + ); + + let processOutput = ''; + validatorProcess.stdout?.on('data', (data: Buffer) => { + processOutput += data.toString(); + }); + validatorProcess.stderr?.on('data', (data: Buffer) => { + processOutput += data.toString(); + }); + + for (let i = 0; i < 60; i++) { + try { + await rpcCall('getHealth'); + return; + } catch { + await new Promise(r => setTimeout(r, 500)); + } + } + throw new Error(`Validator startup failed. output: ${processOutput}`); +} + +// ---- Token setup ---- + +async function setupTestTokens(): Promise { + const mintKeypair = await generateKeyPairSigner(); + const rentExemption = (await rpcCall('getMinimumBalanceForRentExemption', [82])) as number; + + const createMintAccountIx = getCreateAccountInstruction({ + payer, + newAccount: mintKeypair, + lamports: BigInt(rentExemption), + space: 82, + programAddress: TOKEN_PROGRAM_ADDRESS, + }); + + const initMintIx = getInitializeMintInstruction({ + mint: mintKeypair.address, + decimals: MINT_DECIMALS, + mintAuthority: payer.address, + }); + + await sendTx([createMintAccountIx, initMintIx], [payer, mintKeypair]); + mint = mintKeypair.address; + + // Create ATAs for payer and payee. + const [payerAtaAddr] = await findAssociatedTokenPda({ + mint, + owner: payer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const [payeeAtaAddr] = await findAssociatedTokenPda({ + mint, + owner: payee.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + const createPayerAta = getCreateAssociatedTokenIdempotentInstruction({ + payer, + ata: payerAtaAddr, + owner: payer.address, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const createPayeeAta = getCreateAssociatedTokenIdempotentInstruction({ + payer, + ata: payeeAtaAddr, + owner: payee.address, + mint, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + await sendTx([createPayerAta, createPayeeAta], [payer]); + payerAta = payerAtaAddr; + payeeAta = payeeAtaAddr; + + // Mint tokens to payer. + const mintToIx = getMintToInstruction({ + mint, + token: payerAta, + mintAuthority: payer, + amount: 10_000_000n, // 10 tokens at 6 decimals + }); + await sendTx([mintToIx], [payer]); +} + +// ---- Tests ---- + +describe('mpp-channel program', () => { + beforeAll(async () => { + await startValidator(); + payer = await generateKeyPairSigner(); + payee = await generateKeyPairSigner(); + await airdrop(payer.address, 10_000_000_000); + await airdrop(payee.address, 1_000_000_000); + + // Wait for airdrop to be queryable. + for (let attempt = 0; attempt < 30; attempt++) { + const payerBalance = (await rpcCall('getBalance', [payer.address])) as { value: number }; + if (payerBalance.value > 0) break; + if (attempt === 29) throw new Error('Payer airdrop did not land after 15 seconds'); + await new Promise(r => setTimeout(r, 500)); + } + + await setupTestTokens(); + }, 60_000); + + afterAll(() => { + if (validatorProcess) { + validatorProcess.kill('SIGTERM'); + validatorProcess = null; + } + }); + + test('open creates channel PDA and deposits tokens', async () => { + const salt = 100n; + const depositAmount = 1_000_000n; + + const [channelPda] = await deriveChannelPda( + PROGRAM_ID, + payer.address, + payee.address, + mint, + salt, + payer.address, + ); + const vaultPda = await deriveVaultPda(channelPda, mint); + + const openIx = buildOpenInstruction({ + programId: PROGRAM_ID, + payer: payer.address, + payee: payee.address, + mint, + channelPda, + payerTokenAccount: payerAta, + vault: vaultPda, + salt, + deposit: depositAmount, + gracePeriodSeconds: GRACE_PERIOD, + authorizedSigner: payer.address, + }); + + await sendTx([openIx], [payer]); + + const vaultBalance = await getTokenBalance(vaultPda); + expect(vaultBalance).toBe(depositAmount); + }); + + test('settle transfers delta to payee with Ed25519 voucher', async () => { + const salt = 200n; + const depositAmount = 1_000_000n; + const settleAmount = 300_000n; + + const [channelPda] = await deriveChannelPda( + PROGRAM_ID, + payer.address, + payee.address, + mint, + salt, + payer.address, + ); + const vaultPda = await deriveVaultPda(channelPda, mint); + + // Open channel. + await sendTx( + [ + buildOpenInstruction({ + programId: PROGRAM_ID, + payer: payer.address, + payee: payee.address, + mint, + channelPda, + payerTokenAccount: payerAta, + vault: vaultPda, + salt, + deposit: depositAmount, + gracePeriodSeconds: GRACE_PERIOD, + authorizedSigner: payer.address, + }), + ], + [payer], + ); + + // Sign a voucher. + const voucher = { channelId: channelPda, cumulativeAmount: settleAmount.toString() }; + const voucherBytes = serializeVoucher(voucher); + const signedVoucher = await signVoucher(payer, voucher); + const signatureBytes = new Uint8Array(base58Encoder.encode(signedVoucher.signature)); + const signerPubkeyBytes = new Uint8Array(base58Encoder.encode(payer.address)); + + // Build settle tx with Ed25519 verify + settle instructions. + const settleIxs = buildSettleInstructions({ + programId: PROGRAM_ID, + payee: payee.address, + channelPda, + mint, + vault: vaultPda, + payeeTokenAccount: payeeAta, + cumulativeAmount: settleAmount, + voucherMessage: voucherBytes, + signerPublicKey: signerPubkeyBytes, + signature: signatureBytes, + }); + + const payeeBalanceBefore = await getTokenBalance(payeeAta); + await sendTx(settleIxs, [payee]); + const payeeBalanceAfter = await getTokenBalance(payeeAta); + + expect(payeeBalanceAfter - payeeBalanceBefore).toBe(settleAmount); + }); + + test('full lifecycle: open -> settle -> settle more -> close', async () => { + const salt = 300n; + const depositAmount = 1_000_000n; + + const [channelPda] = await deriveChannelPda( + PROGRAM_ID, + payer.address, + payee.address, + mint, + salt, + payer.address, + ); + const vaultPda = await deriveVaultPda(channelPda, mint); + + // Open. + await sendTx( + [ + buildOpenInstruction({ + programId: PROGRAM_ID, + payer: payer.address, + payee: payee.address, + mint, + channelPda, + payerTokenAccount: payerAta, + vault: vaultPda, + salt, + deposit: depositAmount, + gracePeriodSeconds: GRACE_PERIOD, + authorizedSigner: payer.address, + }), + ], + [payer], + ); + + // Settle partial (200k). + const voucher1 = { channelId: channelPda, cumulativeAmount: '200000' }; + const signed1 = await signVoucher(payer, voucher1); + await sendTx( + buildSettleInstructions({ + programId: PROGRAM_ID, + payee: payee.address, + channelPda, + mint, + vault: vaultPda, + payeeTokenAccount: payeeAta, + cumulativeAmount: 200_000n, + voucherMessage: serializeVoucher(voucher1), + signerPublicKey: new Uint8Array(base58Encoder.encode(payer.address)), + signature: new Uint8Array(base58Encoder.encode(signed1.signature)), + }), + [payee], + ); + + // Settle more (500k cumulative). + const voucher2 = { channelId: channelPda, cumulativeAmount: '500000' }; + const signed2 = await signVoucher(payer, voucher2); + await sendTx( + buildSettleInstructions({ + programId: PROGRAM_ID, + payee: payee.address, + channelPda, + mint, + vault: vaultPda, + payeeTokenAccount: payeeAta, + cumulativeAmount: 500_000n, + voucherMessage: serializeVoucher(voucher2), + signerPublicKey: new Uint8Array(base58Encoder.encode(payer.address)), + signature: new Uint8Array(base58Encoder.encode(signed2.signature)), + }), + [payee], + ); + + // Close with final voucher (700k cumulative). + const voucher3 = { channelId: channelPda, cumulativeAmount: '700000' }; + const signed3 = await signVoucher(payer, voucher3); + const payerBalanceBefore = await getTokenBalance(payerAta); + + await sendTx( + buildCloseInstructions({ + programId: PROGRAM_ID, + payee: payee.address, + channelPda, + mint, + vault: vaultPda, + payeeTokenAccount: payeeAta, + payerTokenAccount: payerAta, + cumulativeAmount: 700_000n, + voucherMessage: serializeVoucher(voucher3), + signerPublicKey: new Uint8Array(base58Encoder.encode(payer.address)), + signature: new Uint8Array(base58Encoder.encode(signed3.signature)), + }), + [payee], + ); + + // Payer should get refund of 300k (1M - 700k). + const payerBalanceAfter = await getTokenBalance(payerAta); + expect(payerBalanceAfter - payerBalanceBefore).toBe(300_000n); + + // Vault should be empty. + const vaultBalance = await getTokenBalance(vaultPda); + expect(vaultBalance).toBe(0n); + }); + + test('requestClose + withdraw after grace period', async () => { + const salt = 400n; + const depositAmount = 500_000n; + + const [channelPda] = await deriveChannelPda( + PROGRAM_ID, + payer.address, + payee.address, + mint, + salt, + payer.address, + ); + const vaultPda = await deriveVaultPda(channelPda, mint); + + // Open. + await sendTx( + [ + buildOpenInstruction({ + programId: PROGRAM_ID, + payer: payer.address, + payee: payee.address, + mint, + channelPda, + payerTokenAccount: payerAta, + vault: vaultPda, + salt, + deposit: depositAmount, + gracePeriodSeconds: GRACE_PERIOD, + authorizedSigner: payer.address, + }), + ], + [payer], + ); + + // Request close. + await sendTx( + [ + buildRequestCloseInstruction({ + programId: PROGRAM_ID, + payer: payer.address, + channelPda, + }), + ], + [payer], + ); + + // Wait for grace period to expire. + await new Promise(r => setTimeout(r, 3000)); + + // Withdraw. + const payerBalanceBefore = await getTokenBalance(payerAta); + await sendTx( + [ + buildWithdrawInstruction({ + programId: PROGRAM_ID, + payer: payer.address, + channelPda, + mint, + vault: vaultPda, + payerTokenAccount: payerAta, + }), + ], + [payer], + ); + const payerBalanceAfter = await getTokenBalance(payerAta); + + expect(payerBalanceAfter - payerBalanceBefore).toBe(depositAmount); + }); + + test('topUp increases deposit and cancels pending close', async () => { + const salt = 500n; + const depositAmount = 500_000n; + const topUpAmount = 200_000n; + + const [channelPda] = await deriveChannelPda( + PROGRAM_ID, + payer.address, + payee.address, + mint, + salt, + payer.address, + ); + const vaultPda = await deriveVaultPda(channelPda, mint); + + // Open. + await sendTx( + [ + buildOpenInstruction({ + programId: PROGRAM_ID, + payer: payer.address, + payee: payee.address, + mint, + channelPda, + payerTokenAccount: payerAta, + vault: vaultPda, + salt, + deposit: depositAmount, + gracePeriodSeconds: GRACE_PERIOD, + authorizedSigner: payer.address, + }), + ], + [payer], + ); + + // Request close. + await sendTx( + [ + buildRequestCloseInstruction({ + programId: PROGRAM_ID, + payer: payer.address, + channelPda, + }), + ], + [payer], + ); + + // TopUp should cancel the close request. + await sendTx( + [ + buildTopUpInstruction({ + programId: PROGRAM_ID, + payer: payer.address, + channelPda, + mint, + vault: vaultPda, + payerTokenAccount: payerAta, + amount: topUpAmount, + }), + ], + [payer], + ); + + const vaultBalance = await getTokenBalance(vaultPda); + expect(vaultBalance).toBe(depositAmount + topUpAmount); + + // After topUp, withdraw should fail because closeRequestedAt was reset. + // We'd need to requestClose again and wait. + }); +}); diff --git a/typescript/packages/mpp/src/__tests__/integration.test.ts b/typescript/packages/mpp/src/__tests__/integration.test.ts index 76a9950fe..8c6620bc7 100644 --- a/typescript/packages/mpp/src/__tests__/integration.test.ts +++ b/typescript/packages/mpp/src/__tests__/integration.test.ts @@ -25,7 +25,7 @@ import { Actions, createEd25519SessionAuthorityInfo } from '@swig-wallet/lib'; import { Receipt } from 'mppx'; import { Mppx as ServerMppx, solana as serverSolana, Store } from '../../src/server/index.js'; import { Mppx as ClientMppx, solana as clientSolana } from '../../src/client/index.js'; -import { BudgetAuthorizer, SwigSessionAuthorizer, UnboundedAuthorizer } from '../../src/index.js'; +import { SwigBudgetAuthorizer, SwigSessionAuthorizer, UnboundedAuthorizer } from '../../src/index.js'; import * as SessionChannelStore from '../../src/session/ChannelStore.js'; const RPC_URL = 'http://localhost:8899'; @@ -109,7 +109,8 @@ async function startSessionHarness(overrides: Partial = serverSolana.session({ recipient: recipientSigner.address, network: 'localnet', - asset: { kind: 'sol', decimals: 9 }, + currency: 'sol', + amount: '10', channelProgram: SESSION_CHANNEL_PROGRAM, store, ...overrides, @@ -160,7 +161,7 @@ function createUnboundedSessionAuthorizer() { return new UnboundedAuthorizer({ signer: clientSigner, buildOpenTx: input => `open:${input.channelId}`, - buildTopupTx: input => `topup:${input.channelId}:${input.additionalAmount}`, + buildTopUpTx: input => `topup:${input.channelId}:${input.additionalAmount}`, }); } @@ -759,15 +760,10 @@ test('e2e: native SOL charge with splits', async () => { test('e2e: session auto-open then update over repeated requests', async () => { const harness = await startSessionHarness({ - pricing: { - unit: 'request', - amountPerUnit: '10', - meter: 'api_calls', - }, - sessionDefaults: { - suggestedDeposit: '1000', - ttlSeconds: 60, - }, + amount: '10', + unitType: 'request', + suggestedDeposit: '1000', + ttlSeconds: 60, }); try { @@ -802,8 +798,7 @@ test('e2e: session auto-open then update over repeated requests', async () => { expect(channel).toBeTruthy(); expect(channel!.status).toBe('open'); expect(channel!.escrowedAmount).toBe('1000'); - expect(channel!.lastAuthorizedAmount).toBe('10'); - expect(channel!.lastSequence).toBe(1); + expect(channel!.acceptedCumulative).toBe('10'); expect(events).toContain('challenge'); expect(events).toContain('opening'); @@ -817,15 +812,10 @@ test('e2e: session auto-open then update over repeated requests', async () => { test('e2e: session autoTopup returns 204 management response, then resumes updates', async () => { const harness = await startSessionHarness({ - pricing: { - unit: 'request', - amountPerUnit: '70', - meter: 'api_calls', - }, - sessionDefaults: { - suggestedDeposit: '100', - ttlSeconds: 60, - }, + amount: '70', + unitType: 'request', + suggestedDeposit: '100', + ttlSeconds: 60, }); try { @@ -853,8 +843,7 @@ test('e2e: session autoTopup returns 204 management response, then resumes updat const channelAfterTopup = await getSessionChannel(harness.store, channelId); expect(channelAfterTopup).toBeTruthy(); expect(channelAfterTopup!.escrowedAmount).toBe('200'); - expect(channelAfterTopup!.lastAuthorizedAmount).toBe('70'); - expect(channelAfterTopup!.lastSequence).toBe(1); + expect(channelAfterTopup!.acceptedCumulative).toBe('70'); const postTopupUpdateResponse = await mppx.fetch(endpoint); expect(postTopupUpdateResponse.status).toBe(200); @@ -862,8 +851,7 @@ test('e2e: session autoTopup returns 204 management response, then resumes updat const channelAfterUpdate = await getSessionChannel(harness.store, channelId); expect(channelAfterUpdate).toBeTruthy(); - expect(channelAfterUpdate!.lastAuthorizedAmount).toBe('140'); - expect(channelAfterUpdate!.lastSequence).toBe(2); + expect(channelAfterUpdate!.acceptedCumulative).toBe('140'); } finally { await harness.close(); } @@ -871,15 +859,10 @@ test('e2e: session autoTopup returns 204 management response, then resumes updat test('e2e: session can auto-close when limit is hit and autoTopup is disabled', async () => { const harness = await startSessionHarness({ - pricing: { - unit: 'request', - amountPerUnit: '10', - meter: 'api_calls', - }, - sessionDefaults: { - suggestedDeposit: '10', - ttlSeconds: 60, - }, + amount: '10', + unitType: 'request', + suggestedDeposit: '10', + ttlSeconds: 60, }); try { @@ -919,15 +902,10 @@ test('e2e: session can auto-close when limit is hit and autoTopup is disabled', test('e2e: session close action returns 204 and next request opens a new channel', async () => { const harness = await startSessionHarness({ - pricing: { - unit: 'request', - amountPerUnit: '25', - meter: 'api_calls', - }, - sessionDefaults: { - suggestedDeposit: '500', - ttlSeconds: 60, - }, + amount: '25', + unitType: 'request', + suggestedDeposit: '500', + ttlSeconds: 60, }); try { @@ -965,7 +943,7 @@ test('e2e: session close action returns 204 and next request opens a new channel const reopenedChannel = await getSessionChannel(harness.store, reopenedReceipt.reference); expect(reopenedChannel).toBeTruthy(); expect(reopenedChannel!.status).toBe('open'); - expect(reopenedChannel!.lastSequence).toBe(0); + expect(reopenedChannel!.acceptedCumulative).toBe('0'); } finally { await harness.close(); } @@ -981,23 +959,14 @@ test('e2e: session swig_session mode uses on-chain setup and enforces spend limi let verifiedOpenTx: string | null = null; const harness = await startSessionHarness({ - pricing: { - unit: 'request', - amountPerUnit: '10', - meter: 'api_calls', - }, - sessionDefaults: { - suggestedDeposit: '500', - ttlSeconds: 60, - }, - verifier: { - acceptAuthorizationModes: ['swig_session'], - }, - transactionVerifier: { - async verifyOpen(_channelId, openTx) { - verifiedOpenTx = openTx; - const tx = await getConfirmedTransaction(client, openTx); - expect(tx).toBeTruthy(); + amount: '10', + unitType: 'request', + suggestedDeposit: '500', + ttlSeconds: 60, + transactionHandler: { + async handleOpen(_channelId, transaction) { + verifiedOpenTx = transaction; + return 'mock-signature'; }, }, }); @@ -1053,11 +1022,8 @@ test('e2e: session swig_session mode uses on-chain setup and enforces spend limi const channel = await getSessionChannel(harness.store, channelId); expect(channel).toBeTruthy(); - expect(channel!.authorizationMode).toBe('swig_session'); - expect(channel!.authority.wallet).toBe(clientSigner.address); - expect(channel!.authority.delegatedSessionKey).toBe(delegatedSigner!.address); - expect(channel!.lastAuthorizedAmount).toBe('10'); - expect(channel!.lastSequence).toBe(1); + expect(channel!.authorizedSigner).toBe(delegatedSigner!.address); + expect(channel!.acceptedCumulative).toBe('10'); const recipientBalanceBefore = await getBalance(client, recipientSigner.address); @@ -1085,25 +1051,10 @@ test('e2e: session close can include on-chain settlement transaction', async () let verifiedCloseTx: string | null = null; const harness = await startSessionHarness({ - pricing: { - unit: 'request', - amountPerUnit: '10', - meter: 'api_calls', - }, - sessionDefaults: { - suggestedDeposit: '500', - ttlSeconds: 60, - }, - verifier: { - acceptAuthorizationModes: ['swig_session'], - }, - transactionVerifier: { - async verifyClose(_channelId, closeTx) { - verifiedCloseTx = closeTx; - const tx = await getConfirmedTransaction(client, closeTx); - expect(tx).toBeTruthy(); - }, - }, + amount: '10', + unitType: 'request', + suggestedDeposit: '500', + ttlSeconds: 60, }); try { @@ -1131,8 +1082,6 @@ test('e2e: session close can include on-chain settlement transaction', async () }, rpcUrl: RPC_URL, allowedPrograms: [SESSION_CHANNEL_PROGRAM], - buildCloseTx: async ({ finalCumulativeAmount, recipient }) => - await swig.spendFromSwig(BigInt(finalCumulativeAmount), recipient), }); const clientMethod = clientSolana.session({ @@ -1175,29 +1124,14 @@ test('e2e: session regular_budget mode enforces on-chain Swig role limits', asyn let verifiedOpenTx: string | null = null; const harness = await startSessionHarness({ - pricing: { - unit: 'request', - amountPerUnit: '400', - meter: 'api_calls', - }, - sessionDefaults: { - suggestedDeposit: '500', - ttlSeconds: 60, - }, - verifier: { - acceptAuthorizationModes: ['regular_budget'], - }, - transactionVerifier: { - async verifyOpen(_channelId, openTx) { - verifiedOpenTx = openTx; - const tx = await getConfirmedTransaction(client, openTx); - expect(tx).toBeTruthy(); - }, - }, + amount: '400', + unitType: 'request', + suggestedDeposit: '500', + ttlSeconds: 60, }); try { - const authorizer = new BudgetAuthorizer({ + const authorizer = new SwigBudgetAuthorizer({ signer: clientSigner, maxCumulativeAmount: '1000', swig: { @@ -1210,7 +1144,7 @@ test('e2e: session regular_budget mode enforces on-chain Swig role limits', asyn client, destination: recipientSigner.address, }), - buildTopupTx: async () => + buildTopUpTx: async () => await sendMarkerTransfer({ client, destination: recipientSigner.address, @@ -1242,9 +1176,7 @@ test('e2e: session regular_budget mode enforces on-chain Swig role limits', asyn const channel = await getSessionChannel(harness.store, channelId); expect(channel).toBeTruthy(); - expect(channel!.authorizationMode).toBe('regular_budget'); - expect(channel!.lastAuthorizedAmount).toBe('400'); - expect(channel!.lastSequence).toBe(1); + expect(channel!.acceptedCumulative).toBe('400'); } finally { await harness.close(); } @@ -1260,18 +1192,10 @@ test('e2e: session swig_session mode rejects delegated signer not present on-cha }); const harness = await startSessionHarness({ - pricing: { - unit: 'request', - amountPerUnit: '10', - meter: 'api_calls', - }, - sessionDefaults: { - suggestedDeposit: '100', - ttlSeconds: 60, - }, - verifier: { - acceptAuthorizationModes: ['swig_session'], - }, + amount: '10', + unitType: 'request', + suggestedDeposit: '100', + ttlSeconds: 60, }); try { @@ -1321,22 +1245,14 @@ test('e2e: session regular_budget mode rejects unknown configured Swig role', as }); const harness = await startSessionHarness({ - pricing: { - unit: 'request', - amountPerUnit: '10', - meter: 'api_calls', - }, - sessionDefaults: { - suggestedDeposit: '100', - ttlSeconds: 60, - }, - verifier: { - acceptAuthorizationModes: ['regular_budget'], - }, + amount: '10', + unitType: 'request', + suggestedDeposit: '100', + ttlSeconds: 60, }); try { - const authorizer = new BudgetAuthorizer({ + const authorizer = new SwigBudgetAuthorizer({ signer: clientSigner, maxCumulativeAmount: '1000', swig: { @@ -1349,7 +1265,7 @@ test('e2e: session regular_budget mode rejects unknown configured Swig role', as client, destination: recipientSigner.address, }), - buildTopupTx: async () => + buildTopUpTx: async () => await sendMarkerTransfer({ client, destination: recipientSigner.address, diff --git a/typescript/packages/mpp/src/__tests__/session.test.ts b/typescript/packages/mpp/src/__tests__/session.test.ts index ea7801062..8a28a512f 100644 --- a/typescript/packages/mpp/src/__tests__/session.test.ts +++ b/typescript/packages/mpp/src/__tests__/session.test.ts @@ -4,69 +4,52 @@ import { Store } from 'mppx/server'; import { session } from '../server/Session.js'; import { signVoucher } from '../session/Voucher.js'; import * as ChannelStore from '../session/ChannelStore.js'; -import type { AuthorizationMode, ChannelState, SignedSessionVoucher } from '../session/Types.js'; +import type { ChannelState, SignedSessionVoucher } from '../session/Types.js'; const RECIPIENT = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; const CHANNEL_PROGRAM = 'swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB'; -const NETWORK = 'devnet'; +const TOKEN_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; // USDC mint (mock SPL address for tests) type ChallengeRequest = { + amount: string; + currency: string; recipient: string; - network?: string; - asset: { kind: 'sol' | 'spl'; mint?: string; decimals: number; symbol?: string }; - channelProgram: string; - pricing?: { unit: string; amountPerUnit: string; meter: string; minDebit?: string }; - sessionDefaults?: { - suggestedDeposit?: string; - ttlSeconds?: number; - settleInterval?: { kind: string; minIncrement?: string; seconds?: number }; - closeBehavior?: 'server_may_finalize' | 'payer_must_close'; - }; - verifier?: { - acceptAuthorizationModes?: Array<'swig_session' | 'regular_budget' | 'regular_unbounded'>; - maxClockSkewSeconds?: number; + methodDetails: { + channelProgram: string; + network?: string; + decimals?: number; }; + suggestedDeposit?: string; + unitType?: string; }; type OpenCredentialOptions = { channelId: string; - serverNonce?: string; depositAmount?: string; cumulativeAmount?: string; - sequence?: number; - authorizationMode?: AuthorizationMode; challengeId?: string; - challengeRequestOverrides?: Partial; voucher?: SignedSessionVoucher; }; -type UpdateCredentialOptions = { +type VoucherCredentialOptions = { channelId: string; - serverNonce: string; cumulativeAmount: string; - sequence: number; challengeId?: string; - challengeRequestOverrides?: Partial; voucher?: SignedSessionVoucher; }; type CloseCredentialOptions = { channelId: string; - serverNonce: string; - cumulativeAmount: string; - sequence: number; - closeTx?: string; + cumulativeAmount?: string; challengeId?: string; - challengeRequestOverrides?: Partial; voucher?: SignedSessionVoucher; }; -type TopupCredentialOptions = { +type TopUpCredentialOptions = { channelId: string; additionalAmount: string; - topupTx: string; + transaction: string; challengeId?: string; - challengeRequestOverrides?: Partial; }; let store: Store.Store; @@ -81,95 +64,45 @@ afterEach(() => { store = Store.memory(); }); -test('session() throws when asset is spl without mint', () => { +test('session() throws when currency is empty', () => { expect(() => session({ recipient: RECIPIENT, - network: NETWORK, - asset: { kind: 'spl', decimals: 6 }, + currency: '', + amount: '10', channelProgram: CHANNEL_PROGRAM, store, }), - ).toThrow(/asset\.mint is required/); + ).toThrow(/currency is required/); }); -test('request() populates recipient/network/asset/channel metadata', async () => { +test('request() populates recipient/currency/amount/channelProgram metadata', async () => { const method = session({ recipient: RECIPIENT, - network: NETWORK, - asset: { kind: 'sol', decimals: 9, symbol: 'sol' }, + currency: TOKEN_MINT, + amount: '10', channelProgram: CHANNEL_PROGRAM, - pricing: { - unit: 'request', - amountPerUnit: '10', - meter: 'api_calls', - minDebit: '5', - }, - sessionDefaults: { - suggestedDeposit: '1000', - ttlSeconds: 60, - }, - verifier: { - acceptAuthorizationModes: ['regular_unbounded'], - maxClockSkewSeconds: 10, - }, + suggestedDeposit: '1000', + unitType: 'request', store, }); const request = await method.request!({ credential: null, request: { + amount: '', + currency: '', recipient: '', - network: undefined, - asset: { kind: 'sol', decimals: 9 }, - channelProgram: '', + methodDetails: { channelProgram: '' }, }, }); expect(request.recipient).toBe(RECIPIENT); - expect(request.network).toBe(NETWORK); - expect(request.asset).toEqual({ kind: 'sol', decimals: 9, symbol: 'sol' }); - expect(request.channelProgram).toBe(CHANNEL_PROGRAM); - expect(request.pricing).toEqual({ - unit: 'request', - amountPerUnit: '10', - meter: 'api_calls', - minDebit: '5', - }); - expect(request.sessionDefaults).toEqual({ - suggestedDeposit: '1000', - ttlSeconds: 60, - }); - expect(request.verifier).toEqual({ - acceptAuthorizationModes: ['regular_unbounded'], - maxClockSkewSeconds: 10, - }); -}); - -test('request() returns challenge request when credential is present', async () => { - const method = session({ - recipient: RECIPIENT, - network: NETWORK, - asset: { kind: 'sol', decimals: 9 }, - channelProgram: CHANNEL_PROGRAM, - store, - }); - - const challengeRequest = buildChallengeRequest({ - pricing: { unit: 'request', amountPerUnit: '1', meter: 'api' }, - }); - - const request = await method.request!({ - credential: buildCredentialWithChallengeRequest(challengeRequest), - request: { - recipient: '', - network: undefined, - asset: { kind: 'sol', decimals: 9 }, - channelProgram: '', - }, - }); - - expect(request).toEqual(challengeRequest); + expect(request.currency).toBe(TOKEN_MINT); + expect(request.amount).toBe('10'); + expect(request.methodDetails.channelProgram).toBe(CHANNEL_PROGRAM); + expect(request.suggestedDeposit).toBe('1000'); + expect(request.unitType).toBe('request'); }); test('open flow creates channel state and returns success receipt', async () => { @@ -179,7 +112,6 @@ test('open flow creates channel state and returns success receipt', async () => channelId, depositAmount: '1000', cumulativeAmount: '0', - sequence: 0, challengeId: 'challenge-open', }); @@ -195,9 +127,8 @@ test('open flow creates channel state and returns success receipt', async () => expect(channel).toBeTruthy(); expect(channel!.status).toBe('open'); expect(channel!.escrowedAmount).toBe('1000'); - expect(channel!.lastAuthorizedAmount).toBe('0'); - expect(channel!.lastSequence).toBe(0); - expect(channel!.recipient).toBe(RECIPIENT); + expect(channel!.acceptedCumulative).toBe('0'); + expect(channel!.payee).toBe(RECIPIENT); const response = await method.respond!({ credential, @@ -208,105 +139,118 @@ test('open flow creates channel state and returns success receipt', async () => expect(response).toBeUndefined(); }); -test('update flow enforces monotonic cumulative amount and sequence', async () => { - const channelId = `channel-update-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); +test('voucher flow enforces cumulative monotonicity', async () => { + const channelId = `channel-voucher-${crypto.randomUUID()}`; const method = createMethod(); await method.verify({ credential: await buildOpenCredential({ channelId, - serverNonce, depositAmount: '1000', cumulativeAmount: '0', - sequence: 0, }), request: buildChallengeRequest(), }); - const firstUpdate = await method.verify({ - credential: await buildUpdateCredential({ + const firstVoucher = await method.verify({ + credential: await buildVoucherCredential({ channelId, - serverNonce, cumulativeAmount: '300', - sequence: 1, - challengeId: 'challenge-update-1', + challengeId: 'challenge-voucher-1', }), request: buildChallengeRequest(), }); - expect(firstUpdate.status).toBe('success'); - expect(firstUpdate.reference).toBe(channelId); + expect(firstVoucher.status).toBe('success'); const channelAfterFirst = await getChannel(channelId); - expect(channelAfterFirst).toBeTruthy(); - expect(channelAfterFirst!.lastAuthorizedAmount).toBe('300'); - expect(channelAfterFirst!.lastSequence).toBe(1); + expect(channelAfterFirst!.acceptedCumulative).toBe('300'); +}); - const replayCredential = await buildUpdateCredential({ - channelId, - serverNonce, - cumulativeAmount: '350', - sequence: 1, +test('voucher is idempotent for same cumulative amount and rejects lower', async () => { + const channelId = `channel-idempotent-${crypto.randomUUID()}`; + const method = createMethod(); + + await method.verify({ + credential: await buildOpenCredential({ channelId, depositAmount: '1000', cumulativeAmount: '0' }), + request: buildChallengeRequest(), + }); + + await method.verify({ + credential: await buildVoucherCredential({ channelId, cumulativeAmount: '300' }), + request: buildChallengeRequest(), }); + // Same amount: idempotent success, no state change. + const idempotentResult = await method.verify({ + credential: await buildVoucherCredential({ channelId, cumulativeAmount: '300' }), + request: buildChallengeRequest(), + }); + expect(idempotentResult.status).toBe('success'); + const channelAfter = await getChannel(channelId); + expect(channelAfter!.acceptedCumulative).toBe('300'); + + // Lower amount: rejected — cumulative must not decrease. await expect( method.verify({ - credential: replayCredential, + credential: await buildVoucherCredential({ channelId, cumulativeAmount: '200' }), request: buildChallengeRequest(), }), - ).rejects.toThrow(/replay detected/); + ).rejects.toThrow(/cumulative amount must not decrease/); +}); - const nonMonotonicCredential = await buildUpdateCredential({ - channelId, - serverNonce, - cumulativeAmount: '250', - sequence: 2, +test('rejects voucher on channel with pending forced close', async () => { + const channelId = `channel-pending-close-${crypto.randomUUID()}`; + const method = createMethod(); + + await method.verify({ + credential: await buildOpenCredential({ channelId, depositAmount: '1000', cumulativeAmount: '0' }), + request: buildChallengeRequest(), + }); + + // Simulate a pending forced close. + const channelStore = ChannelStore.fromStore(store); + await channelStore.updateChannel(channelId, current => { + if (!current) return null; + return { ...current, closeRequestedAt: Math.floor(Date.now() / 1000) }; }); await expect( method.verify({ - credential: nonMonotonicCredential, + credential: await buildVoucherCredential({ channelId, cumulativeAmount: '100' }), request: buildChallengeRequest(), }), - ).rejects.toThrow(/monotonically non-decreasing/); + ).rejects.toThrow(/pending forced close/); }); -test('topup flow updates escrowed deposit and respond() returns 204', async () => { +test('topUp flow updates escrowed deposit and respond() returns 204', async () => { const channelId = `channel-topup-${crypto.randomUUID()}`; const method = createMethod(); await method.verify({ - credential: await buildOpenCredential({ - channelId, - depositAmount: '1000', - cumulativeAmount: '0', - sequence: 0, - }), + credential: await buildOpenCredential({ channelId, depositAmount: '1000', cumulativeAmount: '0' }), request: buildChallengeRequest(), }); - const topupCredential = buildTopupCredential({ + const topUpCredential = buildTopUpCredential({ channelId, additionalAmount: '250', - topupTx: 'topup-transaction', + transaction: 'dHJhbnNhY3Rpb24tYnl0ZXM=', challengeId: 'challenge-topup', }); const receipt = await method.verify({ - credential: topupCredential, + credential: topUpCredential, request: buildChallengeRequest(), }); expect(receipt.status).toBe('success'); - expect(receipt.reference).toBe(channelId); const channel = await getChannel(channelId); - expect(channel).toBeTruthy(); expect(channel!.escrowedAmount).toBe('1250'); const response = await method.respond!({ - credential: topupCredential, + credential: topUpCredential, request: buildChallengeRequest(), receipt, input: new Request('http://localhost'), @@ -314,37 +258,52 @@ test('topup flow updates escrowed deposit and respond() returns 204', async () = expect(response?.status).toBe(204); }); -test('close flow marks channel as closed and respond() returns 204', async () => { - const channelId = `channel-close-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); +test('topUp resets closeRequestedAt', async () => { + const channelId = `channel-topup-reset-${crypto.randomUUID()}`; const method = createMethod(); await method.verify({ - credential: await buildOpenCredential({ - channelId, - serverNonce, - depositAmount: '1000', - cumulativeAmount: '0', - sequence: 0, - }), + credential: await buildOpenCredential({ channelId, depositAmount: '1000', cumulativeAmount: '0' }), request: buildChallengeRequest(), }); + // Simulate a pending close by manually setting closeRequestedAt. + const channelStore = ChannelStore.fromStore(store); + await channelStore.updateChannel(channelId, current => { + if (!current) return null; + return { ...current, closeRequestedAt: Math.floor(Date.now() / 1000) }; + }); + await method.verify({ - credential: await buildUpdateCredential({ + credential: buildTopUpCredential({ channelId, - serverNonce, - cumulativeAmount: '400', - sequence: 1, + additionalAmount: '500', + transaction: 'dHJhbnNhY3Rpb24tYnl0ZXM=', }), request: buildChallengeRequest(), }); + const channel = await getChannel(channelId); + expect(channel!.closeRequestedAt).toBe(0); +}); + +test('close flow marks channel as closed and respond() returns 204', async () => { + const channelId = `channel-close-${crypto.randomUUID()}`; + const method = createMethod(); + + await method.verify({ + credential: await buildOpenCredential({ channelId, depositAmount: '1000', cumulativeAmount: '0' }), + request: buildChallengeRequest(), + }); + + await method.verify({ + credential: await buildVoucherCredential({ channelId, cumulativeAmount: '400' }), + request: buildChallengeRequest(), + }); + const closeCredential = await buildCloseCredential({ channelId, - serverNonce, cumulativeAmount: '450', - sequence: 2, challengeId: 'challenge-close', }); @@ -354,13 +313,10 @@ test('close flow marks channel as closed and respond() returns 204', async () => }); expect(receipt.status).toBe('success'); - expect(receipt.reference).toBe(channelId); const channel = await getChannel(channelId); - expect(channel).toBeTruthy(); expect(channel!.status).toBe('closed'); - expect(channel!.lastAuthorizedAmount).toBe('450'); - expect(channel!.lastSequence).toBe(2); + expect(channel!.acceptedCumulative).toBe('450'); const response = await method.respond!({ credential: closeCredential, @@ -371,15 +327,37 @@ test('close flow marks channel as closed and respond() returns 204', async () => expect(response?.status).toBe(204); }); +test('close without voucher marks channel closed', async () => { + const channelId = `channel-close-no-voucher-${crypto.randomUUID()}`; + const method = createMethod(); + + await method.verify({ + credential: await buildOpenCredential({ channelId, depositAmount: '1000', cumulativeAmount: '0' }), + request: buildChallengeRequest(), + }); + + const closeCredential = { + payload: { action: 'close' as const, channelId }, + challenge: { id: 'challenge-close', request: buildChallengeRequest() }, + } as any; + + const receipt = await method.verify({ + credential: closeCredential, + request: buildChallengeRequest(), + }); + + expect(receipt.status).toBe('success'); + const channel = await getChannel(channelId); + expect(channel!.status).toBe('closed'); +}); + test('rejects invalid voucher signature', async () => { - const channelId = `channel-invalid-signature-${crypto.randomUUID()}`; + const channelId = `channel-invalid-sig-${crypto.randomUUID()}`; const method = createMethod(); const validVoucher = await buildSignedVoucher({ channelId, cumulativeAmount: '0', - sequence: 0, - serverNonce: crypto.randomUUID(), }); const invalidVoucher: SignedSessionVoucher = { @@ -387,404 +365,239 @@ test('rejects invalid voucher signature', async () => { signature: `${validVoucher.signature}-tampered`, }; - const invalidOpenCredential = await buildOpenCredential({ + const credential = await buildOpenCredential({ channelId, depositAmount: '1000', voucher: invalidVoucher, }); - await expect( - method.verify({ - credential: invalidOpenCredential, - request: buildChallengeRequest(), - }), - ).rejects.toThrow(/Invalid voucher signature/); + await expect(method.verify({ credential, request: buildChallengeRequest() })).rejects.toThrow( + /Invalid voucher signature/, + ); }); -test('accepts swig-session vouchers signed by delegated session key', async () => { - const channelId = `channel-swig-valid-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); - const method = createMethod(); - const delegatedSessionSigner = await generateKeyPairSigner(); - - await method.verify({ - credential: await buildOpenCredential({ - channelId, - serverNonce, - depositAmount: '1000', - cumulativeAmount: '0', - sequence: 0, - authorizationMode: 'swig_session', - voucher: await buildSignedVoucher({ - channelId, - serverNonce, - cumulativeAmount: '0', - sequence: 0, - signer: delegatedSessionSigner, - signatureType: 'swig-session', - }), - }), - request: buildChallengeRequest(), - }); - - const receipt = await method.verify({ - credential: await buildUpdateCredential({ - channelId, - serverNonce, - cumulativeAmount: '250', - sequence: 1, - voucher: await buildSignedVoucher({ - channelId, - serverNonce, - cumulativeAmount: '250', - sequence: 1, - signer: delegatedSessionSigner, - signatureType: 'swig-session', - }), - }), - request: buildChallengeRequest(), - }); - - expect(receipt.status).toBe('success'); - expect(receipt.reference).toBe(channelId); -}); - -test('rejects swig-session voucher signed by wrong signer', async () => { - const channelId = `channel-swig-wrong-signer-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); +test('rejects voucher signed by unauthorized signer', async () => { + const channelId = `channel-rogue-signer-${crypto.randomUUID()}`; const method = createMethod(); - const delegatedSessionSigner = await generateKeyPairSigner(); - const wrongSigner = await generateKeyPairSigner(); await method.verify({ - credential: await buildOpenCredential({ - channelId, - serverNonce, - depositAmount: '1000', - cumulativeAmount: '0', - sequence: 0, - authorizationMode: 'swig_session', - voucher: await buildSignedVoucher({ - channelId, - serverNonce, - cumulativeAmount: '0', - sequence: 0, - signer: delegatedSessionSigner, - signatureType: 'swig-session', - }), - }), + credential: await buildOpenCredential({ channelId, depositAmount: '1000', cumulativeAmount: '0' }), request: buildChallengeRequest(), }); - const wrongSignerCredential = await buildUpdateCredential({ + const rogueSigner = await generateKeyPairSigner(); + const rogueVoucher = await signVoucher(rogueSigner, { channelId, - serverNonce, - cumulativeAmount: '250', - sequence: 1, - voucher: await buildSignedVoucher({ - channelId, - serverNonce, - cumulativeAmount: '250', - sequence: 1, - signer: wrongSigner, - signatureType: 'swig-session', - }), + cumulativeAmount: '200', }); await expect( method.verify({ - credential: wrongSignerCredential, + credential: await buildVoucherCredential({ + channelId, + cumulativeAmount: '200', + voucher: rogueVoucher, + }), request: buildChallengeRequest(), }), - ).rejects.toThrow(/delegated session key/); + ).rejects.toThrow(/does not match authorized signer/); }); -test('open flow supports configurable transactionVerifier callbacks', async () => { - const channelId = `channel-open-verified-${crypto.randomUUID()}`; - const method = createMethod({ - transactionVerifier: { - verifyOpen: async () => { - throw new Error('open transaction rejected by verifier'); - }, - }, - }); +test('rejects update when cumulative amount exceeds deposit', async () => { + const channelId = `channel-exceed-deposit-${crypto.randomUUID()}`; + const method = createMethod(); - const credential = await buildOpenCredential({ - channelId, - depositAmount: '1000', - cumulativeAmount: '0', - sequence: 0, + await method.verify({ + credential: await buildOpenCredential({ channelId, depositAmount: '100', cumulativeAmount: '0' }), + request: buildChallengeRequest(), }); await expect( method.verify({ - credential, + credential: await buildVoucherCredential({ channelId, cumulativeAmount: '101' }), request: buildChallengeRequest(), }), - ).rejects.toThrow(/open transaction rejected by verifier/); -}); - -test('close flow supports configurable transactionVerifier callbacks', async () => { - const channelId = `channel-close-verified-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); - - let observedCloseTx: string | null = null; - let observedFinalCumulative: string | null = null; - - const method = createMethod({ - transactionVerifier: { - verifyClose: async (_channelId, closeTx, finalCumulativeAmount) => { - observedCloseTx = closeTx; - observedFinalCumulative = finalCumulativeAmount; - }, - }, - }); - - await method.verify({ - credential: await buildOpenCredential({ - channelId, - serverNonce, - depositAmount: '1000', - cumulativeAmount: '0', - sequence: 0, - }), - request: buildChallengeRequest(), - }); - - await method.verify({ - credential: await buildUpdateCredential({ - channelId, - serverNonce, - cumulativeAmount: '400', - sequence: 1, - }), - request: buildChallengeRequest(), - }); - - const receipt = await method.verify({ - credential: await buildCloseCredential({ - channelId, - serverNonce, - cumulativeAmount: '450', - sequence: 2, - closeTx: 'close-transaction-signature', - }), - request: buildChallengeRequest(), - }); - - expect(receipt.reference).toBe('close-transaction-signature'); - expect(observedCloseTx).toBe('close-transaction-signature'); - expect(observedFinalCumulative).toBe('450'); + ).rejects.toThrow(/exceeds channel deposit/); }); -test('close flow requires closeTx when verifyClose callback is configured', async () => { - const channelId = `channel-close-missing-tx-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); - - const method = createMethod({ - transactionVerifier: { - verifyClose: async () => undefined, - }, - }); +test('rejects actions after channel is closed', async () => { + const channelId = `channel-closed-${crypto.randomUUID()}`; + const method = createMethod(); await method.verify({ - credential: await buildOpenCredential({ - channelId, - serverNonce, - depositAmount: '1000', - cumulativeAmount: '0', - sequence: 0, - }), + credential: await buildOpenCredential({ channelId, depositAmount: '500', cumulativeAmount: '0' }), request: buildChallengeRequest(), }); await method.verify({ - credential: await buildUpdateCredential({ - channelId, - serverNonce, - cumulativeAmount: '400', - sequence: 1, - }), + credential: await buildCloseCredential({ channelId, cumulativeAmount: '100' }), request: buildChallengeRequest(), }); await expect( method.verify({ - credential: await buildCloseCredential({ - channelId, - serverNonce, - cumulativeAmount: '450', - sequence: 2, - }), + credential: await buildVoucherCredential({ channelId, cumulativeAmount: '150' }), request: buildChallengeRequest(), }), - ).rejects.toThrow(/closeTx is required/); + ).rejects.toThrow(/closed/); }); -test('rejects update when cumulative amount exceeds deposit', async () => { - const channelId = `channel-exceed-deposit-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); +test('request() returns challenge request when credential is present', async () => { const method = createMethod(); - await method.verify({ - credential: await buildOpenCredential({ - channelId, - serverNonce, - depositAmount: '100', - cumulativeAmount: '0', - sequence: 0, - }), - request: buildChallengeRequest(), - }); + const challengeRequest = buildChallengeRequest(); - const exceedsDepositCredential = await buildUpdateCredential({ - channelId, - serverNonce, - cumulativeAmount: '101', - sequence: 1, + const request = await method.request!({ + credential: { challenge: { request: challengeRequest } } as any, + request: { + amount: '', + currency: '', + recipient: '', + methodDetails: { channelProgram: '' }, + }, }); - await expect( - method.verify({ - credential: exceedsDepositCredential, - request: buildChallengeRequest(), - }), - ).rejects.toThrow(/exceeds channel deposit/); + expect(request).toEqual(challengeRequest); }); -test('rejects replay attempts using duplicate sequence', async () => { - const channelId = `channel-replay-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); +test('accepts swig-session vouchers signed by delegated session key', async () => { + const channelId = `channel-swig-valid-${crypto.randomUUID()}`; const method = createMethod(); + const delegatedSessionSigner = await generateKeyPairSigner(); await method.verify({ credential: await buildOpenCredential({ channelId, - serverNonce, depositAmount: '1000', cumulativeAmount: '0', - sequence: 0, + voucher: await buildSignedVoucher({ + channelId, + cumulativeAmount: '0', + signer: delegatedSessionSigner, + signatureType: 'swig-session', + }), }), request: buildChallengeRequest(), }); - await method.verify({ - credential: await buildUpdateCredential({ + const receipt = await method.verify({ + credential: await buildVoucherCredential({ channelId, - serverNonce, - cumulativeAmount: '200', - sequence: 1, + cumulativeAmount: '250', + voucher: await buildSignedVoucher({ + channelId, + cumulativeAmount: '250', + signer: delegatedSessionSigner, + signatureType: 'swig-session', + }), }), request: buildChallengeRequest(), }); - const replayUpdateCredential = await buildUpdateCredential({ - channelId, - serverNonce, - cumulativeAmount: '250', - sequence: 1, - }); - - await expect( - method.verify({ - credential: replayUpdateCredential, - request: buildChallengeRequest(), - }), - ).rejects.toThrow(/replay detected/); + expect(receipt.status).toBe('success'); }); -test('rejects voucher signed by unauthorized signer', async () => { - const channelId = `channel-rogue-signer-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); +test('rejects swig-session voucher signed by wrong signer', async () => { + const channelId = `channel-swig-wrong-signer-${crypto.randomUUID()}`; const method = createMethod(); + const delegatedSessionSigner = await generateKeyPairSigner(); + const wrongSigner = await generateKeyPairSigner(); await method.verify({ credential: await buildOpenCredential({ channelId, - serverNonce, depositAmount: '1000', cumulativeAmount: '0', - sequence: 0, + voucher: await buildSignedVoucher({ + channelId, + cumulativeAmount: '0', + signer: delegatedSessionSigner, + signatureType: 'swig-session', + }), }), request: buildChallengeRequest(), }); - const rogueSigner = await generateKeyPairSigner(); - const rogueVoucher = await signVoucher(rogueSigner, { - channelId, - payer: payerSigner.address, - recipient: RECIPIENT, - cumulativeAmount: '200', - sequence: 1, - meter: 'api_calls', - units: '1', - serverNonce, - chainId: `solana:${NETWORK}`, - channelProgram: CHANNEL_PROGRAM, - }); - - const rogueCredential = await buildUpdateCredential({ - channelId, - serverNonce, - cumulativeAmount: '200', - sequence: 1, - voucher: rogueVoucher, - }); - await expect( method.verify({ - credential: rogueCredential, + credential: await buildVoucherCredential({ + channelId, + cumulativeAmount: '250', + voucher: await buildSignedVoucher({ + channelId, + cumulativeAmount: '250', + signer: wrongSigner, + signatureType: 'swig-session', + }), + }), request: buildChallengeRequest(), }), - ).rejects.toThrow(/does not match channel payer/); + ).rejects.toThrow(/does not match authorized signer/); }); -test('rejects actions after channel is closed', async () => { - const channelId = `channel-closed-${crypto.randomUUID()}`; - const serverNonce = crypto.randomUUID(); - const method = createMethod(); +test('open flow calls transactionHandler.handleOpen with correct args', async () => { + const channelId = `channel-handler-open-${crypto.randomUUID()}`; + let handledChannelId: string | null = null; + let handledTransaction: string | null = null; + let handledDeposit: string | null = null; - await method.verify({ + const method = createMethod({ + transactionHandler: { + async handleOpen(cid, tx, deposit) { + handledChannelId = cid; + handledTransaction = tx; + handledDeposit = deposit; + return 'mock-open-signature'; + }, + }, + }); + + const receipt = await method.verify({ credential: await buildOpenCredential({ channelId, - serverNonce, - depositAmount: '500', + depositAmount: '1000', cumulativeAmount: '0', - sequence: 0, }), request: buildChallengeRequest(), }); - await method.verify({ - credential: await buildCloseCredential({ - channelId, - serverNonce, - cumulativeAmount: '100', - sequence: 1, - }), - request: buildChallengeRequest(), - }); + expect(receipt.status).toBe('success'); + expect(receipt.reference).toBe('mock-open-signature'); + expect(handledChannelId).toBe(channelId); + expect(handledTransaction).toBe('dHJhbnNhY3Rpb24tYnl0ZXM='); + expect(handledDeposit).toBe('1000'); +}); - const updateAfterCloseCredential = await buildUpdateCredential({ - channelId, - serverNonce, - cumulativeAmount: '150', - sequence: 2, +test('open flow rejects when transactionHandler.handleOpen throws', async () => { + const channelId = `channel-handler-reject-${crypto.randomUUID()}`; + + const method = createMethod({ + transactionHandler: { + async handleOpen() { + throw new Error('open transaction rejected by handler'); + }, + }, }); await expect( method.verify({ - credential: updateAfterCloseCredential, + credential: await buildOpenCredential({ + channelId, + depositAmount: '1000', + cumulativeAmount: '0', + }), request: buildChallengeRequest(), }), - ).rejects.toThrow(/closed/); + ).rejects.toThrow(/open transaction rejected by handler/); }); +// ---------- helpers ---------- + function createMethod(overrides: Partial = {}) { return session({ recipient: RECIPIENT, - network: NETWORK, - asset: { kind: 'sol', decimals: 9 }, + currency: TOKEN_MINT, + amount: '10', channelProgram: CHANNEL_PROGRAM, store, ...overrides, @@ -793,23 +606,17 @@ function createMethod(overrides: Partial = {}) { function buildChallengeRequest(overrides: Partial = {}): ChallengeRequest { return { + amount: '10', + currency: TOKEN_MINT, recipient: RECIPIENT, - network: NETWORK, - asset: { kind: 'sol', decimals: 9 }, - channelProgram: CHANNEL_PROGRAM, + methodDetails: { channelProgram: CHANNEL_PROGRAM, network: 'devnet' }, ...overrides, }; } -function buildCredentialWithChallengeRequest(request: ChallengeRequest): any { - return { challenge: { request } }; -} - async function buildSignedVoucher(input: { channelId: string; - serverNonce: string; cumulativeAmount: string; - sequence: number; signer?: Awaited>; signatureType?: SignedSessionVoucher['signatureType']; }): Promise { @@ -817,15 +624,7 @@ async function buildSignedVoucher(input: { const voucher = await signVoucher(signer, { channelId: input.channelId, - payer: payerSigner.address, - recipient: RECIPIENT, cumulativeAmount: input.cumulativeAmount, - sequence: input.sequence, - meter: 'api_calls', - units: '1', - serverNonce: input.serverNonce, - chainId: `solana:${NETWORK}`, - channelProgram: CHANNEL_PROGRAM, }); if (input.signatureType === undefined || input.signatureType === voucher.signatureType) { @@ -839,14 +638,11 @@ async function buildSignedVoucher(input: { } async function buildOpenCredential(options: OpenCredentialOptions): Promise { - const serverNonce = options.serverNonce ?? crypto.randomUUID(); const voucher = options.voucher ?? (await buildSignedVoucher({ channelId: options.channelId, - serverNonce, cumulativeAmount: options.cumulativeAmount ?? '0', - sequence: options.sequence ?? 0, })); return { @@ -854,76 +650,71 @@ async function buildOpenCredential(options: OpenCredentialOptions): Promise action: 'open', channelId: options.channelId, payer: payerSigner.address, - authorizationMode: options.authorizationMode ?? 'regular_unbounded', depositAmount: options.depositAmount ?? '1000', - openTx: 'open-transaction', + transaction: 'dHJhbnNhY3Rpb24tYnl0ZXM=', voucher, }, challenge: { id: options.challengeId ?? 'challenge-open', - request: buildChallengeRequest(options.challengeRequestOverrides), + request: buildChallengeRequest(), }, }; } -async function buildUpdateCredential(options: UpdateCredentialOptions): Promise { +async function buildVoucherCredential(options: VoucherCredentialOptions): Promise { const voucher = options.voucher ?? (await buildSignedVoucher({ channelId: options.channelId, - serverNonce: options.serverNonce, cumulativeAmount: options.cumulativeAmount, - sequence: options.sequence, })); return { payload: { - action: 'update', + action: 'voucher', channelId: options.channelId, voucher, }, challenge: { - id: options.challengeId ?? 'challenge-update', - request: buildChallengeRequest(options.challengeRequestOverrides), + id: options.challengeId ?? 'challenge-voucher', + request: buildChallengeRequest(), }, }; } async function buildCloseCredential(options: CloseCredentialOptions): Promise { - const voucher = - options.voucher ?? - (await buildSignedVoucher({ - channelId: options.channelId, - serverNonce: options.serverNonce, - cumulativeAmount: options.cumulativeAmount, - sequence: options.sequence, - })); + const voucher = options.cumulativeAmount + ? (options.voucher ?? + (await buildSignedVoucher({ + channelId: options.channelId, + cumulativeAmount: options.cumulativeAmount, + }))) + : options.voucher; return { payload: { action: 'close', channelId: options.channelId, - ...(options.closeTx ? { closeTx: options.closeTx } : {}), - voucher, + ...(voucher ? { voucher } : {}), }, challenge: { id: options.challengeId ?? 'challenge-close', - request: buildChallengeRequest(options.challengeRequestOverrides), + request: buildChallengeRequest(), }, }; } -function buildTopupCredential(options: TopupCredentialOptions): any { +function buildTopUpCredential(options: TopUpCredentialOptions): any { return { payload: { - action: 'topup', + action: 'topUp', channelId: options.channelId, additionalAmount: options.additionalAmount, - topupTx: options.topupTx, + transaction: options.transaction, }, challenge: { id: options.challengeId ?? 'challenge-topup', - request: buildChallengeRequest(options.challengeRequestOverrides), + request: buildChallengeRequest(), }, }; } diff --git a/typescript/packages/mpp/src/anchor/MppChannelClient.ts b/typescript/packages/mpp/src/anchor/MppChannelClient.ts new file mode 100644 index 000000000..f01849a8a --- /dev/null +++ b/typescript/packages/mpp/src/anchor/MppChannelClient.ts @@ -0,0 +1,328 @@ +import { type Address, address, getProgramDerivedAddress, type Instruction } from '@solana/kit'; +import { findAssociatedTokenPda } from '@solana-program/token'; + +import { ASSOCIATED_TOKEN_PROGRAM, SYSTEM_PROGRAM, TOKEN_PROGRAM } from '../constants.js'; +import { createEd25519VerifyInstruction } from '../utils/ed25519.js'; + +const CHANNEL_SEED = 'mpp-channel'; +const INSTRUCTIONS_SYSVAR = address('Sysvar1nstructions1111111111111111111111111'); + +// Anchor discriminators: sha256("global:")[0..8] +export const DISCRIMINATOR_OPEN = new Uint8Array([228, 220, 155, 71, 199, 189, 60, 45]); +export const DISCRIMINATOR_TOP_UP = new Uint8Array([236, 225, 96, 9, 60, 106, 77, 208]); +const DISCRIMINATOR_SETTLE = new Uint8Array([175, 42, 185, 87, 144, 131, 102, 212]); +const DISCRIMINATOR_CLOSE = new Uint8Array([98, 165, 201, 177, 108, 65, 206, 96]); +const DISCRIMINATOR_REQUEST_CLOSE = new Uint8Array([82, 168, 167, 86, 14, 15, 199, 180]); +const DISCRIMINATOR_WITHDRAW = new Uint8Array([183, 18, 70, 156, 148, 109, 161, 34]); + +function addressToBytes(addr: Address): Uint8Array { + const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + const bytes = new Uint8Array(32); + let num = 0n; + for (const char of addr) { + const index = alphabet.indexOf(char); + if (index === -1) throw new Error(`Invalid base58 character: ${char}`); + num = num * 58n + BigInt(index); + } + for (let i = 31; i >= 0; i--) { + bytes[i] = Number(num & 0xffn); + num >>= 8n; + } + return bytes; +} + +function writeU64LE(buf: Uint8Array, offset: number, value: bigint): void { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + view.setBigUint64(offset, value, true); +} + +function writeU32LE(buf: Uint8Array, offset: number, value: number): void { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + view.setUint32(offset, value, true); +} + +// ---- PDA derivation ---- + +export async function deriveChannelPda( + programId: Address, + payer: Address, + payee: Address, + token: Address, + salt: bigint, + authorizedSigner: Address, +): Promise { + const saltBytes = new Uint8Array(8); + writeU64LE(saltBytes, 0, salt); + + return await getProgramDerivedAddress({ + programAddress: programId, + seeds: [ + CHANNEL_SEED, + addressToBytes(payer), + addressToBytes(payee), + addressToBytes(token), + saltBytes, + addressToBytes(authorizedSigner), + ], + }); +} + +export async function deriveVaultPda( + channelPda: Address, + mint: Address, + tokenProgram: Address = address(TOKEN_PROGRAM), +): Promise
{ + const [vaultAddress] = await findAssociatedTokenPda({ mint, owner: channelPda, tokenProgram }); + return vaultAddress; +} + +// ---- Instruction builders ---- + +export function buildOpenInstruction(params: { + authorizedSigner: Address; + channelPda: Address; + deposit: bigint; + gracePeriodSeconds: bigint; + mint: Address; + payee: Address; + payer: Address; + payerTokenAccount: Address; + programId: Address; + salt: bigint; + tokenProgram?: Address; + vault: Address; +}): Instruction { + const tokenProgramAddr = params.tokenProgram ?? address(TOKEN_PROGRAM); + const data = new Uint8Array(8 + 8 + 8 + 8 + 32); + data.set(DISCRIMINATOR_OPEN, 0); + writeU64LE(data, 8, params.salt); + writeU64LE(data, 16, params.deposit); + writeU64LE(data, 24, params.gracePeriodSeconds); + data.set(addressToBytes(params.authorizedSigner), 32); + + return { + accounts: [ + { address: params.payer, role: 3 }, // signer + writable + { address: params.payee, role: 0 }, // readonly + { address: params.mint, role: 0 }, // readonly + { address: params.channelPda, role: 1 }, // writable + { address: params.payerTokenAccount, role: 1 }, // writable + { address: params.vault, role: 1 }, // writable + { address: tokenProgramAddr, role: 0 }, // readonly + { address: address(ASSOCIATED_TOKEN_PROGRAM), role: 0 }, + { address: address(SYSTEM_PROGRAM), role: 0 }, + ], + data, + programAddress: params.programId, + }; +} + +export function buildSettleInstruction(params: { + channelPda: Address; + cumulativeAmount: bigint; + ed25519InstructionIndex: number; + mint: Address; + payee: Address; + payeeTokenAccount: Address; + programId: Address; + tokenProgram?: Address; + vault: Address; + voucherMessage: Uint8Array; +}): Instruction { + const tokenProgramAddr = params.tokenProgram ?? address(TOKEN_PROGRAM); + const messageLen = params.voucherMessage.length; + const data = new Uint8Array(8 + 8 + 4 + messageLen + 1); + data.set(DISCRIMINATOR_SETTLE, 0); + writeU64LE(data, 8, params.cumulativeAmount); + writeU32LE(data, 16, messageLen); + data.set(params.voucherMessage, 20); + data[20 + messageLen] = params.ed25519InstructionIndex; + + return { + accounts: [ + { address: params.payee, role: 2 }, // signer + { address: params.channelPda, role: 1 }, // writable + { address: params.mint, role: 0 }, // readonly + { address: params.vault, role: 1 }, // writable + { address: params.payeeTokenAccount, role: 1 }, // writable + { address: INSTRUCTIONS_SYSVAR, role: 0 }, // readonly + { address: tokenProgramAddr, role: 0 }, // readonly + ], + data, + programAddress: params.programId, + }; +} + +export function buildCloseInstruction(params: { + channelPda: Address; + cumulativeAmount: bigint; + ed25519InstructionIndex: number; + mint: Address; + payee: Address; + payeeTokenAccount: Address; + payerTokenAccount: Address; + programId: Address; + tokenProgram?: Address; + vault: Address; + voucherMessage: Uint8Array; +}): Instruction { + const tokenProgramAddr = params.tokenProgram ?? address(TOKEN_PROGRAM); + const messageLen = params.voucherMessage.length; + const data = new Uint8Array(8 + 8 + 4 + messageLen + 1); + data.set(DISCRIMINATOR_CLOSE, 0); + writeU64LE(data, 8, params.cumulativeAmount); + writeU32LE(data, 16, messageLen); + data.set(params.voucherMessage, 20); + data[20 + messageLen] = params.ed25519InstructionIndex; + + return { + accounts: [ + { address: params.payee, role: 2 }, // signer + { address: params.channelPda, role: 1 }, // writable + { address: params.mint, role: 0 }, // readonly + { address: params.vault, role: 1 }, // writable + { address: params.payeeTokenAccount, role: 1 }, // writable + { address: params.payerTokenAccount, role: 1 }, // writable + { address: INSTRUCTIONS_SYSVAR, role: 0 }, // readonly + { address: tokenProgramAddr, role: 0 }, // readonly + ], + data, + programAddress: params.programId, + }; +} + +export function buildTopUpInstruction(params: { + amount: bigint; + channelPda: Address; + mint: Address; + payer: Address; + payerTokenAccount: Address; + programId: Address; + tokenProgram?: Address; + vault: Address; +}): Instruction { + const tokenProgramAddr = params.tokenProgram ?? address(TOKEN_PROGRAM); + const data = new Uint8Array(8 + 8); + data.set(DISCRIMINATOR_TOP_UP, 0); + writeU64LE(data, 8, params.amount); + + return { + accounts: [ + { address: params.payer, role: 2 }, // signer + { address: params.channelPda, role: 1 }, // writable + { address: params.mint, role: 0 }, // readonly + { address: params.vault, role: 1 }, // writable + { address: params.payerTokenAccount, role: 1 }, // writable + { address: tokenProgramAddr, role: 0 }, // readonly + ], + data, + programAddress: params.programId, + }; +} + +export function buildRequestCloseInstruction(params: { + channelPda: Address; + payer: Address; + programId: Address; +}): Instruction { + const data = new Uint8Array(8); + data.set(DISCRIMINATOR_REQUEST_CLOSE, 0); + + return { + accounts: [ + { address: params.payer, role: 2 }, // signer + { address: params.channelPda, role: 1 }, // writable + ], + data, + programAddress: params.programId, + }; +} + +export function buildWithdrawInstruction(params: { + channelPda: Address; + mint: Address; + payer: Address; + payerTokenAccount: Address; + programId: Address; + tokenProgram?: Address; + vault: Address; +}): Instruction { + const tokenProgramAddr = params.tokenProgram ?? address(TOKEN_PROGRAM); + const data = new Uint8Array(8); + data.set(DISCRIMINATOR_WITHDRAW, 0); + + return { + accounts: [ + { address: params.payer, role: 2 }, // signer + { address: params.channelPda, role: 1 }, // writable + { address: params.mint, role: 0 }, // readonly + { address: params.vault, role: 1 }, // writable + { address: params.payerTokenAccount, role: 1 }, // writable + { address: tokenProgramAddr, role: 0 }, // readonly + ], + data, + programAddress: params.programId, + }; +} + +// ---- Settle/Close transaction helpers ---- + +/** + * Build the instructions for a settle transaction. + * + * Returns two instructions: an Ed25519 verify instruction (index 0) + * followed by the settle instruction (index 1) that references it. + */ +export function buildSettleInstructions(params: { + channelPda: Address; + cumulativeAmount: bigint; + mint: Address; + payee: Address; + payeeTokenAccount: Address; + programId: Address; + signature: Uint8Array; + signerPublicKey: Uint8Array; + tokenProgram?: Address; + vault: Address; + voucherMessage: Uint8Array; +}): Instruction[] { + const ed25519Ix = createEd25519VerifyInstruction(params.signerPublicKey, params.signature, params.voucherMessage); + + const settleIx = buildSettleInstruction({ + ...params, + ed25519InstructionIndex: 0, + }); + + return [ed25519Ix, settleIx]; +} + +/** + * Build the instructions for a close transaction with a final voucher. + * + * Returns two instructions: Ed25519 verify (index 0) + close (index 1). + * For cooperative close without a voucher, use buildCloseInstruction directly + * with an empty voucherMessage and no Ed25519 instruction. + */ +export function buildCloseInstructions(params: { + channelPda: Address; + cumulativeAmount: bigint; + mint: Address; + payee: Address; + payeeTokenAccount: Address; + payerTokenAccount: Address; + programId: Address; + signature: Uint8Array; + signerPublicKey: Uint8Array; + tokenProgram?: Address; + vault: Address; + voucherMessage: Uint8Array; +}): Instruction[] { + const ed25519Ix = createEd25519VerifyInstruction(params.signerPublicKey, params.signature, params.voucherMessage); + + const closeIx = buildCloseInstruction({ + ...params, + ed25519InstructionIndex: 0, + }); + + return [ed25519Ix, closeIx]; +} diff --git a/typescript/packages/mpp/src/anchor/TransactionHandler.ts b/typescript/packages/mpp/src/anchor/TransactionHandler.ts new file mode 100644 index 000000000..0437c5052 --- /dev/null +++ b/typescript/packages/mpp/src/anchor/TransactionHandler.ts @@ -0,0 +1,335 @@ +import { getBase58Encoder } from '@solana/kit'; +import type { TransactionPartialSigner } from '@solana/kit'; + +import { coSignBase64Transaction } from '../utils/transactions.js'; +import { DISCRIMINATOR_OPEN, DISCRIMINATOR_TOP_UP } from './MppChannelClient.js'; + +/** + * Create a TransactionHandler for session open and topUp operations. + * + * Follows the charge intent's pull-mode pattern: + * 1. Optionally co-sign as fee payer + * 2. Simulate to catch errors before broadcast + * 3. Broadcast via sendTransaction + * 4. Poll getSignatureStatuses for confirmation + * 5. Semantically verify the confirmed transaction: discriminator, amounts, and key accounts + * 6. Return the confirmed signature + * + * The semantic verification (step 5) checks that the confirmed transaction: + * - Invokes the expected channel program + * - Contains the correct Anchor discriminator (open or top_up, not an arbitrary instruction) + * - Carries the correct deposit/amount as encoded in the Borsh instruction data + * - Uses the expected payee and token mint (open only) + * + * Without these checks, a client could submit a transaction that merely touches + * the program with different arguments, causing the server to track channel state + * that diverges from on-chain reality. + */ +export function createSessionTransactionHandler(params: { + channelProgram: string; + /** Base58 payee public key. Used to verify the payee account in open transactions. */ + recipient: string; + /** SPL token mint address, or 'sol' for native SOL (not yet supported on-chain). */ + currency: string; + rpcUrl: string; + signer?: TransactionPartialSigner; +}): { + handleOpen: (channelId: string, transaction: string, deposit: string) => Promise; + handleTopUp: (channelId: string, transaction: string, amount: string) => Promise; +} { + const { channelProgram, recipient, currency, rpcUrl, signer } = params; + + async function processTransaction(clientTxBase64: string): Promise { + let txToSend = clientTxBase64; + + if (signer) { + txToSend = await coSignBase64Transaction(signer, clientTxBase64); + } + + await simulateTransaction(rpcUrl, txToSend); + const signature = await broadcastTransaction(rpcUrl, txToSend); + await waitForConfirmation(rpcUrl, signature); + + const tx = await fetchTransaction(rpcUrl, signature); + if (!tx) { + throw new Error(`Transaction not found after confirmation: ${signature}`); + } + if (tx.meta?.err) { + throw new Error(`Transaction failed on-chain: ${JSON.stringify(tx.meta.err)}`); + } + + return signature; + } + + return { + async handleOpen(channelId, transaction, deposit) { + const signature = await processTransaction(transaction); + + const tx = await fetchTransaction(rpcUrl, signature); + if (!tx) throw new Error(`Open transaction not found: ${signature}`); + + verifyOpenInstruction( + tx.transaction.message.instructions, + channelProgram, + recipient, + currency, + BigInt(deposit), + channelId, + ); + + return signature; + }, + async handleTopUp(channelId, transaction, amount) { + const signature = await processTransaction(transaction); + + const tx = await fetchTransaction(rpcUrl, signature); + if (!tx) throw new Error(`TopUp transaction not found: ${signature}`); + + verifyTopUpInstruction(tx.transaction.message.instructions, channelProgram, BigInt(amount), channelId); + + return signature; + }, + }; +} + +// ---- Semantic instruction verification ---- + +/** + * Verify that the transaction contains a valid mpp-channel `open` instruction. + * + * Checks: + * - The channel program instruction has the `open` discriminator (not settle, topUp, etc.) + * - The deposit encoded in instruction data matches the credential's depositAmount + * - accounts[1] (payee) matches the server's configured recipient + * - accounts[2] (mint) matches the server's configured currency, if it is an SPL mint address + * - accounts[3] (channel PDA) matches the session channelId + * + * Account indices come from open.rs: [payer, payee, mint, channelPda, payerTokenAccount, vault, ...]. + * Instruction data layout (Borsh): [0..8] discriminator, [8..16] salt (u64 LE), [16..24] deposit (u64 LE). + */ +function verifyOpenInstruction( + instructions: RawInstruction[], + channelProgram: string, + expectedPayee: string, + currency: string, + expectedDeposit: bigint, + expectedChannelId: string, +): void { + const ix = findChannelInstruction(instructions, channelProgram); + + const data = decodeInstructionData(ix.data, 'open'); + + if (!matchesDiscriminator(data, DISCRIMINATOR_OPEN)) { + throw new Error( + 'Open transaction does not contain an open instruction (discriminator mismatch — possible replay with wrong action)', + ); + } + + // deposit is a u64 LE at byte offset 16 (after 8-byte discriminator + 8-byte salt) + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const onChainDeposit = view.getBigUint64(16, true); + if (onChainDeposit !== expectedDeposit) { + throw new Error( + `Open: deposit amount mismatch (on-chain=${onChainDeposit}, payload=${expectedDeposit})`, + ); + } + + const accounts = ix.accounts ?? []; + + if (accounts[1] !== expectedPayee) { + throw new Error( + `Open: payee mismatch (on-chain=${accounts[1]}, expected=${expectedPayee})`, + ); + } + + // Only verify mint when currency is an SPL mint address (not 'sol'). + if (currency !== 'sol' && accounts[2] !== currency) { + throw new Error( + `Open: token mint mismatch (on-chain=${accounts[2]}, expected=${currency})`, + ); + } + + if (accounts[3] !== expectedChannelId) { + throw new Error( + `Open: channel account mismatch (on-chain=${accounts[3]}, expected=${expectedChannelId})`, + ); + } +} + +/** + * Verify that the transaction contains a valid mpp-channel `top_up` instruction. + * + * Checks: + * - The channel program instruction has the `top_up` discriminator + * - The amount encoded in instruction data matches the credential's additionalAmount + * - accounts[1] (channel PDA) matches the session channelId + * + * Instruction data layout (Borsh): [0..8] discriminator, [8..16] amount (u64 LE). + * Account indices come from top_up.rs: [payer, channel, token, vault, payerTokenAccount, tokenProgram]. + */ +function verifyTopUpInstruction( + instructions: RawInstruction[], + channelProgram: string, + expectedAmount: bigint, + expectedChannelId: string, +): void { + const ix = findChannelInstruction(instructions, channelProgram); + + const data = decodeInstructionData(ix.data, 'topUp'); + + if (!matchesDiscriminator(data, DISCRIMINATOR_TOP_UP)) { + throw new Error( + 'TopUp transaction does not contain a top_up instruction (discriminator mismatch)', + ); + } + + // amount is a u64 LE at byte offset 8 (after 8-byte discriminator) + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const onChainAmount = view.getBigUint64(8, true); + if (onChainAmount !== expectedAmount) { + throw new Error( + `TopUp: amount mismatch (on-chain=${onChainAmount}, payload=${expectedAmount})`, + ); + } + + const accounts = ix.accounts ?? []; + + if (accounts[1] !== expectedChannelId) { + throw new Error( + `TopUp: channel account mismatch (on-chain=${accounts[1]}, expected=${expectedChannelId})`, + ); + } +} + +function findChannelInstruction(instructions: RawInstruction[], channelProgram: string): RawInstruction { + const matching = instructions.filter(i => i.programId === channelProgram); + if (matching.length === 0) { + throw new Error(`Transaction does not invoke the expected channel program ${channelProgram}`); + } + if (matching.length > 1) { + throw new Error( + `Transaction contains ${matching.length} channel-program instructions; expected exactly 1`, + ); + } + return matching[0]; +} + +function decodeInstructionData(data: string | undefined, label: string): Uint8Array { + if (!data) { + throw new Error(`${label} instruction is missing data`); + } + return new Uint8Array(getBase58Encoder().encode(data)); +} + +function matchesDiscriminator(data: Uint8Array, discriminator: Uint8Array): boolean { + if (data.length < 8) return false; + for (let i = 0; i < 8; i++) { + if (data[i] !== discriminator[i]) return false; + } + return true; +} + +// ---- RPC helpers ---- + +type RawInstruction = { + /** Present for non-parsed programs: ordered list of account addresses. */ + accounts?: string[]; + /** Present for non-parsed programs: base58-encoded raw instruction data. */ + data?: string; + programId?: string; +}; + +type ParsedTransaction = { + meta: { err: unknown } | null; + transaction: { + message: { + instructions: RawInstruction[]; + }; + }; +}; + +async function fetchTransaction(rpcUrl: string, signature: string): Promise { + const response = await fetch(rpcUrl, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'getTransaction', + params: [signature, { commitment: 'confirmed', encoding: 'jsonParsed', maxSupportedTransactionVersion: 0 }], + }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const data = (await response.json()) as { error?: { message: string }; result?: ParsedTransaction | null }; + if (data.error) throw new Error(`RPC error: ${data.error.message}`); + return data.result ?? null; +} + +async function simulateTransaction(rpcUrl: string, base64Tx: string): Promise { + const response = await fetch(rpcUrl, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'simulateTransaction', + params: [base64Tx, { commitment: 'confirmed', encoding: 'base64' }], + }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const data = (await response.json()) as { + error?: { message: string }; + result?: { value?: { err: unknown; logs?: string[] } }; + }; + if (data.error) throw new Error(`RPC error: ${data.error.message}`); + const simErr = data.result?.value?.err; + if (simErr) { + const logs = data.result?.value?.logs ?? []; + throw new Error(`Transaction simulation failed: ${JSON.stringify(simErr)}. Logs: ${logs.join('; ')}`); + } +} + +async function broadcastTransaction(rpcUrl: string, base64Tx: string): Promise { + const response = await fetch(rpcUrl, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'sendTransaction', + params: [base64Tx, { encoding: 'base64', skipPreflight: false }], + }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const data = (await response.json()) as { error?: { message: string }; result?: string }; + if (data.error) throw new Error(`RPC error: ${data.error.message}`); + if (!data.result) throw new Error('No signature returned from sendTransaction'); + return data.result; +} + +async function waitForConfirmation(rpcUrl: string, signature: string, timeoutMs = 30_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const response = await fetch(rpcUrl, { + body: JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'getSignatureStatuses', + params: [[signature]], + }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const data = (await response.json()) as { + result?: { value: ({ confirmationStatus: string; err: unknown } | null)[] }; + }; + const status = data.result?.value?.[0]; + if (status) { + if (status.err) { + throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`); + } + if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') { + return; + } + } + await new Promise(r => setTimeout(r, 2_000)); + } + throw new Error('Transaction confirmation timeout'); +} diff --git a/typescript/packages/mpp/src/anchor/index.ts b/typescript/packages/mpp/src/anchor/index.ts new file mode 100644 index 000000000..dd4f4fe5d --- /dev/null +++ b/typescript/packages/mpp/src/anchor/index.ts @@ -0,0 +1,14 @@ +export { + buildCloseInstruction, + buildCloseInstructions, + buildOpenInstruction, + buildRequestCloseInstruction, + buildSettleInstruction, + buildSettleInstructions, + buildTopUpInstruction, + buildWithdrawInstruction, + deriveChannelPda, + deriveVaultPda, +} from './MppChannelClient.js'; + +export { createSessionTransactionHandler } from './TransactionHandler.js'; diff --git a/typescript/packages/mpp/src/client/Session.ts b/typescript/packages/mpp/src/client/Session.ts index 23f6588bc..cf67e177b 100644 --- a/typescript/packages/mpp/src/client/Session.ts +++ b/typescript/packages/mpp/src/client/Session.ts @@ -4,61 +4,45 @@ import { type Challenge, Credential, Method, z } from 'mppx'; import * as Methods from '../Methods.js'; import type { SessionAuthorizer, SessionCredentialPayload } from '../session/Types.js'; -type SessionAsset = { - decimals: number; - kind: 'sol' | 'spl'; - mint?: string; - symbol?: string; -}; - -type SessionPricing = { - amountPerUnit: string; - meter: string; - minDebit?: string; - unit: string; -}; - type SessionChallengeRequest = { - asset: SessionAsset; - channelProgram: string; - network?: string; - pricing?: SessionPricing; - recipient: string; - sessionDefaults?: { - closeBehavior?: 'payer_must_close' | 'server_may_finalize'; - settleInterval?: { kind: string; minIncrement?: string; seconds?: number }; - suggestedDeposit?: string; - ttlSeconds?: number; + amount: string; + currency: string; + methodDetails: { + channelId?: string; + channelProgram: string; + decimals?: number; + feePayer?: boolean; + feePayerKey?: string; + minVoucherDelta?: string; + network?: string; }; + recipient: string; + suggestedDeposit?: string; + unitType?: string; }; type ActiveChannel = { - asset: SessionAsset; channelId: string; channelProgram: string; cumulativeAmount: bigint; + currency: string; depositAmount: bigint; network: string; recipient: string; - sequence: number; - serverNonce: string; }; export const sessionContextSchema = z.object({ - action: z.optional(z.enum(['open', 'update', 'topup', 'close'])), + action: z.optional(z.enum(['open', 'voucher', 'topUp', 'close'])), additionalAmount: z.optional(z.string()), channelId: z.optional(z.string()), cumulativeAmount: z.optional(z.string()), depositAmount: z.optional(z.string()), - openTx: z.optional(z.string()), - sequence: z.optional(z.number()), - topupTx: z.optional(z.string()), }); export type SessionContext = z.infer; export function session(parameters: session.Parameters) { - const { signer, authorizer, autoOpen = true, autoTopup = false, settleOnLimitHit = false, onProgress } = parameters; + const { authorizer, autoOpen = true, autoTopup = false, settleOnLimitHit = false, onProgress } = parameters; let activeChannel: ActiveChannel | null = null; @@ -68,34 +52,33 @@ export function session(parameters: session.Parameters) { async createCredential({ challenge, context }) { const request = challenge.request as SessionChallengeRequest; const recipient = request.recipient; - const network = request.network ?? 'mainnet-beta'; - const asset = request.asset; - const channelProgram = request.channelProgram; - const pricing = request.pricing; - const sessionDefaults = request.sessionDefaults; + const network = request.methodDetails.network ?? 'mainnet-beta'; + const channelProgram = request.methodDetails.channelProgram; + const currency = request.currency; + const amount = request.amount; + const feePayerKey = request.methodDetails.feePayerKey; onProgress?.({ - asset, + currency, network, recipient, type: 'challenge', }); - if (context?.action === 'topup') { - return await handleTopupAction(challenge, context, authorizer, activeChannel, channelProgram, network); - } - - if (context?.action === 'close') { - const credential = await handleCloseAction( + if (context?.action === 'topUp') { + return await handleTopUpAction( challenge, context, authorizer, activeChannel, channelProgram, - recipient, network, - onProgress, + feePayerKey, ); + } + + if (context?.action === 'close') { + const credential = await handleCloseAction(challenge, context, authorizer, activeChannel, onProgress); activeChannel = null; return credential; @@ -113,57 +96,42 @@ export function session(parameters: session.Parameters) { const channelId = context.channelId; const depositAmount = context.depositAmount; const parsedDepositAmount = parseNonNegativeAmount(depositAmount, 'context.depositAmount'); - const serverNonce = crypto.randomUUID(); onProgress?.({ channelId, type: 'opening' }); const openResult = await authorizer.authorizeOpen({ - asset, channelId, channelProgram, + currency, + decimals: request.methodDetails.decimals ?? 9, depositAmount, + feePayerKey, network, - pricing, recipient, - serverNonce, }); - const payer = resolveOpenPayer(openResult.voucher.voucher.payer, signer); + const payer = openResult.voucher.signer; const payload: SessionCredentialPayload = { action: 'open', - authorizationMode: authorizer.getMode(), channelId, depositAmount, - openTx: context.openTx ?? openResult.openTx, payer, - ...(openResult.expiresAt ? { expiresAt: openResult.expiresAt } : {}), - capabilities: { - ...(openResult.capabilities.maxCumulativeAmount - ? { - maxCumulativeAmount: openResult.capabilities.maxCumulativeAmount, - } - : {}), - ...(openResult.capabilities.allowedActions - ? { allowedActions: openResult.capabilities.allowedActions } - : {}), - }, + transaction: openResult.transaction, voucher: openResult.voucher, }; activeChannel = { - asset: normalizeAsset(asset), channelId, channelProgram, cumulativeAmount: parseNonNegativeAmount( openResult.voucher.voucher.cumulativeAmount, 'voucher.cumulativeAmount', ), + currency, depositAmount: parsedDepositAmount, network, recipient, - sequence: assertNonNegativeSequence(openResult.voucher.voucher.sequence), - serverNonce: openResult.voucher.voucher.serverNonce, }; onProgress?.({ channelId, type: 'opened' }); @@ -174,65 +142,51 @@ export function session(parameters: session.Parameters) { }); } - if (context?.action === 'update') { + if (context?.action === 'voucher') { const channelId = context.channelId ?? activeChannel?.channelId; if (!channelId) { - throw new Error('channelId is required for update action'); + throw new Error('channelId is required for voucher action'); } if (!activeChannel || activeChannel.channelId !== channelId) { - throw new Error('Cannot update a channel that is not active'); + throw new Error('Cannot submit voucher for a channel that is not active'); } if (!context.cumulativeAmount) { - throw new Error('cumulativeAmount is required for update action'); - } - - if (context.sequence === undefined) { - throw new Error('sequence is required for update action'); + throw new Error('cumulativeAmount is required for voucher action'); } const nextCumulativeAmount = parseNonNegativeAmount( context.cumulativeAmount, 'context.cumulativeAmount', ); - const nextSequence = assertNonNegativeSequence(context.sequence); onProgress?.({ channelId, cumulativeAmount: nextCumulativeAmount.toString(), - type: 'updating', + type: 'voucher-submitting', }); - const updateResult = await authorizer.authorizeUpdate({ + const voucherResult = await authorizer.authorizeVoucher({ channelId, - channelProgram, cumulativeAmount: nextCumulativeAmount.toString(), - meter: pricing?.meter ?? 'session', - network, - recipient, - sequence: nextSequence, - serverNonce: activeChannel.serverNonce, - units: pricing ? '1' : '0', }); const payload: SessionCredentialPayload = { - action: 'update', + action: 'voucher', channelId, - voucher: updateResult.voucher, + voucher: voucherResult.voucher, }; activeChannel.cumulativeAmount = parseNonNegativeAmount( - updateResult.voucher.voucher.cumulativeAmount, + voucherResult.voucher.voucher.cumulativeAmount, 'voucher.cumulativeAmount', ); - activeChannel.sequence = assertNonNegativeSequence(updateResult.voucher.voucher.sequence); - activeChannel.serverNonce = updateResult.voucher.voucher.serverNonce; onProgress?.({ channelId, cumulativeAmount: activeChannel.cumulativeAmount.toString(), - type: 'updated', + type: 'voucher-accepted', }); return Credential.serialize({ @@ -241,11 +195,12 @@ export function session(parameters: session.Parameters) { }); } + // Auto-flow: no explicit action in context. Determine what to do based on channel state. const scopedActiveChannel = activeChannel && matchesScope(activeChannel, { - asset, channelProgram, + currency, network, recipient, }) @@ -258,59 +213,44 @@ export function session(parameters: session.Parameters) { } const channelId = crypto.randomUUID(); - const serverNonce = crypto.randomUUID(); - const depositAmount = sessionDefaults?.suggestedDeposit ?? '0'; - const parsedDepositAmount = parseNonNegativeAmount(depositAmount, 'sessionDefaults.suggestedDeposit'); + const depositAmount = request.suggestedDeposit ?? '0'; + const parsedDepositAmount = parseNonNegativeAmount(depositAmount, 'suggestedDeposit'); onProgress?.({ channelId, type: 'opening' }); const openResult = await authorizer.authorizeOpen({ - asset, channelId, channelProgram, + currency, + decimals: request.methodDetails.decimals ?? 9, depositAmount, + feePayerKey, network, - pricing, recipient, - serverNonce, }); - const payer = resolveOpenPayer(openResult.voucher.voucher.payer, signer); + const payer = openResult.voucher.signer; const payload: SessionCredentialPayload = { action: 'open', - authorizationMode: authorizer.getMode(), channelId, depositAmount, - openTx: openResult.openTx, payer, - ...(openResult.expiresAt ? { expiresAt: openResult.expiresAt } : {}), - capabilities: { - ...(openResult.capabilities.maxCumulativeAmount - ? { - maxCumulativeAmount: openResult.capabilities.maxCumulativeAmount, - } - : {}), - ...(openResult.capabilities.allowedActions - ? { allowedActions: openResult.capabilities.allowedActions } - : {}), - }, + transaction: openResult.transaction, voucher: openResult.voucher, }; activeChannel = { - asset: normalizeAsset(asset), channelId, channelProgram, cumulativeAmount: parseNonNegativeAmount( openResult.voucher.voucher.cumulativeAmount, 'voucher.cumulativeAmount', ), + currency, depositAmount: parsedDepositAmount, network, recipient, - sequence: assertNonNegativeSequence(openResult.voucher.voucher.sequence), - serverNonce: openResult.voucher.voucher.serverNonce, }; onProgress?.({ channelId, type: 'opened' }); @@ -321,9 +261,9 @@ export function session(parameters: session.Parameters) { }); } - const debitIncrement = resolveDebitIncrement(pricing); + // Auto-voucher: increment cumulative amount by the per-unit price. + const debitIncrement = resolveDebitIncrement(amount, request.methodDetails.minVoucherDelta); const nextCumulativeAmount = scopedActiveChannel.cumulativeAmount + debitIncrement; - const nextSequence = scopedActiveChannel.sequence + 1; if (nextCumulativeAmount > scopedActiveChannel.depositAmount) { if (!autoTopup) { @@ -339,9 +279,6 @@ export function session(parameters: session.Parameters) { }, authorizer, scopedActiveChannel, - channelProgram, - recipient, - network, onProgress, ); @@ -350,25 +287,26 @@ export function session(parameters: session.Parameters) { } const additionalAmount = resolveAutoTopupAmount( - sessionDefaults?.suggestedDeposit, + request.suggestedDeposit, nextCumulativeAmount, scopedActiveChannel.depositAmount, ); - const topupResult = await authorizer.authorizeTopup({ + const topUpResult = await authorizer.authorizeTopUp({ additionalAmount: additionalAmount.toString(), channelId: scopedActiveChannel.channelId, channelProgram, + feePayerKey, network, }); scopedActiveChannel.depositAmount += additionalAmount; const payload: SessionCredentialPayload = { - action: 'topup', + action: 'topUp', additionalAmount: additionalAmount.toString(), channelId: scopedActiveChannel.channelId, - topupTx: topupResult.topupTx, + transaction: topUpResult.transaction, }; return Credential.serialize({ @@ -380,38 +318,29 @@ export function session(parameters: session.Parameters) { onProgress?.({ channelId: scopedActiveChannel.channelId, cumulativeAmount: nextCumulativeAmount.toString(), - type: 'updating', + type: 'voucher-submitting', }); - const updateResult = await authorizer.authorizeUpdate({ + const voucherResult = await authorizer.authorizeVoucher({ channelId: scopedActiveChannel.channelId, - channelProgram, cumulativeAmount: nextCumulativeAmount.toString(), - meter: pricing?.meter ?? 'session', - network, - recipient, - sequence: nextSequence, - serverNonce: scopedActiveChannel.serverNonce, - units: pricing ? '1' : '0', }); const payload: SessionCredentialPayload = { - action: 'update', + action: 'voucher', channelId: scopedActiveChannel.channelId, - voucher: updateResult.voucher, + voucher: voucherResult.voucher, }; scopedActiveChannel.cumulativeAmount = parseNonNegativeAmount( - updateResult.voucher.voucher.cumulativeAmount, + voucherResult.voucher.voucher.cumulativeAmount, 'voucher.cumulativeAmount', ); - scopedActiveChannel.sequence = assertNonNegativeSequence(updateResult.voucher.voucher.sequence); - scopedActiveChannel.serverNonce = updateResult.voucher.voucher.serverNonce; onProgress?.({ channelId: scopedActiveChannel.channelId, cumulativeAmount: scopedActiveChannel.cumulativeAmount.toString(), - type: 'updated', + type: 'voucher-accepted', }); return Credential.serialize({ @@ -422,28 +351,30 @@ export function session(parameters: session.Parameters) { }); } -async function handleTopupAction( +async function handleTopUpAction( challenge: Challenge.Challenge, context: SessionContext, authorizer: SessionAuthorizer, activeChannel: ActiveChannel | null, channelProgram: string, network: string, + feePayerKey?: string, ): Promise { const channelId = context.channelId ?? activeChannel?.channelId; if (!channelId) { - throw new Error('channelId is required for topup action'); + throw new Error('channelId is required for topUp action'); } if (!context.additionalAmount) { - throw new Error('additionalAmount is required for topup action'); + throw new Error('additionalAmount is required for topUp action'); } const additionalAmount = parseNonNegativeAmount(context.additionalAmount, 'context.additionalAmount'); - const topupResult = await authorizer.authorizeTopup({ + const topUpResult = await authorizer.authorizeTopUp({ additionalAmount: additionalAmount.toString(), channelId, channelProgram, + feePayerKey, network, }); @@ -452,10 +383,10 @@ async function handleTopupAction( } const payload: SessionCredentialPayload = { - action: 'topup', + action: 'topUp', additionalAmount: additionalAmount.toString(), channelId, - topupTx: topupResult.topupTx, + transaction: topUpResult.transaction, }; return Credential.serialize({ challenge, payload }); @@ -466,9 +397,6 @@ async function handleCloseAction( context: SessionContext, authorizer: SessionAuthorizer, activeChannel: ActiveChannel | null, - channelProgram: string, - recipient: string, - network: string, onProgress?: session.Parameters['onProgress'], ): Promise { const channelId = context.channelId ?? activeChannel?.channelId; @@ -476,29 +404,17 @@ async function handleCloseAction( throw new Error('channelId is required for close action'); } - if (!activeChannel || activeChannel.channelId !== channelId) { - throw new Error('Cannot close a channel that is not active'); - } - - const finalSequence = activeChannel.sequence + 1; - onProgress?.({ channelId, type: 'closing' }); const closeResult = await authorizer.authorizeClose({ channelId, - channelProgram, - finalCumulativeAmount: activeChannel.cumulativeAmount.toString(), - network, - recipient, - sequence: finalSequence, - serverNonce: activeChannel.serverNonce, + finalCumulativeAmount: activeChannel?.cumulativeAmount.toString(), }); const payload: SessionCredentialPayload = { action: 'close', channelId, - ...(closeResult.closeTx ? { closeTx: closeResult.closeTx } : {}), - voucher: closeResult.voucher, + ...(closeResult.voucher ? { voucher: closeResult.voucher } : {}), }; onProgress?.({ channelId, type: 'closed' }); @@ -506,13 +422,13 @@ async function handleCloseAction( return Credential.serialize({ challenge, payload }); } -function resolveDebitIncrement(pricing?: SessionPricing): bigint { - if (pricing?.minDebit !== undefined) { - return parseNonNegativeAmount(pricing.minDebit, 'pricing.minDebit'); +function resolveDebitIncrement(amount: string, minVoucherDelta?: string): bigint { + if (minVoucherDelta !== undefined) { + return parseNonNegativeAmount(minVoucherDelta, 'minVoucherDelta'); } - if (pricing?.amountPerUnit !== undefined) { - return parseNonNegativeAmount(pricing.amountPerUnit, 'pricing.amountPerUnit'); + if (amount !== undefined) { + return parseNonNegativeAmount(amount, 'amount'); } return 0n; @@ -532,60 +448,27 @@ function resolveAutoTopupAmount( return shortfall; } - const parsedSuggestedDeposit = parseNonNegativeAmount(suggestedDeposit, 'sessionDefaults.suggestedDeposit'); + const parsedSuggestedDeposit = parseNonNegativeAmount(suggestedDeposit, 'suggestedDeposit'); return parsedSuggestedDeposit > shortfall ? parsedSuggestedDeposit : shortfall; } function matchesScope( active: ActiveChannel, scope: { - asset: SessionAsset; channelProgram: string; + currency: string; network: string; recipient: string; }, ): boolean { - if (active.recipient !== scope.recipient) { - return false; - } - - if (active.network !== scope.network) { - return false; - } - - if (active.channelProgram !== scope.channelProgram) { - return false; - } - - return sameAsset(active.asset, scope.asset); -} - -function sameAsset(left: SessionAsset, right: SessionAsset): boolean { return ( - left.kind === right.kind && - left.decimals === right.decimals && - (left.mint ?? '') === (right.mint ?? '') && - (left.symbol ?? '') === (right.symbol ?? '') + active.recipient === scope.recipient && + active.network === scope.network && + active.channelProgram === scope.channelProgram && + active.currency === scope.currency ); } -function normalizeAsset(asset: SessionAsset): SessionAsset { - return { - decimals: asset.decimals, - kind: asset.kind, - ...(asset.mint ? { mint: asset.mint } : {}), - ...(asset.symbol ? { symbol: asset.symbol } : {}), - }; -} - -function assertNonNegativeSequence(value: number): number { - if (!Number.isInteger(value) || value < 0) { - throw new Error('voucher.sequence must be a non-negative integer'); - } - - return value; -} - function parseNonNegativeAmount(value: string, field: string): bigint { let amount: bigint; try { @@ -601,14 +484,6 @@ function parseNonNegativeAmount(value: string, field: string): bigint { return amount; } -function resolveOpenPayer(voucherPayer: string, signer?: TransactionSigner): string { - if (signer && signer.address !== voucherPayer) { - throw new Error(`Open voucher payer ${voucherPayer} does not match signer address ${signer.address}`); - } - - return voucherPayer; -} - export declare namespace session { type Parameters = { authorizer: SessionAuthorizer; @@ -620,11 +495,11 @@ export declare namespace session { }; type ProgressEvent = - | { asset: SessionAsset; network: string; recipient: string; type: 'challenge' } - | { channelId: string; cumulativeAmount: string; type: 'updated' } - | { channelId: string; cumulativeAmount: string; type: 'updating' } + | { channelId: string; cumulativeAmount: string; type: 'voucher-accepted' } + | { channelId: string; cumulativeAmount: string; type: 'voucher-submitting' } | { channelId: string; type: 'closed' } | { channelId: string; type: 'closing' } | { channelId: string; type: 'opened' } - | { channelId: string; type: 'opening' }; + | { channelId: string; type: 'opening' } + | { currency: string; network: string; recipient: string; type: 'challenge' }; } diff --git a/typescript/packages/mpp/src/index.ts b/typescript/packages/mpp/src/index.ts index 41f26f88d..553f7b50b 100644 --- a/typescript/packages/mpp/src/index.ts +++ b/typescript/packages/mpp/src/index.ts @@ -13,10 +13,10 @@ export type { SessionAuthorizer, AuthorizeOpenInput, AuthorizedOpen, - AuthorizeUpdateInput, - AuthorizedUpdate, - AuthorizeTopupInput, - AuthorizedTopup, + AuthorizeVoucherInput, + AuthorizedVoucher, + AuthorizeTopUpInput, + AuthorizedTopUp, AuthorizeCloseInput, AuthorizedClose, AuthorizerCapabilities, @@ -24,7 +24,7 @@ export type { } from './session/Types.js'; export { - BudgetAuthorizer, + SwigBudgetAuthorizer, SwigSessionAuthorizer, UnboundedAuthorizer, makeSessionAuthorizer, diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 543cc9124..947dbc298 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -1,6 +1,5 @@ import { Method, Receipt, Store } from 'mppx'; -import { DEFAULT_RPC_URLS } from '../constants.js'; import * as Methods from '../Methods.js'; import * as ChannelStore from '../session/ChannelStore.js'; import type { @@ -11,38 +10,26 @@ import type { } from '../session/Types.js'; import { parseVoucherFromPayload, verifyVoucherSignature } from '../session/Voucher.js'; -type SessionAsset = { - decimals: number; - kind: 'sol' | 'spl'; - mint?: string; - symbol?: string; -}; - -type SessionPricing = { - amountPerUnit: string; - meter: string; - minDebit?: string; - unit: string; -}; - -type SessionDefaults = { - closeBehavior?: 'payer_must_close' | 'server_may_finalize'; - settleInterval?: { kind: string; minIncrement?: string; seconds?: number }; - suggestedDeposit?: string; - ttlSeconds?: number; -}; - type SessionRequest = { - asset: SessionAsset; - channelProgram: string; - network?: string; - pricing?: SessionPricing; - recipient: string; - sessionDefaults?: SessionDefaults; - verifier?: { - acceptAuthorizationModes?: Array<'regular_budget' | 'regular_unbounded' | 'swig_session'>; - maxClockSkewSeconds?: number; + amount: string; + currency: string; + description?: string; + externalId?: string; + methodDetails: { + channelId?: string; + channelProgram: string; + decimals?: number; + feePayer?: boolean; + feePayerKey?: string; + gracePeriodSeconds?: number; + minVoucherDelta?: string; + network?: string; + tokenProgram?: string; + ttlSeconds?: number; }; + recipient: string; + suggestedDeposit?: string; + unitType?: string; }; type SessionChallenge = { @@ -51,32 +38,30 @@ type SessionChallenge = { }; type OpenPayload = Extract; -type UpdatePayload = Extract; -type TopupPayload = Extract; +type VoucherPayload = Extract; +type TopUpPayload = Extract; type ClosePayload = Extract; -type TransactionVerifier = { - verifyClose?(channelId: string, closeTx: string, finalCumulativeAmount: string): Promise; - verifyOpen?(channelId: string, openTx: string, deposit: string): Promise; - verifyTopup?(channelId: string, topupTx: string, amount: string): Promise; +type TransactionHandler = { + /** Inspect and broadcast a partially-signed open transaction. Returns the confirmed signature. */ + handleOpen?(channelId: string, transaction: string, deposit: string): Promise; + /** Inspect and broadcast a partially-signed top-up transaction. Returns the confirmed signature. */ + handleTopUp?(channelId: string, transaction: string, amount: string): Promise; }; export function session(parameters: session.Parameters) { - const { recipient, network = 'mainnet-beta', asset, channelProgram, store = Store.memory() } = parameters; + const { recipient, currency, channelProgram, store = Store.memory() } = parameters; + const network = parameters.network ?? 'mainnet-beta'; assertSessionParameters(parameters); - const resolvedRpcUrl = parameters.rpcUrl ?? DEFAULT_RPC_URLS[network] ?? DEFAULT_RPC_URLS['mainnet-beta']; - if (!resolvedRpcUrl) { - throw new Error(`Unable to resolve RPC URL for network: ${network}`); - } - const channelStore = ChannelStore.fromStore(store); return Method.toServer(Methods.session, { defaults: { - asset: { decimals: 9, kind: 'sol' as const }, - channelProgram: '', + amount: '0', + currency: '', + methodDetails: { channelProgram: '' }, recipient: '', }, @@ -85,17 +70,24 @@ export function session(parameters: session.Parameters) { return credential.challenge.request as typeof request; } - const verifierRequest = toVerifierRequest(parameters.verifier); - return { ...request, - asset, - channelProgram, - network, + amount: parameters.amount, + currency, + methodDetails: { + channelProgram, + ...(parameters.decimals !== undefined ? { decimals: parameters.decimals } : {}), + ...(parameters.feePayer ? { feePayer: true, feePayerKey: parameters.feePayerKey } : {}), + ...(parameters.gracePeriodSeconds !== undefined + ? { gracePeriodSeconds: parameters.gracePeriodSeconds } + : {}), + ...(parameters.minVoucherDelta ? { minVoucherDelta: parameters.minVoucherDelta } : {}), + network, + ...(parameters.ttlSeconds !== undefined ? { ttlSeconds: parameters.ttlSeconds } : {}), + }, recipient, - ...(parameters.pricing ? { pricing: parameters.pricing } : {}), - ...(parameters.sessionDefaults ? { sessionDefaults: parameters.sessionDefaults } : {}), - ...(verifierRequest ? { verifier: verifierRequest } : {}), + ...(parameters.suggestedDeposit ? { suggestedDeposit: parameters.suggestedDeposit } : {}), + ...(parameters.unitType ? { unitType: parameters.unitType } : {}), }; }, @@ -106,7 +98,7 @@ export function session(parameters: session.Parameters) { return new Response(null, { status: 204 }); } - if (payload.action === 'topup') { + if (payload.action === 'topUp') { return new Response(null, { status: 204 }); } @@ -120,13 +112,13 @@ export function session(parameters: session.Parameters) { switch (payload.action) { case 'open': - return await handleOpen(channelStore, payload, challenge, recipient, parameters, challengeId); - case 'update': - return await handleUpdate(channelStore, payload, challenge, parameters, challengeId); - case 'topup': - return await handleTopup(channelStore, payload, parameters, challengeId); + return await handleOpen(channelStore, payload, challenge, parameters, challengeId); + case 'voucher': + return await handleVoucher(channelStore, payload, parameters, challengeId); + case 'topUp': + return await handleTopUp(channelStore, payload, parameters, challengeId); case 'close': - return await handleClose(channelStore, payload, challenge, parameters, challengeId); + return await handleClose(channelStore, payload, parameters, challengeId); default: { const exhaustive: never = payload; throw new Error(`Unknown session action: ${(exhaustive as { action?: string }).action}`); @@ -140,89 +132,61 @@ async function handleOpen( channelStore: ChannelStore.ChannelStore, payload: OpenPayload, challenge: SessionChallenge, - configuredRecipient: string, parameters: session.Parameters, challengeId?: string, ) { - const request = challenge.request; const voucher = parseVoucherFromPayload(payload); const depositAmount = parseNonNegativeAmount(payload.depositAmount, 'depositAmount'); const cumulativeAmount = parseNonNegativeAmount(voucher.voucher.cumulativeAmount, 'voucher.cumulativeAmount'); - if (!payload.openTx.trim()) { - throw new Error('openTx is required for session open'); + if (!payload.transaction.trim()) { + throw new Error('transaction is required for session open'); } - await parameters.transactionVerifier?.verifyOpen?.(payload.channelId, payload.openTx, payload.depositAmount); - if (voucher.voucher.channelId !== payload.channelId) { throw new Error('Voucher channelId mismatch for open action'); } - if (voucher.voucher.payer !== payload.payer) { - throw new Error('Voucher payer mismatch for open action'); - } - - if (voucher.voucher.recipient !== configuredRecipient) { - throw new Error('Voucher recipient does not match configured recipient'); - } - - if (voucher.voucher.recipient !== request.recipient) { - throw new Error('Voucher recipient does not match challenge recipient'); - } - - if (voucher.voucher.channelProgram !== request.channelProgram) { - throw new Error('Voucher channelProgram mismatch'); - } - - const expectedChainId = normalizeChainId(request.network ?? 'mainnet-beta'); - if (voucher.voucher.chainId !== expectedChainId) { - throw new Error(`Voucher chainId mismatch: expected ${expectedChainId}, received ${voucher.voucher.chainId}`); - } - if (cumulativeAmount > depositAmount) { throw new Error('Voucher cumulative amount exceeds channel deposit'); } - if ( - parameters.verifier?.acceptAuthorizationModes && - !parameters.verifier.acceptAuthorizationModes.includes(payload.authorizationMode) - ) { - throw new Error(`Authorization mode not accepted: ${payload.authorizationMode}`); - } + assertVoucherNotExpired(voucher, parameters.maxClockSkewSeconds); - assertVoucherNotExpired(voucher, parameters.verifier?.maxClockSkewSeconds); + // Inspect and broadcast the partially-signed transaction via the handler. + let openTxSignature: string | undefined; + if (parameters.transactionHandler?.handleOpen) { + openTxSignature = await parameters.transactionHandler.handleOpen( + payload.channelId, + payload.transaction, + payload.depositAmount, + ); + } const createdAt = new Date().toISOString(); - const expiresAtUnix = toUnixSeconds(payload.expiresAt ?? voucher.voucher.expiresAt); const nextState: ChannelState = { - asset: { - decimals: request.asset.decimals, - kind: request.asset.kind, - ...(request.asset.mint ? { mint: request.asset.mint } : {}), - }, - authority: { - wallet: payload.payer, - ...(payload.authorizationMode === 'swig_session' ? { delegatedSessionKey: voucher.signer } : {}), - }, - authorizationMode: payload.authorizationMode, + acceptedCumulative: cumulativeAmount.toString(), + // authorizationPolicy is stored for custom verifiers (e.g. SwigSessionAuthorizer). + // It is not enforced by the built-in ed25519 verification path. + ...(payload.authorizationPolicy ? { authorizationPolicy: payload.authorizationPolicy } : {}), + authorizedSigner: voucher.signer, channelId: payload.channelId, + closeRequestedAt: 0, createdAt, + currency: parameters.currency, + decimals: parameters.decimals ?? 9, escrowedAmount: depositAmount.toString(), - expiresAtUnix, - lastAuthorizedAmount: cumulativeAmount.toString(), - lastSequence: voucher.voucher.sequence, - openSlot: Date.now(), + finalized: false, + payee: parameters.recipient, payer: payload.payer, - recipient: request.recipient, - serverNonce: voucher.voucher.serverNonce, - settledAmount: '0', + settledOnChain: '0', + spentAmount: '0', status: 'open', }; - await verifySignedVoucher(voucher, nextState, parameters.verifier?.voucherVerifier); + await verifySignedVoucher(voucher, nextState, parameters.voucherVerifier); await channelStore.updateChannel(payload.channelId, current => { if (current) { @@ -232,13 +196,17 @@ async function handleOpen( return nextState; }); - return toSuccessReceipt(payload.channelId, challengeId); + return toSessionReceipt( + openTxSignature ?? payload.channelId, + nextState.acceptedCumulative, + nextState.spentAmount, + challengeId, + ); } -async function handleUpdate( +async function handleVoucher( channelStore: ChannelStore.ChannelStore, - payload: UpdatePayload, - challenge: SessionChallenge, + payload: VoucherPayload, parameters: session.Parameters, challengeId?: string, ) { @@ -247,72 +215,90 @@ async function handleUpdate( throw new Error(`Channel not found: ${payload.channelId}`); } - assertChannelOpen(channel, parameters.verifier?.maxClockSkewSeconds); + assertChannelOpen(channel); + + // Reject new vouchers on channels with a pending forced close. + if (channel.closeRequestedAt > 0) { + throw new Error(`Channel has a pending forced close (requested at ${channel.closeRequestedAt})`); + } const voucher = parseVoucherFromPayload(payload); - assertVoucherMatchesChannel(voucher, channel, challenge); - assertVoucherNotExpired(voucher, parameters.verifier?.maxClockSkewSeconds); + assertVoucherNotExpired(voucher, parameters.maxClockSkewSeconds); + + if (voucher.voucher.channelId !== channel.channelId) { + throw new Error('Voucher channelId mismatch'); + } + + await verifySignedVoucher(voucher, channel, parameters.voucherVerifier); const cumulativeAmount = parseNonNegativeAmount(voucher.voucher.cumulativeAmount, 'voucher.cumulativeAmount'); const escrowedAmount = parseNonNegativeAmount(channel.escrowedAmount, 'channel.escrowedAmount'); - const lastAuthorizedAmount = parseNonNegativeAmount(channel.lastAuthorizedAmount, 'channel.lastAuthorizedAmount'); + const acceptedCumulative = parseNonNegativeAmount(channel.acceptedCumulative, 'channel.acceptedCumulative'); - if (voucher.voucher.sequence <= channel.lastSequence) { - throw new Error( - `Voucher sequence replay detected. Last=${channel.lastSequence}, received=${voucher.voucher.sequence}`, - ); + // Idempotent retry: equal cumulative amount is a re-send of an already-accepted voucher. + if (cumulativeAmount === acceptedCumulative) { + return toSessionReceipt(payload.channelId, channel.acceptedCumulative, channel.spentAmount, challengeId); } - if (cumulativeAmount < lastAuthorizedAmount) { - throw new Error('Voucher cumulative amount must be monotonically non-decreasing'); + // Reject stale vouchers. A lower amount was already superseded — accepting it would + // authorize no new value while still allowing the resource to be served. + if (cumulativeAmount < acceptedCumulative) { + throw new Error( + `Voucher cumulative amount must not decrease (received ${cumulativeAmount}, accepted ${acceptedCumulative})`, + ); } if (cumulativeAmount > escrowedAmount) { throw new Error('Voucher cumulative amount exceeds channel deposit'); } - await verifySignedVoucher(voucher, channel, parameters.verifier?.voucherVerifier); - - await channelStore.updateChannel(payload.channelId, current => { + const updatedChannel = await channelStore.updateChannel(payload.channelId, current => { if (!current) { throw new Error(`Channel not found: ${payload.channelId}`); } - assertChannelOpen(current, parameters.verifier?.maxClockSkewSeconds); + assertChannelOpen(current); - if (voucher.voucher.sequence <= current.lastSequence) { - throw new Error( - `Voucher sequence replay detected. Last=${current.lastSequence}, received=${voucher.voucher.sequence}`, - ); + // Re-check closeRequestedAt inside atomic update (TOCTOU guard). + if (current.closeRequestedAt > 0) { + throw new Error(`Channel has a pending forced close (requested at ${current.closeRequestedAt})`); } - const currentEscrowed = parseNonNegativeAmount(current.escrowedAmount, 'channel.escrowedAmount'); - const currentLastAuthorized = parseNonNegativeAmount( - current.lastAuthorizedAmount, - 'channel.lastAuthorizedAmount', - ); + const currentAccepted = parseNonNegativeAmount(current.acceptedCumulative, 'channel.acceptedCumulative'); - if (cumulativeAmount < currentLastAuthorized) { - throw new Error('Voucher cumulative amount must be monotonically non-decreasing'); + // Re-check inside atomic update (idempotent retry — another request may have raced ahead). + if (cumulativeAmount === currentAccepted) { + return current; } + if (cumulativeAmount < currentAccepted) { + throw new Error( + `Voucher cumulative amount must not decrease (received ${cumulativeAmount}, accepted ${currentAccepted})`, + ); + } + + const currentEscrowed = parseNonNegativeAmount(current.escrowedAmount, 'channel.escrowedAmount'); if (cumulativeAmount > currentEscrowed) { throw new Error('Voucher cumulative amount exceeds channel deposit'); } return { ...current, - lastAuthorizedAmount: cumulativeAmount.toString(), - lastSequence: voucher.voucher.sequence, + acceptedCumulative: cumulativeAmount.toString(), }; }); - return toSuccessReceipt(payload.channelId, challengeId); + return toSessionReceipt( + payload.channelId, + updatedChannel?.acceptedCumulative ?? cumulativeAmount.toString(), + updatedChannel?.spentAmount ?? '0', + challengeId, + ); } -async function handleTopup( +async function handleTopUp( channelStore: ChannelStore.ChannelStore, - payload: TopupPayload, + payload: TopUpPayload, parameters: session.Parameters, challengeId?: string, ) { @@ -323,41 +309,48 @@ async function handleTopup( assertChannelOpen(current); - if (!payload.topupTx.trim()) { - throw new Error('topupTx is required for session topup'); + if (!payload.transaction.trim()) { + throw new Error('transaction is required for session topUp'); } const additionalAmount = parseNonNegativeAmount(payload.additionalAmount, 'additionalAmount'); - await parameters.transactionVerifier?.verifyTopup?.(payload.channelId, payload.topupTx, payload.additionalAmount); + if (parameters.transactionHandler?.handleTopUp) { + await parameters.transactionHandler.handleTopUp( + payload.channelId, + payload.transaction, + payload.additionalAmount, + ); + } - await channelStore.updateChannel(payload.channelId, channel => { + const updatedChannel = await channelStore.updateChannel(payload.channelId, channel => { if (!channel) { throw new Error(`Channel not found: ${payload.channelId}`); } assertChannelOpen(channel); - if (channel.channelId !== payload.channelId) { - throw new Error('Channel id mismatch for topup action'); - } - const escrowedAmount = parseNonNegativeAmount(channel.escrowedAmount, 'channel.escrowedAmount'); const nextEscrowed = escrowedAmount + additionalAmount; return { ...channel, + closeRequestedAt: 0, escrowedAmount: nextEscrowed.toString(), }; }); - return toSuccessReceipt(payload.channelId, challengeId); + return toSessionReceipt( + payload.channelId, + updatedChannel?.acceptedCumulative ?? current.acceptedCumulative, + updatedChannel?.spentAmount ?? current.spentAmount, + challengeId, + ); } async function handleClose( channelStore: ChannelStore.ChannelStore, payload: ClosePayload, - challenge: SessionChallenge, parameters: session.Parameters, challengeId?: string, ) { @@ -370,84 +363,55 @@ async function handleClose( throw new Error(`Channel already closed: ${payload.channelId}`); } - assertChannelOpen(channel, parameters.verifier?.maxClockSkewSeconds); - - const voucher = parseVoucherFromPayload(payload); - assertVoucherMatchesChannel(voucher, channel, challenge); - assertVoucherNotExpired(voucher, parameters.verifier?.maxClockSkewSeconds); - - const cumulativeAmount = parseNonNegativeAmount(voucher.voucher.cumulativeAmount, 'voucher.cumulativeAmount'); - const escrowedAmount = parseNonNegativeAmount(channel.escrowedAmount, 'channel.escrowedAmount'); - const lastAuthorizedAmount = parseNonNegativeAmount(channel.lastAuthorizedAmount, 'channel.lastAuthorizedAmount'); - - if (voucher.voucher.sequence <= channel.lastSequence) { - throw new Error( - `Voucher sequence replay detected. Last=${channel.lastSequence}, received=${voucher.voucher.sequence}`, - ); - } - - if (cumulativeAmount < lastAuthorizedAmount) { - throw new Error('Voucher cumulative amount must be monotonically non-decreasing'); - } + assertChannelOpen(channel); - if (cumulativeAmount > escrowedAmount) { - throw new Error('Voucher cumulative amount exceeds channel deposit'); - } + // Close voucher is optional. If provided, validate and update accepted cumulative. + if (payload.voucher) { + const voucher = parseVoucherFromPayload(payload.voucher); + assertVoucherNotExpired(voucher, parameters.maxClockSkewSeconds); - if (parameters.transactionVerifier?.verifyClose) { - if (!payload.closeTx?.trim()) { - throw new Error('closeTx is required for session close'); + if (voucher.voucher.channelId !== channel.channelId) { + throw new Error('Voucher channelId mismatch'); } - await parameters.transactionVerifier.verifyClose( - payload.channelId, - payload.closeTx, - voucher.voucher.cumulativeAmount, - ); - } - - await verifySignedVoucher(voucher, channel, parameters.verifier?.voucherVerifier); - - await channelStore.updateChannel(payload.channelId, current => { - if (!current) { - throw new Error(`Channel not found: ${payload.channelId}`); - } + const cumulativeAmount = parseNonNegativeAmount(voucher.voucher.cumulativeAmount, 'voucher.cumulativeAmount'); + const escrowedAmount = parseNonNegativeAmount(channel.escrowedAmount, 'channel.escrowedAmount'); - if (current.status === 'closed') { - throw new Error(`Channel already closed: ${payload.channelId}`); + if (cumulativeAmount > escrowedAmount) { + throw new Error('Voucher cumulative amount exceeds channel deposit'); } - assertChannelOpen(current, parameters.verifier?.maxClockSkewSeconds); - - if (voucher.voucher.sequence <= current.lastSequence) { - throw new Error( - `Voucher sequence replay detected. Last=${current.lastSequence}, received=${voucher.voucher.sequence}`, - ); - } + await verifySignedVoucher(voucher, channel, parameters.voucherVerifier); - const currentEscrowed = parseNonNegativeAmount(current.escrowedAmount, 'channel.escrowedAmount'); - const currentLastAuthorized = parseNonNegativeAmount( - current.lastAuthorizedAmount, - 'channel.lastAuthorizedAmount', - ); + await channelStore.updateChannel(payload.channelId, current => { + if (!current || current.status === 'closed') { + throw new Error(`Channel already closed: ${payload.channelId}`); + } - if (cumulativeAmount < currentLastAuthorized) { - throw new Error('Voucher cumulative amount must be monotonically non-decreasing'); - } + const currentAccepted = parseNonNegativeAmount(current.acceptedCumulative, 'channel.acceptedCumulative'); - if (cumulativeAmount > currentEscrowed) { - throw new Error('Voucher cumulative amount exceeds channel deposit'); - } + return { + ...current, + acceptedCumulative: + cumulativeAmount > currentAccepted ? cumulativeAmount.toString() : current.acceptedCumulative, + status: 'closed', + }; + }); + } else { + // Close without voucher: just mark closed. + await channelStore.updateChannel(payload.channelId, current => { + if (!current || current.status === 'closed') { + throw new Error(`Channel already closed: ${payload.channelId}`); + } - return { - ...current, - lastAuthorizedAmount: cumulativeAmount.toString(), - lastSequence: voucher.voucher.sequence, - status: 'closed', - }; - }); + return { + ...current, + status: 'closed', + }; + }); + } - return toSuccessReceipt(payload.closeTx ?? payload.channelId, challengeId); + return toSessionReceipt(payload.channelId, channel.acceptedCumulative, channel.spentAmount, challengeId); } function assertSessionParameters(parameters: session.Parameters) { @@ -459,115 +423,25 @@ function assertSessionParameters(parameters: session.Parameters) { throw new Error('channelProgram is required'); } - if (!Number.isInteger(parameters.asset.decimals) || parameters.asset.decimals < 0) { - throw new Error('asset.decimals must be a non-negative integer'); - } - - if (parameters.asset.kind !== 'sol' && parameters.asset.kind !== 'spl') { - throw new Error('asset.kind must be "sol" or "spl"'); - } - - if (parameters.asset.kind === 'spl' && !parameters.asset.mint) { - throw new Error('asset.mint is required when asset.kind is "spl"'); - } - - if ( - parameters.verifier?.maxClockSkewSeconds !== undefined && - (!Number.isInteger(parameters.verifier.maxClockSkewSeconds) || parameters.verifier.maxClockSkewSeconds < 0) - ) { - throw new Error('verifier.maxClockSkewSeconds must be a non-negative integer'); - } -} - -function toVerifierRequest(verifier: session.Parameters['verifier']) { - if (!verifier) { - return undefined; + if (!parameters.currency.trim()) { + throw new Error('currency is required'); } - const requestVerifier: SessionRequest['verifier'] = { - ...(verifier.acceptAuthorizationModes ? { acceptAuthorizationModes: verifier.acceptAuthorizationModes } : {}), - ...(verifier.maxClockSkewSeconds !== undefined ? { maxClockSkewSeconds: verifier.maxClockSkewSeconds } : {}), - }; - - if (!requestVerifier.acceptAuthorizationModes && requestVerifier.maxClockSkewSeconds === undefined) { - return undefined; - } - - return requestVerifier; -} - -function normalizeChainId(network: string): string { - const normalized = network.trim(); - if (normalized.length === 0) { - throw new Error('network must be a non-empty string'); - } - - return normalized.startsWith('solana:') ? normalized : `solana:${normalized}`; -} - -function toUnixSeconds(expiresAt?: string): number | null { - if (!expiresAt) { - return null; + if (!parameters.amount.trim()) { + throw new Error('amount is required'); } - const unixMs = Date.parse(expiresAt); - if (Number.isNaN(unixMs)) { - throw new Error('expiresAt must be a valid ISO timestamp'); + if (parameters.currency.toLowerCase() === 'sol') { + throw new Error( + 'Native SOL is not supported by the mpp-channel program. Provide an SPL token mint address as `currency`.', + ); } - - return Math.floor(unixMs / 1000); } -function assertChannelOpen(channel: ChannelState, maxClockSkewSeconds = 0) { - if (channel.status === 'closed') { +function assertChannelOpen(channel: ChannelState) { + if (channel.status === 'closed' || channel.finalized) { throw new Error(`Channel is closed: ${channel.channelId}`); } - - if (channel.status === 'expired') { - throw new Error(`Channel has expired: ${channel.channelId}`); - } - - if (channel.status !== 'open') { - throw new Error(`Channel must be open to accept this action. Current status=${channel.status}`); - } - - if (channel.expiresAtUnix !== null) { - const nowUnix = Math.floor(Date.now() / 1000); - if (nowUnix > channel.expiresAtUnix + maxClockSkewSeconds) { - throw new Error(`Channel has expired: ${channel.channelId}`); - } - } -} - -function assertVoucherMatchesChannel( - voucher: SignedSessionVoucher, - channel: ChannelState, - challenge: SessionChallenge, -) { - if (voucher.voucher.channelId !== channel.channelId) { - throw new Error('Voucher channelId mismatch'); - } - - if (voucher.voucher.payer !== channel.payer) { - throw new Error('Voucher payer mismatch'); - } - - if (voucher.voucher.recipient !== channel.recipient) { - throw new Error('Voucher recipient mismatch'); - } - - if (voucher.voucher.serverNonce !== channel.serverNonce) { - throw new Error('Voucher serverNonce mismatch'); - } - - if (voucher.voucher.channelProgram !== challenge.request.channelProgram) { - throw new Error('Voucher channelProgram mismatch'); - } - - const expectedChainId = normalizeChainId(challenge.request.network ?? 'mainnet-beta'); - if (voucher.voucher.chainId !== expectedChainId) { - throw new Error(`Voucher chainId mismatch: expected ${expectedChainId}, received ${voucher.voucher.chainId}`); - } } function assertVoucherNotExpired(voucher: SignedSessionVoucher, maxClockSkewSeconds = 0) { @@ -590,7 +464,6 @@ async function verifySignedVoucher( channel: ChannelState, customVerifier?: VoucherVerifier, ) { - // Bind signer to channel authority — reject rogue signers assertSignerAuthorized(voucher, channel); if (voucher.signatureType === 'ed25519' || voucher.signatureType === 'swig-session') { @@ -612,23 +485,11 @@ async function verifySignedVoucher( } function assertSignerAuthorized(voucher: SignedSessionVoucher, channel: ChannelState) { - const signer = voucher.signer; - - if (channel.authorizationMode === 'swig_session') { - // For swig_session mode, the signer must be the delegated session key - const expectedKey = channel.authority.delegatedSessionKey; - if (!expectedKey) { - throw new Error('Channel uses swig_session authorization but no delegated session key is recorded'); - } - if (signer !== expectedKey) { - throw new Error(`Voucher signer ${signer} does not match delegated session key ${expectedKey}`); - } - return; - } - - // For regular_budget and regular_unbounded, the signer must be the channel payer - if (signer !== channel.payer && signer !== channel.authority.wallet) { - throw new Error(`Voucher signer ${signer} does not match channel payer ${channel.payer}`); + // authorizedSigner is established from the open voucher's signer. For standard channels + // it equals the payer; for delegated channels (e.g. Swig session keys) it is the delegated + // key. There is no payer fallback — authorizedSigner is the definitive authority. + if (voucher.signer !== channel.authorizedSigner) { + throw new Error(`Voucher signer ${voucher.signer} does not match authorized signer ${channel.authorizedSigner}`); } } @@ -647,11 +508,16 @@ function parseNonNegativeAmount(value: string, field: string): bigint { return parsed; } -function toSuccessReceipt(channelId: string, challengeId?: string): Receipt.Receipt { +function toSessionReceipt( + reference: string, + _acceptedCumulative: string, + _spent: string, + challengeId?: string, +): Receipt.Receipt { return Receipt.from({ method: 'solana', - ...(challengeId ? { challengeId } : {}), - reference: channelId, + ...(challengeId ? { externalId: challengeId } : {}), + reference, status: 'success', timestamp: new Date().toISOString(), }); @@ -659,29 +525,41 @@ function toSuccessReceipt(channelId: string, challengeId?: string): Receipt.Rece export declare namespace session { type Parameters = { - asset: { decimals: number; kind: 'sol' | 'spl'; mint?: string; symbol?: string }; + /** Price per unit in token base units. */ + amount: string; + /** Channel program address. */ channelProgram: string; + /** Currency identifier: "sol" or SPL mint address. */ + currency: string; + /** Token decimals (required for SPL tokens). */ + decimals?: number; + /** If true, server pays transaction fees. */ + feePayer?: boolean; + /** Server's fee payer public key. Required when feePayer is true. */ + feePayerKey?: string; + /** Grace period in seconds for forced close. */ + gracePeriodSeconds?: number; + /** Maximum clock skew tolerance in seconds for voucher expiry checks. */ + maxClockSkewSeconds?: number; + /** Minimum voucher delta the server will accept. */ + minVoucherDelta?: string; + /** Solana network. Defaults to mainnet-beta. */ network?: 'devnet' | 'localnet' | 'mainnet-beta' | 'surfnet' | (string & {}); - pricing?: { - amountPerUnit: string; - meter: string; - minDebit?: string; - unit: string; - }; + /** Base58-encoded recipient (payee) public key. */ recipient: string; + /** RPC URL override. */ rpcUrl?: string; - sessionDefaults?: { - closeBehavior?: 'payer_must_close' | 'server_may_finalize'; - settleInterval?: { kind: string; minIncrement?: string; seconds?: number }; - suggestedDeposit?: string; - ttlSeconds?: number; - }; + /** Persistence store. Defaults to in-memory. */ store?: Store.Store; - transactionVerifier?: TransactionVerifier; - verifier?: { - acceptAuthorizationModes?: Array<'regular_budget' | 'regular_unbounded' | 'swig_session'>; - maxClockSkewSeconds?: number; - voucherVerifier?: VoucherVerifier; - }; + /** Suggested initial channel deposit. */ + suggestedDeposit?: string; + /** Handlers for inspecting and broadcasting partially-signed transactions. */ + transactionHandler?: TransactionHandler; + /** Suggested TTL for the session in seconds. */ + ttlSeconds?: number; + /** Unit type for pricing (e.g., "request", "token", "byte"). */ + unitType?: string; + /** Custom voucher verifier for non-standard signature types. */ + voucherVerifier?: VoucherVerifier; }; } diff --git a/typescript/packages/mpp/src/session/ChannelStore.ts b/typescript/packages/mpp/src/session/ChannelStore.ts index 217ce49a4..e739a5ee3 100644 --- a/typescript/packages/mpp/src/session/ChannelStore.ts +++ b/typescript/packages/mpp/src/session/ChannelStore.ts @@ -13,6 +13,24 @@ export interface ChannelStore { ): Promise; } +/** + * Create a ChannelStore backed by the given mppx Store. + * + * **Single-process safety only.** The per-channel lock implemented here uses + * an in-memory `Map`, which prevents concurrent races within a single Node.js + * process. It provides no protection when multiple server instances share the + * same backing store (e.g. a shared Redis or Upstash instance). + * + * For multi-instance deployments, you need a store adapter that supports + * atomic compare-and-swap semantics so that read-modify-write cycles on + * channel state are serializable across processes. Suitable approaches: + * - Redis `WATCH`/`MULTI`/`EXEC` (optimistic CAS) + * - Lua scripts executed atomically on the Redis server + * - A database with row-level locking and serializable transactions + * + * The lock here guards against TOCTOU within one process only. It does not + * substitute for distributed atomicity. + */ export function fromStore(store: Store.Store): ChannelStore { const cached = storeCache.get(store); if (cached) { @@ -87,21 +105,21 @@ export async function deductFromChannel( return null; } - const settledAmount = parseAtomicAmount(current.settledAmount, 'settledAmount'); - const authorizedAmount = parseAtomicAmount(current.lastAuthorizedAmount, 'lastAuthorizedAmount'); + const spentAmount = parseAtomicAmount(current.spentAmount, 'spentAmount'); + const acceptedCumulative = parseAtomicAmount(current.acceptedCumulative, 'acceptedCumulative'); const escrowedAmount = parseAtomicAmount(current.escrowedAmount, 'escrowedAmount'); - const spendCeiling = authorizedAmount < escrowedAmount ? authorizedAmount : escrowedAmount; - const nextSettledAmount = settledAmount + amount; + const spendCeiling = acceptedCumulative < escrowedAmount ? acceptedCumulative : escrowedAmount; + const nextSpentAmount = spentAmount + amount; - if (nextSettledAmount > spendCeiling) { + if (nextSpentAmount > spendCeiling) { return current; } deducted = true; return { ...current, - settledAmount: nextSettledAmount.toString(), + spentAmount: nextSpentAmount.toString(), }; }); diff --git a/typescript/packages/mpp/src/session/Types.ts b/typescript/packages/mpp/src/session/Types.ts index 1956bff81..855fe8f42 100644 --- a/typescript/packages/mpp/src/session/Types.ts +++ b/typescript/packages/mpp/src/session/Types.ts @@ -1,17 +1,9 @@ export type AuthorizationMode = 'regular_budget' | 'regular_unbounded' | 'swig_session'; export interface SessionVoucher { - chainId: string; channelId: string; - channelProgram: string; cumulativeAmount: string; expiresAt?: string; - meter: string; - payer: string; - recipient: string; - sequence: number; - serverNonce: string; - units: string; } export interface SignedSessionVoucher { @@ -22,56 +14,59 @@ export interface SignedSessionVoucher { } export interface ChannelState { - asset: { decimals: number; kind: 'sol' | 'spl'; mint?: string }; - authority: { - delegatedSessionKey?: string; - swigRoleId?: number; - wallet: string; - }; - authorizationMode: AuthorizationMode; + /** Highest voucher cumulativeAmount the server has accepted. */ + acceptedCumulative: string; + /** Voucher signer policy from the open credential. */ + authorizationPolicy?: Record; + /** Authorized signer for vouchers (payer or delegated key). */ + authorizedSigner: string; channelId: string; + /** Unix timestamp when forced close was requested, or 0 if none. */ + closeRequestedAt: number; createdAt: string; + /** Currency identifier: "sol" or SPL mint address. */ + currency: string; + /** Token decimals for amount normalization. */ + decimals: number; + /** Total amount deposited into the channel. */ escrowedAmount: string; - expiresAtUnix: number | null; - lastAuthorizedAmount: string; - lastSequence: number; - openSlot: number; + /** Whether the channel has been finalized (closed). */ + finalized: boolean; + /** Payee (recipient) wallet. */ + payee: string; payer: string; - recipient: string; - serverNonce: string; - settledAmount: string; - status: 'closed' | 'closing' | 'expired' | 'open'; + /** Highest cumulativeAmount already claimed via on-chain settle. */ + settledOnChain: string; + /** Cumulative amount charged for delivered service. */ + spentAmount: string; + status: 'closed' | 'open'; } export type SessionCredentialPayload = | { action: 'close'; channelId: string; - closeTx?: string; - voucher: SignedSessionVoucher; + voucher?: SignedSessionVoucher; } | { action: 'open'; - authorizationMode: AuthorizationMode; - capabilities?: { - allowedActions?: string[]; - maxCumulativeAmount?: string; - }; + authorizationPolicy?: Record; + capabilities?: Record; channelId: string; depositAmount: string; expiresAt?: string; - openTx: string; payer: string; + transaction: string; voucher: SignedSessionVoucher; } | { - action: 'topup'; + action: 'topUp'; additionalAmount: string; channelId: string; - topupTx: string; + transaction: string; } | { - action: 'update'; + action: 'voucher'; channelId: string; voucher: SignedSessionVoucher; }; @@ -83,74 +78,60 @@ export interface VoucherVerifier { export interface SessionAuthorizer { authorizeClose(input: AuthorizeCloseInput): Promise; authorizeOpen(input: AuthorizeOpenInput): Promise; - authorizeTopup(input: AuthorizeTopupInput): Promise; - authorizeUpdate(input: AuthorizeUpdateInput): Promise; + authorizeTopUp(input: AuthorizeTopUpInput): Promise; + authorizeVoucher(input: AuthorizeVoucherInput): Promise; getCapabilities(): AuthorizerCapabilities; getMode(): AuthorizationMode; } export interface AuthorizeOpenInput { - asset: { decimals: number; kind: 'sol' | 'spl'; mint?: string }; channelId: string; channelProgram: string; + currency: string; + decimals: number; depositAmount: string; + feePayerKey?: string; network: string; - pricing?: { amountPerUnit: string; meter: string; unit: string }; recipient: string; - serverNonce: string; } export interface AuthorizedOpen { - capabilities: AuthorizerCapabilities; - expiresAt?: string; - openTx: string; + transaction: string; voucher: SignedSessionVoucher; } -export interface AuthorizeUpdateInput { +export interface AuthorizeVoucherInput { channelId: string; - channelProgram: string; cumulativeAmount: string; - meter: string; - network: string; - recipient: string; - sequence: number; - serverNonce: string; - units: string; } -export interface AuthorizedUpdate { +export interface AuthorizedVoucher { voucher: SignedSessionVoucher; } -export interface AuthorizeTopupInput { +export interface AuthorizeTopUpInput { additionalAmount: string; channelId: string; channelProgram: string; + feePayerKey?: string; network: string; } -export interface AuthorizedTopup { - topupTx: string; +export interface AuthorizedTopUp { + transaction: string; } export interface AuthorizeCloseInput { channelId: string; - channelProgram: string; - finalCumulativeAmount: string; - network: string; - recipient: string; - sequence: number; - serverNonce: string; + finalCumulativeAmount?: string; } export interface AuthorizedClose { - closeTx?: string; - voucher: SignedSessionVoucher; + voucher?: SignedSessionVoucher; } export interface AuthorizerCapabilities { - allowedActions?: Array<'close' | 'open' | 'topup' | 'update'>; + allowedActions?: Array<'close' | 'open' | 'topUp' | 'voucher'>; allowedPrograms?: string[]; expiresAt?: string; maxCumulativeAmount?: string; @@ -159,8 +140,8 @@ export interface AuthorizerCapabilities { requiresInteractiveApproval: { close: boolean; open: boolean; - topup: boolean; - update: boolean; + topUp: boolean; + voucher: boolean; }; } @@ -185,5 +166,5 @@ export type SessionPolicyProfile = } | { profile: 'wallet-manual'; - requireApprovalOnEveryUpdate: boolean; + requireApprovalOnEveryVoucher: boolean; }; diff --git a/typescript/packages/mpp/src/session/Voucher.ts b/typescript/packages/mpp/src/session/Voucher.ts index dd73333b9..d8f8d47d0 100644 --- a/typescript/packages/mpp/src/session/Voucher.ts +++ b/typescript/packages/mpp/src/session/Voucher.ts @@ -11,14 +11,20 @@ import { import type { SessionVoucher, SignedSessionVoucher } from './Types.js'; -const DOMAIN_SEPARATOR = 'solana-mpp-session-voucher-v1:'; const textEncoder = new TextEncoder(); const base58Encoder = getBase58Encoder(); const base58Decoder = getBase58Decoder(); +/** + * Serialize a voucher to the bytes that get signed. + * + * Per PR #201, the signed message is the JCS-canonicalized JSON of the + * voucher object (no domain separator). The voucher has 3 fields: + * channelId, cumulativeAmount, and optionally expiresAt. + */ export function serializeVoucher(voucher: SessionVoucher): Uint8Array { const canonical = JSON.stringify(canonicalize(voucher)); - return textEncoder.encode(`${DOMAIN_SEPARATOR}${canonical}`); + return textEncoder.encode(canonical); } export async function signVoucher( @@ -78,15 +84,7 @@ export function parseVoucherFromPayload(payload: unknown): SignedSessionVoucher voucher: { channelId: readString(rawVoucher, 'channelId'), cumulativeAmount: readString(rawVoucher, 'cumulativeAmount'), - meter: readString(rawVoucher, 'meter'), - payer: readString(rawVoucher, 'payer'), - recipient: readString(rawVoucher, 'recipient'), - sequence: readInteger(rawVoucher, 'sequence'), - units: readString(rawVoucher, 'units'), ...(expiresAt !== undefined ? { expiresAt } : {}), - chainId: readString(rawVoucher, 'chainId'), - channelProgram: readString(rawVoucher, 'channelProgram'), - serverNonce: readString(rawVoucher, 'serverNonce'), }, }; } @@ -148,11 +146,3 @@ function readOptionalString(record: Record, key: string): strin } return value; } - -function readInteger(record: Record, key: string): number { - const value = record[key]; - if (!Number.isInteger(value)) { - throw new Error(`Expected integer field: ${key}`); - } - return value as number; -} diff --git a/typescript/packages/mpp/src/session/authorizers/BudgetAuthorizer.ts b/typescript/packages/mpp/src/session/authorizers/SwigBudgetAuthorizer.ts similarity index 64% rename from typescript/packages/mpp/src/session/authorizers/BudgetAuthorizer.ts rename to typescript/packages/mpp/src/session/authorizers/SwigBudgetAuthorizer.ts index 4f7724302..cacc3fc04 100644 --- a/typescript/packages/mpp/src/session/authorizers/BudgetAuthorizer.ts +++ b/typescript/packages/mpp/src/session/authorizers/SwigBudgetAuthorizer.ts @@ -5,12 +5,12 @@ import { type AuthorizeCloseInput, type AuthorizedClose, type AuthorizedOpen, - type AuthorizedTopup, - type AuthorizedUpdate, + type AuthorizedTopUp, + type AuthorizedVoucher, type AuthorizeOpenInput, type AuthorizerCapabilities, - type AuthorizeTopupInput, - type AuthorizeUpdateInput, + type AuthorizeTopUpInput, + type AuthorizeVoucherInput, type SessionAuthorizer, } from '../Types.js'; import { signVoucher } from '../Voucher.js'; @@ -56,17 +56,15 @@ type SwigOnChainRoleConfig = { type ChannelProgress = { deposited: bigint; lastCumulative: bigint; - lastSequence: number; maxCumulativeAmount: bigint; maxDepositAmount?: bigint; swigRoleId?: number; }; -export interface BudgetAuthorizerParameters { +export interface SwigBudgetAuthorizerParameters { allowedPrograms?: string[]; - buildCloseTx?: (input: AuthorizeCloseInput) => Promise | string; buildOpenTx?: (input: AuthorizeOpenInput) => Promise | string; - buildTopupTx?: (input: AuthorizeTopupInput) => Promise | string; + buildTopUpTx?: (input: AuthorizeTopUpInput) => Promise | string; maxCumulativeAmount: string; maxDepositAmount?: string; requireApprovalOnTopup?: boolean; @@ -76,15 +74,7 @@ export interface BudgetAuthorizerParameters { validUntil?: string; } -/** - * Session authorizer for `regular_budget` mode. - * - * Budget limits are fail-closed against a concrete on-chain Swig role: - * - `authorizeOpen` reads role constraints from chain and clamps local limits. - * - `authorizeUpdate`/`authorizeTopup`/`authorizeClose` require open-time state. - * - Program access and spend caps are validated from Swig role actions. - */ -export class BudgetAuthorizer implements SessionAuthorizer { +export class SwigBudgetAuthorizer implements SessionAuthorizer { private readonly signer: MessagePartialSigner; private readonly maxCumulativeAmount: bigint; private readonly maxDepositAmount?: bigint; @@ -93,16 +83,15 @@ export class BudgetAuthorizer implements SessionAuthorizer { private readonly allowedPrograms?: Set; private readonly swig: SwigOnChainRoleConfig; private readonly buildOpenTx?: (input: AuthorizeOpenInput) => Promise | string; - private readonly buildTopupTx?: (input: AuthorizeTopupInput) => Promise | string; - private readonly buildCloseTx?: (input: AuthorizeCloseInput) => Promise | string; + private readonly buildTopUpTx?: (input: AuthorizeTopUpInput) => Promise | string; private readonly channels = new Map(); private readonly capabilities: AuthorizerCapabilities; private swigLoaded = false; private swigModule: BudgetSwigModule | null = null; - constructor(parameters: BudgetAuthorizerParameters) { + constructor(parameters: SwigBudgetAuthorizerParameters) { if (!parameters.swig) { - throw new Error('BudgetAuthorizer requires `swig` configuration with on-chain role details'); + throw new Error('SwigBudgetAuthorizer requires `swig` configuration with on-chain role details'); } if (!Number.isInteger(parameters.swig.swigRoleId) || parameters.swig.swigRoleId < 0) { @@ -133,8 +122,7 @@ export class BudgetAuthorizer implements SessionAuthorizer { this.swigLoaded = true; } this.buildOpenTx = parameters.buildOpenTx; - this.buildTopupTx = parameters.buildTopupTx; - this.buildCloseTx = parameters.buildCloseTx; + this.buildTopUpTx = parameters.buildTopUpTx; this.capabilities = { mode: 'regular_budget', @@ -142,12 +130,12 @@ export class BudgetAuthorizer implements SessionAuthorizer { maxCumulativeAmount: this.maxCumulativeAmount.toString(), ...(parameters.maxDepositAmount ? { maxDepositAmount: parameters.maxDepositAmount } : {}), ...(parameters.allowedPrograms ? { allowedPrograms: [...parameters.allowedPrograms] } : {}), - allowedActions: ['open', 'update', 'topup', 'close'], + allowedActions: ['open', 'voucher', 'topUp', 'close'], requiresInteractiveApproval: { close: false, open: false, - topup: parameters.requireApprovalOnTopup ?? false, - update: false, + topUp: parameters.requireApprovalOnTopup ?? false, + voucher: false, }, }; } @@ -164,7 +152,6 @@ export class BudgetAuthorizer implements SessionAuthorizer { this.assertNotExpired(); this.assertProgramAllowed(input.channelProgram); - // Pin this channel's effective limits from on-chain role metadata. const onChainConstraints = await this.resolveOnChainConstraints(input); const deposit = parseNonNegativeAmount(input.depositAmount, 'depositAmount'); @@ -173,118 +160,94 @@ export class BudgetAuthorizer implements SessionAuthorizer { throw new Error(`Open deposit exceeds maxDepositAmount (${maxDepositAmount.toString()})`); } - const openTx = await this.resolveOpenTx(input); + const transaction = await this.resolveOpenTx(input); const voucher = await signVoucher(this.signer, { channelId: input.channelId, cumulativeAmount: '0', - meter: input.pricing?.meter ?? 'session', - payer: this.signer.address, - recipient: input.recipient, - sequence: 0, - units: '0', ...(this.validUntil ? { expiresAt: this.validUntil } : {}), - chainId: normalizeChainId(input.network), - channelProgram: input.channelProgram, - serverNonce: input.serverNonce, }); this.channels.set(input.channelId, { deposited: deposit, lastCumulative: 0n, - lastSequence: 0, maxCumulativeAmount: onChainConstraints.maxCumulativeAmount ?? this.maxCumulativeAmount, ...(maxDepositAmount !== undefined ? { maxDepositAmount } : {}), swigRoleId: onChainConstraints.swigRoleId, }); - return { - capabilities: this.getCapabilities(), - openTx, - voucher, - ...(this.validUntil ? { expiresAt: this.validUntil } : {}), - }; + return { transaction, voucher }; } - async authorizeUpdate(input: AuthorizeUpdateInput): Promise { + async authorizeVoucher(input: AuthorizeVoucherInput): Promise { this.assertNotExpired(); - this.assertProgramAllowed(input.channelProgram); const cumulativeAmount = parseNonNegativeAmount(input.cumulativeAmount, 'cumulativeAmount'); const progress = this.channels.get(input.channelId); if (!progress) { - throw new Error(`Unknown channel ${input.channelId}. Call authorizeOpen before authorizeUpdate.`); + throw new Error(`Unknown channel ${input.channelId}. Call authorizeOpen before authorizeVoucher.`); } - const maxCumulativeAmount = progress.maxCumulativeAmount; - - if (cumulativeAmount > maxCumulativeAmount) { - throw new Error(`Cumulative amount exceeds maxCumulativeAmount (${maxCumulativeAmount.toString()})`); + if (cumulativeAmount > progress.maxCumulativeAmount) { + throw new Error( + `Cumulative amount exceeds maxCumulativeAmount (${progress.maxCumulativeAmount.toString()})`, + ); } - this.assertMonotonic(input.channelId, input.sequence, cumulativeAmount, progress); + if (cumulativeAmount < progress.lastCumulative) { + throw new Error( + `Cumulative amount must not decrease for channel ${input.channelId}. Last=${progress.lastCumulative.toString()}, received=${cumulativeAmount.toString()}`, + ); + } const voucher = await signVoucher(this.signer, { channelId: input.channelId, cumulativeAmount: cumulativeAmount.toString(), - meter: input.meter, - payer: this.signer.address, - recipient: input.recipient, - sequence: input.sequence, - units: input.units, ...(this.validUntil ? { expiresAt: this.validUntil } : {}), - chainId: normalizeChainId(input.network), - channelProgram: input.channelProgram, - serverNonce: input.serverNonce, }); this.channels.set(input.channelId, { - deposited: progress.deposited, + ...progress, lastCumulative: cumulativeAmount, - lastSequence: input.sequence, - maxCumulativeAmount, - ...(progress.maxDepositAmount !== undefined ? { maxDepositAmount: progress.maxDepositAmount } : {}), - ...(progress.swigRoleId !== undefined ? { swigRoleId: progress.swigRoleId } : {}), }); return { voucher }; } - async authorizeTopup(input: AuthorizeTopupInput): Promise { + async authorizeTopUp(input: AuthorizeTopUpInput): Promise { this.assertNotExpired(); this.assertProgramAllowed(input.channelProgram); const additionalAmount = parseNonNegativeAmount(input.additionalAmount, 'additionalAmount'); const progress = this.channels.get(input.channelId); if (!progress) { - throw new Error(`Unknown channel ${input.channelId}. Call authorizeOpen before authorizeTopup.`); + throw new Error(`Unknown channel ${input.channelId}. Call authorizeOpen before authorizeTopUp.`); } const nextDeposited = progress.deposited + additionalAmount; const maxDepositAmount = progress.maxDepositAmount ?? this.maxDepositAmount; if (maxDepositAmount !== undefined && nextDeposited > maxDepositAmount) { - throw new Error(`Topup exceeds maxDepositAmount (${maxDepositAmount.toString()})`); + throw new Error(`TopUp exceeds maxDepositAmount (${maxDepositAmount.toString()})`); } - const topupTx = await this.resolveTopupTx(input); + const transaction = await this.resolveTopUpTx(input); this.channels.set(input.channelId, { + ...progress, deposited: nextDeposited, - lastCumulative: progress.lastCumulative, - lastSequence: progress.lastSequence, - maxCumulativeAmount: progress.maxCumulativeAmount, - ...(maxDepositAmount !== undefined ? { maxDepositAmount } : {}), - ...(progress.swigRoleId !== undefined ? { swigRoleId: progress.swigRoleId } : {}), }); - return { topupTx }; + return { transaction }; } async authorizeClose(input: AuthorizeCloseInput): Promise { this.assertNotExpired(); - this.assertProgramAllowed(input.channelProgram); + + if (!input.finalCumulativeAmount) { + return {}; + } const finalCumulativeAmount = parseNonNegativeAmount(input.finalCumulativeAmount, 'finalCumulativeAmount'); @@ -293,43 +256,30 @@ export class BudgetAuthorizer implements SessionAuthorizer { throw new Error(`Unknown channel ${input.channelId}. Call authorizeOpen before authorizeClose.`); } - const maxCumulativeAmount = progress.maxCumulativeAmount; - - if (finalCumulativeAmount > maxCumulativeAmount) { - throw new Error(`Final cumulative amount exceeds maxCumulativeAmount (${maxCumulativeAmount.toString()})`); + if (finalCumulativeAmount > progress.maxCumulativeAmount) { + throw new Error( + `Final cumulative amount exceeds maxCumulativeAmount (${progress.maxCumulativeAmount.toString()})`, + ); } - this.assertMonotonic(input.channelId, input.sequence, finalCumulativeAmount, progress); + if (finalCumulativeAmount < progress.lastCumulative) { + throw new Error( + `Cumulative amount must not decrease for channel ${input.channelId}. Last=${progress.lastCumulative.toString()}, received=${finalCumulativeAmount.toString()}`, + ); + } const voucher = await signVoucher(this.signer, { channelId: input.channelId, cumulativeAmount: finalCumulativeAmount.toString(), - meter: 'close', - payer: this.signer.address, - recipient: input.recipient, - sequence: input.sequence, - units: '0', ...(this.validUntil ? { expiresAt: this.validUntil } : {}), - chainId: normalizeChainId(input.network), - channelProgram: input.channelProgram, - serverNonce: input.serverNonce, }); - const closeTx = await this.resolveCloseTx(input); - this.channels.set(input.channelId, { - deposited: progress.deposited, + ...progress, lastCumulative: finalCumulativeAmount, - lastSequence: input.sequence, - maxCumulativeAmount, - ...(progress.maxDepositAmount !== undefined ? { maxDepositAmount: progress.maxDepositAmount } : {}), - ...(progress.swigRoleId !== undefined ? { swigRoleId: progress.swigRoleId } : {}), }); - return { - voucher, - ...(closeTx ? { closeTx } : {}), - }; + return { voucher }; } private assertNotExpired() { @@ -348,39 +298,11 @@ export class BudgetAuthorizer implements SessionAuthorizer { } } - private assertMonotonic( - channelId: string, - sequence: number, - cumulativeAmount: bigint, - progress: ChannelProgress | undefined, - ) { - if (!Number.isInteger(sequence) || sequence < 0) { - throw new Error('Sequence must be a non-negative integer'); - } - - if (!progress) { - return; - } - - if (sequence <= progress.lastSequence) { - throw new Error( - `Sequence must increase for channel ${channelId}. Last=${progress.lastSequence}, received=${sequence}`, - ); - } - - if (cumulativeAmount < progress.lastCumulative) { - throw new Error( - `Cumulative amount must not decrease for channel ${channelId}. Last=${progress.lastCumulative.toString()}, received=${cumulativeAmount.toString()}`, - ); - } - } - private async resolveOnChainConstraints(input: AuthorizeOpenInput): Promise<{ maxCumulativeAmount: bigint; maxDepositAmount: bigint; swigRoleId: number; }> { - // Budget mode requires Swig action metadata at runtime. await this.ensureSwigInstalled(); const swigModule = this.swigModule; @@ -405,10 +327,14 @@ export class BudgetAuthorizer implements SessionAuthorizer { throw new Error(`Swig role ${role.id} does not allow channel program ${input.channelProgram}`); } - const onChainLimit = this.resolveOnChainSpendLimit(actions, input); + const isSpl = input.currency !== 'sol'; + const onChainLimit = isSpl + ? this.resolveTokenSpendLimit(actions, input.currency) + : this.resolveSolSpendLimit(actions); + if (onChainLimit === null) { throw new Error( - `Swig role ${role.id} has uncapped ${input.asset.kind.toUpperCase()} spending; BudgetAuthorizer requires an on-chain spend cap`, + `Swig role ${role.id} has uncapped spending; SwigBudgetAuthorizer requires an on-chain spend cap`, ); } @@ -421,7 +347,6 @@ export class BudgetAuthorizer implements SessionAuthorizer { } private resolveSwigRole(swig: SwigAccount): SwigRole { - // Role ID is required by construction, so this path is deterministic. if (!swig.findRoleById) { throw new Error('Swig account object does not expose findRoleById() required for configured swigRoleId'); } @@ -455,23 +380,17 @@ export class BudgetAuthorizer implements SessionAuthorizer { throw new Error(`Configured Swig role ${role.id} does not match signer ${this.signer.address}`); } - private resolveOnChainSpendLimit(actions: SwigRoleActions, input: AuthorizeOpenInput): bigint | null { - if (input.asset.kind === 'spl') { - if (!input.asset.mint) { - throw new Error('asset.mint is required for SPL budget validation'); - } - - if (!actions.tokenSpendLimit) { - throw new Error('Swig role does not expose tokenSpendLimit() for SPL budget validation'); - } - - return actions.tokenSpendLimit(input.asset.mint); + private resolveTokenSpendLimit(actions: SwigRoleActions, currency: string): bigint | null { + if (!actions.tokenSpendLimit) { + throw new Error('Swig role does not expose tokenSpendLimit() for SPL budget validation'); } + return actions.tokenSpendLimit(currency); + } + private resolveSolSpendLimit(actions: SwigRoleActions): bigint | null { if (!actions.solSpendLimit) { throw new Error('Swig role does not expose solSpendLimit() for SOL budget validation'); } - return actions.solSpendLimit(); } @@ -497,33 +416,25 @@ export class BudgetAuthorizer implements SessionAuthorizer { this.swigLoaded = true; } catch { throw new Error( - 'BudgetAuthorizer with `swig` config requires optional dependency `@swig-wallet/kit`. Install it with `npm install @swig-wallet/kit`.', + 'SwigBudgetAuthorizer with `swig` config requires optional dependency `@swig-wallet/kit`. Install it with `npm install @swig-wallet/kit`.', ); } } private async resolveOpenTx(input: AuthorizeOpenInput): Promise { if (!this.buildOpenTx) { - throw new Error('BudgetAuthorizer requires `buildOpenTx` to authorize open requests'); + throw new Error('SwigBudgetAuthorizer requires `buildOpenTx` to authorize open requests'); } return await this.buildOpenTx(input); } - private async resolveTopupTx(input: AuthorizeTopupInput): Promise { - if (!this.buildTopupTx) { - throw new Error('BudgetAuthorizer requires `buildTopupTx` to authorize topup requests'); + private async resolveTopUpTx(input: AuthorizeTopUpInput): Promise { + if (!this.buildTopUpTx) { + throw new Error('SwigBudgetAuthorizer requires `buildTopUpTx` to authorize topUp requests'); } - return await this.buildTopupTx(input); - } - - private async resolveCloseTx(input: AuthorizeCloseInput): Promise { - if (!this.buildCloseTx) { - return undefined; - } - - return await this.buildCloseTx(input); + return await this.buildTopUpTx(input); } } @@ -532,7 +443,6 @@ function collectAuthorityAddresses(authority: SwigRoleAuthority | undefined): st return []; } - // Some Swig authority variants expose only one of these fields. const candidates = [authority.publicKey, authority.ed25519PublicKey, authority.sessionKey]; return candidates.map(candidate => candidate?.toBase58?.()).filter((candidate): candidate is string => !!candidate); @@ -564,11 +474,3 @@ function parseIsoTimestamp(value: string, field: string): number { function minBigInt(a: bigint, b: bigint): bigint { return a <= b ? a : b; } - -function normalizeChainId(network: string): string { - const normalized = network.trim(); - if (normalized.length === 0) { - throw new Error('network must be a non-empty string'); - } - return normalized.startsWith('solana:') ? normalized : `solana:${normalized}`; -} diff --git a/typescript/packages/mpp/src/session/authorizers/SwigSessionAuthorizer.ts b/typescript/packages/mpp/src/session/authorizers/SwigSessionAuthorizer.ts index cd14e1b62..4094558c6 100644 --- a/typescript/packages/mpp/src/session/authorizers/SwigSessionAuthorizer.ts +++ b/typescript/packages/mpp/src/session/authorizers/SwigSessionAuthorizer.ts @@ -5,12 +5,12 @@ import { type AuthorizeCloseInput, type AuthorizedClose, type AuthorizedOpen, - type AuthorizedTopup, - type AuthorizedUpdate, + type AuthorizedTopUp, + type AuthorizedVoucher, type AuthorizeOpenInput, type AuthorizerCapabilities, - type AuthorizeTopupInput, - type AuthorizeUpdateInput, + type AuthorizeTopUpInput, + type AuthorizeVoucherInput, type SessionAuthorizer, type SessionPolicyProfile, type SessionVoucher, @@ -50,7 +50,6 @@ type SessionSignerState = { type ChannelProgress = { deposited: bigint; lastCumulative: bigint; - lastSequence: number; signerAddress: string; swigRoleId?: number; }; @@ -82,21 +81,14 @@ export interface SwigWalletAdapter { export interface SwigSessionAuthorizerParameters { allowedPrograms?: string[]; - buildCloseTx?: (input: AuthorizeCloseInput) => Promise | string; buildOpenTx?: (input: AuthorizeOpenInput) => Promise | string; - buildTopupTx?: (input: AuthorizeTopupInput) => Promise | string; + buildTopUpTx?: (input: AuthorizeTopUpInput) => Promise | string; policy: SwigPolicy; rpcUrl?: string; swigModule?: SwigSessionModule; wallet: SwigWalletAdapter; } -/** - * Session authorizer for `swig_session` mode. - * - * This authorizer binds delegated session keys to on-chain Swig role policy - * before issuing vouchers, then keeps channel state tied to that signer/role. - */ export class SwigSessionAuthorizer implements SessionAuthorizer { private readonly wallet: SwigWalletAdapter; private readonly policy: SwigPolicy; @@ -105,15 +97,14 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { private readonly spendLimit?: bigint; private readonly depositLimit?: bigint; private readonly buildOpenTx?: (input: AuthorizeOpenInput) => Promise | string; - private readonly buildTopupTx?: (input: AuthorizeTopupInput) => Promise | string; - private readonly buildCloseTx?: (input: AuthorizeCloseInput) => Promise | string; + private readonly buildTopUpTx?: (input: AuthorizeTopUpInput) => Promise | string; private readonly channels = new Map(); private swigLoaded = false; private swigModule: SwigSessionModule | null = null; private sessionSigner: KeyPairSigner | null = null; private sessionStartedAtMs: number | null = null; - private sessionOpenTx: string | null = null; + private sessionOpenTransaction: string | null = null; private sessionRoleId: number | null = null; private validatedPolicyForSessionSigner: string | null = null; @@ -139,8 +130,7 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { ? parseNonNegativeAmount(this.policy.depositLimit, 'depositLimit') : undefined; this.buildOpenTx = parameters.buildOpenTx; - this.buildTopupTx = parameters.buildTopupTx; - this.buildCloseTx = parameters.buildCloseTx; + this.buildTopUpTx = parameters.buildTopUpTx; } getMode() { @@ -154,12 +144,12 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { ...(this.policy.spendLimit ? { maxCumulativeAmount: this.policy.spendLimit } : {}), ...(this.policy.depositLimit ? { maxDepositAmount: this.policy.depositLimit } : {}), ...(this.allowedPrograms ? { allowedPrograms: [...this.allowedPrograms] } : {}), - allowedActions: ['open', 'update', 'topup', 'close'], + allowedActions: ['open', 'voucher', 'topUp', 'close'], requiresInteractiveApproval: { close: false, open: true, - topup: !this.policy.autoTopup?.enabled, - update: false, + topUp: !this.policy.autoTopup?.enabled, + voucher: false, }, }; } @@ -177,42 +167,27 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { await this.assertPolicyAppliedOnChain(input, session); const sessionSigner = session.signer; - const openTx = await this.resolveOpenTx(input, session); + const transaction = await this.resolveOpenTx(input, session); const expiresAt = this.getSessionExpiresAt(); const voucher = await this.signSwigVoucher(sessionSigner, { - chainId: normalizeChainId(input.network), channelId: input.channelId, - channelProgram: input.channelProgram, cumulativeAmount: '0', expiresAt, - meter: input.pricing?.meter ?? 'session', - payer: this.wallet.address, - recipient: input.recipient, - sequence: 0, - serverNonce: input.serverNonce, - units: '0', }); this.channels.set(input.channelId, { deposited: deposit, lastCumulative: 0n, - lastSequence: 0, signerAddress: sessionSigner.address, ...(session.swigRoleId !== undefined ? { swigRoleId: session.swigRoleId } : {}), }); - return { - capabilities: this.getCapabilities(), - expiresAt, - openTx, - voucher, - }; + return { transaction, voucher }; } - async authorizeUpdate(input: AuthorizeUpdateInput): Promise { + async authorizeVoucher(input: AuthorizeVoucherInput): Promise { await this.ensureSwigInstalled(); - this.assertProgramAllowed(input.channelProgram); const cumulativeAmount = parseNonNegativeAmount(input.cumulativeAmount, 'cumulativeAmount'); if (this.spendLimit !== undefined && cumulativeAmount > this.spendLimit) { @@ -222,26 +197,21 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { const progress = this.channels.get(input.channelId); const sessionSigner = this.requireActiveSessionSigner(input.channelId, progress); - this.assertMonotonic(input.channelId, input.sequence, cumulativeAmount, progress); + if (progress && cumulativeAmount < progress.lastCumulative) { + throw new Error( + `Cumulative amount must not decrease for channel ${input.channelId}. Last=${progress.lastCumulative.toString()}, received=${cumulativeAmount.toString()}`, + ); + } const voucher = await this.signSwigVoucher(sessionSigner, { - chainId: normalizeChainId(input.network), channelId: input.channelId, - channelProgram: input.channelProgram, cumulativeAmount: cumulativeAmount.toString(), expiresAt: this.getSessionExpiresAt(), - meter: input.meter, - payer: this.wallet.address, - recipient: input.recipient, - sequence: input.sequence, - serverNonce: input.serverNonce, - units: input.units, }); this.channels.set(input.channelId, { deposited: progress?.deposited ?? 0n, lastCumulative: cumulativeAmount, - lastSequence: input.sequence, signerAddress: sessionSigner.address, ...(progress?.swigRoleId !== undefined ? { swigRoleId: progress.swigRoleId } @@ -253,26 +223,25 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { return { voucher }; } - async authorizeTopup(input: AuthorizeTopupInput): Promise { + async authorizeTopUp(input: AuthorizeTopUpInput): Promise { await this.ensureSwigInstalled(); this.assertProgramAllowed(input.channelProgram); const progress = this.channels.get(input.channelId); - const sessionSigner = this.requireActiveSessionSigner(input.channelId, progress); + this.requireActiveSessionSigner(input.channelId, progress); const additionalAmount = parseNonNegativeAmount(input.additionalAmount, 'additionalAmount'); const nextDeposited = (progress?.deposited ?? 0n) + additionalAmount; if (this.depositLimit !== undefined && nextDeposited > this.depositLimit) { - throw new Error(`Topup exceeds depositLimit (${this.depositLimit.toString()})`); + throw new Error(`TopUp exceeds depositLimit (${this.depositLimit.toString()})`); } - const topupTx = await this.resolveTopupTx(input); + const transaction = await this.resolveTopUpTx(input); this.channels.set(input.channelId, { deposited: nextDeposited, lastCumulative: progress?.lastCumulative ?? 0n, - lastSequence: progress?.lastSequence ?? 0, - signerAddress: sessionSigner.address, + signerAddress: progress?.signerAddress ?? this.sessionSigner!.address, ...(progress?.swigRoleId !== undefined ? { swigRoleId: progress.swigRoleId } : this.sessionRoleId !== null @@ -280,12 +249,15 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { : {}), }); - return { topupTx }; + return { transaction }; } async authorizeClose(input: AuthorizeCloseInput): Promise { await this.ensureSwigInstalled(); - this.assertProgramAllowed(input.channelProgram); + + if (!input.finalCumulativeAmount) { + return {}; + } const finalCumulativeAmount = parseNonNegativeAmount(input.finalCumulativeAmount, 'finalCumulativeAmount'); if (this.spendLimit !== undefined && finalCumulativeAmount > this.spendLimit) { @@ -295,28 +267,21 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { const progress = this.channels.get(input.channelId); const sessionSigner = this.requireActiveSessionSigner(input.channelId, progress); - this.assertMonotonic(input.channelId, input.sequence, finalCumulativeAmount, progress); + if (progress && finalCumulativeAmount < progress.lastCumulative) { + throw new Error( + `Cumulative amount must not decrease for channel ${input.channelId}. Last=${progress.lastCumulative.toString()}, received=${finalCumulativeAmount.toString()}`, + ); + } const voucher = await this.signSwigVoucher(sessionSigner, { - chainId: normalizeChainId(input.network), channelId: input.channelId, - channelProgram: input.channelProgram, cumulativeAmount: finalCumulativeAmount.toString(), expiresAt: this.getSessionExpiresAt(), - meter: 'close', - payer: this.wallet.address, - recipient: input.recipient, - sequence: input.sequence, - serverNonce: input.serverNonce, - units: '0', }); - const closeTx = await this.resolveCloseTx(input); - this.channels.set(input.channelId, { deposited: progress?.deposited ?? 0n, lastCumulative: finalCumulativeAmount, - lastSequence: input.sequence, signerAddress: sessionSigner.address, ...(progress?.swigRoleId !== undefined ? { swigRoleId: progress.swigRoleId } @@ -325,10 +290,7 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { : {}), }); - return { - voucher, - ...(closeTx ? { closeTx } : {}), - }; + return { voucher }; } private async signSwigVoucher(signer: KeyPairSigner, voucher: SessionVoucher): Promise { @@ -349,33 +311,6 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { } } - private assertMonotonic( - channelId: string, - sequence: number, - cumulativeAmount: bigint, - progress: ChannelProgress | undefined, - ) { - if (!Number.isInteger(sequence) || sequence < 0) { - throw new Error('Sequence must be a non-negative integer'); - } - - if (!progress) { - return; - } - - if (sequence <= progress.lastSequence) { - throw new Error( - `Sequence must increase for channel ${channelId}. Last=${progress.lastSequence}, received=${sequence}`, - ); - } - - if (cumulativeAmount < progress.lastCumulative) { - throw new Error( - `Cumulative amount must not decrease for channel ${channelId}. Last=${progress.lastCumulative.toString()}, received=${cumulativeAmount.toString()}`, - ); - } - } - private requireActiveSessionSigner(channelId: string, progress: ChannelProgress | undefined): KeyPairSigner { if (!this.sessionSigner || this.sessionStartedAtMs === null) { throw new Error(`No active Swig session key for channel ${channelId}. Call authorizeOpen first.`); @@ -408,7 +343,7 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { if (this.sessionSigner && !this.isSessionExpired()) { return { signer: this.sessionSigner, - ...(this.sessionOpenTx ? { openTx: this.sessionOpenTx } : {}), + ...(this.sessionOpenTransaction ? { openTx: this.sessionOpenTransaction } : {}), ...(this.sessionRoleId !== null ? { swigRoleId: this.sessionRoleId } : {}), ...(this.sessionStartedAtMs !== null ? { createdAtMs: this.sessionStartedAtMs } : {}), }; @@ -418,7 +353,6 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { if (existingResult) { const existing = normalizeSessionSignerState(existingResult, 'getSessionKey'); - // Reuse only when wallet can prove when this session actually started. if (existing.createdAtMs !== undefined) { this.setSessionState(existing); return existing; @@ -506,26 +440,18 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { return await this.buildOpenTx(input); } - private async resolveTopupTx(input: AuthorizeTopupInput): Promise { - if (!this.buildTopupTx) { - throw new Error('SwigSessionAuthorizer requires `buildTopupTx` to authorize topup requests'); + private async resolveTopUpTx(input: AuthorizeTopUpInput): Promise { + if (!this.buildTopUpTx) { + throw new Error('SwigSessionAuthorizer requires `buildTopUpTx` to authorize topUp requests'); } - return await this.buildTopupTx(input); - } - - private async resolveCloseTx(input: AuthorizeCloseInput): Promise { - if (!this.buildCloseTx) { - return undefined; - } - - return await this.buildCloseTx(input); + return await this.buildTopUpTx(input); } private setSessionState(state: SessionSignerState) { this.sessionSigner = state.signer; this.sessionStartedAtMs = state.createdAtMs ?? Date.now(); - this.sessionOpenTx = state.openTx ?? null; + this.sessionOpenTransaction = state.openTx ?? null; this.sessionRoleId = state.swigRoleId ?? this.wallet.swigRoleId ?? null; this.validatedPolicyForSessionSigner = null; } @@ -535,7 +461,6 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { } private async assertPolicyAppliedOnChain(input: AuthorizeOpenInput, session: SessionSignerState) { - // Cache by delegated signer to avoid repeating RPC lookups on every open. if (this.validatedPolicyForSessionSigner === session.signer.address) { return; } @@ -564,16 +489,19 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { this.assertRoleAllowsProgram(actions, input.channelProgram, role.id); - const onChainSpendLimit = this.resolveOnChainSpendLimit(actions, input); - this.assertLimitAtMostPolicy(onChainSpendLimit, this.spendLimit, 'spendLimit', role.id, input.asset); - this.assertLimitAtMostPolicy(onChainSpendLimit, this.depositLimit, 'depositLimit', role.id, input.asset); + const isSpl = input.currency !== 'sol'; + const onChainSpendLimit = isSpl + ? this.resolveTokenSpendLimit(actions, input.currency) + : this.resolveSolSpendLimit(actions); + + this.assertLimitAtMostPolicy(onChainSpendLimit, this.spendLimit, 'spendLimit', role.id, input.currency); + this.assertLimitAtMostPolicy(onChainSpendLimit, this.depositLimit, 'depositLimit', role.id, input.currency); this.sessionRoleId = role.id; this.validatedPolicyForSessionSigner = session.signer.address; } private resolveSessionRole(swig: SwigAccount, session: SessionSignerState): SwigRole { - // Prefer explicit role binding, then validate it against session key lookup. const preferredRoleId = session.swigRoleId ?? this.sessionRoleId ?? this.wallet.swigRoleId; if (preferredRoleId !== undefined && preferredRoleId !== null && swig.findRoleById) { @@ -624,23 +552,17 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { } } - private resolveOnChainSpendLimit(actions: SwigRoleActions, input: AuthorizeOpenInput): bigint | null { - if (input.asset.kind === 'spl') { - if (!input.asset.mint) { - throw new Error('asset.mint is required for SPL session policy validation'); - } - - if (!actions.tokenSpendLimit) { - throw new Error('Swig role does not expose tokenSpendLimit() for SPL policy validation'); - } - - return actions.tokenSpendLimit(input.asset.mint); + private resolveTokenSpendLimit(actions: SwigRoleActions, currency: string): bigint | null { + if (!actions.tokenSpendLimit) { + throw new Error('Swig role does not expose tokenSpendLimit() for SPL policy validation'); } + return actions.tokenSpendLimit(currency); + } + private resolveSolSpendLimit(actions: SwigRoleActions): bigint | null { if (!actions.solSpendLimit) { throw new Error('Swig role does not expose solSpendLimit() for SOL policy validation'); } - return actions.solSpendLimit(); } @@ -649,7 +571,7 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { policyLimit: bigint | undefined, field: 'depositLimit' | 'spendLimit', roleId: number, - asset: AuthorizeOpenInput['asset'], + currency: string, ) { if (policyLimit === undefined) { return; @@ -657,7 +579,7 @@ export class SwigSessionAuthorizer implements SessionAuthorizer { if (onChainLimit === null) { throw new Error( - `Swig role ${roleId} has uncapped ${asset.kind.toUpperCase()} spending, but policy ${field}=${policyLimit.toString()} requires an on-chain cap`, + `Swig role ${roleId} has uncapped ${currency.toUpperCase()} spending, but policy ${field}=${policyLimit.toString()} requires an on-chain cap`, ); } @@ -673,7 +595,6 @@ function normalizeSessionSignerState( value: SwigSessionKeyResult, source: 'createSessionKey' | 'getSessionKey', ): SessionSignerState { - // Backward compatibility: some wallet adapters return a signer directly. if (isSignerLike(value)) { return { signer: value }; } @@ -757,11 +678,3 @@ function parseNonNegativeAmount(value: string, field: string): bigint { return amount; } - -function normalizeChainId(network: string): string { - const normalized = network.trim(); - if (normalized.length === 0) { - throw new Error('network must be a non-empty string'); - } - return normalized.startsWith('solana:') ? normalized : `solana:${normalized}`; -} diff --git a/typescript/packages/mpp/src/session/authorizers/UnboundedAuthorizer.ts b/typescript/packages/mpp/src/session/authorizers/UnboundedAuthorizer.ts index a4a030351..065a26795 100644 --- a/typescript/packages/mpp/src/session/authorizers/UnboundedAuthorizer.ts +++ b/typescript/packages/mpp/src/session/authorizers/UnboundedAuthorizer.ts @@ -4,26 +4,24 @@ import { type AuthorizeCloseInput, type AuthorizedClose, type AuthorizedOpen, - type AuthorizedTopup, - type AuthorizedUpdate, + type AuthorizedTopUp, + type AuthorizedVoucher, type AuthorizeOpenInput, type AuthorizerCapabilities, - type AuthorizeTopupInput, - type AuthorizeUpdateInput, + type AuthorizeTopUpInput, + type AuthorizeVoucherInput, type SessionAuthorizer, } from '../Types.js'; import { signVoucher } from '../Voucher.js'; type ChannelProgress = { lastCumulative: bigint; - lastSequence: number; }; export interface UnboundedAuthorizerParameters { allowedPrograms?: string[]; - buildCloseTx?: (input: AuthorizeCloseInput) => Promise | string; buildOpenTx?: (input: AuthorizeOpenInput) => Promise | string; - buildTopupTx?: (input: AuthorizeTopupInput) => Promise | string; + buildTopUpTx?: (input: AuthorizeTopUpInput) => Promise | string; expiresAt?: string; requiresInteractiveApproval?: Partial; signer: MessagePartialSigner; @@ -35,8 +33,7 @@ export class UnboundedAuthorizer implements SessionAuthorizer { private readonly expiresAt?: string; private readonly expiresAtUnixMs?: number; private readonly buildOpenTx?: (input: AuthorizeOpenInput) => Promise | string; - private readonly buildTopupTx?: (input: AuthorizeTopupInput) => Promise | string; - private readonly buildCloseTx?: (input: AuthorizeCloseInput) => Promise | string; + private readonly buildTopUpTx?: (input: AuthorizeTopUpInput) => Promise | string; private readonly channels = new Map(); private readonly capabilities: AuthorizerCapabilities; @@ -47,21 +44,20 @@ export class UnboundedAuthorizer implements SessionAuthorizer { this.expiresAtUnixMs = parameters.expiresAt !== undefined ? parseIsoTimestamp(parameters.expiresAt, 'expiresAt') : undefined; this.buildOpenTx = parameters.buildOpenTx; - this.buildTopupTx = parameters.buildTopupTx; - this.buildCloseTx = parameters.buildCloseTx; + this.buildTopUpTx = parameters.buildTopUpTx; const requiresInteractiveApproval = { close: parameters.requiresInteractiveApproval?.close ?? false, open: parameters.requiresInteractiveApproval?.open ?? false, - topup: parameters.requiresInteractiveApproval?.topup ?? false, - update: parameters.requiresInteractiveApproval?.update ?? false, + topUp: parameters.requiresInteractiveApproval?.topUp ?? false, + voucher: parameters.requiresInteractiveApproval?.voucher ?? false, }; this.capabilities = { mode: 'regular_unbounded', ...(this.expiresAt ? { expiresAt: this.expiresAt } : {}), ...(parameters.allowedPrograms ? { allowedPrograms: [...parameters.allowedPrograms] } : {}), - allowedActions: ['open', 'update', 'topup', 'close'], + allowedActions: ['open', 'voucher', 'topUp', 'close'], requiresInteractiveApproval, }; } @@ -78,110 +74,77 @@ export class UnboundedAuthorizer implements SessionAuthorizer { this.assertNotExpired(); this.assertProgramAllowed(input.channelProgram); - const openTx = await this.resolveOpenTx(input); + const transaction = await this.resolveOpenTx(input); const voucher = await signVoucher(this.signer, { channelId: input.channelId, cumulativeAmount: '0', - meter: input.pricing?.meter ?? 'session', - payer: this.signer.address, - recipient: input.recipient, - sequence: 0, - units: '0', ...(this.expiresAt ? { expiresAt: this.expiresAt } : {}), - chainId: normalizeChainId(input.network), - channelProgram: input.channelProgram, - serverNonce: input.serverNonce, }); - this.channels.set(input.channelId, { - lastCumulative: 0n, - lastSequence: 0, - }); + this.channels.set(input.channelId, { lastCumulative: 0n }); - return { - capabilities: this.getCapabilities(), - openTx, - voucher, - ...(this.expiresAt ? { expiresAt: this.expiresAt } : {}), - }; + return { transaction, voucher }; } - async authorizeUpdate(input: AuthorizeUpdateInput): Promise { + async authorizeVoucher(input: AuthorizeVoucherInput): Promise { this.assertNotExpired(); - this.assertProgramAllowed(input.channelProgram); const cumulativeAmount = parseNonNegativeAmount(input.cumulativeAmount, 'cumulativeAmount'); const progress = this.channels.get(input.channelId); - this.assertMonotonic(input.channelId, input.sequence, cumulativeAmount, progress); + if (progress !== undefined && cumulativeAmount < progress.lastCumulative) { + throw new Error( + `Cumulative amount must not decrease for channel ${input.channelId}. Last=${progress.lastCumulative.toString()}, received=${cumulativeAmount.toString()}`, + ); + } const voucher = await signVoucher(this.signer, { channelId: input.channelId, cumulativeAmount: cumulativeAmount.toString(), - meter: input.meter, - payer: this.signer.address, - recipient: input.recipient, - sequence: input.sequence, - units: input.units, ...(this.expiresAt ? { expiresAt: this.expiresAt } : {}), - chainId: normalizeChainId(input.network), - channelProgram: input.channelProgram, - serverNonce: input.serverNonce, }); - this.channels.set(input.channelId, { - lastCumulative: cumulativeAmount, - lastSequence: input.sequence, - }); + this.channels.set(input.channelId, { lastCumulative: cumulativeAmount }); return { voucher }; } - async authorizeTopup(input: AuthorizeTopupInput): Promise { + async authorizeTopUp(input: AuthorizeTopUpInput): Promise { this.assertNotExpired(); this.assertProgramAllowed(input.channelProgram); parseNonNegativeAmount(input.additionalAmount, 'additionalAmount'); return { - topupTx: await this.resolveTopupTx(input), + transaction: await this.resolveTopUpTx(input), }; } async authorizeClose(input: AuthorizeCloseInput): Promise { this.assertNotExpired(); - this.assertProgramAllowed(input.channelProgram); + + if (!input.finalCumulativeAmount) { + return {}; + } const finalCumulativeAmount = parseNonNegativeAmount(input.finalCumulativeAmount, 'finalCumulativeAmount'); const progress = this.channels.get(input.channelId); - this.assertMonotonic(input.channelId, input.sequence, finalCumulativeAmount, progress); + if (progress !== undefined && finalCumulativeAmount < progress.lastCumulative) { + throw new Error( + `Cumulative amount must not decrease for channel ${input.channelId}. Last=${progress.lastCumulative.toString()}, received=${finalCumulativeAmount.toString()}`, + ); + } const voucher = await signVoucher(this.signer, { channelId: input.channelId, cumulativeAmount: finalCumulativeAmount.toString(), - meter: 'close', - payer: this.signer.address, - recipient: input.recipient, - sequence: input.sequence, - units: '0', ...(this.expiresAt ? { expiresAt: this.expiresAt } : {}), - chainId: normalizeChainId(input.network), - channelProgram: input.channelProgram, - serverNonce: input.serverNonce, }); - const closeTx = await this.resolveCloseTx(input); - - this.channels.set(input.channelId, { - lastCumulative: finalCumulativeAmount, - lastSequence: input.sequence, - }); + this.channels.set(input.channelId, { lastCumulative: finalCumulativeAmount }); - return { - voucher, - ...(closeTx ? { closeTx } : {}), - }; + return { voucher }; } private assertNotExpired() { @@ -200,33 +163,6 @@ export class UnboundedAuthorizer implements SessionAuthorizer { } } - private assertMonotonic( - channelId: string, - sequence: number, - cumulativeAmount: bigint, - progress: ChannelProgress | undefined, - ) { - if (!Number.isInteger(sequence) || sequence < 0) { - throw new Error('Sequence must be a non-negative integer'); - } - - if (!progress) { - return; - } - - if (sequence <= progress.lastSequence) { - throw new Error( - `Sequence must increase for channel ${channelId}. Last=${progress.lastSequence}, received=${sequence}`, - ); - } - - if (cumulativeAmount < progress.lastCumulative) { - throw new Error( - `Cumulative amount must not decrease for channel ${channelId}. Last=${progress.lastCumulative.toString()}, received=${cumulativeAmount.toString()}`, - ); - } - } - private async resolveOpenTx(input: AuthorizeOpenInput): Promise { if (!this.buildOpenTx) { throw new Error('UnboundedAuthorizer requires `buildOpenTx` to authorize open requests'); @@ -235,20 +171,12 @@ export class UnboundedAuthorizer implements SessionAuthorizer { return await this.buildOpenTx(input); } - private async resolveTopupTx(input: AuthorizeTopupInput): Promise { - if (!this.buildTopupTx) { - throw new Error('UnboundedAuthorizer requires `buildTopupTx` to authorize topup requests'); - } - - return await this.buildTopupTx(input); - } - - private async resolveCloseTx(input: AuthorizeCloseInput): Promise { - if (!this.buildCloseTx) { - return undefined; + private async resolveTopUpTx(input: AuthorizeTopUpInput): Promise { + if (!this.buildTopUpTx) { + throw new Error('UnboundedAuthorizer requires `buildTopUpTx` to authorize topUp requests'); } - return await this.buildCloseTx(input); + return await this.buildTopUpTx(input); } } @@ -274,11 +202,3 @@ function parseIsoTimestamp(value: string, field: string): number { } return unixMs; } - -function normalizeChainId(network: string): string { - const normalized = network.trim(); - if (normalized.length === 0) { - throw new Error('network must be a non-empty string'); - } - return normalized.startsWith('solana:') ? normalized : `solana:${normalized}`; -} diff --git a/typescript/packages/mpp/src/session/authorizers/index.ts b/typescript/packages/mpp/src/session/authorizers/index.ts index b883770f3..e78b7cdef 100644 --- a/typescript/packages/mpp/src/session/authorizers/index.ts +++ b/typescript/packages/mpp/src/session/authorizers/index.ts @@ -1,4 +1,4 @@ -export * from './BudgetAuthorizer.js'; +export * from './SwigBudgetAuthorizer.js'; export * from './SwigSessionAuthorizer.js'; export * from './UnboundedAuthorizer.js'; export * from './makeSessionAuthorizer.js'; diff --git a/typescript/packages/mpp/src/session/authorizers/makeSessionAuthorizer.ts b/typescript/packages/mpp/src/session/authorizers/makeSessionAuthorizer.ts index 919086d19..7f5d72eb9 100644 --- a/typescript/packages/mpp/src/session/authorizers/makeSessionAuthorizer.ts +++ b/typescript/packages/mpp/src/session/authorizers/makeSessionAuthorizer.ts @@ -1,20 +1,17 @@ import type { MessagePartialSigner } from '@solana/kit'; import type { SessionAuthorizer, SessionPolicyProfile } from '../Types.js'; -import { BudgetAuthorizer, type BudgetAuthorizerParameters } from './BudgetAuthorizer.js'; +import { SwigBudgetAuthorizer, type SwigBudgetAuthorizerParameters } from './SwigBudgetAuthorizer.js'; import { SwigSessionAuthorizer, type SwigWalletAdapter } from './SwigSessionAuthorizer.js'; import { UnboundedAuthorizer, type UnboundedAuthorizerParameters } from './UnboundedAuthorizer.js'; export interface MakeSessionAuthorizerParameters { allowedPrograms?: string[]; - buildCloseTx?: ( - input: Parameters>[0], - ) => Promise | string; buildOpenTx?: ( - input: Parameters>[0], + input: Parameters>[0], ) => Promise | string; - buildTopupTx?: ( - input: Parameters>[0], + buildTopUpTx?: ( + input: Parameters>[0], ) => Promise | string; profile: SessionPolicyProfile; rpcUrl?: string; @@ -41,7 +38,7 @@ export function makeSessionAuthorizer(parameters: MakeSessionAuthorizerParameter throw new Error('makeSessionAuthorizer requires `swigWallet.swigRoleId` for profile "wallet-budget"'); } - return new BudgetAuthorizer({ + return new SwigBudgetAuthorizer({ maxCumulativeAmount: profile.maxCumulativeAmount, signer, ...(profile.maxDepositAmount ? { maxDepositAmount: profile.maxDepositAmount } : {}), @@ -54,8 +51,7 @@ export function makeSessionAuthorizer(parameters: MakeSessionAuthorizerParameter ...(parameters.rpcUrl ? { rpcUrl: parameters.rpcUrl } : {}), }, ...(parameters.buildOpenTx ? { buildOpenTx: parameters.buildOpenTx } : {}), - ...(parameters.buildTopupTx ? { buildTopupTx: parameters.buildTopupTx } : {}), - ...(parameters.buildCloseTx ? { buildCloseTx: parameters.buildCloseTx } : {}), + ...(parameters.buildTopUpTx ? { buildTopUpTx: parameters.buildTopUpTx } : {}), }); } @@ -65,10 +61,9 @@ export function makeSessionAuthorizer(parameters: MakeSessionAuthorizerParameter signer, ...(parameters.allowedPrograms ? { allowedPrograms: parameters.allowedPrograms } : {}), ...(parameters.buildOpenTx ? { buildOpenTx: parameters.buildOpenTx } : {}), - ...(parameters.buildTopupTx ? { buildTopupTx: parameters.buildTopupTx } : {}), - ...(parameters.buildCloseTx ? { buildCloseTx: parameters.buildCloseTx } : {}), + ...(parameters.buildTopUpTx ? { buildTopUpTx: parameters.buildTopUpTx } : {}), requiresInteractiveApproval: { - update: profile.requireApprovalOnEveryUpdate, + voucher: profile.requireApprovalOnEveryVoucher, }, }; @@ -86,8 +81,7 @@ export function makeSessionAuthorizer(parameters: MakeSessionAuthorizerParameter ...(parameters.rpcUrl ? { rpcUrl: parameters.rpcUrl } : {}), ...(parameters.allowedPrograms ? { allowedPrograms: parameters.allowedPrograms } : {}), ...(parameters.buildOpenTx ? { buildOpenTx: parameters.buildOpenTx } : {}), - ...(parameters.buildTopupTx ? { buildTopupTx: parameters.buildTopupTx } : {}), - ...(parameters.buildCloseTx ? { buildCloseTx: parameters.buildCloseTx } : {}), + ...(parameters.buildTopUpTx ? { buildTopUpTx: parameters.buildTopUpTx } : {}), }); } } diff --git a/typescript/packages/mpp/src/utils/ed25519.ts b/typescript/packages/mpp/src/utils/ed25519.ts new file mode 100644 index 000000000..370cd918a --- /dev/null +++ b/typescript/packages/mpp/src/utils/ed25519.ts @@ -0,0 +1,81 @@ +/** + * Ed25519 precompile instruction builder. + * + * Constructs the instruction data for the Ed25519 precompile program + * (Ed25519SigVerify111111111111111111111111111) to verify a signature + * over an arbitrary message. + * + * The instruction data layout for a single signature (all inline): + * [0..2] num_signatures: u16 LE = 1 + * [2..4] signature_offset: u16 LE = 16 + * [4..6] signature_instruction_index: u16 LE = 0xFFFF (same instruction) + * [6..8] public_key_offset: u16 LE = 80 + * [8..10] public_key_instruction_index: u16 LE = 0xFFFF + * [10..12] message_data_offset: u16 LE = 112 + * [12..14] message_data_size: u16 LE + * [14..16] message_instruction_index: u16 LE = 0xFFFF + * [16..80] signature: 64 bytes + * [80..112] public_key: 32 bytes + * [112..] message: variable length + */ + +import type { Address, Instruction } from '@solana/kit'; + +const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111' as Address; + +const SAME_INSTRUCTION: number = 0xffff; +// Layout: 2 (num_sigs u16) + 14 (descriptor: 7 x u16) = 16 bytes header +const HEADER_SIZE = 16; +const SIGNATURE_SIZE = 64; +const PUBKEY_SIZE = 32; +const SIGNATURE_OFFSET = HEADER_SIZE; +const PUBKEY_OFFSET = SIGNATURE_OFFSET + SIGNATURE_SIZE; +const MESSAGE_OFFSET = PUBKEY_OFFSET + PUBKEY_SIZE; + +/** + * Build an Ed25519 precompile verify instruction. + * + * The instruction tells the precompile to verify that `signature` is a valid + * Ed25519 signature of `message` by `publicKey`. The runtime verifies the + * signature; our on-chain program then reads the instructions sysvar to + * confirm the precompile was asked to verify the correct key and message. + */ +export function createEd25519VerifyInstruction( + publicKey: Uint8Array, + signature: Uint8Array, + message: Uint8Array, +): Instruction { + if (publicKey.length !== PUBKEY_SIZE) { + throw new Error(`Public key must be ${PUBKEY_SIZE} bytes, got ${publicKey.length}`); + } + if (signature.length !== SIGNATURE_SIZE) { + throw new Error(`Signature must be ${SIGNATURE_SIZE} bytes, got ${signature.length}`); + } + + const totalSize = MESSAGE_OFFSET + message.length; + const data = new Uint8Array(totalSize); + const view = new DataView(data.buffer); + + // num_signatures + view.setUint16(0, 1, true); + + // Descriptor (starts at offset 2, immediately after num_signatures) + view.setUint16(2, SIGNATURE_OFFSET, true); // signature_offset + view.setUint16(4, SAME_INSTRUCTION, true); // signature_instruction_index + view.setUint16(6, PUBKEY_OFFSET, true); // public_key_offset + view.setUint16(8, SAME_INSTRUCTION, true); // public_key_instruction_index + view.setUint16(10, MESSAGE_OFFSET, true); // message_data_offset + view.setUint16(12, message.length, true); // message_data_size + view.setUint16(14, SAME_INSTRUCTION, true); // message_instruction_index + + // Data + data.set(signature, SIGNATURE_OFFSET); + data.set(publicKey, PUBKEY_OFFSET); + data.set(message, MESSAGE_OFFSET); + + return { + accounts: [], + data, + programAddress: ED25519_PROGRAM_ID, + }; +} diff --git a/typescript/vitest.config.anchor.ts b/typescript/vitest.config.anchor.ts new file mode 100644 index 000000000..037dd5ce3 --- /dev/null +++ b/typescript/vitest.config.anchor.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['packages/*/src/__tests__/anchor-channel.test.ts'], + testTimeout: 120_000, + fileParallelism: false, + maxWorkers: 1, + globals: true, + }, +}); diff --git a/typescript/vitest.config.ts b/typescript/vitest.config.ts index 52f829a08..17f788efc 100644 --- a/typescript/vitest.config.ts +++ b/typescript/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['packages/*/src/__tests__/*.test.ts'], - exclude: ['**/integration.test.ts'], + exclude: ['**/integration.test.ts', '**/anchor-channel.test.ts'], testTimeout: 15_000, globals: true, },