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

[features]
resolution = true
skip-lint = false

[programs.localnet]
auth_capture_escrow = "AcESCRow1111111111111111111111111111111111"
spl_token_collector = "SPLCo11ector1111111111111111111111111111111"

[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"
17 changes: 17 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[workspace]
members = [
"programs/auth-capture-escrow",
"programs/spl-token-collector",
"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
69 changes: 43 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,66 @@
# 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

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).
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.

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).
## Architectural symmetry with `base/commerce-payments`

The base primitives are structured so each piece has a direct SVM counterpart of its EVM analog:

| `base/commerce-payments` (EVM) | This workspace (SVM) |
| :--- | :--- |
| `AuthCaptureEscrow` | `auth-capture-escrow` |
| `EIP3009TokenCollector` / `Permit2TokenCollector` | `spl-token-collector` (and future Token-2022 / bridge collectors slot in via the same `ITokenCollector` interface) |

## 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. |
| `spl-token-collector` | The pilot's only `ITokenCollector` impl. SPL Token transfers for both `collect_authorize` (payer ATA → vault) and `collect_refund` (treasury → 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.

## 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
```

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/spl-token-collector # ITokenCollector impl for SPL
tests/ # Vitest + Codama-generated Kit clients
fuzz/ # Trident fuzz harnesses
migrations/ # Deploy scripts (devnet + mainnet-beta)
codama/ # Codama Kit-client generation
```
55 changes: 55 additions & 0 deletions codama/generate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Generate Solana Kit clients for `auth-capture-escrow` and `spl-token-collector`
* 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", "spl_token_collector"];

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}`);
}
22 changes: 22 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "auth-capture-escrow-fuzz"
version = "0.2.0"
edition = "2021"
description = "Trident fuzz harness for auth-capture-escrow"
license = "BUSL-1.1"

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

[[bin]]
name = "fuzz_splits"
path = "fuzz_tests/fuzz_splits.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 = []
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>,
}
83 changes: 83 additions & 0 deletions migrations/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Deploy + initialize the protocol-fee config in one shot.
*
* Usage:
* pnpm tsx migrations/deploy.ts <devnet|mainnet-beta> <protocolFeeBps> <protocolFeeReceiver>
*
* Run AFTER `anchor deploy --provider.cluster <cluster>`. This script only
* invokes the `initialize_protocol_fee_config` instruction — `anchor deploy`
* is what actually loads the .so onto the cluster.
*
* The config is **immutable** once initialized; do not run twice on the same
* deployment.
*/
import {
createSolanaRpc,
createSolanaRpcSubscriptions,
devnet,
mainnet,
pipe,
appendTransactionMessageInstructions,
createTransactionMessage,
generateKeyPairSigner,
sendAndConfirmTransactionFactory,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
signTransactionMessageWithSigners,
type Address,
address,
} from "@solana/kit";

async function main() {
const [, , clusterArg, bpsArg, receiverArg] = process.argv;
if (!clusterArg || !bpsArg || !receiverArg) {
console.error(
"Usage: pnpm tsx migrations/deploy.ts <devnet|mainnet-beta> <protocolFeeBps> <protocolFeeReceiver>",
);
process.exit(1);
}
const protocolFeeBps = Number(bpsArg);
if (!Number.isInteger(protocolFeeBps) || protocolFeeBps < 0 || protocolFeeBps > 10_000) {
throw new Error("protocolFeeBps must be 0..10000");
}
const protocolFeeReceiver = address(receiverArg) as Address;

const cluster = clusterArg === "devnet" ? "devnet" : "mainnet-beta";
const rpcUrl =
cluster === "devnet"
? "https://api.devnet.solana.com"
: "https://api.mainnet-beta.solana.com";
const wsUrl =
cluster === "devnet"
? "wss://api.devnet.solana.com"
: "wss://api.mainnet-beta.solana.com";

const rpc = createSolanaRpc(cluster === "devnet" ? devnet(rpcUrl) : mainnet(rpcUrl));
const rpcSubs = createSolanaRpcSubscriptions(wsUrl);

// TODO: replace with the Codama-generated initialize_protocol_fee_config
// ix builder once the client lives at `../codama/generated/auth_capture_escrow/`.
console.log(
`[deploy] cluster=${cluster} protocolFeeBps=${protocolFeeBps} protocolFeeReceiver=${protocolFeeReceiver}`,
);
console.log(
"[deploy] TODO: invoke auth_capture_escrow.initialize_protocol_fee_config via Codama client",
);

// Suppress unused-vars warnings until the Codama wiring lands.
void rpc;
void rpcSubs;
void sendAndConfirmTransactionFactory;
void pipe;
void appendTransactionMessageInstructions;
void createTransactionMessage;
void generateKeyPairSigner;
void setTransactionMessageFeePayerSigner;
void setTransactionMessageLifetimeUsingBlockhash;
void signTransactionMessageWithSigners;
}

main().catch(err => {
console.error(err);
process.exit(1);
});
Loading