diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69835fb..2177eeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,77 +19,68 @@ jobs: name: Contract · build (Wasm) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Install Rust (pinned toolchain) - uses: dtolnay/rust-toolchain@master - with: - toolchain: nightly-2025-02-04 - targets: wasm32-unknown-unknown - - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml') }} + run: | + rustup toolchain install nightly-2025-02-04 --profile minimal + rustup target add wasm32-unknown-unknown --toolchain nightly-2025-02-04 - name: Build Wasm (release) - run: cargo build --release + run: cargo +nightly-2025-02-04 build --release --locked --target wasm32-unknown-unknown - name: Upload contract artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: aifinpay-casper-wasm path: target/wasm32-unknown-unknown/release/*.wasm - if-no-files-found: warn + if-no-files-found: error quality: - name: Contract · fmt · clippy (non-blocking) + name: Contract · unit · fmt · clippy runs-on: ubuntu-latest - continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Install Rust with components - uses: dtolnay/rust-toolchain@master - with: - toolchain: nightly-2025-02-04 - components: rustfmt, clippy - targets: wasm32-unknown-unknown + run: | + rustup toolchain install nightly-2025-02-04 --profile minimal --component rustfmt --component clippy + rustup target add wasm32-unknown-unknown --toolchain nightly-2025-02-04 + rustup target add x86_64-unknown-linux-gnu --toolchain nightly-2025-02-04 + + - name: Canonical economics unit tests (native host) + run: cargo +nightly-2025-02-04 test --lib --locked --target x86_64-unknown-linux-gnu - name: Format check - run: cargo fmt --all -- --check - continue-on-error: true + run: cargo +nightly-2025-02-04 fmt --all -- --check - - name: Clippy - run: cargo clippy --all-targets -- -D warnings - continue-on-error: true + - name: Clippy library (native host) + run: cargo +nightly-2025-02-04 clippy --lib --locked --target x86_64-unknown-linux-gnu -- -D warnings + + - name: Clippy Wasm contract + run: cargo +nightly-2025-02-04 clippy --bin aifinpay_casper --target wasm32-unknown-unknown --locked -- -D warnings demo: - name: Demo / SDK · lint · format (non-blocking) + name: Demo / SDK · tests runs-on: ubuntu-latest - continue-on-error: true defaults: run: working-directory: demo steps: - - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Install dependencies - run: npm install --no-audit --no-fund - - - name: Prettier format check - run: npx --yes prettier --check "**/*.{js,mjs,json,md}" - continue-on-error: true - - - name: ESLint - run: npx --yes eslint . --ext .js,.mjs - continue-on-error: true + run: npm ci --no-audit --no-fund + + - name: Settlement verification tests + run: npm test + + - name: Syntax checks + run: | + node --check settlement-verifier.js + node --check trusted-contract.js + node --check compute-bridge.js + node --check agent-compute-demo.js + node --check demo.js + node --check demo-mainnet.js + node --check casper-mcp.mjs diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f60353d..d7bc95c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,18 +22,18 @@ jobs: matrix: language: ["javascript-typescript"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@e60ea984bd3baa95954f2856bcf24f9eaba46637 with: language: ${{ matrix.language }} queries: security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@e60ea984bd3baa95954f2856bcf24f9eaba46637 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@e60ea984bd3baa95954f2856bcf24f9eaba46637 with: category: "/language:${{ matrix.language }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index c25c806..dbdf73a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [2.0.0] - 2026-08-04 + +### Security +- Changed `pay_agent` from receipt-only bookkeeping to an atomic native CSPR + transfer followed by an immutable settlement record and event. +- Bound every agent registration to `runtime::get_caller()` and authorize a + payment only when the caller owns `from_agent`; reject zero-value, + self-payment, malformed identifier/wallet, duplicate request and counter + overflow cases. +- Replaced permissive bridge verification with exact, fail-closed checks of + execution success, contract hash, entry point and all quoted payment terms. +- Added request expiry, bounded pending state, replay/in-flight protection and + retry-safe upstream failure handling to the HTTP 402 bridge. +- Quarantined all demo/MCP payment entry points until a complete, reviewed v2 + deployment manifest has `status: verified`. Environment variables cannot + override the trusted contract. +- Removed the legacy mainnet demo's second native transfer and require the + provider to self-register with a distinct funded key. + +### Tests +- Added 13 Node regression/negative tests for exact settlement verification, + failed/pending deploys, malformed sessions, amount mismatches and deployment + quarantine. +- Made Rust formatting, Clippy, locked Wasm build, artifact presence, Node + tests and syntax checks blocking in CI; pinned third-party GitHub actions. + ### Added - World-class repository structure for the Casper Agentic Buildathon final round: full README with architecture (Mermaid) and payment-lifecycle diagrams, badges, diff --git a/Cargo.lock b/Cargo.lock index 31f2e6a..ceacc07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "aifinpay-casper" -version = "1.0.0" +version = "3.0.0-rc.1" dependencies = [ "casper-contract", "casper-types", diff --git a/Cargo.toml b/Cargo.toml index b28552e..c188e78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aifinpay-casper" -version = "1.0.0" +version = "3.0.0-rc.1" edition = "2021" [[bin]] diff --git a/README.md b/README.md index ab251f9..286b202 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Autonomous AI agents need to pay each other and pay for services — compute, da ## Introduction -AiFinPay is payment infrastructure for the machine economy. As autonomous AI agents begin to buy compute, data, and API access on their own, they need a way to **pay and be paid** with a verifiable, non-custodial settlement record. AiFinPay provides that as a protocol layer over [HTTP 402](https://en.wikipedia.org/wiki/HTTP_402) (x402), and **this repository implements the Casper settlement backend**: a Rust → Wasm smart contract that gives every agent an on-chain identity and records every payment permanently. +AiFinPay is payment infrastructure for the machine economy. This repository contains the Casper settlement backend: a Rust → Wasm contract in which agents self-register and `pay_agent` atomically transfers native CSPR before writing an immutable receipt. ## Problem @@ -75,14 +75,14 @@ Card rails and custodial wallets assume a human and a browser. Agents need progr AiFinPay closes the loop: - **x402 protocol** — a service returns `HTTP 402 Payment Required`; the agent settles on-chain and retries with proof. -- **Casper settlement contract** — agents `register_agent` for an on-chain identity, then `pay_agent` to settle. Every settlement emits a `PaymentSettled` event. +- **Casper settlement contract** — agents self-register an on-chain identity, then `pay_agent` atomically transfers CSPR and emits `PaymentSettled`. - **Idempotent settlement** — payments are keyed by `request_id`, so retries are safe and double-settlement is impossible. - **MCP server** — AI agents (e.g. Claude via Claude Code / Claude Desktop) settle on Casper as a native tool call. ## Features -- 🧾 **On-chain agent registry** — `register_agent(agent_id, wallet)` with an `AgentRegistered` event. -- 💸 **Verifiable settlement** — `pay_agent(from, to, amount, request_id)` emits `PaymentSettled`, permanently recorded on Casper. +- 🧾 **Caller-bound registry** — `register_agent(agent_id, wallet)` accepts only the caller's account hash. +- 💸 **Atomic settlement** — `pay_agent(from, to, amount, request_id)` moves CSPR and records the exact terms in one transaction. - 🔁 **Idempotent by design** — duplicate `request_id` is rejected (no double spend). - 🌐 **x402 bridge** — a reference compute gate that enforces `HTTP 402` and verifies settlement on-chain before releasing a resource. - 🤖 **MCP integration** — drive settlements directly from an AI agent runtime. @@ -210,7 +210,7 @@ rustup target add wasm32-unknown-unknown cd demo && npm install && cp .env.example .env ``` -Set `CONTRACT_HASH` in `demo/.env` after deploying (or use the live hash below). +Payment clients also require a reviewed `deployments/casper-v2.json`; an environment variable alone cannot enable an unverified contract. ## Local Development @@ -231,11 +231,11 @@ make build # target/wasm32-unknown-unknown/release/aifinpay_casper.wasm make deploy # deploys via demo/deploy.js, prints the new CONTRACT_HASH ``` -Save the printed `CONTRACT_HASH` into `demo/.env`. Full walkthrough: [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). +Do not enable payment routes until the deployed Wasm/source provenance has been independently checked and the v2 manifest is committed with `status: verified`. Full walkthrough: [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). -## 🟢 Casper Mainnet Deployment +## Historical Casper deployments — quarantined -The settlement contract is **live on Casper Mainnet** — not only testnet. Every entry point below has been exercised on mainnet with real CSPR. +The hashes below are retained as historical evidence only. They are v1 deployments: `pay_agent` recorded an amount but did not transfer it, and a separate transfer produced the old demo balance change. They are not valid payment proof and all current clients reject them. | Field | Value | |-------|-------| @@ -245,9 +245,9 @@ The settlement contract is **live on Casper Mainnet** — not only testnet. Ever | **Explorer** | [cspr.live mainnet](https://cspr.live/contract/9903a5e3948e799196df54b17270bc6769338ac1cc36c9eb47e113f88d23f019) | | **Install deploy** | [`0d560c62…`](https://cspr.live/deploy/0d560c62679d109525ee8b2b1ce1a275cba7deff50a90352f8b4aabf4f070386) | -### Live mainnet settlement (real value moved) +### Historical mainnet demonstration -A full agent-to-agent settlement executed on mainnet — two agents registered, a payment settled on-chain, and real CSPR delivered to the provider's wallet: +The following two independent operations were previously described as one settlement. The contract call recorded a receipt; the later native transfer moved value. | Action | Deploy | Explorer | |--------|--------|----------| @@ -256,9 +256,9 @@ A full agent-to-agent settlement executed on mainnet — two agents registered, | **PaymentSettled** (`pay_agent`) | `80df5895…` | [view](https://cspr.live/deploy/80df58959f81d99d717027cdc069e95a3464d867150184b0f05312de6c6eb6d7) | | Value transfer (2.5 CSPR → provider) | `564f19be…` | [view](https://cspr.live/deploy/564f19be2c89140a6dda9e97e4440d49890cf8df5b678b00bd0c625c6d975f3a) | -Reproduce on mainnet with a funded key at `demo/keys-mainnet/secret_key.pem`: `node demo/deploy-mainnet.js` then `node demo/demo-mainnet.js`. +Do not reproduce this v1 flow. The v2 mainnet script requires separately controlled buyer/provider keys and performs no second transfer. -## Casper Testnet Deployment +## Historical Casper Testnet Deployment | Field | Value | |-------|-------| @@ -271,8 +271,8 @@ Reproduce on mainnet with a funded key at `demo/keys-mainnet/secret_key.pem`: `n | Entry Point | Args | Description | |-------------|------|-------------| -| `register_agent` | `agent_id: String, wallet: String` | Register an AI agent on-chain → `AgentRegistered` | -| `pay_agent` | `from_agent: String, to_agent: String, amount: U512, request_id: String` | Settle a payment → `PaymentSettled` | +| `register_agent` | `agent_id: String, wallet: String` | Self-register caller wallet → `AgentRegistered` | +| `pay_agent` | `from_agent: String, to_agent: String, amount: U512, request_id: String` | Transfer CSPR and record settlement → `PaymentSettled` | | `get_payment_count` | — | Total settled payments | ### Events @@ -290,7 +290,7 @@ hash-47df409829ddf0612617460293ba591a19b26fa0c06918878204088d3eb9b78a ## Sample Transactions -Real transactions from the agent-compute demo on `casper-test`: +Historical v1 transactions from the agent-compute demo on `casper-test` (not valid settlement proof): | Action | Deploy | Explorer | |--------|--------|----------| @@ -321,7 +321,9 @@ Configure in `demo/.env` (see [`demo/.env.example`](demo/.env.example)): | `NODE_URL` | `https://node.testnet.casper.network/rpc` | Casper RPC endpoint | | `NETWORK_NAME` | `casper-test` | Casper network name | | `KEYS_DIR` | `./keys` | Directory holding the signing key | -| `CONTRACT_HASH` | — | Deployed contract hash (`hash-…`) | +| `CONTRACT_HASH` | — | Must match the verified v2 deployment manifest | +| `PROVIDER_AGENT_ID` | — | Merchant identity self-registered by its own wallet | +| `PROVIDER_KEYS_DIR` | — | Separate funded merchant key for two-party demos only | | `COMPUTE_UPSTREAM_URL` | _(optional)_ | Real OpenAI-compatible compute endpoint | | `COMPUTE_API_KEY` | _(optional)_ | API key for the upstream provider | diff --git a/SECURITY.md b/SECURITY.md index 20d24d4..f521e49 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Supported | Network | |---------|-----------|---------| -| 1.x | ✅ | Casper Testnet (`casper-test`) | +| 2.x | Source fixed; not deployed | None until manifest verification | +| 1.x | ❌ vulnerable / quarantined | Historical testnet and mainnet deployments | See [SUPPORTED.md](SUPPORTED.md) for the full support matrix. @@ -44,3 +45,11 @@ the public Casper testnet infrastructure. This repository runs **CodeQL** static analysis, **Dependabot** dependency alerts and updates, and **secret scanning with push protection**. All High or greater severity alerts are triaged and resolved before release. + +## Deployment safety state + +Version 1.x recorded a settlement without transferring CSPR and did not bind +the claimed payer or registered wallet to the caller. It must not be used as a +payment proof. Version 2.0 fixes these defects in source, but clients remain +fail-closed until `deployments/casper-v2.json` contains independently checked +deployment, bytecode and source provenance with `status: verified`. diff --git a/SUPPORTED.md b/SUPPORTED.md index d4551f1..df9abf2 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -4,14 +4,15 @@ | Version | Status | Notes | |---------|-------------|-------| -| 1.x | ✅ Active | Current line. Bug fixes + security patches. | +| 2.x | Source candidate | Payment routes stay quarantined until verified deployment. | +| 1.x | ❌ Unsupported | Receipt-only settlement and missing caller binding. | ## Networks | Network | Chain name | Status | Contract | |----------------------------|---------------|----------------|----------| -| Casper Testnet | `casper-test` | ✅ Live | `hash-47df409829ddf0612617460293ba591a19b26fa0c06918878204088d3eb9b78a` | -| Casper Mainnet | `casper` | 🔜 Planned | — | +| Casper Testnet | `casper-test` | ⚠️ Legacy v1 only | Quarantined | +| Casper Mainnet | `casper` | ⚠️ Legacy v1 only | Quarantined | ## Toolchain diff --git a/demo/.env.example b/demo/.env.example index 41dff2a..a264507 100644 --- a/demo/.env.example +++ b/demo/.env.example @@ -1,5 +1,12 @@ NODE_URL=https://node.testnet.casper.network/rpc NETWORK_NAME=casper-test KEYS_DIR=./keys +# Separately controlled, funded merchant account used by demo.js. +PROVIDER_KEYS_DIR=./provider-keys # Set after deployment: CONTRACT_HASH=hash-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# Merchant identity, registered on-chain by the merchant's own account. +PROVIDER_AGENT_ID=aifinpay-compute-provider +PRICE_MOTES=100000000 +ORDER_TTL_MS=600000 +MAX_PENDING_ORDERS=10000 diff --git a/demo/agent-compute-demo.js b/demo/agent-compute-demo.js index 441e951..61c8fbd 100644 --- a/demo/agent-compute-demo.js +++ b/demo/agent-compute-demo.js @@ -3,7 +3,7 @@ * * An autonomous AI agent buys LLM compute and SETTLES THE PAYMENT ON CASPER: * - * 1. agent + provider register on-chain (register_agent) + * 1. agent registers; provider is pre-registered by its own wallet * 2. agent asks the bridge for compute → HTTP 402 (pay_casper) * 3. agent settles on Casper → pay_agent (REAL testnet tx) * 4. bridge verifies the settlement on-chain → returns the compute result @@ -24,6 +24,7 @@ require('dotenv').config(); const { CasperClient, DeployUtil, Keys, CLValueBuilder, RuntimeArgs } = require('casper-js-sdk'); const { spawn } = require('child_process'); const path = require('path'); +const { assertTrustedContract } = require('./trusted-contract'); const NODE_URL = process.env.NODE_URL || 'https://node.testnet.casper.network/rpc'; const NETWORK = process.env.NETWORK_NAME || 'casper-test'; @@ -31,6 +32,7 @@ const KEYS_DIR = process.env.KEYS_DIR || path.join(__dirname, 'keys') const CONTRACT_HASH = process.env.CONTRACT_HASH; const BRIDGE_PORT = parseInt(process.env.BRIDGE_PORT || '4055', 10); const PRICE_MOTES = process.env.PRICE_MOTES || '100000000'; // 0.1 CSPR / call +const PROVIDER = process.env.PROVIDER_AGENT_ID; const GAS_CALL = '5000000000'; // 5 CSPR per entry-point call const PROMPT = process.env.PROMPT || @@ -40,6 +42,11 @@ if (!CONTRACT_HASH) { console.error('❌ CONTRACT_HASH not set in .env — run `node deploy.js` first.'); process.exit(1); } +if (!PROVIDER) { + console.error('❌ PROVIDER_AGENT_ID is required and must already be registered by the provider wallet.'); + process.exit(1); +} +assertTrustedContract(CONTRACT_HASH); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -124,7 +131,6 @@ async function main() { const nonce = Date.now().toString(36); const BUYER = `aifinpay-buyer-${nonce}`; - const PROVIDER = `aifinpay-provider-${nonce}`; console.log('🤖 AiFinPay × Casper — AI agent pays for compute, settled on Casper'); console.log('===================================================================='); @@ -139,19 +145,14 @@ async function main() { try { await waitForBridge(BRIDGE_URL); - // ── 1. Register both agents on-chain ────────────────────────────────────── - console.log('📝 Step 1: Registering agents on Casper...'); + // ── 1. Register the payer. The merchant must self-register separately. ─── + console.log('📝 Step 1: Registering buyer on Casper...'); const r1 = await callEntry(client, keypair, 'register_agent', RuntimeArgs.fromMap({ agent_id: CLValueBuilder.string(BUYER), wallet: CLValueBuilder.string(accountHash), })); console.log(' buyer register tx:', r1, '→', explorer(r1)); await waitForSuccess(client, r1, 'register buyer'); - const r2 = await callEntry(client, keypair, 'register_agent', RuntimeArgs.fromMap({ - agent_id: CLValueBuilder.string(PROVIDER), wallet: CLValueBuilder.string(accountHash), - })); - console.log(' provider register tx:', r2, '→', explorer(r2)); - await waitForSuccess(client, r2, 'register provider'); - console.log(' ✅ both agents registered\n'); + console.log(' ✅ buyer registered; provider is pre-registered by its own wallet\n'); // ── 2. Ask the bridge for compute → expect HTTP 402 ─────────────────────── console.log('💡 Step 2: Agent requests compute →', JSON.stringify(PROMPT)); @@ -197,7 +198,7 @@ async function main() { console.log(''); console.log('On-chain settlement:'); console.log(' register buyer: ', explorer(r1)); - console.log(' register provider:', explorer(r2)); + console.log(' provider agent: ', PROVIDER, '(pre-registered)'); console.log(' PaymentSettled: ', explorer(pay)); console.log(' contract state: https://testnet.cspr.live/contract/' + CONTRACT_HASH.replace('hash-', '')); console.log(''); diff --git a/demo/casper-mcp.mjs b/demo/casper-mcp.mjs index ad47107..25508a6 100644 --- a/demo/casper-mcp.mjs +++ b/demo/casper-mcp.mjs @@ -19,7 +19,8 @@ * node casper-mcp.mjs (usually launched by Claude Desktop, not by hand) * * Config (demo/.env): CONTRACT_HASH (required), NODE_URL, NETWORK_NAME, KEYS_DIR, - * PRICE_MOTES, and optional COMPUTE_UPSTREAM_URL + COMPUTE_API_KEY for a real + * PRICE_MOTES, PROVIDER_AGENT_ID (pre-registered by the provider), and optional + * COMPUTE_UPSTREAM_URL + COMPUTE_API_KEY for a real * LLM answer instead of the labelled demo mock. */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; @@ -29,8 +30,10 @@ import { fileURLToPath } from 'node:url'; import path from 'node:path'; import dotenv from 'dotenv'; import casper from 'casper-js-sdk'; +import trustedContract from './trusted-contract.js'; const { CasperClient, DeployUtil, Keys, CLValueBuilder, RuntimeArgs } = casper; +const { assertTrustedContract } = trustedContract; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.join(__dirname, '.env') }); @@ -44,6 +47,7 @@ const KEYS_DIR_RAW = process.env.KEYS_DIR || 'keys'; const KEYS_DIR = path.isAbsolute(KEYS_DIR_RAW) ? KEYS_DIR_RAW : path.join(__dirname, KEYS_DIR_RAW); const CONTRACT_HASH = process.env.CONTRACT_HASH; const PRICE_MOTES = process.env.PRICE_MOTES || '100000000'; // 0.1 CSPR / call +const PROVIDER = process.env.PROVIDER_AGENT_ID; const GAS_CALL = '5000000000'; // 5 CSPR per entry-point call const UPSTREAM_URL = process.env.COMPUTE_UPSTREAM_URL || ''; const UPSTREAM_KEY = process.env.COMPUTE_API_KEY || ''; @@ -58,6 +62,11 @@ if (!CONTRACT_HASH) { log('FATAL: CONTRACT_HASH not set in demo/.env (the deployed settlement contract).'); process.exit(1); } +if (!PROVIDER) { + log('FATAL: PROVIDER_AGENT_ID is required and must be registered by the provider wallet.'); + process.exit(1); +} +assertTrustedContract(CONTRACT_HASH); // ── Casper plumbing (same pattern as agent-compute-demo.js) ─────────────────── const keypair = Keys.Ed25519.loadKeyPairFromPrivateFile(path.join(KEYS_DIR, 'secret_key.pem')); @@ -104,28 +113,23 @@ async function waitForSuccess(deployHash, label, maxWait = 180000) { // ── Session state ───────────────────────────────────────────────────────────── const SESSION = Math.random().toString(36).slice(2, 8); const BUYER = `claude-agent-${SESSION}`; -const PROVIDER = `aifinpay-compute-${SESSION}`; const orders = new Map(); // request_id -> { from, to, amount, prompt } const settled = new Map(); // request_id -> deployHash let reqSeq = 0; -// Both agents must exist on-chain before pay_agent. Register once, lazily, and -// cache the promise so concurrent/later calls reuse the same registration. +// The buyer self-registers once. The merchant is a separately-owned identity +// and must have been registered by its own wallet before this server starts. let registrationPromise = null; function ensureRegistered() { if (!registrationPromise) { registrationPromise = (async () => { - log(`registering agents on-chain: ${BUYER} + ${PROVIDER} (one-time, ~30-60s)...`); + log(`registering buyer on-chain: ${BUYER} (one-time, ~30-60s)...`); const r1 = await callEntry('register_agent', RuntimeArgs.fromMap({ agent_id: CLValueBuilder.string(BUYER), wallet: CLValueBuilder.string(accountHash), })); await waitForSuccess(r1, 'register buyer'); - const r2 = await callEntry('register_agent', RuntimeArgs.fromMap({ - agent_id: CLValueBuilder.string(PROVIDER), wallet: CLValueBuilder.string(accountHash), - })); - await waitForSuccess(r2, 'register provider'); - log(`agents registered (buyer ${explorer(r1)} · provider ${explorer(r2)})`); - return { buyer: r1, provider: r2 }; + log(`buyer registered (${explorer(r1)}); provider ${PROVIDER} is pre-registered`); + return { buyer: r1 }; })().catch((e) => { registrationPromise = null; throw e; }); } return registrationPromise; diff --git a/demo/compute-bridge.js b/demo/compute-bridge.js index e6ebea6..b923dab 100644 --- a/demo/compute-bridge.js +++ b/demo/compute-bridge.js @@ -19,14 +19,17 @@ require('dotenv').config(); const http = require('http'); -const { CasperClient } = require('casper-js-sdk'); +const { validateExecutedSettlement } = require('./settlement-verifier'); +const { assertTrustedContract } = require('./trusted-contract'); const PORT = parseInt(process.env.BRIDGE_PORT || '4055', 10); const NODE_URL = process.env.NODE_URL || 'https://node.testnet.casper.network/rpc'; const NETWORK = process.env.NETWORK_NAME || 'casper-test'; const CONTRACT_HASH = process.env.CONTRACT_HASH || ''; -const PROVIDER_AGENT = process.env.PROVIDER_AGENT_ID || 'aifinpay-compute-provider'; +const PROVIDER_AGENT = process.env.PROVIDER_AGENT_ID || ''; const PRICE_MOTES = process.env.PRICE_MOTES || '100000000'; // 0.1 CSPR / call +const ORDER_TTL_MS = parseInt(process.env.ORDER_TTL_MS || '600000', 10); +const MAX_PENDING = parseInt(process.env.MAX_PENDING_ORDERS || '10000', 10); // Optional real upstream (OpenAI-compatible). If unset, a labelled demo mock runs. const UPSTREAM_URL = process.env.COMPUTE_UPSTREAM_URL || ''; const UPSTREAM_KEY = process.env.COMPUTE_API_KEY || ''; @@ -36,10 +39,15 @@ if (!CONTRACT_HASH) { console.error('[bridge] FATAL: CONTRACT_HASH not set (the deployed Casper settlement contract).'); process.exit(1); } +if (!PROVIDER_AGENT) { + console.error('[bridge] FATAL: PROVIDER_AGENT_ID is required and must be registered by the provider wallet.'); + process.exit(1); +} +assertTrustedContract(CONTRACT_HASH); -const client = new CasperClient(NODE_URL); -const orders = new Map(); // request_id -> { from_agent, to_agent, amount_motes } -const consumed = new Set(); // request_id already fulfilled (replay guard) +const orders = new Map(); // request_id -> quote terms + creation time +const consumed = new Map(); // request_id -> fulfillment time +const inflight = new Set(); // verified requests currently computing let seq = 0; function newRequestId() { @@ -55,8 +63,19 @@ function send(res, code, obj) { // 402 challenge — tells the agent exactly how to settle on Casper. function challenge(res, fromAgent) { + const now = Date.now(); + for (const [id, order] of orders) if (now - order.created_at > ORDER_TTL_MS) orders.delete(id); + for (const [id, timestamp] of consumed) if (now - timestamp > ORDER_TTL_MS) consumed.delete(id); + if (orders.size >= MAX_PENDING) { + return send(res, 503, { error: 'payment_capacity_exceeded' }); + } const request_id = newRequestId(); - orders.set(request_id, { from_agent: fromAgent, to_agent: PROVIDER_AGENT, amount_motes: PRICE_MOTES }); + orders.set(request_id, { + from_agent: fromAgent, + to_agent: PROVIDER_AGENT, + amount_motes: PRICE_MOTES, + created_at: now, + }); return send(res, 402, { error: 'Payment Required', protocol: 'AiFinPay-x402', @@ -80,25 +99,17 @@ function challenge(res, fromAgent) { }); } -// Pull an arg's parsed value out of a getDeploy raw response (StoredContractByHash). -function readSessionArgs(raw) { - const s = raw && raw.deploy && raw.deploy.session; - const sc = s && (s.StoredContractByHash || s.StoredVersionedContractByHash); - if (!sc || !Array.isArray(sc.args)) return null; - const out = { entry_point: sc.entry_point }; - for (const pair of sc.args) { - if (!Array.isArray(pair) || pair.length < 2) continue; - const [name, clv] = pair; - out[name] = clv && (clv.parsed !== undefined ? clv.parsed : clv); - } - return out; -} - // Verify the agent's Casper deploy actually settled THIS order. async function verifySettlement(deployHash, request_id) { const order = orders.get(request_id); if (!order) return { ok: false, reason: 'unknown_or_expired_request_id' }; - if (consumed.has(request_id)) return { ok: false, reason: 'request_id_already_fulfilled' }; + if (Date.now() - order.created_at > ORDER_TTL_MS) { + orders.delete(request_id); + return { ok: false, reason: 'unknown_or_expired_request_id' }; + } + if (consumed.has(request_id) || inflight.has(request_id)) { + return { ok: false, reason: 'request_id_already_fulfilled' }; + } // Casper 2.0: read info_get_deploy directly (casper-js-sdk 2.15.4 parses the // legacy execution_results, empty on a 2.0 node). @@ -112,36 +123,13 @@ async function verifySettlement(deployHash, request_id) { } catch (e) { return { ok: false, reason: `info_get_deploy failed: ${e.message || e}` }; } - if (!rpc) return { ok: false, reason: 'deploy_not_found' }; - const er = rpc.execution_info && rpc.execution_info.execution_result; - if (!er) return { ok: false, reason: 'deploy_not_executed_yet' }; - if (er.Version2 && er.Version2.error_message) return { ok: false, reason: `deploy_failed_on_chain: ${er.Version2.error_message}` }; - if (er.Version1 && er.Version1.Failure) return { ok: false, reason: `deploy_failed_on_chain: ${er.Version1.Failure.error_message || 'unknown'}` }; - if (!er.Version2 && !er.Version1) return { ok: false, reason: 'deploy_not_successful' }; - - // Strict check: confirm it was pay_agent for THIS request_id / recipient / amount. - const args = readSessionArgs(rpc); - if (args) { - if (args.entry_point && args.entry_point !== 'pay_agent') { - return { ok: false, reason: `wrong_entry_point: ${args.entry_point}` }; - } - if (args.request_id != null && String(args.request_id) !== String(request_id)) { - return { ok: false, reason: `request_id_mismatch: ${args.request_id}` }; - } - if (args.to_agent != null && String(args.to_agent) !== String(order.to_agent)) { - return { ok: false, reason: `recipient_mismatch: ${args.to_agent}` }; - } - if (args.amount != null) { - try { - if (BigInt(String(args.amount)) < BigInt(String(order.amount_motes))) { - return { ok: false, reason: `underpaid: ${args.amount} < ${order.amount_motes}` }; - } - } catch { /* non-numeric parsed amount — skip strict amount check */ } - } - } else { - console.warn('[bridge] could not parse session args — accepting on execution success only'); - } - return { ok: true }; + return validateExecutedSettlement(rpc, { + contract_hash: CONTRACT_HASH, + request_id, + from_agent: order.from_agent, + to_agent: order.to_agent, + amount_motes: order.amount_motes, + }); } // The actual compute. Real OpenAI-compatible upstream if configured, else a @@ -197,23 +185,26 @@ const server = http.createServer((req, res) => { const v = await verifySettlement(String(deployHash), String(reqId)); if (!v.ok) return send(res, 402, { error: 'payment_verification_failed', detail: v.reason }); - consumed.add(String(reqId)); const order = orders.get(String(reqId)); - const compute = await runCompute(body.prompt); - return send(res, 200, { - ok: true, - settlement: { - chain: 'casper', - contract_hash: CONTRACT_HASH, - request_id: reqId, - from_agent: order && order.from_agent, - to_agent: order && order.to_agent, - amount_motes: order && order.amount_motes, - deploy: deployHash, - explorer: `https://testnet.cspr.live/deploy/${deployHash}`, - }, - compute, - }); + inflight.add(String(reqId)); + try { + const compute = await runCompute(body.prompt); + consumed.set(String(reqId), Date.now()); + return send(res, 200, { + ok: true, + settlement: { + chain: 'casper', contract_hash: CONTRACT_HASH, request_id: reqId, + from_agent: order && order.from_agent, to_agent: order && order.to_agent, + amount_motes: order && order.amount_motes, deploy: deployHash, + explorer: `https://testnet.cspr.live/deploy/${deployHash}`, + }, + compute, + }); + } catch (error) { + return send(res, 502, { error: 'compute_upstream_failed', detail: error.message || String(error) }); + } finally { + inflight.delete(String(reqId)); + } }); }); diff --git a/demo/demo-mainnet.js b/demo/demo-mainnet.js index 3c0527c..7239721 100644 --- a/demo/demo-mainnet.js +++ b/demo/demo-mainnet.js @@ -1,23 +1,27 @@ /** * demo-mainnet.js — live AiFinPay settlement demo on Casper MAINNET. - * 1. register_agent (buyer + provider) on the live contract - * 2. pay_agent records the settlement on-chain (event + idempotency) - * 3. a real native CSPR transfer moves value to the provider's wallet - * Proves the provider balance went 0 -> N CSPR. Payee key is SAVED so funds aren't lost. + * 1. buyer and provider self-register using distinct funded accounts + * 2. pay_agent atomically moves native CSPR and records the settlement + * Proves the provider balance increases by the exact settlement amount. */ const { DeployUtil, Keys, CLValueBuilder, RuntimeArgs } = require('casper-js-sdk'); const fetch = require('node-fetch'); -const fs = require('fs'); const path = require('path'); +const { assertTrustedContract } = require('./trusted-contract'); const NODE_URL = 'https://node.cspr.cloud/rpc'; const API = 'https://api.cspr.cloud'; const KEY = process.env.CSPR_API_KEY || ''; const NETWORK = 'casper'; const KEYS_DIR = path.join(__dirname, 'keys-mainnet'); -const CONTRACT = process.env.CONTRACT || 'contract-9903a5e3948e799196df54b17270bc6769338ac1cc36c9eb47e113f88d23f019'; +const PROVIDER_KEYS_DIR = process.env.PROVIDER_KEYS_DIR; +const CONTRACT = process.env.CONTRACT_HASH; const GAS_CALL = '3000000000'; // 3 CSPR per contract call -const AMOUNT = '2500000000'; // 2.5 CSPR (native-transfer minimum) +const AMOUNT = '2500000000'; // 2.5 CSPR +if (!CONTRACT || !PROVIDER_KEYS_DIR) { + throw new Error('CONTRACT_HASH and PROVIDER_KEYS_DIR are required'); +} +assertTrustedContract(CONTRACT); async function rpc(method, params) { const r = await fetch(NODE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': KEY }, body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }) }); @@ -48,14 +52,10 @@ function callContract(kp, ep, args) { async function main() { const kp = Keys.Ed25519.loadKeyPairFromPrivateFile(path.join(KEYS_DIR, 'secret_key.pem')); + const payee = Keys.Ed25519.loadKeyPairFromPrivateFile(path.join(PROVIDER_KEYS_DIR, 'secret_key.pem')); const payerHash = kp.publicKey.toAccountHashStr(); - // provider wallet — SAVE the key so the transferred CSPR is recoverable - const payee = Keys.Ed25519.new(); - const payeeDir = path.join(__dirname, 'keys-mainnet-demo-payee'); - fs.mkdirSync(payeeDir, { recursive: true }); - fs.writeFileSync(path.join(payeeDir, 'secret_key.pem'), payee.exportPrivateKeyInPem()); - fs.writeFileSync(path.join(payeeDir, 'public_key.pem'), payee.exportPublicKeyInPem()); - fs.writeFileSync(path.join(payeeDir, 'public_key_hex.txt'), payee.publicKey.toHex()); + const providerHash = payee.publicKey.toAccountHashStr(); + if (providerHash === payerHash) throw new Error('buyer and provider accounts must be distinct'); console.log('AiFinPay x Casper — LIVE MAINNET agent settlement'); console.log('contract', CONTRACT); @@ -63,10 +63,6 @@ async function main() { console.log('provider(agent-002):', payee.publicKey.toAccountHashStr().slice(0, 28) + '...'); console.log(''); - const before = await balanceCSPR(payee.publicKey.toHex()); - console.log(`provider balance BEFORE: ${before} CSPR`); - console.log(''); - const SUF = String(process.pid) + '' + payerHash.slice(-4); const A1 = `aifinpay-buyer-${SUF}`, A2 = `aifinpay-provider-${SUF}`, REQ = `req-${SUF}`; const out = {}; @@ -77,21 +73,17 @@ async function main() { if (!r.ok) process.exit(1); console.log(`2) register_agent ${A2} ...`); - h = await submit(callContract(kp, 'register_agent', RuntimeArgs.fromMap({ agent_id: CLValueBuilder.string(A2), wallet: CLValueBuilder.string(payee.publicKey.toAccountHashStr()) }))); + h = await submit(callContract(payee, 'register_agent', RuntimeArgs.fromMap({ agent_id: CLValueBuilder.string(A2), wallet: CLValueBuilder.string(providerHash) }))); r = await wait(h); console.log(r.ok ? ` ok ${h}` : ` FAIL ${r.err}`); out.register_provider = h; if (!r.ok) process.exit(1); - console.log(`3) pay_agent settle 2.5 CSPR ${REQ} ...`); + const before = await balanceCSPR(payee.publicKey.toHex()); + console.log(`provider balance BEFORE settlement: ${before} CSPR`); + console.log(`3) pay_agent atomically settles 2.5 CSPR ${REQ} ...`); h = await submit(callContract(kp, 'pay_agent', RuntimeArgs.fromMap({ from_agent: CLValueBuilder.string(A1), to_agent: CLValueBuilder.string(A2), amount: CLValueBuilder.u512(AMOUNT), request_id: CLValueBuilder.string(REQ) }))); r = await wait(h); console.log(r.ok ? ` settled ${h}` : ` FAIL ${r.err}`); out.settle = h; if (!r.ok) process.exit(1); - console.log('4) native transfer 2.5 CSPR buyer -> provider ...'); - const dp = new DeployUtil.DeployParams(kp.publicKey, NETWORK, 1, 1800000); - const xfer = DeployUtil.ExecutableDeployItem.newTransfer(AMOUNT, payee.publicKey, null, 1); - h = await submit(DeployUtil.signDeploy(DeployUtil.makeDeploy(dp, xfer, DeployUtil.standardPayment('100000000')), kp)); - r = await wait(h); console.log(r.ok ? ` moved ${h}` : ` FAIL ${r.err}`); out.transfer = h; - const after = await balanceCSPR(payee.publicKey.toHex()); console.log(''); console.log(`provider balance AFTER: ${after} CSPR`); @@ -100,6 +92,5 @@ async function main() { console.log('=== RESULTS (mainnet) ==='); for (const [k, v] of Object.entries(out)) console.log(`${k}: https://cspr.live/deploy/${v}`); console.log('provider pubkey:', payee.publicKey.toHex()); - fs.writeFileSync(path.join(__dirname, 'demo-mainnet.out.json'), JSON.stringify({ contract: CONTRACT, ...out, provider_pubkey: payee.publicKey.toHex() }, null, 2)); } main().catch(e => { console.error('ERR', e.message || e); process.exit(1); }); diff --git a/demo/demo.js b/demo/demo.js index 3d6874e..6b09b86 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -1,7 +1,7 @@ /** * demo.js — full AiFinPay x Casper demo flow: * 1. Register AI Agent A (aifinpay-agent-001) - * 2. Register AI Agent B (aifinpay-agent-002) + * 2. Provider self-registers AI Agent B (aifinpay-agent-002) * 3. Agent A pays Agent B (settle 2.5 CSPR, request ID: req-001) * 4. Query payment count → confirm on-chain * @@ -14,10 +14,12 @@ const { CasperClient, DeployUtil, Keys, CLValueBuilder, RuntimeArgs } = require('casper-js-sdk'); const path = require('path'); +const { assertTrustedContract } = require('./trusted-contract'); const NODE_URL = process.env.NODE_URL || 'https://node.testnet.casper.network/rpc'; const NETWORK = process.env.NETWORK_NAME || 'casper-test'; const KEYS_DIR = process.env.KEYS_DIR || path.join(__dirname, 'keys'); +const PROVIDER_KEYS_DIR = process.env.PROVIDER_KEYS_DIR; const CONTRACT_HASH = process.env.CONTRACT_HASH; const GAS_CALL = '5000000000'; // 5 CSPR per call @@ -27,6 +29,11 @@ if (!CONTRACT_HASH) { console.error('❌ CONTRACT_HASH not set in .env — run node deploy.js first'); process.exit(1); } +if (!PROVIDER_KEYS_DIR) { + console.error('❌ PROVIDER_KEYS_DIR is required; buyer and provider must use distinct funded accounts'); + process.exit(1); +} +assertTrustedContract(CONTRACT_HASH); async function callEntry(client, keypair, entryPoint, args) { const hashBytes = Buffer.from(CONTRACT_HASH.replace('hash-', ''), 'hex'); @@ -47,8 +54,20 @@ async function waitForDeploy(client, deployHash, maxWait = 120000) { const start = Date.now(); while (Date.now() - start < maxWait) { try { - const result = await client.getDeploy(deployHash); - if (result[1].execution_results.length > 0) return result; + const response = await fetch(NODE_URL, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'info_get_deploy', params: { deploy_hash: deployHash } }), + }); + const result = await response.json(); + const er = result && result.result && result.result.execution_info && result.result.execution_info.execution_result; + if (er && er.Version2) { + if (er.Version2.error_message) throw new Error(`deploy failed: ${er.Version2.error_message}`); + return result; + } + if (er && er.Version1) { + if (er.Version1.Failure) throw new Error(`deploy failed: ${er.Version1.Failure.error_message || 'unknown'}`); + return result; + } } catch (_) {} await new Promise(r => setTimeout(r, 3000)); } @@ -59,7 +78,12 @@ async function main() { const keypair = Keys.Ed25519.loadKeyPairFromPrivateFile( require('path').join(KEYS_DIR, 'secret_key.pem') ); + const providerKeypair = Keys.Ed25519.loadKeyPairFromPrivateFile( + path.join(PROVIDER_KEYS_DIR, 'secret_key.pem') + ); const accountHash = keypair.publicKey.toAccountHashStr(); + const providerHash = providerKeypair.publicKey.toAccountHashStr(); + if (providerHash === accountHash) throw new Error('buyer and provider accounts must be distinct'); const client = new CasperClient(NODE_URL); console.log('🤖 AiFinPay x Casper — Demo Flow'); @@ -81,9 +105,9 @@ async function main() { // ── Step 2: Register Agent B ───────────────────────────────────────────── console.log('📝 Step 2: Registering aifinpay-agent-002...'); - const tx2 = await callEntry(client, keypair, 'register_agent', RuntimeArgs.fromMap({ + const tx2 = await callEntry(client, providerKeypair, 'register_agent', RuntimeArgs.fromMap({ agent_id: CLValueBuilder.string('aifinpay-agent-002'), - wallet: CLValueBuilder.string('account-hash-0000000000000000000000000000000000000000000000000000000000000002'), + wallet: CLValueBuilder.string(providerHash), })); console.log(' Deploy hash:', tx2); console.log(' Explorer: ', `https://testnet.cspr.live/deploy/${tx2}`); diff --git a/demo/deploy-mainnet.js b/demo/deploy-mainnet.js index 19e6865..d51e664 100644 --- a/demo/deploy-mainnet.js +++ b/demo/deploy-mainnet.js @@ -1,28 +1,46 @@ /** - * deploy-mainnet.js — install the AiFinPay settlement contract on Casper MAINNET. + * deploy-mainnet.js — install canonical AiFinPay settlement v3 on Casper MAINNET. * - * Same logic as deploy.js but targets the live network: - * NETWORK = 'casper' (mainnet chain name, vs 'casper-test') - * NODE_URL = mainnet RPC (override via .env.mainnet if needed) - * keys = ./keys-mainnet/ (dedicated mainnet key, not the testnet demo key) - * explorer = https://cspr.live (vs testnet.cspr.live) - * - * Requires the mainnet account to be funded with real CSPR first (~250 CSPR). + * This script is fail-closed: it requires an explicit treasury account-hash, + * a clean reviewed git commit, real mainnet confirmation, and records the + * deployment as deployed_unverified. It never marks a payment route verified + * or live; paid E2E evidence is a separate release gate. */ require('dotenv').config({ path: require('path').join(__dirname, '.env.mainnet') }); -const { DeployUtil, Keys, RuntimeArgs } = require('casper-js-sdk'); +const { DeployUtil, Keys, RuntimeArgs, CLValueBuilder } = require('casper-js-sdk'); const fetch = require('node-fetch'); const fs = require('fs'); +const crypto = require('crypto'); const path = require('path'); +const { execFileSync } = require('child_process'); -// Mainnet defaults — override in .env.mainnet if the cspr.cloud key isn't mainnet-enabled. const NODE_URL = process.env.NODE_URL || 'https://node.mainnet.cspr.cloud/rpc'; const CSPR_API_KEY = process.env.CSPR_API_KEY || ''; const NETWORK = process.env.NETWORK_NAME || 'casper'; const KEYS_DIR = process.env.KEYS_DIR || path.join(__dirname, 'keys-mainnet'); -const WASM_PATH = path.join(__dirname, '..', 'aifinpay_casper.wasm'); +const WASM_PATH = path.join(__dirname, '..', 'target', 'wasm32-unknown-unknown', 'release', 'aifinpay_casper.wasm'); +const MANIFEST_PATH = path.join(__dirname, '..', 'deployments', 'casper-v3.json'); const GAS_INSTALL = process.env.GAS_INSTALL || '200000000000'; // 200 CSPR +const TREASURY_ACCOUNT_HASH = process.env.TREASURY_ACCOUNT_HASH || ''; + +const ACCOUNT_HASH_RE = /^account-hash-[0-9a-f]{64}$/i; +const COMMIT_RE = /^[0-9a-f]{40}$/i; + +function git(args) { + return execFileSync('git', args, { cwd: path.join(__dirname, '..'), encoding: 'utf8' }).trim(); +} + +function reviewedSourceCommit() { + const dirty = git(['status', '--porcelain']); + if (dirty) throw new Error('Refusing deployment from a dirty working tree'); + const head = git(['rev-parse', 'HEAD']); + const expected = process.env.SOURCE_COMMIT || head; + if (!COMMIT_RE.test(expected) || expected.toLowerCase() !== head.toLowerCase()) { + throw new Error(`SOURCE_COMMIT must equal the checked-out reviewed HEAD (${head})`); + } + return head; +} async function rpc(method, params) { const headers = { 'Content-Type': 'application/json' }; @@ -46,8 +64,18 @@ async function waitForDeploy(deployHash, maxWait = 240000) { while (Date.now() - start < maxWait) { try { const result = await rpc('info_get_deploy', { deploy_hash: deployHash }); - if (result.execution_results && result.execution_results.length > 0) return result; - } catch (_) {} + const er = result.execution_info && result.execution_info.execution_result; + if (er && er.Version2) { + if (er.Version2.error_message) throw new Error(`install failed: ${er.Version2.error_message}`); + return result; + } + if (er && er.Version1) { + if (er.Version1.Failure) throw new Error(`install failed: ${er.Version1.Failure.error_message || 'unknown'}`); + if (er.Version1.Success) return result; + } + } catch (error) { + if (/install failed/.test(error.message || '')) throw error; + } await new Promise(r => setTimeout(r, 5000)); process.stdout.write('.'); } @@ -55,81 +83,87 @@ async function waitForDeploy(deployHash, maxWait = 240000) { } async function main() { - const keyPath = path.join(KEYS_DIR, 'secret_key.pem'); - if (!fs.existsSync(keyPath)) { - console.error('❌ No mainnet keypair found. Run: node keygen-mainnet.js'); - process.exit(1); + if (process.env.ALLOW_MAINNET_DEPLOY !== 'I_UNDERSTAND_THIS_SPENDS_REAL_CSPR') { + throw new Error('Set ALLOW_MAINNET_DEPLOY=I_UNDERSTAND_THIS_SPENDS_REAL_CSPR for an intentional mainnet install'); + } + if (!ACCOUNT_HASH_RE.test(TREASURY_ACCOUNT_HASH)) { + throw new Error('TREASURY_ACCOUNT_HASH must be an explicit formatted account-hash-<64 hex> value'); } - const keypair = Keys.Ed25519.loadKeyPairFromPrivateFile(keyPath); - console.log('🔑 Deployer (mainnet):', keypair.publicKey.toHex()); - console.log(' Account hash :', keypair.publicKey.toAccountHashStr()); - if (!fs.existsSync(WASM_PATH)) { - console.error('❌ Wasm not found at:', WASM_PATH); - process.exit(1); + const sourceCommit = reviewedSourceCommit(); + const keyPath = path.join(KEYS_DIR, 'secret_key.pem'); + if (!fs.existsSync(keyPath)) throw new Error('No mainnet keypair found in KEYS_DIR'); + const keypair = Keys.Ed25519.loadKeyPairFromPrivateFile(keyPath); + const adminAccountHash = keypair.publicKey.toAccountHashStr(); + if (adminAccountHash.toLowerCase() === TREASURY_ACCOUNT_HASH.toLowerCase()) { + console.warn('⚠️ Admin and treasury are the same account. This is allowed by the contract but should be an explicit governance decision.'); } + + if (!fs.existsSync(WASM_PATH)) throw new Error(`Wasm not found at ${WASM_PATH}`); const wasm = new Uint8Array(fs.readFileSync(WASM_PATH)); - console.log(`📦 Wasm: ${(wasm.length / 1024).toFixed(1)} KB`); + const wasmSha256 = crypto.createHash('sha256').update(wasm).digest('hex'); - // Verify connection + that we're really on mainnet const status = await rpc('info_get_status', {}); - console.log(`🌐 Connected: ${status.chainspec_name} | Block: ${status.last_added_block_info.height}`); if (status.chainspec_name !== NETWORK) { - console.error(`❌ Connected chain "${status.chainspec_name}" != expected "${NETWORK}". Aborting.`); - process.exit(1); + throw new Error(`Connected chain "${status.chainspec_name}" != expected "${NETWORK}"`); } + await rpc('state_get_account_info', { public_key: keypair.publicKey.toHex() }); - // Confirm the account is funded before spending gas - try { - const bal = await rpc('state_get_account_info', { public_key: keypair.publicKey.toHex() }); - if (!bal || !bal.account) { - console.error('❌ Account not on-chain yet — fund it with CSPR and wait a couple minutes.'); - process.exit(1); - } - } catch (e) { - console.error('❌ Account not found on mainnet — fund it first.', e.message); - process.exit(1); - } + console.log('AiFinPay Casper settlement v3 mainnet deployment'); + console.log('sourceCommit=', sourceCommit); + console.log('admin=', adminAccountHash); + console.log('treasury=', TREASURY_ACCOUNT_HASH); + console.log('wasmSha256=', wasmSha256); const deployParams = new DeployUtil.DeployParams(keypair.publicKey, NETWORK, 1, 1800000); - const session = DeployUtil.ExecutableDeployItem.newModuleBytes(wasm, RuntimeArgs.fromMap({})); + const session = DeployUtil.ExecutableDeployItem.newModuleBytes( + wasm, + RuntimeArgs.fromMap({ treasury: CLValueBuilder.string(TREASURY_ACCOUNT_HASH) }), + ); const payment = DeployUtil.standardPayment(GAS_INSTALL); const deploy = DeployUtil.makeDeploy(deployParams, session, payment); const signed = DeployUtil.signDeploy(deploy, keypair); - console.log('\n🚀 Submitting MAINNET deploy...'); const result = await putDeploy(signed); const deployHash = result.deploy_hash; - console.log('\n✅ Deploy hash:', deployHash); - console.log('🔗 Explorer: ', `https://cspr.live/deploy/${deployHash}`); - - console.log('\n⏳ Waiting for execution'); - const execResult = await waitForDeploy(deployHash); - const outcome = execResult.execution_results[0]?.result; - if (outcome?.Failure) { - console.error('\n❌ Deploy failed:', outcome.Failure.error_message); - process.exit(1); - } + console.log('Deploy hash:', deployHash); + console.log('Explorer:', `https://cspr.live/deploy/${deployHash}`); + await waitForDeploy(deployHash); - console.log('\n\n🔍 Fetching contract hash from account named keys...'); const accountResult = await rpc('state_get_account_info', { public_key: keypair.publicKey.toHex() }); - const contractKey = accountResult.account.named_keys.find(k => k.name === 'aifinpay_casper_hash'); - if (!contractKey) { - console.log('⚠️ Named key not found yet — re-run in 30s. Deploy:', `https://cspr.live/deploy/${deployHash}`); - process.exit(0); + const contractKey = accountResult.account.named_keys.find(k => k.name === 'aifinpay_casper_v3_hash'); + const versionKey = accountResult.account.named_keys.find(k => k.name === 'aifinpay_casper_v3_version'); + if (!contractKey || !versionKey) { + throw new Error('v3 named keys not found after successful install'); } const contractHash = contractKey.key; - console.log('\n🎉 =========================================='); - console.log(' CONTRACT LIVE ON CASPER MAINNET'); - console.log('=========================================='); - console.log('Deploy hash: ', deployHash); - console.log('Contract hash:', contractHash); - console.log('Explorer: ', `https://cspr.live/contract/${contractHash.replace('hash-','')}`); + + const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); + const deployedAt = new Date().toISOString(); + const updated = { + ...manifest, + contractVersion: '3.0.0-rc.1', + status: 'deployed_unverified', + contractHash, + deployHash, + wasmSha256, + sourceCommit, + treasuryAccountHash: TREASURY_ACCOUNT_HASH, + adminAccountHash, + deployedAt, + verifiedAt: null, + e2eEvidence: null, + }; + fs.writeFileSync(MANIFEST_PATH, JSON.stringify(updated, null, 2) + '\n'); fs.writeFileSync(path.join(__dirname, '.env.mainnet.out'), - `NODE_URL=${NODE_URL}\nNETWORK_NAME=${NETWORK}\nKEYS_DIR=./keys-mainnet\nCONTRACT_HASH=${contractHash}\n` + `NODE_URL=${NODE_URL}\nNETWORK_NAME=${NETWORK}\nKEYS_DIR=./keys-mainnet\nCONTRACT_HASH=${contractHash}\nTREASURY_ACCOUNT_HASH=${TREASURY_ACCOUNT_HASH}\nSOURCE_COMMIT=${sourceCommit}\n` ); - console.log('\n📝 Saved contract hash to .env.mainnet.out'); + + console.log('Contract hash:', contractHash); + console.log('Manifest updated:', MANIFEST_PATH); + console.log('STATUS=deployed_unverified — DO NOT unpause or enable payments yet.'); + console.log('Next: independent manifest review + config readback + paid AIFP-1/AIFP-2 E2E + replay/expiry negatives.'); } main().catch(err => { diff --git a/demo/deploy.js b/demo/deploy.js index 4b5ba11..699f42b 100644 --- a/demo/deploy.js +++ b/demo/deploy.js @@ -12,6 +12,7 @@ require('dotenv').config(); const { CasperClient, DeployUtil, Keys, RuntimeArgs } = require('casper-js-sdk'); const fs = require('fs'); +const crypto = require('crypto'); const path = require('path'); const NODE_URL = process.env.NODE_URL || 'https://node.testnet.casper.network/rpc'; @@ -37,7 +38,9 @@ async function main() { process.exit(1); } const wasm = new Uint8Array(fs.readFileSync(WASM_PATH)); + const wasmSha256 = crypto.createHash('sha256').update(wasm).digest('hex'); console.log(`📦 Wasm size: ${(wasm.length / 1024).toFixed(1)} KB`); + console.log(`🔒 Wasm SHA-256: ${wasmSha256}`); const client = new CasperClient(NODE_URL); @@ -60,13 +63,7 @@ async function main() { // Wait for inclusion console.log('\n⏳ Waiting for execution (~60s)...'); - const result = await waitForDeploy(client, deployHash); - const execResult = result[1].execution_results[0]?.result; - - if (execResult?.Failure) { - console.error('❌ Deploy failed:', execResult.Failure.error_message); - process.exit(1); - } + await waitForDeploy(deployHash); // Retrieve contract hash from account named keys console.log('\n🔍 Fetching contract hash...'); @@ -88,17 +85,30 @@ async function main() { console.log('📝 Add to .env:'); console.log(` CONTRACT_HASH=${contractHash}`); - // Save to file - fs.appendFileSync('.env', `\nCONTRACT_HASH=${contractHash}\n`); + console.log('Release remains quarantined until deployments/casper-v2.json is independently verified.'); } -async function waitForDeploy(client, deployHash, maxWait = 120000) { +async function waitForDeploy(deployHash, maxWait = 120000) { const start = Date.now(); while (Date.now() - start < maxWait) { try { - const result = await client.getDeploy(deployHash); - if (result[1].execution_results.length > 0) return result; - } catch (_) {} + const response = await fetch(NODE_URL, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'info_get_deploy', params: { deploy_hash: deployHash } }), + }); + const result = await response.json(); + const er = result && result.result && result.result.execution_info && result.result.execution_info.execution_result; + if (er && er.Version2) { + if (er.Version2.error_message) throw new Error(`install failed: ${er.Version2.error_message}`); + return result; + } + if (er && er.Version1) { + if (er.Version1.Failure) throw new Error(`install failed: ${er.Version1.Failure.error_message || 'unknown'}`); + return result; + } + } catch (error) { + if (/install failed/.test(error.message || '')) throw error; + } await new Promise(r => setTimeout(r, 3000)); } throw new Error('Deploy timed out after 2 minutes'); diff --git a/demo/package.json b/demo/package.json index 756ab7b..e2a536b 100644 --- a/demo/package.json +++ b/demo/package.json @@ -9,7 +9,8 @@ "agent-demo": "node agent-compute-demo.js", "dashboard": "node serve-dashboard.js", "mcp": "node casper-mcp.mjs", - "mcp:test": "node test-mcp.mjs" + "mcp:test": "node test-mcp.mjs", + "test": "node --test test/*.test.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/demo/settlement-verifier.js b/demo/settlement-verifier.js new file mode 100644 index 0000000..8e8ff85 --- /dev/null +++ b/demo/settlement-verifier.js @@ -0,0 +1,107 @@ +'use strict'; + +function normalizeHash(value) { + return typeof value === 'string' ? value.toLowerCase().replace(/^(hash|contract)-/, '') : null; +} + +function readSessionArgs(raw) { + const session = raw && raw.deploy && raw.deploy.session; + const stored = session && (session.StoredContractByHash || session.StoredVersionedContractByHash); + if (!stored || !Array.isArray(stored.args)) return null; + const out = { entry_point: stored.entry_point, contract_hash: stored.hash }; + for (const pair of stored.args) { + if (!Array.isArray(pair) || pair.length < 2) continue; + const [name, clv] = pair; + out[name] = clv && (clv.parsed !== undefined ? clv.parsed : clv); + } + return out; +} + +function executionSucceeded(rpc) { + if (!rpc) return { ok: false, reason: 'deploy_not_found' }; + const result = rpc.execution_info && rpc.execution_info.execution_result; + if (!result) return { ok: false, reason: 'deploy_not_executed_yet' }; + if (result.Version2) { + if (result.Version2.error_message) { + return { ok: false, reason: `deploy_failed_on_chain: ${result.Version2.error_message}` }; + } + return { ok: true }; + } + if (result.Version1) { + if (result.Version1.Failure) { + return { + ok: false, + reason: `deploy_failed_on_chain: ${result.Version1.Failure.error_message || 'unknown'}`, + }; + } + return result.Version1.Success ? { ok: true } : { ok: false, reason: 'deploy_not_successful' }; + } + return { ok: false, reason: 'deploy_not_successful' }; +} + +/** + * Verify an executed canonical Casper settlement v3 call. + * + * expected fields: + * - contract_hash + * - route: 1 (AIFP-1) or 2 (AIFP-2) + * - merchant: account-hash-... + * - gross_amount_motes + * - request_id + * - valid_until_ms + * - payer_public_key (optional; if supplied it must match deploy.header.account) + */ +function validateExecutedSettlement(rpc, expected) { + const executed = executionSucceeded(rpc); + if (!executed.ok) return executed; + + const args = readSessionArgs(rpc); + if (!args) return { ok: false, reason: 'unparseable_session_args' }; + if (normalizeHash(args.contract_hash) !== normalizeHash(expected.contract_hash)) { + return { ok: false, reason: 'contract_hash_mismatch' }; + } + if (args.entry_point !== 'pay') return { ok: false, reason: 'wrong_entry_point' }; + + for (const key of ['route', 'merchant', 'gross_amount', 'request_id', 'valid_until_ms']) { + if (args[key] == null) return { ok: false, reason: `missing_${key}` }; + } + + const route = Number(args.route); + if (!Number.isInteger(route) || (route !== 1 && route !== 2)) { + return { ok: false, reason: 'route_invalid' }; + } + if (route !== Number(expected.route)) { + return { ok: false, reason: 'route_mismatch' }; + } + if (String(args.request_id) !== String(expected.request_id)) { + return { ok: false, reason: 'request_id_mismatch' }; + } + if (String(args.merchant).toLowerCase() !== String(expected.merchant).toLowerCase()) { + return { ok: false, reason: 'merchant_mismatch' }; + } + try { + if (BigInt(String(args.gross_amount)) !== BigInt(String(expected.gross_amount_motes))) { + return { ok: false, reason: 'gross_amount_mismatch' }; + } + } catch { + return { ok: false, reason: 'gross_amount_invalid' }; + } + try { + if (BigInt(String(args.valid_until_ms)) !== BigInt(String(expected.valid_until_ms))) { + return { ok: false, reason: 'valid_until_mismatch' }; + } + } catch { + return { ok: false, reason: 'valid_until_invalid' }; + } + + if (expected.payer_public_key != null) { + const payer = rpc?.deploy?.header?.account; + if (!payer || String(payer).toLowerCase() !== String(expected.payer_public_key).toLowerCase()) { + return { ok: false, reason: 'payer_signer_mismatch' }; + } + } + + return { ok: true }; +} + +module.exports = { readSessionArgs, validateExecutedSettlement }; diff --git a/demo/test/settlement-verifier.test.js b/demo/test/settlement-verifier.test.js new file mode 100644 index 0000000..7bdfdb6 --- /dev/null +++ b/demo/test/settlement-verifier.test.js @@ -0,0 +1,89 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { validateExecutedSettlement } = require('../settlement-verifier'); +const { assertTrustedContract } = require('../trusted-contract'); + +const expected = { + contract_hash: 'hash-' + 'aa'.repeat(32), + route: 1, + merchant: 'account-hash-' + 'bb'.repeat(32), + gross_amount_motes: '100000000', + request_id: 'order-1', + valid_until_ms: '1900000000000', + payer_public_key: '01' + 'cc'.repeat(32), +}; + +function rpc(patch = {}) { + const values = { + ...expected, + gross_amount: expected.gross_amount_motes, + ...patch, + }; + return { + execution_info: { execution_result: { Version2: { error_message: null } } }, + deploy: { + header: { account: values.payer_public_key }, + session: { + StoredContractByHash: { + hash: values.contract_hash, + entry_point: values.entry_point || 'pay', + args: [ + ['route', { parsed: values.route }], + ['merchant', { parsed: values.merchant }], + ['gross_amount', { parsed: values.gross_amount }], + ['request_id', { parsed: values.request_id }], + ['valid_until_ms', { parsed: values.valid_until_ms }], + ].filter(([name]) => !values.omit || values.omit !== name), + }, + }, + }, + }; +} + +test('accepts only the exact successful canonical v3 settlement', () => { + assert.deepEqual(validateExecutedSettlement(rpc(), expected), { ok: true }); +}); + +for (const [name, patch, reason] of [ + ['contract', { contract_hash: 'hash-' + 'dd'.repeat(32) }, 'contract_hash_mismatch'], + ['entry point', { entry_point: 'pay_agent' }, 'wrong_entry_point'], + ['route', { route: 2 }, 'route_mismatch'], + ['request', { request_id: 'order-2' }, 'request_id_mismatch'], + ['merchant', { merchant: 'account-hash-' + 'ee'.repeat(32) }, 'merchant_mismatch'], + ['underpayment', { gross_amount: '99999999' }, 'gross_amount_mismatch'], + ['overpayment', { gross_amount: '100000001' }, 'gross_amount_mismatch'], + ['invalid amount', { gross_amount: 'not-a-number' }, 'gross_amount_invalid'], + ['expiry', { valid_until_ms: '1900000000001' }, 'valid_until_mismatch'], + ['payer signer', { payer_public_key: '01' + 'ff'.repeat(32) }, 'payer_signer_mismatch'], +]) { + test(`rejects wrong ${name}`, () => { + assert.equal(validateExecutedSettlement(rpc(patch), expected).reason, reason); + }); +} + +test('rejects invalid route values', () => { + assert.equal(validateExecutedSettlement(rpc({ route: 3 }), expected).reason, 'route_invalid'); +}); + +test('rejects missing required arguments instead of accepting execution success', () => { + assert.equal(validateExecutedSettlement(rpc({ omit: 'gross_amount' }), expected).reason, 'missing_gross_amount'); +}); + +test('rejects an unparseable session instead of accepting execution success', () => { + const value = rpc(); + value.deploy.session = { ModuleBytes: { module_bytes: '', args: [] } }; + assert.equal(validateExecutedSettlement(value, expected).reason, 'unparseable_session_args'); +}); + +test('rejects failed and pending deploys', () => { + const failed = rpc(); + failed.execution_info.execution_result.Version2.error_message = 'revert'; + assert.match(validateExecutedSettlement(failed, expected).reason, /deploy_failed_on_chain/); + assert.equal(validateExecutedSettlement({ deploy: rpc().deploy }, expected).reason, 'deploy_not_executed_yet'); +}); + +test('payment entry points remain quarantined until the v3 manifest is verified', () => { + assert.throws(() => assertTrustedContract(expected.contract_hash), /v3 deployment manifest/); +}); diff --git a/demo/trusted-contract.js b/demo/trusted-contract.js new file mode 100644 index 0000000..11f355a --- /dev/null +++ b/demo/trusted-contract.js @@ -0,0 +1,33 @@ +'use strict'; + +// Production payment routes consume the reviewed v3 deployment manifest. +// Environment variables cannot override trust. Deploying alone is insufficient. +const deployment = require('../deployments/casper-v3.json'); +const CASPER_V3_CONTRACT_HASH = deployment.contractHash; + +function normalize(value) { + return typeof value === 'string' ? value.toLowerCase().replace(/^(hash|contract)-/, '') : ''; +} + +function assertTrustedContract(candidate) { + const complete = deployment.status === 'verified' + && deployment.contractVersion === '3.0.0-rc.1' + && deployment.routeModel?.['AIFP-1']?.treasuryBps === 100 + && deployment.routeModel?.['AIFP-1']?.creatorBps === 0 + && deployment.routeModel?.['AIFP-2']?.treasuryBps === 0 + && deployment.routeModel?.['AIFP-2']?.creatorBps === 0 + && /^(hash-|contract-)?[0-9a-f]{64}$/i.test(deployment.contractHash || '') + && /^[0-9a-f]{64}$/i.test(deployment.deployHash || '') + && /^[0-9a-f]{64}$/i.test(deployment.wasmSha256 || '') + && /^[0-9a-f]{40}$/i.test(deployment.sourceCommit || '') + && /^account-hash-[0-9a-f]{64}$/i.test(deployment.treasuryAccountHash || '') + && /^account-hash-[0-9a-f]{64}$/i.test(deployment.adminAccountHash || '') + && Boolean(deployment.deployedAt && deployment.verifiedAt && deployment.e2eEvidence); + if (!complete || normalize(candidate) !== normalize(CASPER_V3_CONTRACT_HASH)) { + throw new Error( + 'Casper payments are quarantined until the canonical v3 deployment manifest, artifact hash, governance and paid E2E evidence are complete and verified.', + ); + } +} + +module.exports = { CASPER_V3_CONTRACT_HASH, assertTrustedContract, deployment }; diff --git a/deployments/casper-v2.json b/deployments/casper-v2.json new file mode 100644 index 0000000..2627ebb --- /dev/null +++ b/deployments/casper-v2.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "contractVersion": "2.0.0", + "network": "casper", + "status": "source_only", + "contractHash": null, + "deployHash": null, + "wasmSha256": null, + "sourceCommit": null, + "deployedAt": null, + "verifiedAt": null +} diff --git a/deployments/casper-v3.json b/deployments/casper-v3.json new file mode 100644 index 0000000..019c57a --- /dev/null +++ b/deployments/casper-v3.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 2, + "contractVersion": "3.0.0-rc.1", + "network": "casper", + "routeModel": { + "AIFP-1": { "route": 1, "treasuryBps": 100, "creatorBps": 0, "grossInclusive": true }, + "AIFP-2": { "route": 2, "treasuryBps": 0, "creatorBps": 0, "grossInclusive": true } + }, + "status": "source_only", + "contractHash": null, + "deployHash": null, + "wasmSha256": null, + "sourceCommit": null, + "treasuryAccountHash": null, + "adminAccountHash": null, + "deployedAt": null, + "verifiedAt": null, + "e2eEvidence": null +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4ad9111..45f72bc 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,24 +1,31 @@ -# Deployment Guide — AiFinPay × Casper +# Deployment Guide — AiFinPay × Casper v2 + +> **Release gate:** existing v1 testnet/mainnet hashes are quarantined. A new +> deployment is not trusted until its Wasm hash, source commit, deploy result, +> contract hash and independent verification are committed to +> `deployments/casper-v2.json` with `status: verified`. ## Prerequisites ```bash -# Rust + wasm32 target +# The CI/release toolchain is intentionally pinned. curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -rustup target add wasm32-unknown-unknown +rustup toolchain install nightly-2025-02-04 --profile minimal +rustup target add wasm32-unknown-unknown --toolchain nightly-2025-02-04 -# Node.js 18+ +# Node.js 24+ node --version # Install demo dependencies -cd demo && npm install +cd demo && npm ci ``` ## Step 1 — Build the Wasm ```bash cd aifinpay-casper/ -cargo build --release +cargo +nightly-2025-02-04 build --release --locked --target wasm32-unknown-unknown +sha256sum target/wasm32-unknown-unknown/release/aifinpay_casper.wasm ``` Output: `target/wasm32-unknown-unknown/release/aifinpay_casper.wasm` (~55KB) @@ -30,7 +37,9 @@ cd demo/ node keygen.js ``` -This creates: +Generate two separately controlled and funded accounts. Each agent may only +register the account that signed the deploy. Never use one key for buyer and +provider. Key generation creates: - `keys/secret_key.pem` — private key (keep secret, never commit) - `keys/public_key.pem` — public key - `keys/public_key_hex.txt` — hex public key @@ -64,7 +73,11 @@ Contract hash: hash-xxxxxxxx... Explorer: https://testnet.cspr.live/contract/xxxxxxxx... ``` -The contract hash is auto-appended to `.env`. +Deployment alone does not enable payment traffic. Record the final successful +deploy hash, contract hash, exact Wasm SHA-256, 40-character source commit and +UTC deployment time in `deployments/casper-v2.json`. Independently query +`info_get_deploy`, compare the installed Wasm/source build, then add +`verifiedAt` and change `status` to `verified` in a reviewed commit. ## Step 5 — Run Demo Flow @@ -72,10 +85,10 @@ The contract hash is auto-appended to `.env`. node demo.js ``` -This will: +Set `PROVIDER_KEYS_DIR` to the separately funded provider key. This will: 1. Register `aifinpay-agent-001` on-chain -2. Register `aifinpay-agent-002` on-chain -3. Settle a payment (2.5 CSPR, request ID: `req-001`) +2. Have the provider self-register `aifinpay-agent-002` +3. Atomically transfer and record 2.5 CSPR (`req-001`) 4. Print all transaction hashes + explorer links ## Step 6 — Verify On-Chain @@ -122,4 +135,24 @@ Paste the contract hash → click Connect → live data loads from Casper RPC. **"Wasm not found"** — Run `cargo build --release` from the root directory first. -**Contract hash not in named keys** — Wait 30s after deploy hash confirms, then re-run `node deploy.js` (it won't re-deploy, just fetches the hash). +**Payments are quarantined** — this is expected until the v2 manifest is +complete, independently verified and committed. `CONTRACT_HASH` cannot bypass +the gate. + +**Contract hash not in named keys** — inspect the successful deploy/account +state directly. Do not rerun the deploy script blindly because it creates a new +contract. + +## Production sign-off + +Before changing the manifest to `verified`, all of the following must be true: + +- blocking CI succeeded for the exact source commit and its uploaded Wasm; +- the artifact SHA-256 equals `wasmSha256` in the manifest; +- `info_get_deploy` reports success on the expected chain; +- `contractHash` belongs to that install deploy and exposes the v2 entry points; +- buyer and provider use distinct accounts and self-registration was tested; +- zero amount, forged payer, duplicate request, self-payment and malformed ID + calls revert; a valid payment changes recipient balance by the exact amount; +- bridge regression tests pass and the SDK/MCP address comes from the reviewed + manifest. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6568983 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,77 @@ +#![no_std] + +use casper_types::U512; + +pub const ROUTE_AIFP1: u8 = 1; +pub const ROUTE_AIFP2: u8 = 2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SplitError { + ZeroAmount, + FeeRoundsToZero, + InvalidRoute, +} + +/// Canonical immutable product economics shared by the v3 contract and host tests. +/// AIFP-1: merchant 99%, treasury 1%, creator 0%, all from the gross payer amount. +/// AIFP-2: merchant/provider 100%, treasury 0%, creator 0%. +pub fn split_gross(route: u8, gross: U512) -> Result<(U512, U512), SplitError> { + if gross.is_zero() { + return Err(SplitError::ZeroAmount); + } + match route { + ROUTE_AIFP1 => { + let treasury = gross / U512::from(100u64); + if treasury.is_zero() { + return Err(SplitError::FeeRoundsToZero); + } + Ok((gross - treasury, treasury)) + } + ROUTE_AIFP2 => Ok((gross, U512::zero())), + _ => Err(SplitError::InvalidRoute), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aifp1_is_exactly_gross_inclusive_99_1_0() { + let (merchant, treasury) = split_gross(ROUTE_AIFP1, U512::from(10_000u64)).unwrap(); + assert_eq!(merchant, U512::from(9_900u64)); + assert_eq!(treasury, U512::from(100u64)); + assert_eq!(merchant + treasury, U512::from(10_000u64)); + } + + #[test] + fn aifp2_is_exactly_zero_percent() { + let (provider, treasury) = split_gross(ROUTE_AIFP2, U512::from(1u64)).unwrap(); + assert_eq!(provider, U512::from(1u64)); + assert_eq!(treasury, U512::zero()); + } + + #[test] + fn aifp1_rejects_when_one_percent_rounds_to_zero() { + assert_eq!( + split_gross(ROUTE_AIFP1, U512::from(99u64)), + Err(SplitError::FeeRoundsToZero) + ); + assert_eq!( + split_gross(ROUTE_AIFP1, U512::from(100u64)).unwrap(), + (U512::from(99u64), U512::from(1u64)) + ); + } + + #[test] + fn zero_and_unknown_routes_fail_closed() { + assert_eq!( + split_gross(ROUTE_AIFP1, U512::zero()), + Err(SplitError::ZeroAmount) + ); + assert_eq!( + split_gross(3, U512::from(100u64)), + Err(SplitError::InvalidRoute) + ); + } +} diff --git a/src/main.rs b/src/main.rs index dd44990..ea2b763 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ extern crate alloc; +use aifinpay_casper::{split_gross as canonical_split_gross, SplitError}; use alloc::{ format, string::{String, ToString}, @@ -10,45 +11,64 @@ use alloc::{ }; use casper_contract::{ - contract_api::{runtime, storage}, + contract_api::{runtime, storage, system}, unwrap_or_revert::UnwrapOrRevert, }; use casper_types::{ - CLType, CLValue, EntityEntryPoint, EntryPointAccess, EntryPointPayment, - EntryPointType, EntryPoints, Key, Parameter, URef, U512, - api_error::ApiError, - contracts::NamedKeys, + account::AccountHash, api_error::ApiError, contracts::NamedKeys, CLType, CLValue, + EntityEntryPoint, EntryPointAccess, EntryPointPayment, EntryPointType, EntryPoints, Key, + Parameter, URef, U512, }; +// AiFinPay Casper settlement v3 is a VALUE SETTLEMENT contract only. +// Global AIFP-3 Agent Passport identity (@username + immutable Agent ID + signed +// wallet bindings) lives above the chain and MUST NOT be re-created here. + // ── Storage keys ───────────────────────────────────────────────────────────── -const KEY_AGENTS: &str = "agents"; const KEY_PAYMENTS: &str = "payments"; const KEY_EVENTS: &str = "events"; const KEY_PAYMENT_COUNT: &str = "payment_count"; const KEY_EVENT_COUNT: &str = "event_count"; -const KEY_CONTRACT_HASH: &str = "aifinpay_casper_hash"; -const KEY_CONTRACT_VERSION: &str = "aifinpay_casper_version"; +const KEY_ADMIN: &str = "admin"; +const KEY_TREASURY: &str = "treasury"; +const KEY_PAUSED: &str = "paused"; +const KEY_CONTRACT_HASH: &str = "aifinpay_casper_v3_hash"; +const KEY_CONTRACT_VERSION: &str = "aifinpay_casper_v3_version"; -// ── Entry point names ───────────────────────────────────────────────────────── -const EP_REGISTER_AGENT: &str = "register_agent"; -const EP_PAY_AGENT: &str = "pay_agent"; +// ── Entry points ────────────────────────────────────────────────────────────── +const EP_PAY: &str = "pay"; +const EP_SET_PAUSED: &str = "set_paused"; +const EP_SET_TREASURY: &str = "set_treasury"; +const EP_SET_ADMIN: &str = "set_admin"; const EP_GET_PAYMENT_COUNT: &str = "get_payment_count"; -// ── Argument names ──────────────────────────────────────────────────────────── -const ARG_AGENT_ID: &str = "agent_id"; -const ARG_WALLET: &str = "wallet"; -const ARG_FROM_AGENT: &str = "from_agent"; -const ARG_TO_AGENT: &str = "to_agent"; -const ARG_AMOUNT: &str = "amount"; +// ── Arguments ──────────────────────────────────────────────────────────────── +const ARG_ROUTE: &str = "route"; +const ARG_MERCHANT: &str = "merchant"; +const ARG_GROSS_AMOUNT: &str = "gross_amount"; const ARG_REQUEST_ID: &str = "request_id"; +const ARG_VALID_UNTIL_MS: &str = "valid_until_ms"; +const ARG_PAUSED: &str = "paused"; +const ARG_TREASURY: &str = "treasury"; +const ARG_ADMIN: &str = "admin"; + +const EXPIRY_MAX_AHEAD_MS: u64 = 20 * 60 * 1000; -// ── Error codes ─────────────────────────────────────────────────────────────── +// ── Error codes (stable for SDK/E2E assertions) ────────────────────────────── const ERR_MISSING_KEY: u16 = 1; -const ERR_ALREADY_REGISTERED: u16 = 100; -const ERR_AGENT_NOT_FOUND: u16 = 101; const ERR_ALREADY_SETTLED: u16 = 102; - -// ── Helpers ─────────────────────────────────────────────────────────────────── +const ERR_UNAUTHORIZED: u16 = 103; +const ERR_INVALID_WALLET: u16 = 104; +const ERR_INVALID_IDENTIFIER: u16 = 105; +const ERR_INVALID_AMOUNT: u16 = 106; +const ERR_SELF_PAYMENT: u16 = 107; +const ERR_TRANSFER_FAILED: u16 = 108; +const ERR_OVERFLOW: u16 = 109; +const ERR_INVALID_ROUTE: u16 = 110; +const ERR_PAUSED: u16 = 111; +const ERR_EXPIRED: u16 = 112; +const ERR_EXPIRY_TOO_FAR: u16 = 113; +const ERR_FEE_ROUNDS_TO_ZERO: u16 = 114; fn get_uref(name: &str) -> URef { match runtime::get_key(name).unwrap_or_revert_with(ApiError::User(ERR_MISSING_KEY)) { @@ -67,6 +87,62 @@ fn write_u64(key: &str, value: u64) { storage::write(get_uref(key), value); } +fn read_string(key: &str) -> String { + storage::read::(get_uref(key)) + .unwrap_or_revert() + .unwrap_or_revert_with(ApiError::User(ERR_MISSING_KEY)) +} + +fn write_string(key: &str, value: String) { + storage::write(get_uref(key), value); +} + +fn read_bool(key: &str) -> bool { + storage::read::(get_uref(key)) + .unwrap_or_revert() + .unwrap_or(true) +} + +fn write_bool(key: &str, value: bool) { + storage::write(get_uref(key), value); +} + +fn checked_increment(value: u64) -> u64 { + value + .checked_add(1) + .unwrap_or_revert_with(ApiError::User(ERR_OVERFLOW)) +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || byte == b'-' + || byte == b'_' + || byte == b'.' + || byte == b':' + }) +} + +fn require_identifier(value: &str) { + if !valid_identifier(value) { + runtime::revert(ApiError::User(ERR_INVALID_IDENTIFIER)); + } +} + +fn parse_account(value: &str) -> AccountHash { + AccountHash::from_formatted_str(value) + .unwrap_or_else(|_| runtime::revert(ApiError::User(ERR_INVALID_WALLET))) +} + +fn require_admin() { + let caller = runtime::get_caller().to_formatted_string(); + if caller != read_string(KEY_ADMIN) { + runtime::revert(ApiError::User(ERR_UNAUTHORIZED)); + } +} + fn emit_event(event_type: &str, payload: &str) { let seed = get_uref(KEY_EVENTS); let idx = read_u64(KEY_EVENT_COUNT); @@ -75,101 +151,150 @@ fn emit_event(event_type: &str, payload: &str) { &format!("evt_{}", idx), format!("{{\"type\":\"{}\",\"payload\":{}}}", event_type, payload), ); - write_u64(KEY_EVENT_COUNT, idx + 1); + write_u64(KEY_EVENT_COUNT, checked_increment(idx)); } -// ── Entry points ────────────────────────────────────────────────────────────── - -/// Register an AI agent with the settlement layer. -/// Args: agent_id (String), wallet (String) -#[no_mangle] -pub extern "C" fn register_agent() { - let agent_id: String = runtime::get_named_arg(ARG_AGENT_ID); - let wallet: String = runtime::get_named_arg(ARG_WALLET); - - let seed = get_uref(KEY_AGENTS); +fn split_gross(route: u8, gross: U512) -> (U512, U512) { + canonical_split_gross(route, gross).unwrap_or_else(|error| match error { + SplitError::ZeroAmount => runtime::revert(ApiError::User(ERR_INVALID_AMOUNT)), + SplitError::FeeRoundsToZero => runtime::revert(ApiError::User(ERR_FEE_ROUNDS_TO_ZERO)), + SplitError::InvalidRoute => runtime::revert(ApiError::User(ERR_INVALID_ROUTE)), + }) +} - let existing: Option = storage::dictionary_get(seed, &agent_id).unwrap_or_revert(); - if existing.is_some() { - runtime::revert(ApiError::User(ERR_ALREADY_REGISTERED)); +fn validate_expiry(valid_until_ms: u64) { + let now_ms = runtime::get_blocktime().value(); + if valid_until_ms < now_ms { + runtime::revert(ApiError::User(ERR_EXPIRED)); + } + let max = now_ms + .checked_add(EXPIRY_MAX_AHEAD_MS) + .unwrap_or_revert_with(ApiError::User(ERR_OVERFLOW)); + if valid_until_ms > max { + runtime::revert(ApiError::User(ERR_EXPIRY_TOO_FAR)); } - - storage::dictionary_put(seed, &agent_id, wallet.clone()); - - emit_event( - "AgentRegistered", - &format!( - "{{\"agent_id\":\"{}\",\"wallet\":\"{}\"}}", - agent_id, wallet - ), - ); } -/// Settle a payment between two registered AI agents and emit PaymentSettled. -/// Args: from_agent (String), to_agent (String), amount (U512 motes), request_id (String) +// ── Settlement ─────────────────────────────────────────────────────────────── + +/// Canonical CSPR settlement. +/// +/// Args: +/// - route: 1=AIFP-1 (99/1/0), 2=AIFP-2 (100/0/0) +/// - merchant: formatted `account-hash-...` +/// - gross_amount: payer total in motes +/// - request_id: unique idempotency/payment id +/// - valid_until_ms: block-time expiry, max 20 minutes ahead +/// +/// The caller is the payer. No caller-supplied `from_agent` is accepted. #[no_mangle] -pub extern "C" fn pay_agent() { - let from_agent: String = runtime::get_named_arg(ARG_FROM_AGENT); - let to_agent: String = runtime::get_named_arg(ARG_TO_AGENT); - let amount: U512 = runtime::get_named_arg(ARG_AMOUNT); +pub extern "C" fn pay() { + if read_bool(KEY_PAUSED) { + runtime::revert(ApiError::User(ERR_PAUSED)); + } + + let route: u8 = runtime::get_named_arg(ARG_ROUTE); + let merchant_raw: String = runtime::get_named_arg(ARG_MERCHANT); + let gross: U512 = runtime::get_named_arg(ARG_GROSS_AMOUNT); let request_id: String = runtime::get_named_arg(ARG_REQUEST_ID); + let valid_until_ms: u64 = runtime::get_named_arg(ARG_VALID_UNTIL_MS); - let agents_seed = get_uref(KEY_AGENTS); - let payments_seed = get_uref(KEY_PAYMENTS); + require_identifier(&request_id); + validate_expiry(valid_until_ms); - // Both agents must be registered - let _: String = storage::dictionary_get(agents_seed, &from_agent) - .unwrap_or_revert() - .unwrap_or_revert_with(ApiError::User(ERR_AGENT_NOT_FOUND)); + let payer = runtime::get_caller(); + let merchant = parse_account(&merchant_raw); + if payer == merchant { + runtime::revert(ApiError::User(ERR_SELF_PAYMENT)); + } - let _: String = storage::dictionary_get(agents_seed, &to_agent) - .unwrap_or_revert() - .unwrap_or_revert_with(ApiError::User(ERR_AGENT_NOT_FOUND)); + let treasury_raw = read_string(KEY_TREASURY); + let treasury = parse_account(&treasury_raw); + let (merchant_amount, treasury_amount) = split_gross(route, gross); - // Idempotent — reject duplicate request IDs + // Replay is checked BEFORE any transfer. Casper execution reverts atomically + // on a later transfer failure, so no receipt survives an unsuccessful pay. + let payments_seed = get_uref(KEY_PAYMENTS); let existing: Option = storage::dictionary_get(payments_seed, &request_id).unwrap_or_revert(); if existing.is_some() { runtime::revert(ApiError::User(ERR_ALREADY_SETTLED)); } - // Record settlement on-chain + system::transfer_to_account(merchant, merchant_amount, None) + .unwrap_or_revert_with(ApiError::User(ERR_TRANSFER_FAILED)); + if !treasury_amount.is_zero() { + system::transfer_to_account(treasury, treasury_amount, None) + .unwrap_or_revert_with(ApiError::User(ERR_TRANSFER_FAILED)); + } + + let payer_raw = payer.to_formatted_string(); let record = format!( - "{{\"from\":\"{}\",\"to\":\"{}\",\"amount\":\"{}\",\"request_id\":\"{}\",\"status\":\"SETTLED\"}}", - from_agent, to_agent, amount, request_id + "{{\"route\":{},\"payer\":\"{}\",\"merchant\":\"{}\",\"gross_amount\":\"{}\",\"merchant_amount\":\"{}\",\"treasury_amount\":\"{}\",\"creator_amount\":\"0\",\"request_id\":\"{}\",\"valid_until_ms\":{},\"status\":\"SETTLED\"}}", + route, + payer_raw, + merchant_raw, + gross, + merchant_amount, + treasury_amount, + request_id, + valid_until_ms ); - storage::dictionary_put(payments_seed, &request_id, record); - + storage::dictionary_put(payments_seed, &request_id, record.clone()); let count = read_u64(KEY_PAYMENT_COUNT); - write_u64(KEY_PAYMENT_COUNT, count + 1); + write_u64(KEY_PAYMENT_COUNT, checked_increment(count)); + emit_event("PaymentSettled", &record); +} - // Emit PaymentSettled event +#[no_mangle] +pub extern "C" fn set_paused() { + require_admin(); + let paused: bool = runtime::get_named_arg(ARG_PAUSED); + write_bool(KEY_PAUSED, paused); + emit_event("PausedChanged", &format!("{{\"paused\":{}}}", paused)); +} + +#[no_mangle] +pub extern "C" fn set_treasury() { + require_admin(); + let treasury: String = runtime::get_named_arg(ARG_TREASURY); + let parsed = parse_account(&treasury); + write_string(KEY_TREASURY, parsed.to_formatted_string()); + emit_event( + "TreasuryChanged", + &format!("{{\"treasury\":\"{}\"}}", parsed.to_formatted_string()), + ); +} + +#[no_mangle] +pub extern "C" fn set_admin() { + require_admin(); + let admin: String = runtime::get_named_arg(ARG_ADMIN); + let parsed = parse_account(&admin); + write_string(KEY_ADMIN, parsed.to_formatted_string()); emit_event( - "PaymentSettled", - &format!( - "{{\"from\":\"{}\",\"to\":\"{}\",\"amount\":\"{}\",\"request_id\":\"{}\"}}", - from_agent, to_agent, amount, request_id - ), + "AdminChanged", + &format!("{{\"admin\":\"{}\"}}", parsed.to_formatted_string()), ); } -/// Returns total settled payments count. #[no_mangle] pub extern "C" fn get_payment_count() { let count = read_u64(KEY_PAYMENT_COUNT); runtime::ret(CLValue::from_t(count).unwrap_or_revert()); } -// ── Contract installation ───────────────────────────────────────────────────── - fn build_entry_points() -> EntryPoints { let mut eps = EntryPoints::new(); eps.add_entry_point(EntityEntryPoint::new( - EP_REGISTER_AGENT, + EP_PAY, vec![ - Parameter::new(ARG_AGENT_ID, CLType::String), - Parameter::new(ARG_WALLET, CLType::String), + Parameter::new(ARG_ROUTE, CLType::U8), + Parameter::new(ARG_MERCHANT, CLType::String), + Parameter::new(ARG_GROSS_AMOUNT, CLType::U512), + Parameter::new(ARG_REQUEST_ID, CLType::String), + Parameter::new(ARG_VALID_UNTIL_MS, CLType::U64), ], CLType::Unit, EntryPointAccess::Public, @@ -178,13 +303,26 @@ fn build_entry_points() -> EntryPoints { )); eps.add_entry_point(EntityEntryPoint::new( - EP_PAY_AGENT, - vec![ - Parameter::new(ARG_FROM_AGENT, CLType::String), - Parameter::new(ARG_TO_AGENT, CLType::String), - Parameter::new(ARG_AMOUNT, CLType::U512), - Parameter::new(ARG_REQUEST_ID, CLType::String), - ], + EP_SET_PAUSED, + vec![Parameter::new(ARG_PAUSED, CLType::Bool)], + CLType::Unit, + EntryPointAccess::Public, + EntryPointType::Called, + EntryPointPayment::Caller, + )); + + eps.add_entry_point(EntityEntryPoint::new( + EP_SET_TREASURY, + vec![Parameter::new(ARG_TREASURY, CLType::String)], + CLType::Unit, + EntryPointAccess::Public, + EntryPointType::Called, + EntryPointPayment::Caller, + )); + + eps.add_entry_point(EntityEntryPoint::new( + EP_SET_ADMIN, + vec![Parameter::new(ARG_ADMIN, CLType::String)], CLType::Unit, EntryPointAccess::Public, EntryPointType::Called, @@ -203,28 +341,38 @@ fn build_entry_points() -> EntryPoints { eps } -/// Called once on deploy — installs the contract and initialises storage. +/// New install only. The installer becomes admin; treasury is explicit and +/// validated. Settlement starts PAUSED until deployment evidence and E2E are +/// reviewed. This avoids carrying unsafe v1/v2 state into the canonical route. #[no_mangle] pub extern "C" fn call() { - let agents_uref = storage::new_dictionary(KEY_AGENTS).unwrap_or_revert(); + let treasury_arg: String = runtime::get_named_arg(ARG_TREASURY); + let treasury = parse_account(&treasury_arg).to_formatted_string(); + let admin = runtime::get_caller().to_formatted_string(); + let payments_uref = storage::new_dictionary(KEY_PAYMENTS).unwrap_or_revert(); let events_uref = storage::new_dictionary(KEY_EVENTS).unwrap_or_revert(); let payment_count_uref: URef = storage::new_uref(0u64); let event_count_uref: URef = storage::new_uref(0u64); + let admin_uref: URef = storage::new_uref(admin.clone()); + let treasury_uref: URef = storage::new_uref(treasury.clone()); + let paused_uref: URef = storage::new_uref(true); let mut named_keys = NamedKeys::new(); - named_keys.insert(KEY_AGENTS.to_string(), Key::URef(agents_uref)); named_keys.insert(KEY_PAYMENTS.to_string(), Key::URef(payments_uref)); named_keys.insert(KEY_EVENTS.to_string(), Key::URef(events_uref)); named_keys.insert(KEY_PAYMENT_COUNT.to_string(), Key::URef(payment_count_uref)); named_keys.insert(KEY_EVENT_COUNT.to_string(), Key::URef(event_count_uref)); + named_keys.insert(KEY_ADMIN.to_string(), Key::URef(admin_uref)); + named_keys.insert(KEY_TREASURY.to_string(), Key::URef(treasury_uref)); + named_keys.insert(KEY_PAUSED.to_string(), Key::URef(paused_uref)); let (contract_hash, contract_version) = storage::new_contract( build_entry_points(), Some(named_keys), Some(KEY_CONTRACT_HASH.to_string()), Some(KEY_CONTRACT_VERSION.to_string()), - None, // no message topics + None, ); runtime::put_key(KEY_CONTRACT_HASH, Key::Hash(contract_hash.value())); @@ -232,4 +380,8 @@ pub extern "C" fn call() { KEY_CONTRACT_VERSION, Key::URef(storage::new_uref(contract_version)), ); + + // Installation event is not written inside the new contract context by this + // session function; deployment evidence must record installer/admin/treasury + // and contract hash externally and then read named keys before unpausing. }