diff --git a/Anchor.toml b/Anchor.toml index 4a30105..4072d8e 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -8,7 +8,11 @@ skip-lint = false [programs.localnet] auth_capture_escrow = "AcESCRow1111111111111111111111111111111111" +payment_operator = "PmtOpr11111111111111111111111111111111111111" spl_token_collector = "SPLCo11ector1111111111111111111111111111111" +static_address_condition = "StAtAdr1111111111111111111111111111111111" +receiver_condition = "rcvr111111111111111111111111111111111111111" +payer_condition = "pyr1111111111111111111111111111111111111111" [programs.devnet] # Filled in by `migrations/deploy-devnet.ts` after `anchor deploy --provider.cluster devnet`. diff --git a/Cargo.toml b/Cargo.toml index 179e80f..ae74cc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,11 @@ [workspace] members = [ "programs/auth-capture-escrow", + "programs/payment-operator", "programs/spl-token-collector", + "programs/static-address-condition", + "programs/receiver-condition", + "programs/payer-condition", "fuzz", ] resolver = "2" diff --git a/README.md b/README.md index 0ce2601..5e19461 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,29 @@ Anchor workspace for the x402r `authCapture` scheme on Solana. > **Pilot. Unaudited.** Mainnet usage is at users' own risk. Pilot scope: `x402r-notes/plans/AUTHCAPTURE_SVM_PILOT.md`. Spec: `x402r-scheme/specs/schemes/authCapture/scheme_authCapture_svm.md`. -## Scope - -This branch ships the base commerce-payments primitives only: the escrow and the SPL token collector. The x402r-specific extensions (payment-operator factory, condition plugins) ship in a follow-up PR that stacks on top of this one. - ## Architectural symmetry with `base/commerce-payments` -The base primitives are structured so each piece has a direct SVM counterpart of its EVM analog: +Each commerce-payments-adjacent piece has a direct SVM counterpart of its EVM analog: | `base/commerce-payments` (EVM) | This workspace (SVM) | | :--- | :--- | | `AuthCaptureEscrow` | `auth-capture-escrow` | +| `PaymentOperatorFactory` + `PaymentOperator` | `payment-operator` (factory + per-merchant instances via PDAs) | | `EIP3009TokenCollector` / `Permit2TokenCollector` | `spl-token-collector` (and future Token-2022 / bridge collectors slot in via the same `ITokenCollector` interface) | +| `Operator` plugin slots (conditions, hooks) | `static-address-condition`, `receiver-condition`, `payer-condition` (any third-party `ICondition` / `IHook` impl slots in) | ## Programs | Program | Role | | --- | --- | | `auth-capture-escrow` | Pure escrow primitive. Holds funds, enforces expiries, validates splits + protocol fee. CPIs into a token collector for fund movement. Knows nothing about plugins / arbitration. | +| `payment-operator` | Factory: `create_operator` allocates a per-merchant `OperatorState` PDA at `[b"operator", authority]` whose address IS the merchant's operator pubkey. Wraps escrow ixs with pre-action conditions + post-action hooks; CPIs into escrow signing as the per-merchant PDA via `invoke_signed`. | | `spl-token-collector` | The pilot's only `ITokenCollector` impl. SPL Token transfers for both `collect_authorize` (payer ATA → vault) and `collect_refund` (treasury → payer). | +| `static-address-condition` | Generic `ICondition`: outer signer must equal a configured target address. | +| `receiver-condition` | Stateless `ICondition`: outer signer must equal `payment_info.receiver`. | +| `payer-condition` | Stateless `ICondition`: outer signer must equal `payment_info.payer`. | -`reclaim` lives on the escrow and is called by the payer directly: the payer's deadline-based escape hatch is intentionally not delegated, so it remains callable regardless of any wrappers later layered on top. +`reclaim` lives only on the escrow and is called by the payer directly: it intentionally bypasses the operator program so the payer's deadline-based escape hatch cannot be defeasibly delegated. ## Toolchain @@ -40,7 +42,7 @@ The base primitives are structured so each piece has a direct SVM counterpart of anchor build anchor test --skip-deploy # localnet, vitest under the hood pnpm codama:generate # regenerates the @x402r/svm-client Kit clients -pnpm fuzz # Trident fuzz on splits +pnpm fuzz # Trident fuzz on splits + slot dispatch ``` ## Deploy @@ -58,7 +60,11 @@ The protocol-fee config (`protocolFeeBps`, `protocolFeeReceiver`) is **immutable ``` programs/auth-capture-escrow # Escrow primitive +programs/payment-operator # Factory + slot dispatch programs/spl-token-collector # ITokenCollector impl for SPL +programs/static-address-condition # ICondition #1 +programs/receiver-condition # ICondition #2 +programs/payer-condition # ICondition #3 tests/ # Vitest + Codama-generated Kit clients fuzz/ # Trident fuzz harnesses migrations/ # Deploy scripts (devnet + mainnet-beta) diff --git a/codama/generate.ts b/codama/generate.ts index 4136108..3af1945 100644 --- a/codama/generate.ts +++ b/codama/generate.ts @@ -1,6 +1,6 @@ /** - * Generate Solana Kit clients for `auth-capture-escrow` and `spl-token-collector` - * from their Anchor IDLs. + * Generate Solana Kit clients for the escrow, collector, payment-operator, + * and the three built-in condition programs from their Anchor IDLs. * * Run after `anchor build`. Outputs land in * `x402r-contracts-svm/codama/generated//` AND @@ -13,7 +13,14 @@ import { createFromRoot } from "codama"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -const PROGRAMS = ["auth_capture_escrow", "spl_token_collector"]; +const PROGRAMS = [ + "auth_capture_escrow", + "payment_operator", + "spl_token_collector", + "static_address_condition", + "receiver_condition", + "payer_condition", +]; const ROOT = join(import.meta.dirname, ".."); diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index dbbb61e..ccd0647 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -2,7 +2,7 @@ name = "auth-capture-escrow-fuzz" version = "0.2.0" edition = "2021" -description = "Trident fuzz harness for auth-capture-escrow" +description = "Trident fuzz harness for auth-capture-escrow + slot dispatch" license = "BUSL-1.1" [lib] @@ -13,6 +13,10 @@ path = "src/lib.rs" name = "fuzz_splits" path = "fuzz_tests/fuzz_splits.rs" +[[bin]] +name = "fuzz_slot_dispatch" +path = "fuzz_tests/fuzz_slot_dispatch.rs" + [dependencies] trident-client = "0.10" arbitrary = { version = "1.4", features = ["derive"] } diff --git a/fuzz/fuzz_tests/fuzz_slot_dispatch.rs b/fuzz/fuzz_tests/fuzz_slot_dispatch.rs new file mode 100644 index 0000000..0647da8 --- /dev/null +++ b/fuzz/fuzz_tests/fuzz_slot_dispatch.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! Fuzz target: slot dispatch. Generates random condition_programs / +//! hook_programs slot arrays and exercises the CPI fan-out logic, looking +//! for malformed-account-list crashes or signer leakage. Real CPI execution +//! requires the trident "honggfuzz" feature with a banks client; this +//! scaffold defines the input shape so a follow-up can wire it through. + +use auth_capture_escrow::SLOT_COUNT; +use trident_client::fuzzing::*; + +#[derive(Arbitrary, Debug, Clone)] +pub struct FuzzSlotInput { + pub condition_present: [bool; SLOT_COUNT], + pub hook_present: [bool; SLOT_COUNT], + pub action: u8, + pub payment_info_hash: [u8; 32], + pub amount: u64, +} + +fuzz_target!(|_input: FuzzSlotInput| { + // TODO: stand up a banks-client harness, build the action ix with the + // sampled slot mask, and invoke. For the pilot we keep this as a scaffold. +}); diff --git a/migrations/pin-program-ids.ts b/migrations/pin-program-ids.ts index f6d0c75..56e2e6a 100644 --- a/migrations/pin-program-ids.ts +++ b/migrations/pin-program-ids.ts @@ -13,7 +13,20 @@ import { join } from "node:path"; const PROGRAMS = [ { name: "auth_capture_escrow", keypairPath: "target/deploy/auth_capture_escrow-keypair.json" }, + { name: "payment_operator", keypairPath: "target/deploy/payment_operator-keypair.json" }, { name: "spl_token_collector", keypairPath: "target/deploy/spl_token_collector-keypair.json" }, + { + name: "static_address_condition", + keypairPath: "target/deploy/static_address_condition-keypair.json", + }, + { + name: "receiver_condition", + keypairPath: "target/deploy/receiver_condition-keypair.json", + }, + { + name: "payer_condition", + keypairPath: "target/deploy/payer_condition-keypair.json", + }, ]; function programId(keypairPath: string): string { diff --git a/programs/payer-condition/Cargo.toml b/programs/payer-condition/Cargo.toml new file mode 100644 index 0000000..d18e27a --- /dev/null +++ b/programs/payer-condition/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "payer-condition" +version = "0.2.0" +description = "Built-in ICondition: outer signer must equal payment_info.payer" +license = "BUSL-1.1" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "payer_condition" + +[features] +default = [] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = "0.31.1" +solana-program = "~2.1" +auth-capture-escrow = { path = "../auth-capture-escrow", features = ["cpi"] } diff --git a/programs/payer-condition/src/lib.rs b/programs/payer-condition/src/lib.rs new file mode 100644 index 0000000..8cc87ee --- /dev/null +++ b/programs/payer-condition/src/lib.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! Payer condition — outer signer must equal `payment_info.payer`. +//! Mirrors `receiver-condition` but checks the payer field. Useful for +//! payer-self-service refunds or payer-arbitrated voids. + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::sysvar::instructions::{ + load_current_index_checked, load_instruction_at_checked, +}; + +declare_id!("pyr1111111111111111111111111111111111111111"); + +#[program] +pub mod payer_condition { + use super::*; + + pub fn check_condition( + ctx: Context, + _payment_info_hash: [u8; 32], + _action: u8, + ) -> Result<()> { + let outer_signer = read_outer_signer(&ctx.accounts.instructions_sysvar)?; + require_keys_eq!( + outer_signer, + ctx.accounts.expected_payer.key(), + ConditionError::SignerMismatch + ); + Ok(()) + } +} + +#[derive(Accounts)] +pub struct CheckCondition<'info> { + /// CHECK: instructions sysvar. + pub instructions_sysvar: UncheckedAccount<'info>, + /// CHECK: payer pubkey passed by caller. + pub expected_payer: UncheckedAccount<'info>, +} + +pub fn read_outer_signer(sysvar: &AccountInfo) -> Result { + let current_idx = load_current_index_checked(sysvar)? as usize; + let outer_ix = load_instruction_at_checked(current_idx, sysvar)?; + let signer = outer_ix + .accounts + .iter() + .find(|m| m.is_signer) + .ok_or(ConditionError::NoOuterSigner)?; + Ok(signer.pubkey) +} + +#[error_code] +pub enum ConditionError { + #[msg("outer signer does not match payment_info.payer")] + SignerMismatch, + #[msg("no signer in outer instruction")] + NoOuterSigner, +} diff --git a/programs/payment-operator/Cargo.toml b/programs/payment-operator/Cargo.toml new file mode 100644 index 0000000..d202086 --- /dev/null +++ b/programs/payment-operator/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "payment-operator" +version = "0.2.0" +description = "x402r payment operator: factory + slot dispatch (conditions/hooks). CPIs into auth-capture-escrow." +license = "BUSL-1.1" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "payment_operator" + +[features] +default = [] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] + +[dependencies] +anchor-lang = "0.31.1" +anchor-spl = "0.31.1" +solana-program = "~2.1" +auth-capture-escrow = { path = "../auth-capture-escrow", features = ["cpi"] } diff --git a/programs/payment-operator/src/errors.rs b/programs/payment-operator/src/errors.rs new file mode 100644 index 0000000..b1200ca --- /dev/null +++ b/programs/payment-operator/src/errors.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +use anchor_lang::prelude::*; + +#[error_code] +pub enum OperatorError { + #[msg("operator pubkey in PaymentInfo does not match the OperatorState PDA")] + OperatorPubkeyMismatch, + #[msg("authority signer does not match the operator's authority")] + AuthorityMismatch, + #[msg("condition program account list mismatch")] + ConditionAccountsMismatch, + #[msg("hook program account list mismatch")] + HookAccountsMismatch, + #[msg("CPI to slot program returned non-zero")] + SlotProgramFailed, + #[msg("CPI to auth-capture-escrow failed")] + EscrowCpiFailed, +} diff --git a/programs/payment-operator/src/events.rs b/programs/payment-operator/src/events.rs new file mode 100644 index 0000000..5e3bb1d --- /dev/null +++ b/programs/payment-operator/src/events.rs @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +use anchor_lang::prelude::*; + +#[event] +pub struct OperatorCreated { + pub authority: Pubkey, + pub operator: Pubkey, +} + +#[event] +pub struct OperatorUpdated { + pub authority: Pubkey, + pub operator: Pubkey, +} diff --git a/programs/payment-operator/src/instructions/authorize.rs b/programs/payment-operator/src/instructions/authorize.rs new file mode 100644 index 0000000..7096594 --- /dev/null +++ b/programs/payment-operator/src/instructions/authorize.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! `authorize` — runs pre-action conditions, CPIs into +//! `auth-capture-escrow.authorize` signing as the per-merchant operator +//! PDA, runs post-action hooks. + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{instruction::Instruction, program::invoke_signed}; + +use auth_capture_escrow::PaymentInfo; + +use crate::errors::OperatorError; +use crate::slots::{count_filled_slots, run_post_action_hooks, run_pre_action_conditions}; +use crate::state::{ActionKind, OperatorState}; + +/// `sha256("global:authorize")[..8]` of the escrow's `authorize` ix. +/// Hard-coded to keep CPI lean. Keep in sync with the escrow's IDL. +const ESCROW_AUTHORIZE_DISC: [u8; 8] = [46, 9, 7, 154, 184, 220, 197, 87]; + +#[derive(Accounts)] +#[instruction(payment_info: PaymentInfo)] +pub struct OperatorAuthorize<'info> { + /// Operator authority (merchant). Signer. + pub authority: Signer<'info>, + /// Per-merchant `OperatorState`. Its address is `paymentInfo.operator`. + #[account( + seeds = [OperatorState::SEED, authority.key().as_ref()], + bump = operator.bump, + constraint = operator.authority == authority.key() @ OperatorError::AuthorityMismatch, + constraint = operator.key() == payment_info.operator @ OperatorError::OperatorPubkeyMismatch, + )] + pub operator: Account<'info, OperatorState>, + /// CHECK: instructions sysvar — slot programs read the outer signer through this. + pub instructions_sysvar: UncheckedAccount<'info>, + /// CHECK: escrow program — invoke_signed target. + pub auth_capture_escrow_program: UncheckedAccount<'info>, + // Remaining accounts: + // 1. The full `auth_capture_escrow::authorize` account list, in the + // order declared by the escrow's `Authorize` Accounts struct. + // 2. Then any additional accounts required by the token collector + // (forwarded through escrow's remaining_accounts to the collector). + // 3. Then condition slot accounts: per filled slot, [program_account, + // ...declared_accounts_per_slot[i]] — but for the pilot every slot + // declares zero extra accounts so it's just [program_account]. + // 4. Then hook slot accounts in the same shape. +} + +pub fn handler( + ctx: Context, + payment_info: PaymentInfo, + amount: u64, + collector_data: Vec, +) -> Result<()> { + let payment_info_hash = payment_info.hash()?; + let conditions = ctx.accounts.operator.condition_programs; + let hooks = ctx.accounts.operator.hook_programs; + let n_conditions = count_filled_slots(&conditions); + let n_hooks = count_filled_slots(&hooks); + let zero_accs = [0u8; crate::SLOT_COUNT]; + + // Partition `remaining_accounts`: + // [escrow_accounts (count = N) | condition_slots | hook_slots] + // The escrow's Authorize struct declares 9 named accounts: operator, + // payment_state, vault, mint, rent_payer, token_collector, token_program, + // associated_token_program, system_program. We compute that count from + // the metas the escrow expects. For the pilot we hardcode 9 and forward + // any "above-9" accounts as the escrow's own remaining_accounts (which + // it uses for the collector's account list). + // + // The caller is responsible for laying out accounts in the right order. + // Slot accounts come strictly after escrow's account list. + const ESCROW_NAMED_ACCOUNTS: usize = 9; + + let total = ctx.remaining_accounts.len(); + let escrow_extras = total + .checked_sub(ESCROW_NAMED_ACCOUNTS) + .ok_or(OperatorError::ConditionAccountsMismatch)? + .checked_sub(n_conditions + n_hooks) + .ok_or(OperatorError::ConditionAccountsMismatch)?; + + let escrow_section_end = ESCROW_NAMED_ACCOUNTS + escrow_extras; + let escrow_accounts = &ctx.remaining_accounts[..escrow_section_end]; + + // Run pre-action conditions. + let mut slot_iter = ctx.remaining_accounts[escrow_section_end..].iter(); + if n_conditions > 0 { + run_pre_action_conditions( + &conditions, + &payment_info_hash, + ActionKind::Authorize, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + + // Build CPI to escrow.authorize. + let mut data = Vec::with_capacity(8 + 256); + data.extend_from_slice(&ESCROW_AUTHORIZE_DISC); + data.extend_from_slice(&payment_info.try_to_vec()?); + data.extend_from_slice(&amount.to_le_bytes()); + data.extend_from_slice(&(collector_data.len() as u32).to_le_bytes()); + data.extend_from_slice(&collector_data); + + let metas: Vec = escrow_accounts + .iter() + .map(|a| AccountMeta { + pubkey: *a.key, + is_signer: a.is_signer, + is_writable: a.is_writable, + }) + .collect(); + + let mut infos: Vec = escrow_accounts.iter().cloned().collect(); + infos.push(ctx.accounts.auth_capture_escrow_program.to_account_info()); + + let auth_key = ctx.accounts.authority.key(); + let bump = ctx.accounts.operator.bump; + let signer_seeds: &[&[u8]] = &[OperatorState::SEED, auth_key.as_ref(), std::slice::from_ref(&bump)]; + let signers = &[signer_seeds]; + + let ix = Instruction { + program_id: *ctx.accounts.auth_capture_escrow_program.key, + accounts: metas, + data, + }; + invoke_signed(&ix, &infos, signers).map_err(|_| error!(OperatorError::EscrowCpiFailed))?; + + if n_hooks > 0 { + run_post_action_hooks( + &hooks, + &payment_info_hash, + ActionKind::Authorize, + amount, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + Ok(()) +} diff --git a/programs/payment-operator/src/instructions/capture.rs b/programs/payment-operator/src/instructions/capture.rs new file mode 100644 index 0000000..4c5ffb7 --- /dev/null +++ b/programs/payment-operator/src/instructions/capture.rs @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{instruction::Instruction, program::invoke_signed}; + +use auth_capture_escrow::{PaymentInfo, SplitEntry}; + +use crate::errors::OperatorError; +use crate::slots::{count_filled_slots, run_post_action_hooks, run_pre_action_conditions}; +use crate::state::{ActionKind, OperatorState}; + +const ESCROW_CAPTURE_DISC: [u8; 8] = [105, 251, 160, 9, 26, 247, 187, 187]; + +/// Escrow `Capture` named accounts: operator, payment_state, vault, +/// receiver_ata, receiver, protocol_fee_receiver_ata, protocol_fee_receiver, +/// operator_fee_receiver_ata, operator_fee_receiver, protocol_fee_config, +/// mint, token_program. +const ESCROW_NAMED_ACCOUNTS: usize = 12; + +#[derive(Accounts)] +#[instruction(payment_info: PaymentInfo)] +pub struct OperatorCapture<'info> { + pub authority: Signer<'info>, + #[account( + seeds = [OperatorState::SEED, authority.key().as_ref()], + bump = operator.bump, + constraint = operator.authority == authority.key() @ OperatorError::AuthorityMismatch, + constraint = operator.key() == payment_info.operator @ OperatorError::OperatorPubkeyMismatch, + )] + pub operator: Account<'info, OperatorState>, + /// CHECK: instructions sysvar. + pub instructions_sysvar: UncheckedAccount<'info>, + /// CHECK: escrow program. + pub auth_capture_escrow_program: UncheckedAccount<'info>, +} + +pub fn handler( + ctx: Context, + payment_info: PaymentInfo, + amount: u64, + splits: Vec, +) -> Result<()> { + let payment_info_hash = payment_info.hash()?; + let conditions = ctx.accounts.operator.condition_programs; + let hooks = ctx.accounts.operator.hook_programs; + let n_conditions = count_filled_slots(&conditions); + let n_hooks = count_filled_slots(&hooks); + let zero_accs = [0u8; crate::SLOT_COUNT]; + + let total = ctx.remaining_accounts.len(); + let escrow_extras = total + .checked_sub(ESCROW_NAMED_ACCOUNTS) + .ok_or(OperatorError::ConditionAccountsMismatch)? + .checked_sub(n_conditions + n_hooks) + .ok_or(OperatorError::ConditionAccountsMismatch)?; + let escrow_section_end = ESCROW_NAMED_ACCOUNTS + escrow_extras; + let escrow_accounts = &ctx.remaining_accounts[..escrow_section_end]; + + let mut slot_iter = ctx.remaining_accounts[escrow_section_end..].iter(); + if n_conditions > 0 { + run_pre_action_conditions( + &conditions, + &payment_info_hash, + ActionKind::Capture, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + + let mut data = Vec::new(); + data.extend_from_slice(&ESCROW_CAPTURE_DISC); + data.extend_from_slice(&payment_info.try_to_vec()?); + data.extend_from_slice(&amount.to_le_bytes()); + data.extend_from_slice(&(splits.len() as u32).to_le_bytes()); + for entry in &splits { + data.extend_from_slice(&entry.try_to_vec()?); + } + + let metas: Vec = escrow_accounts + .iter() + .map(|a| AccountMeta { + pubkey: *a.key, + is_signer: a.is_signer, + is_writable: a.is_writable, + }) + .collect(); + let mut infos: Vec = escrow_accounts.iter().cloned().collect(); + infos.push(ctx.accounts.auth_capture_escrow_program.to_account_info()); + + let auth_key = ctx.accounts.authority.key(); + let bump = ctx.accounts.operator.bump; + let signer_seeds: &[&[u8]] = &[OperatorState::SEED, auth_key.as_ref(), std::slice::from_ref(&bump)]; + let signers = &[signer_seeds]; + let ix = Instruction { + program_id: *ctx.accounts.auth_capture_escrow_program.key, + accounts: metas, + data, + }; + invoke_signed(&ix, &infos, signers).map_err(|_| error!(OperatorError::EscrowCpiFailed))?; + + if n_hooks > 0 { + run_post_action_hooks( + &hooks, + &payment_info_hash, + ActionKind::Capture, + amount, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + Ok(()) +} diff --git a/programs/payment-operator/src/instructions/charge.rs b/programs/payment-operator/src/instructions/charge.rs new file mode 100644 index 0000000..d8a94d1 --- /dev/null +++ b/programs/payment-operator/src/instructions/charge.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! `charge` — pre-conditions, CPI escrow.charge, post-hooks. + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{instruction::Instruction, program::invoke_signed}; + +use auth_capture_escrow::{PaymentInfo, SplitEntry}; + +use crate::errors::OperatorError; +use crate::slots::{count_filled_slots, run_post_action_hooks, run_pre_action_conditions}; +use crate::state::{ActionKind, OperatorState}; + +const ESCROW_CHARGE_DISC: [u8; 8] = [146, 158, 35, 245, 197, 6, 235, 27]; + +/// Number of named accounts in the escrow's `Charge` Accounts struct. +/// (operator, payment_state, vault, receiver_ata, receiver, +/// protocol_fee_receiver_ata, protocol_fee_receiver, operator_fee_receiver_ata, +/// operator_fee_receiver, protocol_fee_config, mint, rent_payer, +/// token_collector, token_program, associated_token_program, system_program) +const ESCROW_NAMED_ACCOUNTS: usize = 16; + +#[derive(Accounts)] +#[instruction(payment_info: PaymentInfo)] +pub struct OperatorCharge<'info> { + pub authority: Signer<'info>, + #[account( + seeds = [OperatorState::SEED, authority.key().as_ref()], + bump = operator.bump, + constraint = operator.authority == authority.key() @ OperatorError::AuthorityMismatch, + constraint = operator.key() == payment_info.operator @ OperatorError::OperatorPubkeyMismatch, + )] + pub operator: Account<'info, OperatorState>, + /// CHECK: instructions sysvar. + pub instructions_sysvar: UncheckedAccount<'info>, + /// CHECK: escrow program. + pub auth_capture_escrow_program: UncheckedAccount<'info>, +} + +pub fn handler( + ctx: Context, + payment_info: PaymentInfo, + amount: u64, + splits: Vec, + collector_data: Vec, +) -> Result<()> { + let payment_info_hash = payment_info.hash()?; + let conditions = ctx.accounts.operator.condition_programs; + let hooks = ctx.accounts.operator.hook_programs; + let n_conditions = count_filled_slots(&conditions); + let n_hooks = count_filled_slots(&hooks); + let zero_accs = [0u8; crate::SLOT_COUNT]; + + let total = ctx.remaining_accounts.len(); + let escrow_extras = total + .checked_sub(ESCROW_NAMED_ACCOUNTS) + .ok_or(OperatorError::ConditionAccountsMismatch)? + .checked_sub(n_conditions + n_hooks) + .ok_or(OperatorError::ConditionAccountsMismatch)?; + + let escrow_section_end = ESCROW_NAMED_ACCOUNTS + escrow_extras; + let escrow_accounts = &ctx.remaining_accounts[..escrow_section_end]; + + let mut slot_iter = ctx.remaining_accounts[escrow_section_end..].iter(); + if n_conditions > 0 { + run_pre_action_conditions( + &conditions, + &payment_info_hash, + ActionKind::Charge, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + + let mut data = Vec::new(); + data.extend_from_slice(&ESCROW_CHARGE_DISC); + data.extend_from_slice(&payment_info.try_to_vec()?); + data.extend_from_slice(&amount.to_le_bytes()); + data.extend_from_slice(&(splits.len() as u32).to_le_bytes()); + for entry in &splits { + data.extend_from_slice(&entry.try_to_vec()?); + } + data.extend_from_slice(&(collector_data.len() as u32).to_le_bytes()); + data.extend_from_slice(&collector_data); + + let metas: Vec = escrow_accounts + .iter() + .map(|a| AccountMeta { + pubkey: *a.key, + is_signer: a.is_signer, + is_writable: a.is_writable, + }) + .collect(); + let mut infos: Vec = escrow_accounts.iter().cloned().collect(); + infos.push(ctx.accounts.auth_capture_escrow_program.to_account_info()); + + let auth_key = ctx.accounts.authority.key(); + let bump = ctx.accounts.operator.bump; + let signer_seeds: &[&[u8]] = &[OperatorState::SEED, auth_key.as_ref(), std::slice::from_ref(&bump)]; + let signers = &[signer_seeds]; + + let ix = Instruction { + program_id: *ctx.accounts.auth_capture_escrow_program.key, + accounts: metas, + data, + }; + invoke_signed(&ix, &infos, signers).map_err(|_| error!(OperatorError::EscrowCpiFailed))?; + + if n_hooks > 0 { + run_post_action_hooks( + &hooks, + &payment_info_hash, + ActionKind::Charge, + amount, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + Ok(()) +} diff --git a/programs/payment-operator/src/instructions/create_operator.rs b/programs/payment-operator/src/instructions/create_operator.rs new file mode 100644 index 0000000..9eb3e79 --- /dev/null +++ b/programs/payment-operator/src/instructions/create_operator.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! Factory ix. Allocates an `OperatorState` PDA at `[b"operator", authority]` +//! and writes the initial slot config. The PDA's address is the operator +//! pubkey for every payment subsequently routed through this merchant. + +use anchor_lang::prelude::*; + +use crate::events::OperatorCreated; +use crate::state::OperatorState; +use crate::SLOT_COUNT; + +#[derive(Accounts)] +pub struct CreateOperator<'info> { + #[account(mut)] + pub authority: Signer<'info>, + #[account( + init, + payer = authority, + space = OperatorState::LEN, + seeds = [OperatorState::SEED, authority.key().as_ref()], + bump, + )] + pub operator: Account<'info, OperatorState>, + pub system_program: Program<'info, System>, +} + +pub fn handler( + ctx: Context, + condition_programs: [Option; SLOT_COUNT], + hook_programs: [Option; SLOT_COUNT], +) -> Result<()> { + let op = &mut ctx.accounts.operator; + op.authority = ctx.accounts.authority.key(); + op.condition_programs = condition_programs; + op.hook_programs = hook_programs; + op.bump = ctx.bumps.operator; + + emit!(OperatorCreated { + authority: op.authority, + operator: op.key(), + }); + Ok(()) +} diff --git a/programs/payment-operator/src/instructions/mod.rs b/programs/payment-operator/src/instructions/mod.rs new file mode 100644 index 0000000..9599352 --- /dev/null +++ b/programs/payment-operator/src/instructions/mod.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +pub mod authorize; +pub mod capture; +pub mod charge; +pub mod create_operator; +pub mod refund; +pub mod update_operator; +pub mod void; + +pub use authorize::*; +pub use capture::*; +pub use charge::*; +pub use create_operator::*; +pub use refund::*; +pub use update_operator::*; +pub use void::*; diff --git a/programs/payment-operator/src/instructions/refund.rs b/programs/payment-operator/src/instructions/refund.rs new file mode 100644 index 0000000..d4f7174 --- /dev/null +++ b/programs/payment-operator/src/instructions/refund.rs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{instruction::Instruction, program::invoke_signed}; + +use auth_capture_escrow::PaymentInfo; + +use crate::errors::OperatorError; +use crate::slots::{count_filled_slots, run_post_action_hooks, run_pre_action_conditions}; +use crate::state::{ActionKind, OperatorState}; + +const ESCROW_REFUND_DISC: [u8; 8] = [2, 96, 183, 251, 63, 208, 46, 46]; + +/// Escrow `Refund` named accounts: operator, payment_state, mint, +/// token_collector, token_program. +const ESCROW_NAMED_ACCOUNTS: usize = 5; + +#[derive(Accounts)] +#[instruction(payment_info: PaymentInfo)] +pub struct OperatorRefund<'info> { + pub authority: Signer<'info>, + #[account( + seeds = [OperatorState::SEED, authority.key().as_ref()], + bump = operator.bump, + constraint = operator.authority == authority.key() @ OperatorError::AuthorityMismatch, + constraint = operator.key() == payment_info.operator @ OperatorError::OperatorPubkeyMismatch, + )] + pub operator: Account<'info, OperatorState>, + /// CHECK: instructions sysvar. + pub instructions_sysvar: UncheckedAccount<'info>, + /// CHECK: escrow program. + pub auth_capture_escrow_program: UncheckedAccount<'info>, +} + +pub fn handler( + ctx: Context, + payment_info: PaymentInfo, + amount: u64, + collector_data: Vec, +) -> Result<()> { + let payment_info_hash = payment_info.hash()?; + let conditions = ctx.accounts.operator.condition_programs; + let hooks = ctx.accounts.operator.hook_programs; + let n_conditions = count_filled_slots(&conditions); + let n_hooks = count_filled_slots(&hooks); + let zero_accs = [0u8; crate::SLOT_COUNT]; + + let total = ctx.remaining_accounts.len(); + let escrow_extras = total + .checked_sub(ESCROW_NAMED_ACCOUNTS) + .ok_or(OperatorError::ConditionAccountsMismatch)? + .checked_sub(n_conditions + n_hooks) + .ok_or(OperatorError::ConditionAccountsMismatch)?; + let escrow_section_end = ESCROW_NAMED_ACCOUNTS + escrow_extras; + let escrow_accounts = &ctx.remaining_accounts[..escrow_section_end]; + + let mut slot_iter = ctx.remaining_accounts[escrow_section_end..].iter(); + if n_conditions > 0 { + run_pre_action_conditions( + &conditions, + &payment_info_hash, + ActionKind::Refund, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + + let mut data = Vec::new(); + data.extend_from_slice(&ESCROW_REFUND_DISC); + data.extend_from_slice(&payment_info.try_to_vec()?); + data.extend_from_slice(&amount.to_le_bytes()); + data.extend_from_slice(&(collector_data.len() as u32).to_le_bytes()); + data.extend_from_slice(&collector_data); + + let metas: Vec = escrow_accounts + .iter() + .map(|a| AccountMeta { + pubkey: *a.key, + is_signer: a.is_signer, + is_writable: a.is_writable, + }) + .collect(); + let mut infos: Vec = escrow_accounts.iter().cloned().collect(); + infos.push(ctx.accounts.auth_capture_escrow_program.to_account_info()); + + let auth_key = ctx.accounts.authority.key(); + let bump = ctx.accounts.operator.bump; + let signer_seeds: &[&[u8]] = &[OperatorState::SEED, auth_key.as_ref(), std::slice::from_ref(&bump)]; + let signers = &[signer_seeds]; + let ix = Instruction { + program_id: *ctx.accounts.auth_capture_escrow_program.key, + accounts: metas, + data, + }; + invoke_signed(&ix, &infos, signers).map_err(|_| error!(OperatorError::EscrowCpiFailed))?; + + if n_hooks > 0 { + run_post_action_hooks( + &hooks, + &payment_info_hash, + ActionKind::Refund, + amount, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + Ok(()) +} diff --git a/programs/payment-operator/src/instructions/update_operator.rs b/programs/payment-operator/src/instructions/update_operator.rs new file mode 100644 index 0000000..06e17ef --- /dev/null +++ b/programs/payment-operator/src/instructions/update_operator.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +use anchor_lang::prelude::*; + +use crate::errors::OperatorError; +use crate::events::OperatorUpdated; +use crate::state::OperatorState; +use crate::SLOT_COUNT; + +#[derive(Accounts)] +pub struct UpdateOperator<'info> { + pub authority: Signer<'info>, + #[account( + mut, + seeds = [OperatorState::SEED, authority.key().as_ref()], + bump = operator.bump, + constraint = operator.authority == authority.key() @ OperatorError::AuthorityMismatch, + )] + pub operator: Account<'info, OperatorState>, +} + +pub fn handler( + ctx: Context, + condition_programs: [Option; SLOT_COUNT], + hook_programs: [Option; SLOT_COUNT], +) -> Result<()> { + let op = &mut ctx.accounts.operator; + op.condition_programs = condition_programs; + op.hook_programs = hook_programs; + emit!(OperatorUpdated { + authority: op.authority, + operator: op.key(), + }); + Ok(()) +} diff --git a/programs/payment-operator/src/instructions/void.rs b/programs/payment-operator/src/instructions/void.rs new file mode 100644 index 0000000..b250c9c --- /dev/null +++ b/programs/payment-operator/src/instructions/void.rs @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{instruction::Instruction, program::invoke_signed}; + +use auth_capture_escrow::PaymentInfo; + +use crate::errors::OperatorError; +use crate::slots::{count_filled_slots, run_post_action_hooks, run_pre_action_conditions}; +use crate::state::{ActionKind, OperatorState}; + +const ESCROW_VOID_DISC: [u8; 8] = [147, 218, 58, 239, 81, 31, 91, 98]; + +/// Escrow `VoidAuthorization` named accounts: operator, payment_state, +/// vault, payer_ata, payer, mint, token_program. +const ESCROW_NAMED_ACCOUNTS: usize = 7; + +#[derive(Accounts)] +#[instruction(payment_info: PaymentInfo)] +pub struct OperatorVoid<'info> { + pub authority: Signer<'info>, + #[account( + seeds = [OperatorState::SEED, authority.key().as_ref()], + bump = operator.bump, + constraint = operator.authority == authority.key() @ OperatorError::AuthorityMismatch, + constraint = operator.key() == payment_info.operator @ OperatorError::OperatorPubkeyMismatch, + )] + pub operator: Account<'info, OperatorState>, + /// CHECK: instructions sysvar. + pub instructions_sysvar: UncheckedAccount<'info>, + /// CHECK: escrow program. + pub auth_capture_escrow_program: UncheckedAccount<'info>, +} + +pub fn handler(ctx: Context, payment_info: PaymentInfo) -> Result<()> { + let payment_info_hash = payment_info.hash()?; + let conditions = ctx.accounts.operator.condition_programs; + let hooks = ctx.accounts.operator.hook_programs; + let n_conditions = count_filled_slots(&conditions); + let n_hooks = count_filled_slots(&hooks); + let zero_accs = [0u8; crate::SLOT_COUNT]; + + let total = ctx.remaining_accounts.len(); + let escrow_extras = total + .checked_sub(ESCROW_NAMED_ACCOUNTS) + .ok_or(OperatorError::ConditionAccountsMismatch)? + .checked_sub(n_conditions + n_hooks) + .ok_or(OperatorError::ConditionAccountsMismatch)?; + let escrow_section_end = ESCROW_NAMED_ACCOUNTS + escrow_extras; + let escrow_accounts = &ctx.remaining_accounts[..escrow_section_end]; + + let mut slot_iter = ctx.remaining_accounts[escrow_section_end..].iter(); + if n_conditions > 0 { + run_pre_action_conditions( + &conditions, + &payment_info_hash, + ActionKind::Void, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + + let mut data = Vec::new(); + data.extend_from_slice(&ESCROW_VOID_DISC); + data.extend_from_slice(&payment_info.try_to_vec()?); + + let metas: Vec = escrow_accounts + .iter() + .map(|a| AccountMeta { + pubkey: *a.key, + is_signer: a.is_signer, + is_writable: a.is_writable, + }) + .collect(); + let mut infos: Vec = escrow_accounts.iter().cloned().collect(); + infos.push(ctx.accounts.auth_capture_escrow_program.to_account_info()); + + let auth_key = ctx.accounts.authority.key(); + let bump = ctx.accounts.operator.bump; + let signer_seeds: &[&[u8]] = &[OperatorState::SEED, auth_key.as_ref(), std::slice::from_ref(&bump)]; + let signers = &[signer_seeds]; + let ix = Instruction { + program_id: *ctx.accounts.auth_capture_escrow_program.key, + accounts: metas, + data, + }; + invoke_signed(&ix, &infos, signers).map_err(|_| error!(OperatorError::EscrowCpiFailed))?; + + if n_hooks > 0 { + // amount=0 because void returns the full capturable; the slot doesn't + // need the figure here (it can read PaymentState if it cares). + run_post_action_hooks( + &hooks, + &payment_info_hash, + ActionKind::Void, + 0, + &ctx.accounts.instructions_sysvar.to_account_info(), + &mut slot_iter, + &zero_accs, + )?; + } + Ok(()) +} diff --git a/programs/payment-operator/src/lib.rs b/programs/payment-operator/src/lib.rs new file mode 100644 index 0000000..9d98e90 --- /dev/null +++ b/programs/payment-operator/src/lib.rs @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! `payment-operator` — factory + slot dispatch program for x402r. +//! +//! Architectural role: equivalent to `PaymentOperatorFactory` + +//! `PaymentOperator` in `base/commerce-payments`, collapsed into one Solana +//! program because per-merchant program deploys aren't economical on SVM. +//! +//! Each merchant calls `create_operator(authority, slots...)` once. That +//! ix: +//! - Allocates an `OperatorState` PDA at `[b"operator", authority]`. +//! - The PDA's address IS the merchant's operator pubkey +//! (`paymentInfo.operator` for every payment routed through this merchant). +//! - The PDA holds the merchant's slot configuration +//! (`condition_programs`, `hook_programs`). +//! +//! Subsequent ixs (`authorize`, `charge`, `capture`, `void`, `refund`) +//! load the per-merchant `OperatorState`, run pre-action conditions +//! (CPI into each non-null `condition_programs[i]`), CPI into the escrow +//! signing as the `OperatorState` PDA, then run post-action hooks. +//! +//! `reclaim` is NOT exposed here — payers call `auth-capture-escrow.reclaim` +//! directly. The escape hatch must not pass through any operator code. + +use anchor_lang::prelude::*; + +pub mod errors; +pub mod events; +pub mod instructions; +pub mod slots; +pub mod state; + +pub use errors::*; +pub use events::*; +pub use instructions::*; +pub use state::*; + +declare_id!("PmtOpr11111111111111111111111111111111111111"); + +/// Slot-array width. Mirrors EVM's 10-slot operator model at a smaller width +/// for the pilot (open question 5). Bumping this requires a coordinated SDK +/// + program release. +pub const SLOT_COUNT: usize = 3; + +#[program] +pub mod payment_operator { + use super::*; + + /// Factory ix. Creates a per-merchant operator instance: allocates the + /// `OperatorState` PDA at `[b"operator", authority]` and writes the + /// initial slot config. The PDA's address becomes + /// `paymentInfo.operator` for all subsequent payments through this + /// merchant. + pub fn create_operator( + ctx: Context, + condition_programs: [Option; SLOT_COUNT], + hook_programs: [Option; SLOT_COUNT], + ) -> Result<()> { + instructions::create_operator::handler(ctx, condition_programs, hook_programs) + } + + /// Update slot configuration. Signer: the `authority` that created the + /// operator instance. The operator pubkey itself doesn't change — only + /// the policy. + pub fn update_operator( + ctx: Context, + condition_programs: [Option; SLOT_COUNT], + hook_programs: [Option; SLOT_COUNT], + ) -> Result<()> { + instructions::update_operator::handler(ctx, condition_programs, hook_programs) + } + + pub fn authorize( + ctx: Context, + payment_info: auth_capture_escrow::PaymentInfo, + amount: u64, + collector_data: Vec, + ) -> Result<()> { + instructions::authorize::handler(ctx, payment_info, amount, collector_data) + } + + pub fn charge( + ctx: Context, + payment_info: auth_capture_escrow::PaymentInfo, + amount: u64, + splits: Vec, + collector_data: Vec, + ) -> Result<()> { + instructions::charge::handler(ctx, payment_info, amount, splits, collector_data) + } + + pub fn capture( + ctx: Context, + payment_info: auth_capture_escrow::PaymentInfo, + amount: u64, + splits: Vec, + ) -> Result<()> { + instructions::capture::handler(ctx, payment_info, amount, splits) + } + + pub fn void( + ctx: Context, + payment_info: auth_capture_escrow::PaymentInfo, + ) -> Result<()> { + instructions::void::handler(ctx, payment_info) + } + + pub fn refund( + ctx: Context, + payment_info: auth_capture_escrow::PaymentInfo, + amount: u64, + collector_data: Vec, + ) -> Result<()> { + instructions::refund::handler(ctx, payment_info, amount, collector_data) + } +} diff --git a/programs/payment-operator/src/slots.rs b/programs/payment-operator/src/slots.rs new file mode 100644 index 0000000..3065aca --- /dev/null +++ b/programs/payment-operator/src/slots.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! Slot dispatch — pre-action `condition_programs` and post-action +//! `hook_programs`. CPI fan-out lives here, not in the escrow. +//! +//! Wire format (Anchor instruction discriminator + Borsh args): +//! +//! ```text +//! check_condition: [disc(8) | payment_info_hash(32) | action(u8)] +//! run : [disc(8) | payment_info_hash(32) | action(u8) | amount(u64 LE)] +//! ``` +//! +//! Slot programs receive the outer signer through the `instructions` sysvar +//! (Solana's per-tx introspection account). The escrow does not propagate +//! signer status into the slot CPI, so address-match conditions read the +//! outer signer themselves. + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::{instruction::Instruction, program::invoke}; +use anchor_lang::solana_program::sysvar::instructions::ID as SYSVAR_INSTRUCTIONS_ID; + +use crate::errors::OperatorError; +use crate::state::ActionKind; +use crate::SLOT_COUNT; + +/// `sha256("global:check_condition")[..8]`. Slot programs MUST expose an +/// instruction with this exact discriminator. +pub const CHECK_CONDITION_DISC: [u8; 8] = [248, 39, 159, 162, 154, 144, 233, 240]; + +/// `sha256("global:run")[..8]`. +pub const HOOK_RUN_DISC: [u8; 8] = [123, 28, 160, 105, 247, 128, 121, 9]; + +/// Invoke every non-null condition program. Each filled slot consumes one +/// program account from `remaining` followed by `accounts_per_slot[i]` +/// declared accounts. +pub fn run_pre_action_conditions<'info>( + condition_programs: &[Option; SLOT_COUNT], + payment_info_hash: &[u8; 32], + action: ActionKind, + instructions_sysvar: &AccountInfo<'info>, + remaining: &mut std::slice::Iter>, + accounts_per_slot: &[u8], +) -> Result<()> { + require!( + instructions_sysvar.key() == SYSVAR_INSTRUCTIONS_ID, + OperatorError::ConditionAccountsMismatch + ); + for (i, slot) in condition_programs.iter().enumerate() { + let Some(program_id) = slot else { continue }; + let count = *accounts_per_slot + .get(i) + .ok_or(OperatorError::ConditionAccountsMismatch)? as usize; + + let program_info = remaining + .next() + .ok_or(OperatorError::ConditionAccountsMismatch)?; + require!( + program_info.key == program_id, + OperatorError::ConditionAccountsMismatch + ); + + let mut metas = Vec::with_capacity(count + 1); + let mut infos = Vec::with_capacity(count + 2); + metas.push(AccountMeta::new_readonly(SYSVAR_INSTRUCTIONS_ID, false)); + infos.push(instructions_sysvar.clone()); + for _ in 0..count { + let acc = remaining + .next() + .ok_or(OperatorError::ConditionAccountsMismatch)?; + metas.push(AccountMeta { + pubkey: *acc.key, + is_signer: acc.is_signer, + is_writable: acc.is_writable, + }); + infos.push(acc.clone()); + } + + let mut data = Vec::with_capacity(8 + 32 + 1); + data.extend_from_slice(&CHECK_CONDITION_DISC); + data.extend_from_slice(payment_info_hash); + data.push(action.as_u8()); + + let ix = Instruction { + program_id: *program_id, + accounts: metas, + data, + }; + infos.push(program_info.clone()); + invoke(&ix, &infos).map_err(|_| error!(OperatorError::SlotProgramFailed))?; + } + Ok(()) +} + +/// Invoke every non-null hook program after the action's core logic has +/// run. A hook failure aborts the parent ix (CEI: state mutation already +/// happened, abort rolls everything back). +pub fn run_post_action_hooks<'info>( + hook_programs: &[Option; SLOT_COUNT], + payment_info_hash: &[u8; 32], + action: ActionKind, + amount: u64, + instructions_sysvar: &AccountInfo<'info>, + remaining: &mut std::slice::Iter>, + accounts_per_slot: &[u8], +) -> Result<()> { + require!( + instructions_sysvar.key() == SYSVAR_INSTRUCTIONS_ID, + OperatorError::HookAccountsMismatch + ); + for (i, slot) in hook_programs.iter().enumerate() { + let Some(program_id) = slot else { continue }; + let count = *accounts_per_slot + .get(i) + .ok_or(OperatorError::HookAccountsMismatch)? as usize; + + let program_info = remaining + .next() + .ok_or(OperatorError::HookAccountsMismatch)?; + require!( + program_info.key == program_id, + OperatorError::HookAccountsMismatch + ); + + let mut metas = Vec::with_capacity(count + 1); + let mut infos = Vec::with_capacity(count + 2); + metas.push(AccountMeta::new_readonly(SYSVAR_INSTRUCTIONS_ID, false)); + infos.push(instructions_sysvar.clone()); + for _ in 0..count { + let acc = remaining + .next() + .ok_or(OperatorError::HookAccountsMismatch)?; + metas.push(AccountMeta { + pubkey: *acc.key, + is_signer: acc.is_signer, + is_writable: acc.is_writable, + }); + infos.push(acc.clone()); + } + + let mut data = Vec::with_capacity(8 + 32 + 1 + 8); + data.extend_from_slice(&HOOK_RUN_DISC); + data.extend_from_slice(payment_info_hash); + data.push(action.as_u8()); + data.extend_from_slice(&amount.to_le_bytes()); + + let ix = Instruction { + program_id: *program_id, + accounts: metas, + data, + }; + infos.push(program_info.clone()); + invoke(&ix, &infos).map_err(|_| error!(OperatorError::SlotProgramFailed))?; + } + Ok(()) +} + +pub fn count_filled_slots(slots: &[Option; SLOT_COUNT]) -> usize { + slots.iter().filter(|s| s.is_some()).count() +} diff --git a/programs/payment-operator/src/state.rs b/programs/payment-operator/src/state.rs new file mode 100644 index 0000000..769f00d --- /dev/null +++ b/programs/payment-operator/src/state.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! Per-merchant operator state. The PDA's address is also the merchant's +//! operator pubkey (set as `paymentInfo.operator` for every payment routed +//! through this merchant). Storing data + signing-as-PDA in a single +//! account is deliberate: it keeps the operator-pubkey ↔ slot-config +//! binding atomic and means the escrow only sees one "operator" address +//! per merchant. + +use anchor_lang::prelude::*; + +use crate::SLOT_COUNT; + +/// Per-merchant operator instance. PDA seeds: `[b"operator", authority]`. +#[account] +#[derive(Default)] +pub struct OperatorState { + /// Merchant's wallet/PDA. Signs `update_operator`, `capture`, `void`, + /// `refund`. Independent from the operator pubkey itself (which is + /// THIS account's address — derived via the PDA seeds). + pub authority: Pubkey, + /// Pre-action `ICondition` programs. `None` slots are skipped. + pub condition_programs: [Option; SLOT_COUNT], + /// Post-action `IHook` programs. `None` slots are skipped. + pub hook_programs: [Option; SLOT_COUNT], + /// PDA bump for `[b"operator", authority]`. Used in `invoke_signed` + /// when CPIing into the escrow. + pub bump: u8, +} + +impl OperatorState { + pub const SEED: &'static [u8] = b"operator"; + /// Discriminator (8) + Pubkey + 3*(1 + 32) + 3*(1 + 32) + u8. + /// Each `Option` is 1 (tag) + 32 (data) = 33 bytes. + pub const LEN: usize = 8 + 32 + (SLOT_COUNT * 33) + (SLOT_COUNT * 33) + 1; +} + +/// Action discriminants passed to slot programs as the third byte of the +/// `check_condition` / `run` payloads. Lifecycle order so slot programs that +/// switch on the action don't memorize an arbitrary scheme. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActionKind { + Authorize = 0, + Charge = 1, + Capture = 2, + Void = 3, + Refund = 4, +} + +impl ActionKind { + pub fn as_u8(self) -> u8 { + self as u8 + } +} diff --git a/programs/receiver-condition/Cargo.toml b/programs/receiver-condition/Cargo.toml new file mode 100644 index 0000000..b01952a --- /dev/null +++ b/programs/receiver-condition/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "receiver-condition" +version = "0.2.0" +description = "Built-in ICondition: outer signer must equal payment_info.receiver" +license = "BUSL-1.1" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "receiver_condition" + +[features] +default = [] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = "0.31.1" +solana-program = "~2.1" +auth-capture-escrow = { path = "../auth-capture-escrow", features = ["cpi"] } diff --git a/programs/receiver-condition/src/lib.rs b/programs/receiver-condition/src/lib.rs new file mode 100644 index 0000000..d7f9972 --- /dev/null +++ b/programs/receiver-condition/src/lib.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! Receiver condition — outer signer must equal `payment_info.receiver`. +//! +//! Stateless. The condition program receives the `PaymentInfo` struct +//! Borsh-encoded as one of the optional accounts so it can read the receiver +//! field without re-deriving from the hash. (We pass it through the escrow's +//! `remaining_accounts` channel; see `slots::run_pre_action_conditions`.) +//! +//! For brevity in the pilot, this implementation reads the receiver from a +//! caller-supplied `payment_info_account` instead of decoding from the hash. +//! The escrow guarantees the signer of the outer instruction is the operator +//! (for capture/void/refund) or the payer (for charge), so requiring "outer +//! signer == receiver" only triggers for charge — by design — covering the +//! "payer is also the receiver" arbitration corner case. + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::sysvar::instructions::{ + load_current_index_checked, load_instruction_at_checked, +}; + +declare_id!("rcvr111111111111111111111111111111111111111"); + +#[program] +pub mod receiver_condition { + use super::*; + + pub fn check_condition( + ctx: Context, + _payment_info_hash: [u8; 32], + _action: u8, + ) -> Result<()> { + let outer_signer = read_outer_signer(&ctx.accounts.instructions_sysvar)?; + require_keys_eq!( + outer_signer, + ctx.accounts.expected_receiver.key(), + ConditionError::SignerMismatch + ); + Ok(()) + } +} + +#[derive(Accounts)] +pub struct CheckCondition<'info> { + /// CHECK: instructions sysvar. + pub instructions_sysvar: UncheckedAccount<'info>, + /// CHECK: receiver pubkey passed by caller; the escrow's account-list + /// validation ensures this is `payment_info.receiver`. + pub expected_receiver: UncheckedAccount<'info>, +} + +pub fn read_outer_signer(sysvar: &AccountInfo) -> Result { + let current_idx = load_current_index_checked(sysvar)? as usize; + let outer_ix = load_instruction_at_checked(current_idx, sysvar)?; + let signer = outer_ix + .accounts + .iter() + .find(|m| m.is_signer) + .ok_or(ConditionError::NoOuterSigner)?; + Ok(signer.pubkey) +} + +#[error_code] +pub enum ConditionError { + #[msg("outer signer does not match payment_info.receiver")] + SignerMismatch, + #[msg("no signer in outer instruction")] + NoOuterSigner, +} diff --git a/programs/static-address-condition/Cargo.toml b/programs/static-address-condition/Cargo.toml new file mode 100644 index 0000000..b020555 --- /dev/null +++ b/programs/static-address-condition/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "static-address-condition" +version = "0.2.0" +description = "Built-in ICondition: outer signer must equal a configured target address" +license = "BUSL-1.1" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "static_address_condition" + +[features] +default = [] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = "0.31.1" +solana-program = "~2.1" diff --git a/programs/static-address-condition/src/lib.rs b/programs/static-address-condition/src/lib.rs new file mode 100644 index 0000000..6197269 --- /dev/null +++ b/programs/static-address-condition/src/lib.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: BUSL-1.1 +// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK +//! Static-address `ICondition` — generic. +//! +//! `init_config(target: Pubkey)` creates a config PDA seeded by +//! `[b"static-address-config", caller_pubkey]`. `check_condition(payment_info_hash, action)` +//! reads the outer transaction's signer through the `instructions` sysvar and +//! requires it equals `config.target`. +//! +//! Most third-party arbiter integrations should fork this as a starting point — +//! it is the smallest useful `ICondition` impl. + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::sysvar::instructions::{ + load_current_index_checked, load_instruction_at_checked, +}; + +declare_id!("StAtAdr1111111111111111111111111111111111"); + +#[program] +pub mod static_address_condition { + use super::*; + + pub fn init_config(ctx: Context, target: Pubkey) -> Result<()> { + let config = &mut ctx.accounts.config; + config.target = target; + config.bump = ctx.bumps.config; + Ok(()) + } + + /// `ICondition` entry point. Discriminator `sha256("global:check_condition")[..8]`. + /// Args: `payment_info_hash: [u8;32], action: u8`. + pub fn check_condition( + ctx: Context, + _payment_info_hash: [u8; 32], + _action: u8, + ) -> Result<()> { + let outer_signer = read_outer_signer(&ctx.accounts.instructions_sysvar)?; + require_keys_eq!( + outer_signer, + ctx.accounts.config.target, + ConditionError::SignerMismatch + ); + Ok(()) + } +} + +#[account] +#[derive(Default)] +pub struct StaticAddressConfig { + pub target: Pubkey, + pub bump: u8, +} + +impl StaticAddressConfig { + /// Discriminator (8) + Pubkey + u8. + pub const LEN: usize = 8 + 32 + 1; + pub const SEED_PREFIX: &'static [u8] = b"static-address-config"; +} + +#[derive(Accounts)] +#[instruction(target: Pubkey)] +pub struct InitConfig<'info> { + #[account(mut)] + pub authority: Signer<'info>, + #[account( + init, + payer = authority, + space = StaticAddressConfig::LEN, + seeds = [StaticAddressConfig::SEED_PREFIX, authority.key().as_ref()], + bump, + )] + pub config: Account<'info, StaticAddressConfig>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct CheckCondition<'info> { + /// CHECK: instructions sysvar — read-only introspection of outer signer. + pub instructions_sysvar: UncheckedAccount<'info>, + pub config: Account<'info, StaticAddressConfig>, +} + +/// Read the outer transaction's first signer via the instructions sysvar. +/// The outer instruction is the one currently executing one CPI level up; +/// for this CPI, that is the parent action (`charge`/`capture`/`void`/`refund`) +/// in the auth-capture-escrow program. We pick the first signer because the +/// escrow's gated actions place the operator (or the payer for `charge`) in +/// position 0. +pub fn read_outer_signer(sysvar: &AccountInfo) -> Result { + let current_idx = load_current_index_checked(sysvar)? as usize; + let outer_ix = load_instruction_at_checked(current_idx, sysvar)?; + let signer = outer_ix + .accounts + .iter() + .find(|m| m.is_signer) + .ok_or(ConditionError::NoOuterSigner)?; + Ok(signer.pubkey) +} + +#[error_code] +pub enum ConditionError { + #[msg("outer signer does not match target")] + SignerMismatch, + #[msg("no signer in outer instruction")] + NoOuterSigner, +} diff --git a/tests/auth-capture-escrow.test.ts b/tests/auth-capture-escrow.test.ts index 20e9458..da24f88 100644 --- a/tests/auth-capture-escrow.test.ts +++ b/tests/auth-capture-escrow.test.ts @@ -100,3 +100,29 @@ describe("auth-capture-escrow", () => { expect.fail("TODO"); }); }); + +describe("auth-capture-escrow / slot dispatch", () => { + it("static-address-condition allows operator-signed capture", async () => { + expect.fail("TODO: condition slot wired with target=operator"); + }); + + it("static-address-condition rejects capture when target != outer signer", async () => { + expect.fail("TODO"); + }); + + it("receiver-condition only passes for charge (payer == receiver edge case)", async () => { + expect.fail("TODO"); + }); + + it("payer-condition gates payer-self-service refund flow", async () => { + expect.fail("TODO"); + }); + + it("multi-slot dispatch: [conditions=[static, receiver], hooks=[]]", async () => { + expect.fail("TODO"); + }); + + it("slot mismatch: paymentInfo's condition_programs differs from accounts -> rejected", async () => { + expect.fail("TODO"); + }); +});