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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Anchor.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
Expand Down
13 changes: 10 additions & 3 deletions codama/generate.ts
Original file line number Diff line number Diff line change
@@ -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/<program>/` AND
Expand All @@ -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, "..");

Expand Down
6 changes: 5 additions & 1 deletion fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"] }
Expand Down
24 changes: 24 additions & 0 deletions fuzz/fuzz_tests/fuzz_slot_dispatch.rs
Original file line number Diff line number Diff line change
@@ -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.
});
13 changes: 13 additions & 0 deletions migrations/pin-program-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions programs/payer-condition/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
58 changes: 58 additions & 0 deletions programs/payer-condition/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<CheckCondition>,
_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<Pubkey> {
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,
}
24 changes: 24 additions & 0 deletions programs/payment-operator/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
19 changes: 19 additions & 0 deletions programs/payment-operator/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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,
}
15 changes: 15 additions & 0 deletions programs/payment-operator/src/events.rs
Original file line number Diff line number Diff line change
@@ -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,
}
Loading