Skip to content
Closed
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
45 changes: 45 additions & 0 deletions Anchor.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
[toolchain]
anchor_version = "0.31.1"
solana_version = "2.1.0"

[features]
resolution = true
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`.
# Pin the addresses produced there into this section + `packages/svm/.../constants.ts`.

[programs.mainnet]
# Filled in by `migrations/deploy-mainnet.ts` after `anchor deploy --provider.cluster mainnet`.

[registry]
url = "https://api.apr.dev"

[provider]
cluster = "Localnet"
wallet = "~/.config/solana/id.json"

[scripts]
test = "pnpm vitest run --config tests/vitest.config.ts"

[test]
startup_wait = 10000
shutdown_wait = 2000
upgradeable = false

[test.validator]
url = "https://api.devnet.solana.com"

# Clone USDC devnet mint into the test validator so SPL transfers work without
# spinning up a fresh mint per test.
[[test.validator.clone]]
address = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"
21 changes: 21 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[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"

[profile.release]
overflow-checks = true
lto = "fat"
codegen-units = 1

[profile.release.build-override]
opt-level = 3
incremental = false
codegen-units = 1
77 changes: 50 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,72 @@
# x402r-contracts-svm

Anchor programs for x402r on Solana — sibling to [`x402r-contracts`](https://github.com/BackTrackCo/x402r-contracts) (Solidity, Foundry).
Anchor workspace for the x402r `authCapture` scheme on Solana.

> **Status: Pilot, unaudited.** Mainnet usage is at users' own risk.
> **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
## Architectural symmetry with `base/commerce-payments`

Faithful SVM port of the [`base/commerce-payments`](https://github.com/base/commerce-payments) authCapture primitives, plus x402r-specific extensions (operator factory, plugin slots, condition programs).
The pilot is structured so each commerce-payments-adjacent piece has a direct SVM counterpart and could plausibly merge upstream into `x402-foundation/x402` later:

The scheme spec lives at [`x402r-scheme/specs/schemes/authCapture/scheme_authCapture_svm.md`](https://github.com/BackTrackCo/x402r-scheme/blob/main/specs/schemes/authCapture/scheme_authCapture_svm.md).
| `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 only on the escrow and is called by the payer directly — it intentionally bypasses the operator program because the payer's deadline-based escape hatch must not be defeasible.

## Toolchain

- Anchor 0.31+
- Solana CLI 2.x
- Rust stable (with `overflow-checks = true` in release)
- Rust stable
- pnpm 10
- Codama (Kit-client generator)
- Vitest (tests, via Codama-generated Solana Kit client no `anchor-ts`)
- Vitest (tests) — Codama-generated client only, no `anchor-ts`
- Trident (fuzz)

## Layout
```bash
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 + slot dispatch
```

To be filled in by the initial implementation PR. Planned shape:
## Deploy

```
programs/ # Anchor programs
auth-capture-escrow # AuthCaptureEscrow analog
spl-token-collector # ITokenCollector for SPL Token
payment-operator # x402r factory + slot dispatch
static-address-condition
receiver-condition
payer-condition
tests/ # Vitest + Codama Kit client
fuzz/ # Trident fuzz harnesses
migrations/ # Deploy + program-ID pinning scripts
codama/ # Codama generator
```
Program IDs are keypair-derived per cluster. After `anchor deploy`, pin the resulting addresses into `Anchor.toml` and `x402r-scheme/packages/svm/src/authCapture/shared/constants.ts`.

## License
```bash
pnpm deploy:devnet
pnpm deploy:mainnet
```

[BUSL-1.1](./LICENSE). Same parameters as `x402r-contracts`. Change Date: 2029-12-09. Change License: MIT.
The protocol-fee config (`protocolFeeBps`, `protocolFeeReceiver`) is **immutable** for this pilot. Both are baked into the escrow program at deploy via the `initialize_protocol_fee_config` one-shot ix; see `migrations/`.

Every Rust source file in this repo MUST carry the SPDX header:
## Layout

