From 57a891b99bc8a07b44bc0a67b2d98000c256e00c Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:18:07 +0000 Subject: [PATCH] feat(tempo): generate native MPP settlement sequences Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com> --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 53 ++++ crates/txgen-cli/src/generate.rs | 84 ++++++- crates/txgen-core/src/spec.rs | 4 + crates/txgen-tempo/Cargo.toml | 1 + crates/txgen-tempo/src/lib.rs | 22 +- crates/txgen-tempo/src/mpp.rs | 143 +++++++++++ crates/txgen-tempo/src/template.rs | 3 + crates/txgen-tempo/tests/mpp_settlement.rs | 270 +++++++++++++++++++++ 10 files changed, 576 insertions(+), 6 deletions(-) create mode 100644 crates/txgen-tempo/src/mpp.rs create mode 100644 crates/txgen-tempo/tests/mpp_settlement.rs diff --git a/Cargo.lock b/Cargo.lock index 577357d9..2c94323f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6071,6 +6071,7 @@ dependencies = [ "alloy-primitives", "alloy-provider", "alloy-signer", + "alloy-sol-types", "alloy-transport", "axum", "clap", diff --git a/Cargo.toml b/Cargo.toml index 5d3eed80..3e706340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ serde_yaml = "0.9" # alloy alloy-primitives = { version = "1", features = ["rand", "serde"] } +alloy-sol-types = "1" alloy-dyn-abi = { version = "1", features = ["eip712"] } alloy-json-abi = "1" alloy-signer = "2" diff --git a/README.md b/README.md index 4468b699..d534e110 100644 --- a/README.md +++ b/README.md @@ -1632,6 +1632,13 @@ Supported binding references: | `u64` | `` | | `string` | `` | +Add `save: ` to a sequence step to expose its signed transaction to later +steps as `.raw` (EIP-2718 bytes), `.tx_hash`, and `.sender`. +Saved names must be nonempty, contain no dots, and not collide with another +binding or saved step in that instance. These outputs are derived offline, not +from receipts or return values. Only saved steps are signed synchronously; +other steps retain parallel signing and ordered output. + Sequences also expose `{ var: chain_id }` as the top-level workload `chain_id` unless a binding named `chain_id` is defined. Hash bindings can reference other sequence bindings and are resolved once per sequence instance. For deterministic IDs that contracts compute with `keccak256(abi.encode(...))` (such as Tempo MPP channel IDs), use `abi_hash`: @@ -1665,6 +1672,52 @@ When set, `txgen generate -n` counts emitted transactions, not sequence instance See `examples/sequence.yaml` for a small syntax example, `examples/tip20-sequence.yaml` for a Tempo TIP20 `approve -> transferFrom` sequence whose second transaction depends on the first, and `examples/tip20-mpp.yaml` for TIP20 transfers mixed with deterministic MPP channel `open -> close` sequences. +#### Native MPP settlement + +For the native TIP-20 Channel Reserve at +`0x4d50500000000000000000000000000000000000`, save the signed opening transaction +and use a Tempo `mpp_settle` template for the next step: + +```yaml +templates: + # mpp_open calls the native reserve's open(payee, operator, token, deposit, + # salt, authorizedSigner), with payer.ref as from and payee.address as payee. + mpp_settle: + type: tempo + from: { var: payee.ref } + gas_limit: 1000000 + expiring_nonce: true + valid_for_secs: 25 + mpp_settle: + open_transaction: { var: opened.raw } + voucher_signer: { var: payer.ref } + cumulative_amount: 1 + +sequences: + mpp_open_settle: + bindings: + payer: { account: { pool: users, select: random } } + payee: { account: { pool: users, select: random } } + steps: + - template: mpp_open + save: opened + - template: mpp_settle +``` + +The adapter decodes the signed open, recovers the payer, derives its transaction +context hash and channel descriptor, and signs the reserve's EIP-712 voucher. +This includes the opening transaction's actual nonce, validity window, and +signing context; a salt alone is not sufficient to derive the native channel ID. +No RPC receipt lookup is required, and sequence scheduling still enforces order. + +`call_index` selects the open in a batched transaction (default `0`). The +settlement sender must be the payee or operator; `voucher_signer` must select the +authorized signer, or the payer if `authorizedSigner` was zero. The helper +supports a positive initial settlement up to the opening deposit, not a +top-up-dependent cumulative amount. It requires `type: tempo` and cannot be +combined with `call`, `calls`, `to`, `input`, or a nonzero `value`. +Settlement pays the cumulative amount but does not close the channel. + ## Supported Chains ### Ethereum (`txgen-ethereum`) diff --git a/crates/txgen-cli/src/generate.rs b/crates/txgen-cli/src/generate.rs index 287e758b..dcfab82e 100644 --- a/crates/txgen-cli/src/generate.rs +++ b/crates/txgen-cli/src/generate.rs @@ -946,6 +946,7 @@ enum ResolvedBinding { U256(U256), U64(u64), String(String), + SignedTx { raw: Bytes, tx_hash: B256, sender: Address }, SetupTx { address: Option
, tx_hash: B256, sender: Address, nonce: u64 }, } @@ -1214,6 +1215,20 @@ struct SigningPool { } impl SigningPool { + fn submit_ready( + &mut self, + sequence: u64, + tx: GeneratedTx, + writer: &mut NdjsonWriter, + ) -> Result<()> { + self.drain_available(writer)?; + while self.in_flight >= self.max_in_flight { + self.recv_one(writer)?; + } + self.in_flight += 1; + self.handle_result(SigningResult { sequence, result: Ok(tx) }, writer) + } + fn new(worker_count: usize) -> Result { if worker_count == 0 { bail!("signing worker count must be at least 1"); @@ -1477,13 +1492,19 @@ where .checked_add(1) .ok_or_else(|| eyre::eyre!("sequence instance counter overflowed u64"))?; let sequence_key = compute_sequence_key(&name, sequence_instance); - let bindings = resolve_sequence_bindings(&sequence.bindings, ctx, setup_bindings) - .wrap_err_with(|| { - format!("failed to resolve bindings for sequence '{name}'") - })?; + let mut bindings = + resolve_sequence_bindings(&sequence.bindings, ctx, setup_bindings) + .wrap_err_with(|| { + format!("failed to resolve bindings for sequence '{name}'") + })?; for (idx, step) in sequence.steps.iter().enumerate() { let label = step.name.as_deref().unwrap_or(&step.template); + if let Some(save) = &step.save && + (save.is_empty() || save.contains('.') || bindings.contains_key(save)) + { + bail!("sequence '{name}' step '{label}' has invalid or duplicate save '{save}'"); + } let base = spec .templates .get(&step.template) @@ -1503,7 +1524,24 @@ where sequence, ctx, )?; - signing_pool.submit(job, writer)?; + if let Some(save) = &step.save { + // The next step may depend on these signed bytes. Sign this + // step now, retaining the pool's ordered, bounded output. + let tx = sign_workload_job::(job)?; + bindings.insert( + save.clone(), + ResolvedBinding::SignedTx { + raw: tx.raw.clone(), + tx_hash: keccak256(&tx.raw), + sender: tx.sender.ok_or_else(|| { + eyre::eyre!("saved transaction has no sender") + })?, + }, + ); + signing_pool.submit_ready(sequence, tx, writer)?; + } else { + signing_pool.submit(job, writer)?; + } written += 1; } } @@ -1852,6 +1890,18 @@ fn binding_to_value( (ResolvedBinding::U256(value), None) => Ok(serde_yaml::Value::String(value.to_string())), (ResolvedBinding::U64(value), None) => Ok(serde_yaml::to_value(value)?), (ResolvedBinding::String(value), None) => Ok(serde_yaml::Value::String(value.clone())), + (ResolvedBinding::SignedTx { raw, .. }, Some("raw")) => { + Ok(serde_yaml::Value::String(raw.to_string())) + } + (ResolvedBinding::SignedTx { tx_hash, .. }, Some("tx_hash")) => { + Ok(serde_yaml::Value::String(tx_hash.to_string())) + } + (ResolvedBinding::SignedTx { sender, .. }, Some("sender")) => { + Ok(serde_yaml::Value::String(sender.to_string())) + } + (ResolvedBinding::SignedTx { .. }, None) => { + bail!("signed transaction binding '{name}' requires a field"); + } (ResolvedBinding::SetupTx { address: Some(address), .. }, Some("address")) => { Ok(serde_yaml::Value::String(address.to_string())) } @@ -1918,6 +1968,30 @@ mod tests { use std::collections::HashMap; use txgen_core::{derive_mnemonic_signer, GasConfig}; + #[test] + fn signed_transaction_bindings_expose_only_offline_outputs() { + let raw = Bytes::from_static(&[0x76, 0x01]); + let hash = keccak256(&raw); + let sender = Address::repeat_byte(7); + let bindings = HashMap::from([( + "opened".to_string(), + ResolvedBinding::SignedTx { raw: raw.clone(), tx_hash: hash, sender }, + )]); + for (field, value) in [ + ("raw", raw.to_string()), + ("tx_hash", hash.to_string()), + ("sender", sender.to_string()), + ] { + assert_eq!( + binding_to_value(&format!("opened.{field}"), &bindings).unwrap(), + serde_yaml::Value::String(value) + ); + } + assert!(binding_to_value("opened", &bindings).is_err()); + assert!(binding_to_value("opened.receipt", &bindings).is_err()); + assert!(binding_to_value("future.raw", &bindings).is_err()); + } + struct PendingPrepareAdapter; struct MutatingKeyAdapter; struct MutatingKeySignContext; diff --git a/crates/txgen-core/src/spec.rs b/crates/txgen-core/src/spec.rs index dde7ef48..daadc3cb 100644 --- a/crates/txgen-core/src/spec.rs +++ b/crates/txgen-core/src/spec.rs @@ -163,6 +163,10 @@ pub struct SequenceStep { /// Optional human-readable step name for diagnostics. #[serde(default)] pub name: Option, + /// Save the signed transaction for subsequent steps as `.raw`, + /// `.tx_hash`, and `.sender`. This does not submit the transaction. + #[serde(default)] + pub save: Option, /// Template name to instantiate for this step. pub template: String, /// Per-step YAML overlay applied over the referenced template. diff --git a/crates/txgen-tempo/Cargo.toml b/crates/txgen-tempo/Cargo.toml index 12dd516f..4d555bad 100644 --- a/crates/txgen-tempo/Cargo.toml +++ b/crates/txgen-tempo/Cargo.toml @@ -24,6 +24,7 @@ clap.workspace = true # alloy alloy-primitives.workspace = true +alloy-sol-types.workspace = true alloy-consensus.workspace = true alloy-signer.workspace = true alloy-network.workspace = true diff --git a/crates/txgen-tempo/src/lib.rs b/crates/txgen-tempo/src/lib.rs index 6e054c31..174fdb0a 100644 --- a/crates/txgen-tempo/src/lib.rs +++ b/crates/txgen-tempo/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth_token_map; +mod mpp; mod nonce; mod template; mod zone; @@ -455,7 +456,25 @@ impl NetworkAdapter for TempoAdapter { } }; - let (to, value, input, calls) = resolve_call_data(&template, is_tempo, ctx)?; + let (to, value, input, calls) = if let Some(settle) = &template.mpp_settle { + if !is_tempo || + template.call.is_some() || + template.calls.is_some() || + template.to.is_some() || + template.input.is_some() || + ctx.resolve_value(&template.value)? != U256::ZERO + { + bail!("mpp_settle requires type: tempo and cannot be combined with call/calls/to/input/value"); + } + ( + TxKind::Create, + U256::ZERO, + Bytes::new(), + vec![mpp::settlement_call(settle, selected.address, ctx)?], + ) + } else { + resolve_call_data(&template, is_tempo, ctx)? + }; let mut req = TempoTransactionRequest::default(); req.set_chain_id(ctx.chain_id); @@ -1242,6 +1261,7 @@ mod tests { fn base_template(tx_type: TempoTxType) -> TempoTemplate { TempoTemplate { + mpp_settle: None, tx_type, from: AccountRef { pool: "users".to_string(), select: SelectMode::Index(0) }, gas_limit: 21000, diff --git a/crates/txgen-tempo/src/mpp.rs b/crates/txgen-tempo/src/mpp.rs new file mode 100644 index 00000000..7c7f42e3 --- /dev/null +++ b/crates/txgen-tempo/src/mpp.rs @@ -0,0 +1,143 @@ +//! Native TIP-20 Channel Reserve settlement from a signed opening transaction. + +use alloy_consensus::transaction::SignerRecoverable; +use alloy_eips::eip2718::Decodable2718; +use alloy_primitives::{address, keccak256, Address, Bytes, TxKind, B256, U256}; +use alloy_signer::SignerSync; +use alloy_sol_types::{sol, SolCall, SolValue}; +use eyre::{bail, Result, WrapErr}; +use serde::Deserialize; +use tempo_primitives::{transaction::Call, TempoTxEnvelope}; +use txgen_core::{AccountRef, BuildContext}; + +const RESERVE: Address = address!("4d50500000000000000000000000000000000000"); + +sol! { + struct ChannelDescriptor { + address payer; + address payee; + address operator; + address token; + bytes32 salt; + address authorizedSigner; + bytes32 expiringNonceHash; + } + + function open(address payee, address operator, address token, uint96 deposit, + bytes32 salt, address authorizedSigner) returns (bytes32 channelId); + function settle(ChannelDescriptor descriptor, uint96 cumulativeAmount, bytes signature); +} + +/// A settlement's payload is derived from the actual signed open, never a guessed channel ID. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MppSettleDef { + pub open_transaction: Bytes, + /// Index of the native `open` call in the opening transaction's call array. + #[serde(default)] + pub call_index: usize, + pub voucher_signer: AccountRef, + pub cumulative_amount: alloy_primitives::aliases::U96, +} + +pub(crate) fn settlement_call( + def: &MppSettleDef, + sender: Address, + ctx: &mut BuildContext<'_>, +) -> Result { + let mut encoded = def.open_transaction.as_ref(); + let envelope = TempoTxEnvelope::decode_2718(&mut encoded) + .wrap_err("mpp_settle requires a signed opening transaction")?; + if !encoded.is_empty() { + bail!("mpp_settle opening transaction has trailing bytes"); + } + let signed = envelope + .as_aa() + .ok_or_else(|| eyre::eyre!("mpp_settle requires a Tempo opening transaction"))?; + let tx = signed.tx(); + if tx.chain_id != ctx.chain_id { + bail!("mpp_settle opening transaction belongs to another chain"); + } + let call = tx + .calls + .get(def.call_index) + .ok_or_else(|| eyre::eyre!("mpp_settle opening call index out of bounds"))?; + if call.to != TxKind::Call(RESERVE) || !call.value.is_zero() { + bail!("mpp_settle opening call must target the native Channel Reserve with zero value"); + } + let open = openCall::abi_decode_validate(&call.input) + .wrap_err("mpp_settle opening call is not a valid native open")?; + let payer = signed.recover_signer().wrap_err("invalid opening transaction signature")?; + let descriptor = ChannelDescriptor { + payer, + payee: open.payee, + operator: open.operator, + token: open.token, + salt: open.salt, + authorizedSigner: open.authorizedSigner, + expiringNonceHash: signed.expiring_nonce_hash(payer), + }; + if sender != descriptor.payee && + (descriptor.operator.is_zero() || sender != descriptor.operator) + { + bail!("mpp_settle sender must be the channel payee or operator"); + } + if def.cumulative_amount.is_zero() || def.cumulative_amount > open.deposit { + bail!( + "mpp_settle cumulative_amount must be positive and no greater than the opening deposit" + ); + } + let selected = ctx.select_signer(&def.voucher_signer)?; + let expected_signer = + if descriptor.authorizedSigner.is_zero() { payer } else { descriptor.authorizedSigner }; + if selected.address != expected_signer { + bail!("mpp_settle voucher_signer does not match the channel's authorized signer"); + } + let channel_id = keccak256( + ( + descriptor.payer, + descriptor.payee, + descriptor.operator, + descriptor.token, + descriptor.salt, + descriptor.authorizedSigner, + descriptor.expiringNonceHash, + RESERVE, + U256::from(ctx.chain_id), + ) + .abi_encode(), + ); + let digest = voucher_digest(ctx.chain_id, channel_id, def.cumulative_amount); + let signer = ctx.accounts.get_by_index(&selected.pool, selected.index)?; + let signature = signer.sign_hash_sync(&digest)?; + Ok(Call { + to: TxKind::Call(RESERVE), + value: U256::ZERO, + input: settleCall { + descriptor, + cumulativeAmount: def.cumulative_amount, + signature: signature.as_bytes().into(), + } + .abi_encode() + .into(), + }) +} + +fn voucher_digest(chain_id: u64, channel_id: B256, amount: alloy_primitives::aliases::U96) -> B256 { + let domain = keccak256(( + keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(b"TIP20 Channel Reserve"), + keccak256(b"1"), + U256::from(chain_id), + RESERVE, + ).abi_encode()); + let message = keccak256( + (keccak256(b"Voucher(bytes32 channelId,uint96 cumulativeAmount)"), channel_id, amount) + .abi_encode(), + ); + let mut input = [0u8; 66]; + input[..2].copy_from_slice(&[0x19, 0x01]); + input[2..34].copy_from_slice(domain.as_slice()); + input[34..].copy_from_slice(message.as_slice()); + keccak256(input) +} diff --git a/crates/txgen-tempo/src/template.rs b/crates/txgen-tempo/src/template.rs index 1ebd878e..f7c1f39f 100644 --- a/crates/txgen-tempo/src/template.rs +++ b/crates/txgen-tempo/src/template.rs @@ -21,6 +21,9 @@ pub enum TempoTxType { /// fee tokens, and batched calls. #[derive(Debug, Clone, Deserialize)] pub struct TempoTemplate { + /// Native Channel Reserve settlement built from a prior signed opening transaction. + #[serde(default)] + pub mpp_settle: Option, /// Transaction type. #[serde(rename = "type")] pub tx_type: TempoTxType, diff --git a/crates/txgen-tempo/tests/mpp_settlement.rs b/crates/txgen-tempo/tests/mpp_settlement.rs new file mode 100644 index 00000000..0145d6d0 --- /dev/null +++ b/crates/txgen-tempo/tests/mpp_settlement.rs @@ -0,0 +1,270 @@ +use alloy_consensus::transaction::SignerRecoverable; +use alloy_eips::eip2718::Decodable2718; +use alloy_primitives::{ + address, aliases::U96, keccak256, Address, Bytes, Signature, TxKind, B256, U256, +}; +use alloy_sol_types::{eip712_domain, sol, SolCall, SolStruct, SolValue}; +use serde_json::{json, Value}; +use std::{ + fs, + process::Command, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tempo_primitives::TempoTxEnvelope; +use txgen_core::derive_mnemonic_signer; + +const MNEMONIC: &str = "test test test test test test test test test test test junk"; +const RESERVE: Address = address!("4d50500000000000000000000000000000000000"); +const TOKEN: Address = address!("20c0000000000000000000000000000000000001"); + +sol! { + struct ChannelDescriptor { + address payer; + address payee; + address operator; + address token; + bytes32 salt; + address authorizedSigner; + bytes32 expiringNonceHash; + } + struct Voucher { bytes32 channelId; uint96 cumulativeAmount; } + function open(address payee, address operator, address token, uint96 deposit, + bytes32 salt, address authorizedSigner) returns (bytes32 channelId); + function settle(ChannelDescriptor descriptor, uint96 cumulativeAmount, bytes signature); +} + +fn account(index: u32) -> Address { + static ACCOUNTS: std::sync::OnceLock<[Address; 3]> = std::sync::OnceLock::new(); + ACCOUNTS.get_or_init(|| { + std::array::from_fn(|i| derive_mnemonic_signer(MNEMONIC, i as u32).unwrap().address()) + })[index as usize] +} + +fn spec(authorized_signer: bool) -> Value { + let input = openCall { + payee: account(1), + operator: Address::ZERO, + token: TOKEN, + deposit: U96::from(10), + salt: B256::repeat_byte(1), + authorizedSigner: if authorized_signer { account(2) } else { Address::ZERO }, + } + .abi_encode(); + json!({ + "chain_id": 1337, + "gas": {"max_fee_per_gas": 1000000000, "max_priority_fee_per_gas": 1000000000}, + "accounts": {"users": {"mnemonic": MNEMONIC, "range": [0, 3]}}, + "templates": { + "open": { + "type": "tempo", "from": {"pool": "users", "select": {"index": 0}}, + "gas_limit": 1000000, "expiring_nonce": true, "valid_before": 2000000000, + "to": RESERVE, "input": Bytes::from(input) + }, + "settle": { + "type": "tempo", "from": {"pool": "users", "select": {"index": 1}}, + "gas_limit": 1000000, "expiring_nonce": true, "valid_before": 2000000000, + "mpp_settle": { + "open_transaction": {"var": "opened.raw"}, + "voucher_signer": {"pool": "users", "select": {"index": if authorized_signer {2} else {0}}}, + "cumulative_amount": 10 + } + } + }, + "sequences": {"channel": {"steps": [ + {"template": "open", "save": "opened"}, {"template": "settle"} + ]}}, + "mix": [{"sequence": "channel", "weight": 1}] + }) +} + +fn generate(spec: &Value, count: usize, workers: usize) -> Result, String> { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "txgen-mpp-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("spec.yaml"); + let output_path = dir.join("txs.ndjson"); + fs::write(&path, serde_yaml::to_string(spec).unwrap()).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_txgen-tempo")) + .args([ + "generate", + "--seed", + "42", + "--count", + &count.to_string(), + "--signing-workers", + &workers.to_string(), + ]) + .arg("--spec") + .arg(path) + .arg("--output") + .arg(&output_path) + .output() + .unwrap(); + let result = if output.status.success() { + Ok(fs::read_to_string(output_path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect()) + } else { + Err(String::from_utf8_lossy(&output.stderr).into_owned()) + }; + fs::remove_dir_all(dir).unwrap(); + result +} + +fn decode(row: &Value) -> TempoTxEnvelope { + let raw: Bytes = serde_json::from_value(row["raw"].clone()).unwrap(); + TempoTxEnvelope::decode_2718(&mut raw.as_ref()).unwrap() +} + +#[test] +fn saved_opens_produce_ordered_deterministic_settlements() { + // Exceeds the single worker's bounded signing queue. An odd budget must not + // emit an opening transaction without its settlement. + let spec = spec(false); + let rows = generate(&spec, 257, 1).unwrap(); + assert_eq!(rows, generate(&spec, 257, 4).unwrap()); + assert_eq!(rows.len(), 256); + let mut channels = std::collections::HashSet::new(); + for pair in rows.as_chunks::<2>().0 { + assert_eq!(pair[0]["inclusion_keys"], pair[1]["inclusion_keys"]); + assert!(!pair[0]["inclusion_keys"].as_array().unwrap().is_empty()); + let channel = assert_settlement(&pair[0], &pair[1], account(0)); + assert!(channels.insert(channel)); + } +} + +#[test] +fn authorized_voucher_signer_is_used_instead_of_payer() { + let rows = generate(&spec(true), 2, 2).unwrap(); + assert_settlement(&rows[0], &rows[1], account(2)); +} + +#[test] +fn sponsored_open_uses_its_actual_signed_context() { + let mut spec = spec(false); + spec["templates"]["open"]["sponsor"] = json!({"pool": "users", "select": {"index": 2}}); + let rows = generate(&spec, 2, 2).unwrap(); + assert_settlement(&rows[0], &rows[1], account(0)); +} + +#[test] +fn wrong_chain_and_malformed_open_are_rejected() { + let original = generate(&spec(false), 2, 1).unwrap(); + let raw = original[0]["raw"].as_str().unwrap(); + let mut wrong_chain = spec(false); + wrong_chain["chain_id"] = json!(1); + wrong_chain["templates"]["settle"]["mpp_settle"]["open_transaction"] = json!(raw); + assert!(generate(&wrong_chain, 2, 1).unwrap_err().contains("belongs to another chain")); + + let mut trailing = spec(false); + trailing["templates"]["settle"]["mpp_settle"]["open_transaction"] = json!(format!("{raw}00")); + assert!(generate(&trailing, 2, 1).unwrap_err().contains("trailing bytes")); + + let mut wrong_target = spec(false); + wrong_target["templates"]["open"]["to"] = json!(Address::ZERO); + assert!(generate(&wrong_target, 2, 1) + .unwrap_err() + .contains("must target the native Channel Reserve")); + + let mut wrong_call = spec(false); + wrong_call["templates"]["open"]["input"] = json!("0xdeadbeef"); + assert!(generate(&wrong_call, 2, 1).unwrap_err().contains("not a valid native open")); + + let mut duplicate = spec(false); + duplicate["sequences"]["channel"]["steps"][1]["save"] = json!("opened"); + assert!(generate(&duplicate, 2, 1).unwrap_err().contains("invalid or duplicate save")); +} + +fn assert_settlement(open: &Value, settled: &Value, signer: Address) -> B256 { + let opening = decode(open); + let opening = opening.as_aa().unwrap(); + let settlement = decode(settled); + let settlement = settlement.as_aa().unwrap(); + assert_eq!(settlement.recover_signer().unwrap(), account(1)); + let call = &settlement.tx().calls[0]; + assert_eq!(call.to, TxKind::Call(RESERVE)); + let decoded = settleCall::abi_decode_validate(&call.input).unwrap(); + let d = &decoded.descriptor; + assert_eq!(d.payer, account(0)); + assert_eq!(d.payee, account(1)); + assert_eq!(d.operator, Address::ZERO); + assert_eq!(d.token, TOKEN); + assert_eq!(d.salt, B256::repeat_byte(1)); + assert_eq!(d.expiringNonceHash, opening.expiring_nonce_hash(d.payer)); + assert_ne!(d.expiringNonceHash, B256::ZERO); + let id = keccak256( + ( + d.payer, + d.payee, + d.operator, + d.token, + d.salt, + d.authorizedSigner, + d.expiringNonceHash, + RESERVE, + U256::from(1337), + ) + .abi_encode(), + ); + assert_eq!(decoded.cumulativeAmount, U96::from(10)); + // Use Alloy's typed EIP-712 implementation independently of the generator's + // manual domain/struct encoding. + let digest = Voucher { channelId: id, cumulativeAmount: decoded.cumulativeAmount } + .eip712_signing_hash(&eip712_domain! { + name: "TIP20 Channel Reserve", version: "1", chain_id: 1337, + verifying_contract: RESERVE, + }); + assert_eq!( + Signature::try_from(decoded.signature.as_ref()) + .unwrap() + .recover_address_from_prehash(&digest) + .unwrap(), + signer + ); + id +} + +#[test] +fn invalid_settlements_and_save_names_are_rejected() { + for (pointer, value, expected) in [ + ( + "/templates/settle/mpp_settle/voucher_signer/select/index", + json!(2), + "voucher_signer does not match", + ), + ( + "/templates/settle/from/select/index", + json!(2), + "sender must be the channel payee or operator", + ), + ( + "/templates/settle/mpp_settle/cumulative_amount", + json!(0), + "cumulative_amount must be positive", + ), + ( + "/templates/settle/mpp_settle/cumulative_amount", + json!(11), + "no greater than the opening deposit", + ), + ("/templates/settle/mpp_settle/call_index", json!(1), "opening call index out of bounds"), + ("/sequences/channel/steps/0/save", json!("bad.name"), "invalid or duplicate save"), + ("/sequences/channel/steps/0/save", json!(""), "invalid or duplicate save"), + ] { + let mut spec = spec(false); + // call_index is optional in the successful fixture. + if pointer.ends_with("/call_index") { + spec["templates"]["settle"]["mpp_settle"]["call_index"] = value; + } else { + *spec.pointer_mut(pointer).unwrap() = value; + } + let error = generate(&spec, 2, 1).unwrap_err(); + assert!(error.contains(expected), "expected {expected}, got {error}"); + } +}