```rust
// SPDX-License-Identifier: BUSL-1.1
```
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)
codama/ # Codama Kit-client generation
```
62 changes: 62 additions & 0 deletions codama/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Generate Solana Kit clients for `auth-capture-escrow` 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
* `x402r-scheme/packages/svm/src/codama-generated/<program>/` so both the
* test suite and the SDK consume the same Kit client.
*/
import { rootNodeFromAnchor } from "@codama/nodes-from-anchor";
import { renderJavaScriptVisitor } from "@codama/renderers-js";
import { createFromRoot } from "codama";
import { readFileSync } from "node:fs";
import { join } from "node:path";

const PROGRAMS = [
"auth_capture_escrow",
"payment_operator",
"spl_token_collector",
"static_address_condition",
"receiver_condition",
"payer_condition",
];

const ROOT = join(import.meta.dirname, "..");

for (const program of PROGRAMS) {
const idlPath = join(ROOT, "target", "idl", `${program}.json`);
let idl;
try {
idl = JSON.parse(readFileSync(idlPath, "utf8"));
} catch (err) {
console.error(
`[codama] could not read IDL ${idlPath}. Run \`anchor build\` first.`,
err,
);
process.exit(1);
}
const codama = createFromRoot(rootNodeFromAnchor(idl));

// Local copy for in-repo tests.
codama.accept(
renderJavaScriptVisitor(join(ROOT, "codama", "generated", program), {
formatCode: true,
}),
);

// SDK copy so `@x402r/svm` ships without re-deriving.
const sdkOut = join(
ROOT,
"..",
"x402r-scheme",
"packages",
"svm",
"src",
"codama-generated",
program,
);
codama.accept(renderJavaScriptVisitor(sdkOut, { formatCode: true }));

console.log(`[codama] generated ${program} -> ${sdkOut}`);
}
26 changes: 26 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "auth-capture-escrow-fuzz"
version = "0.2.0"
edition = "2021"
description = "Trident fuzz harness for auth-capture-escrow + slot dispatch"
license = "BUSL-1.1"

[lib]
name = "auth_capture_escrow_fuzz"
path = "src/lib.rs"

[[bin]]
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"] }
auth-capture-escrow = { path = "../programs/auth-capture-escrow", features = ["no-entrypoint"] }

[features]
default = []
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.
});
51 changes: 51 additions & 0 deletions fuzz/fuzz_tests/fuzz_splits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: BUSL-1.1
// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK
//! Fuzz target: `splits::validate_splits`. Generates random splits vectors
//! and runs them through the validator. Asserts that **any** validator-accept
//! satisfies the algebraic invariants:
//! - sum(splits) == amount
//! - exactly one receiver entry, amount > 0
//! - protocol fee entry exists iff protocol_fee_bps > 0
//! - protocol fee amount == floor(amount * protocol_fee_bps / 10_000)
//! - operator fee bps in [min_fee_bps, max_fee_bps]
//! - no recipient outside {receiver, protocol_fee_receiver, operator_fee_receiver}
//!
//! Validator-rejects don't need to be invariant-checked but the harness
//! prints them periodically so we can sanity-check coverage.

use anchor_lang::prelude::Pubkey;
use auth_capture_escrow::{splits::validate_splits, state::SplitEntry};
use auth_capture_escrow_fuzz::{FuzzSplit, FuzzSplitsInput};
use trident_client::fuzzing::*;

fuzz_target!(|input: FuzzSplitsInput| {
let receiver = Pubkey::new_unique();
let protocol_fee_receiver = Pubkey::new_unique();
let operator_fee_receiver = Pubkey::new_unique();
let random = Pubkey::new_unique();

let entries: Vec<SplitEntry> = input
.entries
.iter()
.map(|e: &FuzzSplit| SplitEntry {
recipient: match e.recipient_kind % 4 {
0 => receiver,
1 => protocol_fee_receiver,
2 => operator_fee_receiver,
_ => random,
},
amount: e.amount,
})
.collect();

let _ = validate_splits(
&entries,
input.amount,
&receiver,
input.protocol_fee_bps,
&protocol_fee_receiver,
&operator_fee_receiver,
input.min_fee_bps,
input.max_fee_bps,
);
});
23 changes: 23 additions & 0 deletions fuzz/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: BUSL-1.1
// CONTRACTS UNAUDITED: USE AT YOUR OWN RISK
//! Trident fuzz harness shared types. Each fuzz target binary in
//! `fuzz_tests/` reuses these arbitrary-derived input shapes.

use arbitrary::Arbitrary;

/// One fuzz-generated split entry. Recipient is sampled from a small set of
/// fixtures (receiver / protocol-fee / operator-fee / random unrelated key).
#[derive(Arbitrary, Debug, Clone)]
pub struct FuzzSplit {
pub recipient_kind: u8, // 0=receiver 1=protocol_fee 2=operator_fee 3=random
pub amount: u64,
}

#[derive(Arbitrary, Debug, Clone)]
pub struct FuzzSplitsInput {
pub amount: u64,
pub protocol_fee_bps: u16,
pub min_fee_bps: u16,
pub max_fee_bps: u16,
pub entries: Vec<FuzzSplit>,
}
Loading