diff --git a/ai-tools/claude-code.mdx b/ai-tools/claude-code.mdx deleted file mode 100644 index bdc4e04..0000000 --- a/ai-tools/claude-code.mdx +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: "Claude Code setup" -description: "Configure Claude Code for your documentation workflow" -icon: "asterisk" ---- - -Claude Code is Anthropic's official CLI tool. This guide will help you set up Claude Code to help you write and maintain your documentation. - -## Prerequisites - -- Active Claude subscription (Pro, Max, or API access) - -## Setup - -1. Install Claude Code globally: - - ```bash - npm install -g @anthropic-ai/claude-code -``` - -2. Navigate to your docs directory. -3. (Optional) Add the `CLAUDE.md` file below to your project. -4. Run `claude` to start. - -## Create `CLAUDE.md` - -Create a `CLAUDE.md` file at the root of your documentation repository to train Claude Code on your specific documentation standards: - -````markdown -# Mintlify documentation - -## Working relationship -- You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so -- ALWAYS ask for clarification rather than making assumptions -- NEVER lie, guess, or make up information - -## Project context -- Format: MDX files with YAML frontmatter -- Config: docs.json for navigation, theme, settings -- Components: Mintlify components - -## Content strategy -- Document just enough for user success - not too much, not too little -- Prioritize accuracy and usability of information -- Make content evergreen when possible -- Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason -- Check existing patterns for consistency -- Start by making the smallest reasonable changes - -## Frontmatter requirements for pages -- title: Clear, descriptive page title -- description: Concise summary for SEO/navigation - -## Writing standards -- Second-person voice ("you") -- Prerequisites at start of procedural content -- Test all code examples before publishing -- Match style and formatting of existing pages -- Include both basic and advanced use cases -- Language tags on all code blocks -- Alt text on all images -- Relative paths for internal links - -## Git workflow -- NEVER use --no-verify when committing -- Ask how to handle uncommitted changes before starting -- Create a new branch when no clear branch exists for changes -- Commit frequently throughout development -- NEVER skip or disable pre-commit hooks - -## Do not -- Skip frontmatter on any MDX file -- Use absolute URLs for internal links -- Include untested code examples -- Make assumptions - always ask for clarification -```` diff --git a/ai-tools/cursor.mdx b/ai-tools/cursor.mdx deleted file mode 100644 index fbb7761..0000000 --- a/ai-tools/cursor.mdx +++ /dev/null @@ -1,420 +0,0 @@ ---- -title: "Cursor setup" -description: "Configure Cursor for your documentation workflow" -icon: "arrow-pointer" ---- - -Use Cursor to help write and maintain your documentation. This guide shows how to configure Cursor for better results on technical writing tasks and using Mintlify components. - -## Prerequisites - -- Cursor editor installed -- Access to your documentation repository - -## Project rules - -Create project rules that all team members can use. In your documentation repository root: - -```bash -mkdir -p .cursor -``` - -Create `.cursor/rules.md`: - -````markdown -# Mintlify technical writing rule - -You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices. - -## Core writing principles - -### Language and style requirements - -- Use clear, direct language appropriate for technical audiences -- Write in second person ("you") for instructions and procedures -- Use active voice over passive voice -- Employ present tense for current states, future tense for outcomes -- Avoid jargon unless necessary and define terms when first used -- Maintain consistent terminology throughout all documentation -- Keep sentences concise while providing necessary context -- Use parallel structure in lists, headings, and procedures - -### Content organization standards - -- Lead with the most important information (inverted pyramid structure) -- Use progressive disclosure: basic concepts before advanced ones -- Break complex procedures into numbered steps -- Include prerequisites and context before instructions -- Provide expected outcomes for each major step -- Use descriptive, keyword-rich headings for navigation and SEO -- Group related information logically with clear section breaks - -### User-centered approach - -- Focus on user goals and outcomes rather than system features -- Anticipate common questions and address them proactively -- Include troubleshooting for likely failure points -- Write for scannability with clear headings, lists, and white space -- Include verification steps to confirm success - -## Mintlify component reference - -### Callout components - -#### Note - Additional helpful information - - -Supplementary information that supports the main content without interrupting flow - - -#### Tip - Best practices and pro tips - - -Expert advice, shortcuts, or best practices that enhance user success - - -#### Warning - Important cautions - - -Critical information about potential issues, breaking changes, or destructive actions - - -#### Info - Neutral contextual information - - -Background information, context, or neutral announcements - - -#### Check - Success confirmations - - -Positive confirmations, successful completions, or achievement indicators - - -### Code components - -#### Single code block - -Example of a single code block: - -```javascript config.js -const apiConfig = { - baseURL: 'https://api.example.com', - timeout: 5000, - headers: { - 'Authorization': `Bearer ${process.env.API_TOKEN}` - } -}; -``` - -#### Code group with multiple languages - -Example of a code group: - - -```javascript Node.js -const response = await fetch('/api/endpoint', { - headers: { Authorization: `Bearer ${apiKey}` } -}); -``` - -```python Python -import requests -response = requests.get('/api/endpoint', - headers={'Authorization': f'Bearer {api_key}'}) -``` - -```curl cURL -curl -X GET '/api/endpoint' \ - -H 'Authorization: Bearer YOUR_API_KEY' -``` - - -#### Request/response examples - -Example of request/response documentation: - - -```bash cURL -curl -X POST 'https://api.example.com/users' \ - -H 'Content-Type: application/json' \ - -d '{"name": "John Doe", "email": "john@example.com"}' -``` - - - -```json Success -{ - "id": "user_123", - "name": "John Doe", - "email": "john@example.com", - "created_at": "2024-01-15T10:30:00Z" -} -``` - - -### Structural components - -#### Steps for procedures - -Example of step-by-step instructions: - - - - Run `npm install` to install required packages. - - - Verify installation by running `npm list`. - - - - - Create a `.env` file with your API credentials. - - ```bash - API_KEY=your_api_key_here - ``` - - - Never commit API keys to version control. - - - - -#### Tabs for alternative content - -Example of tabbed content: - - - - ```bash - brew install node - npm install -g package-name - ``` - - - - ```powershell - choco install nodejs - npm install -g package-name - ``` - - - - ```bash - sudo apt install nodejs npm - npm install -g package-name - ``` - - - -#### Accordions for collapsible content - -Example of accordion groups: - - - - - **Firewall blocking**: Ensure ports 80 and 443 are open - - **Proxy configuration**: Set HTTP_PROXY environment variable - - **DNS resolution**: Try using 8.8.8.8 as DNS server - - - - ```javascript - const config = { - performance: { cache: true, timeout: 30000 }, - security: { encryption: 'AES-256' } - }; - ``` - - - -### Cards and columns for emphasizing information - -Example of cards and card groups: - - -Complete walkthrough from installation to your first API call in under 10 minutes. - - - - - Learn how to authenticate requests using API keys or JWT tokens. - - - - Understand rate limits and best practices for high-volume usage. - - - -### API documentation components - -#### Parameter fields - -Example of parameter documentation: - - -Unique identifier for the user. Must be a valid UUID v4 format. - - - -User's email address. Must be valid and unique within the system. - - - -Maximum number of results to return. Range: 1-100. - - - -Bearer token for API authentication. Format: `Bearer YOUR_API_KEY` - - -#### Response fields - -Example of response field documentation: - - -Unique identifier assigned to the newly created user. - - - -ISO 8601 formatted timestamp of when the user was created. - - - -List of permission strings assigned to this user. - - -#### Expandable nested fields - -Example of nested field documentation: - - -Complete user object with all associated data. - - - - User profile information including personal details. - - - - User's first name as entered during registration. - - - - URL to user's profile picture. Returns null if no avatar is set. - - - - - - -### Media and advanced components - -#### Frames for images - -Wrap all images in frames: - - -Main dashboard showing analytics overview - - - -Analytics dashboard with charts - - -#### Videos - -Use the HTML video element for self-hosted video content: - - - -Embed YouTube videos using iframe elements: - - - -#### Tooltips - -Example of tooltip usage: - - -API - - -#### Updates - -Use updates for changelogs: - - -## New features -- Added bulk user import functionality -- Improved error messages with actionable suggestions - -## Bug fixes -- Fixed pagination issue with large datasets -- Resolved authentication timeout problems - - -## Required page structure - -Every documentation page must begin with YAML frontmatter: - -```yaml ---- -title: "Clear, specific, keyword-rich title" -description: "Concise description explaining page purpose and value" ---- -``` - -## Content quality standards - -### Code examples requirements - -- Always include complete, runnable examples that users can copy and execute -- Show proper error handling and edge case management -- Use realistic data instead of placeholder values -- Include expected outputs and results for verification -- Test all code examples thoroughly before publishing -- Specify language and include filename when relevant -- Add explanatory comments for complex logic -- Never include real API keys or secrets in code examples - -### API documentation requirements - -- Document all parameters including optional ones with clear descriptions -- Show both success and error response examples with realistic data -- Include rate limiting information with specific limits -- Provide authentication examples showing proper format -- Explain all HTTP status codes and error handling -- Cover complete request/response cycles - -### Accessibility requirements - -- Include descriptive alt text for all images and diagrams -- Use specific, actionable link text instead of "click here" -- Ensure proper heading hierarchy starting with H2 -- Provide keyboard navigation considerations -- Use sufficient color contrast in examples and visuals -- Structure content for easy scanning with headers and lists - -## Component selection logic - -- Use **Steps** for procedures and sequential instructions -- Use **Tabs** for platform-specific content or alternative approaches -- Use **CodeGroup** when showing the same concept in multiple programming languages -- Use **Accordions** for progressive disclosure of information -- Use **RequestExample/ResponseExample** specifically for API endpoint documentation -- Use **ParamField** for API parameters, **ResponseField** for API responses -- Use **Expandable** for nested object properties or hierarchical information -```` diff --git a/ai-tools/windsurf.mdx b/ai-tools/windsurf.mdx deleted file mode 100644 index fce12bf..0000000 --- a/ai-tools/windsurf.mdx +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: "Windsurf setup" -description: "Configure Windsurf for your documentation workflow" -icon: "water" ---- - -Configure Windsurf's Cascade AI assistant to help you write and maintain documentation. This guide shows how to set up Windsurf specifically for your Mintlify documentation workflow. - -## Prerequisites - -- Windsurf editor installed -- Access to your documentation repository - -## Workspace rules - -Create workspace rules that provide Windsurf with context about your documentation project and standards. - -Create `.windsurf/rules.md` in your project root: - -````markdown -# Mintlify technical writing rule - -## Project context - -- This is a documentation project on the Mintlify platform -- We use MDX files with YAML frontmatter -- Navigation is configured in `docs.json` -- We follow technical writing best practices - -## Writing standards - -- Use second person ("you") for instructions -- Write in active voice and present tense -- Start procedures with prerequisites -- Include expected outcomes for major steps -- Use descriptive, keyword-rich headings -- Keep sentences concise but informative - -## Required page structure - -Every page must start with frontmatter: - -```yaml ---- -title: "Clear, specific title" -description: "Concise description for SEO and navigation" ---- -``` - -## Mintlify components - -### Callouts - -- `` for helpful supplementary information -- `` for important cautions and breaking changes -- `` for best practices and expert advice -- `` for neutral contextual information -- `` for success confirmations - -### Code examples - -- When appropriate, include complete, runnable examples -- Use `` for multiple language examples -- Specify language tags on all code blocks -- Include realistic data, not placeholders -- Use `` and `` for API docs - -### Procedures - -- Use `` component for sequential instructions -- Include verification steps with `` components when relevant -- Break complex procedures into smaller steps - -### Content organization - -- Use `` for platform-specific content -- Use `` for progressive disclosure -- Use `` and `` for highlighting content -- Wrap images in `` components with descriptive alt text - -## API documentation requirements - -- Document all parameters with `` -- Show response structure with `` -- Include both success and error examples -- Use `` for nested object properties -- Always include authentication examples - -## Quality standards - -- Test all code examples before publishing -- Use relative paths for internal links -- Include alt text for all images -- Ensure proper heading hierarchy (start with h2) -- Check existing patterns for consistency -```` diff --git a/contracts/architecture.mdx b/contracts/architecture.mdx index c5f5458..97bbca2 100644 --- a/contracts/architecture.mdx +++ b/contracts/architecture.mdx @@ -23,26 +23,26 @@ flowchart TB subgraph Operator["PaymentOperator (per config)"] Auth[authorize] Charge[charge] - Release[release] - RefundIE[refundInEscrow] - RefundPE[refundPostEscrow] + Capture[capture] + Void[void] + Refund[refund] end - subgraph Plugins["Conditions & Recorders"] + subgraph Plugins["Conditions & Hooks"] Cond["ICondition
(check before action)"] - Rec["IRecorder
(record after action)"] + Hook["IHook
(run after action)"] EP[EscrowPeriod] Freeze[Freeze] And[AndCondition] Or[OrCondition] + RR[RefundRequest] end Escrow[AuthCaptureEscrow] - RR[RefundRequest] Payer -->|authorize / freeze| Operator - Receiver -->|release / charge| Operator - DesAddr -->|refund / release| Operator + Receiver -->|capture / charge| Operator + DesAddr -->|void / refund / capture| Operator Payer -->|requestRefund| RR POF -->|deploys| Operator @@ -50,47 +50,47 @@ flowchart TB FF -->|deploys| Freeze Operator -->|checks| Cond - Operator -->|calls| Rec - Operator -->|locks/releases funds| Escrow + Operator -->|calls| Hook + Operator -->|locks/captures funds| Escrow EP -.->|implements| Cond - EP -.->|implements| Rec + EP -.->|implements| Hook Freeze -.->|implements| Cond + RR -.->|implements| Hook And -.->|composes| Cond Or -.->|composes| Cond ``` -For additional visual diagrams, see the [x402r-contracts repository](https://github.com/BackTrackCo/x402r-contracts#architecture). +For more visual diagrams, see the [x402r-contracts repository](https://github.com/BackTrackCo/x402r-contracts#architecture). ## Payment Flow Sequence ### Standard Payment (Happy Path) 1. **Payer** calls `operator.authorize(paymentInfo, amount, tokenCollector, collectorData)` -2. **Operator** checks `AUTHORIZE_CONDITION` (if set) +2. **Operator** checks `AUTHORIZE_PRE_ACTION_CONDITION` (if set) 3. **Operator** validates fee bounds and stores fees at authorization time 4. **Operator** calls `escrow.authorize()` to lock funds -5. **Operator** calls `AUTHORIZE_RECORDER` to record timestamp -6. **Escrow period** begins (e.g., 7 days) - if configured -7. After escrow period: **Authorized address(es)** call `operator.release(paymentInfo, amount)` (e.g., receiver, designated address, or both) -8. **Operator** checks `RELEASE_CONDITION` (configurable - can include time checks, role checks, etc.) +5. **Operator** calls `AUTHORIZE_POST_ACTION_HOOK` to record timestamp +6. **Escrow period** begins (for example, 7 days) if configured +7. After escrow period: **Authorized addresses** call `operator.capture(paymentInfo, amount)` (for example, receiver, designated address, or both) +8. **Operator** checks `CAPTURE_PRE_ACTION_CONDITION` (configurable, can include time checks or role checks) 9. **Operator** calls `escrow.capture()` to transfer funds to receiver 10. **Operator** accumulates protocol fees for later distribution -11. **Operator** calls `RELEASE_RECORDER` to update state +11. **Operator** calls `CAPTURE_POST_ACTION_HOOK` to update state -### Refund Flow (In-Escrow) +### Void Flow (before capture) **Example: Marketplace with arbiter dispute resolution** -1. **Payer** calls `refundRequest.requestRefund(paymentInfo, amount, nonce)` +1. **Payer** calls `refundRequest.requestRefund(paymentInfo, amount)` 2. **RefundRequest** creates request with status `Pending` -3. **Designated address** (e.g., arbiter, DAO multisig) reviews dispute -4. **Designated address** calls `refundRequest.updateStatus(paymentInfo, nonce, Approved)` -5. **Designated address** calls `operator.refundInEscrow(paymentInfo, amount)` -6. **Operator** checks `REFUND_IN_ESCROW_CONDITION` (configured per operator) -7. **Operator** calls `escrow.partialVoid()` to return funds to payer -8. **Operator** calls `REFUND_IN_ESCROW_RECORDER` -9. Funds transferred back to payer +3. **Designated address** (for example, arbiter or DAO multisig) reviews dispute +4. **Designated address** calls `operator.void(paymentInfo)` +5. **Operator** checks `VOID_PRE_ACTION_CONDITION` (configured per operator) +6. **Operator** calls `escrow.void()` to return all escrowed funds to payer +7. **Operator** calls `VOID_POST_ACTION_HOOK` (RefundRequest flips status to `Approved`) +8. Funds transferred back to payer Refund conditions are configurable. Can be arbiter-only (marketplace), receiver-allowed (return policy), DAO-controlled (governance), or disabled (subscriptions). @@ -104,10 +104,10 @@ Refund conditions are configurable. Can be arbiter-only (marketplace), receiver- - **Day 0:** Payment authorized, escrow period begins - **Day 0-7:** Payer can freeze if suspicious (per Freeze contract configuration) - **Day 3:** Payer freezes payment (freeze lasts 3 days per configuration) -- **Day 3-6:** Payment frozen, release blocked +- **Day 3-6:** Payment frozen, capture blocked - **Day 6:** Freeze expires automatically (or authorized address unfreezes early) - **Day 7:** Escrow period ends -- **Day 7+:** Authorized address(es) can release (if not frozen) +- **Day 7+:** Authorized addresses can capture (if not frozen) Freeze policies are optional and configurable. Define who can freeze, who can unfreeze, and how long freeze lasts. @@ -121,18 +121,18 @@ Freeze policies are optional and configurable. Define who can freeze, who can un ### Authorization Check (Before Action) -When an action is called (e.g., `release()`): +When you invoke an action (for example, `capture()`): 1. **Load Condition** - Get the condition address from operator slot -2. **Evaluate Condition** - Call `condition.check(paymentInfo, amount, caller)` - - Check if caller matches required role (e.g., receiver, arbiter) - - Check state (e.g., escrow period passed, not frozen) - - Check other requirements (e.g., time constraints) +2. **Check Condition** - Call `condition.check(paymentInfo, amount, caller, data)` + - Check if caller matches required role (for example, receiver or arbiter) + - Check state (for example, escrow period passed, not frozen) + - Check other requirements (for example, time constraints) 3. **Result:** - `true` → Proceed to execute action - - `false` → Revert with `ConditionNotMet` error + - `false` → Revert with `PreActionConditionNotMet` error 4. **Execute Action** - Call escrow method -5. **Call Recorder** - Update state after successful execution +5. **Call Hook** - Run the matching `*_POST_ACTION_HOOK` after successful execution ### Combinator Example @@ -141,7 +141,7 @@ When an action is called (e.g., `release()`): - If not receiver, checks if caller is arbiter: Yes → PASS - If neither: FAIL -**AndCondition([OrCondition(...), EscrowPeriod])** +**AndCondition([OrCondition, EscrowPeriod])** - First checks OrCondition: PASS (caller is receiver or arbiter) - Then checks EscrowPeriod: PASS (escrow period elapsed) - Both passed → PASS (action allowed) @@ -189,7 +189,7 @@ mapping(address token => uint256) public accumulatedProtocolFees; ### EscrowPeriod Recording ```solidity -// In EscrowPeriod (extends AuthorizationTimeRecorder) +// In EscrowPeriod (extends AuthorizationTimeRecorderHook) mapping(bytes32 paymentInfoHash => uint256 authorizedAt) public authorizationTimes; ``` @@ -202,17 +202,11 @@ mapping(bytes32 paymentInfoHash => uint256 frozenUntil) public frozenUntil; ### Fee Distribution (Additive Model) -Fees are **additive**: `totalFee = protocolFee + operatorFee` +Fees are **additive**: `totalFee = protocolFee + operatorFee`. They are split between the protocol fee recipient (on ProtocolFeeConfig) and the operator's `FEE_RECEIVER`. For a worked example with concrete amounts, see the [Fee System](/contracts/fees#example-calculation). -For a 1000 USDC payment with 3 bps protocol fee + 2 bps operator fee: -- **Protocol Fee:** 0.30 USDC (3 bps) → `protocolFeeRecipient` on ProtocolFeeConfig -- **Operator Fee:** 0.20 USDC (2 bps) → `FEE_RECIPIENT` on operator -- **Total Fee:** 0.50 USDC (5 bps) -- **Receiver Gets:** 999.50 USDC +Fees accumulate in the operator. Anyone can call `distributeFees(token)` to disburse them. -Fees accumulate in the operator and are distributed via `distributeFees(token)`. - -**FEE_RECIPIENT** can be: +**FEE_RECEIVER** can be: - Arbiter address (marketplace with disputes) - Service provider address (subscriptions, APIs) - Platform treasury (platform-controlled) @@ -223,12 +217,12 @@ Fees accumulate in the operator and are distributed via `distributeFees(token)`. | Role | Capabilities | Restrictions | |------|-------------|--------------| | **Payer** | `authorize()`, `freeze()`, `unfreeze()`, `requestRefund()`, `cancelRefundRequest()` | Can only act on own payments | -| **Receiver** | `release()` (if condition allows), `charge()`, `requestRefund()` | Can only act on payments where they are receiver | -| **Designated Address** | Any action per conditions (e.g., `refundInEscrow()`, `release()`, `updateStatus()`) | Defined by StaticAddressCondition (arbiter, DAO, service provider, etc.) | +| **Receiver** | `capture()` (if condition allows), `charge()`, `requestRefund()` | Can only act on payments where they are receiver | +| **Designated Address** | Any action per conditions (for example, `void()`, `capture()`, or `refund()`) | Defined by StaticAddressCondition (arbiter, DAO, or service provider) | | **Protocol Owner** | `queueCalculator()`, `executeCalculator()`, `queueRecipient()`, `executeRecipient()` | 7-day timelock on ProtocolFeeConfig changes | -"Designated Address" is configured per operator via StaticAddressCondition. Can be: +Each operator sets its "Designated Address" via StaticAddressCondition. Common roles include: - **Arbiter** (marketplace with disputes) - **Service Provider** (subscriptions, APIs) - **DAO Multisig** (governance-controlled) @@ -247,32 +241,10 @@ All state-changing functions use `ReentrancyGuardTransient` (EIP-1153): ### Timelock Protection -Protocol fee calculator changes require 7-day delay on `ProtocolFeeConfig`: - -```typescript -// Step 1: Queue new calculator -await protocolFeeConfig.queueCalculator(newCalculatorAddress); - -// Step 2: Wait 7 days - -// Step 3: Execute -await protocolFeeConfig.executeCalculator(); -``` - -Protocol fee recipient changes also require 7-day timelock: - -```typescript -// Step 1: Queue new recipient -await protocolFeeConfig.queueRecipient(newRecipientAddress); - -// Step 2: Wait 7 days - -// Step 3: Execute -await protocolFeeConfig.executeRecipient(); -``` +Protocol fee calculator and recipient changes require a 7-day delay on `ProtocolFeeConfig` (queue, wait, execute). See [Fee System: 7-day timelock](/contracts/fees#calculator-changes-7-day-timelock) for the full workflow with events and the cancel path. -Operator fees are **immutable** - set at deploy time via `IFeeCalculator`. Only protocol fees can be changed (with 7-day timelock). +Operator fees are **immutable**: set at deploy time via `IFeeCalculator`. Only protocol fees can change, and only after a 7-day timelock. ### Two-Step Ownership @@ -289,15 +261,15 @@ Ownership transfers use Solady's Ownable pattern: ```solidity // Payment lifecycle (PaymentOperator events) -event AuthorizationCreated(bytes32 indexed paymentInfoHash, address indexed payer, address indexed receiver, uint256 amount, uint256 timestamp); -event ChargeExecuted(bytes32 indexed paymentInfoHash, address indexed payer, address indexed receiver, uint256 amount, uint256 timestamp); -event ReleaseExecuted(AuthCaptureEscrow.PaymentInfo paymentInfo, uint256 amount, uint256 timestamp); -event RefundInEscrowExecuted(AuthCaptureEscrow.PaymentInfo paymentInfo, address indexed payer, uint256 amount); -event RefundPostEscrowExecuted(AuthCaptureEscrow.PaymentInfo paymentInfo, address indexed payer, uint256 amount); +event AuthorizeExecuted(AuthCaptureEscrow.PaymentInfo paymentInfo, bytes32 indexed paymentInfoHash, address indexed payer, address indexed receiver, uint256 amount); +event ChargeExecuted(AuthCaptureEscrow.PaymentInfo paymentInfo, bytes32 indexed paymentInfoHash, address indexed payer, address indexed receiver, uint256 amount); +event CaptureExecuted(AuthCaptureEscrow.PaymentInfo paymentInfo, bytes32 indexed paymentInfoHash, address indexed payer, address indexed receiver, uint256 amount); +event VoidExecuted(AuthCaptureEscrow.PaymentInfo paymentInfo, bytes32 indexed paymentInfoHash, address indexed payer, address indexed receiver); +event RefundExecuted(AuthCaptureEscrow.PaymentInfo paymentInfo, bytes32 indexed paymentInfoHash, address indexed payer, address indexed receiver, uint256 amount); // Fee distribution -event FeesDistributed(address indexed token, uint256 protocolAmount, uint256 arbiterAmount); -event OperatorDeployed(address indexed operator, address indexed feeRecipient, address indexed releaseCondition); +event FeesDistributed(address indexed token, uint256 protocolAmount, uint256 operatorAmount); +event OperatorDeployed(address indexed operator, address indexed deployer, address indexed feeReceiver); // Freeze state (Freeze contract events) event PaymentFrozen(bytes32 indexed paymentInfoHash, uint40 frozenAt); @@ -318,7 +290,4 @@ These events enable off-chain monitoring and indexing. Deploy a PaymentOperator using the SDK. - - Understand how the SDK maps to contract architecture. -
diff --git a/contracts/audits.mdx b/contracts/audits.mdx index 004648b..67581de 100644 --- a/contracts/audits.mdx +++ b/contracts/audits.mdx @@ -6,41 +6,39 @@ icon: "shield-halved" ## Audit Status -x402r is built on top of a **fork** of Base's [commerce-payments](https://github.com/base/commerce-payments) protocol. The original commerce-payments contracts have been professionally audited. Our fork adds `partialVoid()` to the escrow contract (for partial refunds during escrow), and all x402r-specific contracts built on top of the fork are **not yet audited**. +x402r extends the canonical [commerce-payments](https://github.com/base/commerce-payments) protocol from Base. The commerce-payments contracts have professional audits and run directly at their universal CREATE2 addresses (no fork). The x402r-specific contracts on top of them are **not yet audited**. ### What's Audited (Upstream) -The original commerce-payments `AuthCaptureEscrow` contract and its supporting infrastructure (TokenCollectors, TokenStore, Permit2 integration) were audited by: +x402r runs the commerce-payments primitives at their canonical addresses, so their audit coverage applies directly with no fork to re-audit. Base maintains the authoritative, dated report list, defer to it rather than this page: -- **Spearbit** (2 audits) -- **Coinbase Protocol Security** (3 audits) +- [commerce-payments `audits/` directory](https://github.com/base/commerce-payments/tree/main/audits) hosts the report PDFs. +- [Security Audits section](https://github.com/base/commerce-payments#security-audits) of the upstream README lists each audit with its date and report link. -These audits cover the core escrow lifecycle: authorize, capture, void, reclaim, and refund. Audit reports are available in the [commerce-payments repository](https://github.com/base/commerce-payments). +As of the latest published list, the `AuthCaptureEscrow` contract and its supporting infrastructure (TokenCollectors, TokenStore, Permit2 integration) were covered by five reports, three from Coinbase Protocol Security and two from Spearbit. These cover the core escrow lifecycle: `authorize`, `capture`, `void`, `reclaim`, and `refund`. ### What's Not Audited | Component | Status | Risk | |-----------|--------|------| -| `partialVoid()` on AuthCaptureEscrow | **Unaudited** (fork addition) | Follows same patterns as audited `void()`, but adds partial amount logic | -| PaymentOperator | **Unaudited** | Core operator with condition/recorder dispatch and fee system | +| PaymentOperator | **Unaudited** | Core operator with condition/hook dispatch and fee system | | PaymentOperatorFactory | **Unaudited** | CREATE2 deterministic deployment | | ProtocolFeeConfig | **Unaudited** | Timelocked fee governance | | StaticFeeCalculator | **Unaudited** | Simple immutable fee calculator | | Condition plugins | **Unaudited** | PayerCondition, ReceiverCondition, StaticAddressCondition, AlwaysTrueCondition | | Combinator plugins | **Unaudited** | AndCondition, OrCondition, NotCondition | -| EscrowPeriod | **Unaudited** | Combined recorder + time-lock condition | +| EscrowPeriod | **Unaudited** | Combined hook + time-lock condition | | Freeze | **Unaudited** | Freeze/unfreeze state management | -| Recorder plugins | **Unaudited** | AuthorizationTimeRecorder, PaymentIndexRecorder, RecorderCombinator | +| Hook plugins | **Unaudited** | AuthorizationTimeRecorderHook, PaymentIndexRecorderHook, HookCombinator | | RefundRequest | **Unaudited** | Refund request lifecycle management | ### What This Means -- The audited escrow layer provides a strong foundation — fund custody, token transfers, and payment state transitions have been professionally reviewed -- `partialVoid()` is a minimal addition (~20 lines) that mirrors the audited `void()` function with an additional amount parameter and balance check -- The condition/recorder plugin system is stateless or minimal-state by design, reducing attack surface +- The audited escrow layer covers fund custody, token transfers, and payment state transitions +- The condition/hook plugin system is stateless or minimal-state by design, reducing attack surface -Use x402r contracts on mainnet at your own risk. While we've built with security best practices (CEI pattern, reentrancy guards, immutable configuration, timelocked governance), the x402r-specific code has not undergone a formal audit. +Use x402r contracts on mainnet at your own risk. The x402r-specific code follows security best practices (CEI pattern, reentrancy guards, immutable configuration, timelocked governance), but has not undergone a formal audit. ## Security Practices @@ -49,23 +47,22 @@ Even without a formal audit, the x402r contracts follow established security pat - **CEI (Checks-Effects-Interactions)** ordering in all state-changing functions - **ReentrancyGuardTransient** (EIP-1153) on all external entry points -- **Immutable configuration** — operator conditions and fee calculators cannot be changed after deployment +- **Immutable configuration**: deployment locks the operator conditions and fee calculators - **7-day timelock** on protocol fee changes via ProtocolFeeConfig - **2-step ownership transfers** via Solady's Ownable -- **Comprehensive Forge test suite** covering core flows and edge cases +- **Forge test suite** covering core flows and edge cases ## Audit Roadmap -We plan to pursue third-party audits as the contract architecture and use cases stabilize. Priority order: +The plan is to pursue third-party audits as the contract architecture and use cases stabilize. Priority order: -1. **`partialVoid()`** — our only change to the audited commerce-payments escrow, and the foundation everything else depends on -2. **PaymentOperator** — condition dispatch, fee calculation, fee locking, distribution -3. **Plugin system** — conditions, recorders, combinators, and their factories -4. **EscrowPeriod + Freeze** — time-lock enforcement and freeze state management -5. **RefundRequest** — request lifecycle and access control +1. **PaymentOperator**: condition dispatch, fee calculation, fee locking, distribution +2. **Plugin system**: conditions, hooks, combinators, and their factories +3. **EscrowPeriod + Freeze**: time-lock enforcement and freeze state management +4. **RefundRequest**: request lifecycle and access control -We'll publish audit reports publicly once completed. +Completed audit reports go public. -If you're interested in integrating x402r and want to discuss the security posture in more detail, or if you've found a vulnerability, reach out at [security@x402r.org](mailto:security@x402r.org). +To discuss the security posture in more detail before integrating, or to report a vulnerability, reach out at [security@x402r.org](mailto:security@x402r.org). diff --git a/contracts/conditions/always-true.mdx b/contracts/conditions/always-true.mdx index 577ea67..a942e04 100644 --- a/contracts/conditions/always-true.mdx +++ b/contracts/conditions/always-true.mdx @@ -6,18 +6,16 @@ icon: "check" ## Overview -AlwaysTrueCondition allows anyone to call the action — no restrictions applied. +AlwaysTrueCondition allows anyone to call the action, no restrictions applied. -**Type:** Singleton (deployed once, reused by all operators) +**Type:** Singleton, CREATE2 (deployed once, reused by all operators) -**Address (Base Sepolia):** `0x785cC83DEa3d46D5509f3bf7496EAb26D42EE610` - -**Address (Base Mainnet):** `0xc9BbA6A2CF9838e7Dd8c19BC8B3BAC620B9D8178` +**Address (all supported chains):** `0x2ef2A6162aEF9Df1022ff51c011af94D99AB4904` ## Logic ```solidity -function check(PaymentInfo calldata payment, uint256, address caller) +function check(PaymentInfo calldata payment, uint256, address caller, bytes calldata) external pure returns (bool) { return true; @@ -28,10 +26,10 @@ function check(PaymentInfo calldata payment, uint256, address caller) | Slot | Use Case | |------|----------| -| `AUTHORIZE_CONDITION` | Let anyone create payments (common for marketplace/e-commerce) | +| `AUTHORIZE_PRE_ACTION_CONDITION` | Let anyone create payments (common for marketplace/e-commerce) | -**Use with caution for release/refund slots.** Setting `RELEASE_CONDITION` or `REFUND_IN_ESCROW_CONDITION` to AlwaysTrueCondition means anyone can release or refund funds. This is functionally equivalent to leaving the slot as `address(0)` (the default behavior), but makes the intent explicit. +**Use with caution for capture/refund slots.** Setting `CAPTURE_PRE_ACTION_CONDITION` or `VOID_PRE_ACTION_CONDITION` to AlwaysTrueCondition means anyone can capture or refund funds. This matches leaving the slot as `address(0)` (the default behavior), but makes the intent explicit. ## AlwaysTrueCondition vs `address(0)` @@ -48,7 +46,7 @@ Use `address(0)` when you simply don't need a condition. Use AlwaysTrueCondition ## Gas -**Cost:** Minimal — `pure` function returning a constant. +**Cost:** Minimal, `pure` function returning a constant. ## Next Steps diff --git a/contracts/conditions/combinators.mdx b/contracts/conditions/combinators.mdx index 1ab5c22..ee74b14 100644 --- a/contracts/conditions/combinators.mdx +++ b/contracts/conditions/combinators.mdx @@ -6,7 +6,7 @@ icon: "puzzle-piece" ## Overview -Combinator conditions compose multiple conditions with logical operators. Deploy each via its respective factory. +Combinator conditions compose two or more conditions with logical operators. Deploy each via its respective factory. ## AndCondition @@ -19,25 +19,25 @@ const comboAddress = await andConditionFactory.write.deploy([ ]); // Use in operator config -config.releaseCondition = comboAddress; +config.capturePreActionCondition = comboAddress; ``` -**Example:** Release requires receiver AND escrow period passed. +**Example:** Capture requires receiver AND escrow period passed. ## OrCondition At least one condition must pass (`A || B`). ```typescript -// Receiver OR Arbiter can release +// Receiver OR Arbiter can capture const comboAddress = await orConditionFactory.write.deploy([ [RECEIVER_CONDITION, ARBITER_CONDITION] ]); -config.releaseCondition = comboAddress; +config.capturePreActionCondition = comboAddress; ``` -**Example:** Either receiver or arbiter can release. +**Example:** Either receiver or arbiter can capture. ## NotCondition @@ -47,7 +47,7 @@ Inverts a condition (`!A`). // Anyone EXCEPT payer can call const comboAddress = await notConditionFactory.write.deploy([PAYER_CONDITION]); -config.releaseCondition = comboAddress; +config.capturePreActionCondition = comboAddress; ``` **Example:** Prevent payer from releasing their own payment. @@ -62,11 +62,11 @@ const receiverOrArbiter = await orConditionFactory.write.deploy([ [RECEIVER_CONDITION, ARBITER_CONDITION] ]); -const releaseCondition = await andConditionFactory.write.deploy([ +const capturePreActionCondition = await andConditionFactory.write.deploy([ [receiverOrArbiter, ESCROW_PERIOD_ADDRESS] ]); -config.releaseCondition = releaseCondition; +config.capturePreActionCondition = capturePreActionConditionAddress; ``` **Logic Tree:** @@ -85,7 +85,7 @@ flowchart TD ## Limits -**Max 10 conditions per combinator.** Keep combinators simple — deeply nested trees increase gas costs and make debugging harder. +**Max 10 conditions per combinator.** Keep combinators simple. Nested trees increase gas costs and make debugging harder. ## Gas @@ -100,7 +100,7 @@ OrCondition([A, B]) // ~25K gas per check OrCondition([A, B, C, D]) // ~45K gas per check ``` -Each additional condition adds one external call. Prefer fewer conditions where possible. +Each extra condition adds one external call. Prefer fewer conditions where possible. ## Next Steps @@ -109,7 +109,7 @@ Each additional condition adds one external call. Prefer fewer conditions where Time-based condition for escrow windows.
- Block releases when payments are frozen. + Block releases on frozen payments. See combinators in real configurations. diff --git a/contracts/conditions/custom.mdx b/contracts/conditions/custom.mdx index dc3e6c9..43cd16b 100644 --- a/contracts/conditions/custom.mdx +++ b/contracts/conditions/custom.mdx @@ -6,18 +6,16 @@ icon: "wrench" ## Overview -You can create custom conditions for specialized logic beyond what the built-in conditions provide. Implement the `ICondition` interface and follow the security rules below. +You can create custom conditions for specialized logic beyond what the built-in conditions provide. Build against the `ICondition` interface and follow the security rules below. ## ICondition Rules From the `ICondition.sol` NatSpec: -1. **MUST NOT revert** — return `false` to deny, never `revert` -2. **Should be `view` or `pure`** — no state-changing operations -3. **No external calls** — avoid calling other contracts to prevent reentrancy risks -4. **Return `true` to allow, `false` to deny** +1. **MUST NOT revert**: return `false` to deny, never `revert` +2. **Return `true` to allow, `false` to deny** -The operator converts a `false` return into a `ConditionNotMet` revert. +The operator converts a `false` return into a `PreActionConditionNotMet` revert. Prefer `view` or `pure` implementations to keep call sites cheap and gas predictable. ## Example: TimeOfDayCondition @@ -36,7 +34,8 @@ contract TimeOfDayCondition is ICondition { function check( PaymentInfo calldata payment, uint256, - address caller + address caller, + bytes calldata ) external view returns (bool) { uint256 hour = (block.timestamp / 3600) % 24; return hour >= startHour && hour < endHour; @@ -44,14 +43,14 @@ contract TimeOfDayCondition is ICondition { } ``` -Usage — deploy via a factory and use in operator config: +Usage, deploy via a factory and use in operator config: ```typescript // Deploy via your custom factory const businessHours = await timeOfDayConditionFactory.write.deploy([9, 17]); // Use in operator config -config.releaseCondition = businessHours; +config.capturePreActionCondition = businessHours; ``` ## Security Checklist @@ -63,10 +62,10 @@ Before deploying a custom condition: - [ ] No external calls to untrusted contracts - [ ] No state modifications - [ ] Handles edge cases (zero address, zero amount, uninitialized payments) -- [ ] Comprehensive test coverage with Forge tests +- [ ] Full test coverage with Forge tests -Custom conditions with bugs can lead to **permanently locked funds** (if `check()` always returns `false`) or **unauthorized access** (if `check()` always returns `true`). Test thoroughly on testnet before mainnet deployment. +Custom conditions with bugs can lead to **permanently locked funds** (if `check()` always returns `false`) or **unauthorized access** (if `check()` always returns `true`). Test on Base Sepolia before mainnet deployment. ## Testing @@ -84,13 +83,13 @@ contract TimeOfDayConditionTest is Test { function test_allowsDuringBusinessHours() public { // Set block.timestamp to 10 AM UTC vm.warp(10 * 3600); - assertTrue(condition.check(paymentInfo, 0, caller)); + assertTrue(condition.check(paymentInfo, 0, caller, "")); } function test_deniesOutsideBusinessHours() public { // Set block.timestamp to 8 PM UTC vm.warp(20 * 3600); - assertFalse(condition.check(paymentInfo, 0, caller)); + assertFalse(condition.check(paymentInfo, 0, caller, "")); } } ``` @@ -101,7 +100,7 @@ contract TimeOfDayConditionTest is Test { Review the condition system architecture. - - Build custom state recorders. + + Build custom state hooks. diff --git a/contracts/conditions/escrow-period.mdx b/contracts/conditions/escrow-period.mdx index f97387f..2b8bf61 100644 --- a/contracts/conditions/escrow-period.mdx +++ b/contracts/conditions/escrow-period.mdx @@ -6,12 +6,12 @@ icon: "clock" ## Overview -EscrowPeriod is a dual-purpose contract — it functions as both a **recorder** and a **condition**: +EscrowPeriod is a dual-purpose contract, it functions as both a **hook** and a **condition**: -- **As a recorder:** Records the `block.timestamp` when a payment is authorized +- **As a hook:** Records the `block.timestamp` at payment authorization - **As a condition:** Returns `true` only after the escrow period has elapsed -Use the **same address** for both `AUTHORIZE_RECORDER` and `RELEASE_CONDITION` slots on the operator. +Use the **same address** for both `AUTHORIZE_POST_ACTION_HOOK` and `CAPTURE_PRE_ACTION_CONDITION` slots on the operator. **Type:** Per-deployment via [EscrowPeriodFactory](/contracts/factories) @@ -19,21 +19,22 @@ Use the **same address** for both `AUTHORIZE_RECORDER` and `RELEASE_CONDITION` s ```mermaid flowchart LR - EP[EscrowPeriod] -->|extends| ATR[AuthorizationTimeRecorder] + EP[EscrowPeriod] -->|extends| ATR[AuthorizationTimeRecorderHook] EP -->|implements| IC[ICondition] - ATR -->|implements| IR[IRecorder] + ATR -->|implements| IR[IHook] ``` -EscrowPeriod extends [AuthorizationTimeRecorder](/contracts/recorders/authorization-time) and adds `ICondition` implementation. You don't need to deploy AuthorizationTimeRecorder separately — use EscrowPeriod directly. +EscrowPeriod extends [AuthorizationTimeRecorderHook](/contracts/hooks/authorization-time) and adds `ICondition` implementation. You don't need to deploy AuthorizationTimeRecorderHook on its own, use EscrowPeriod directly. ## Logic ```solidity -// ICondition — returns true when escrow period has passed +// ICondition, returns true when escrow period has passed function check( AuthCaptureEscrow.PaymentInfo calldata paymentInfo, uint256, - address + address, + bytes calldata ) external view returns (bool allowed) { return !isDuringEscrowPeriod(paymentInfo); } @@ -50,7 +51,7 @@ function isDuringEscrowPeriod( ``` **Checks:** -1. Payment was authorized (has a recorded timestamp) +1. The payment has a recorded authorization timestamp 2. Current time >= authorization time + escrow period ## Deployment @@ -68,31 +69,25 @@ Then configure the operator: ```typescript const config = { - authorizeRecorder: escrowPeriodAddress, // Record auth time - releaseCondition: escrowPeriodAddress, // Check escrow passed + authorizePostActionHook: escrowPeriodAddress, // Record auth time + capturePreActionCondition: escrowPeriodAddress, // Check escrow passed // ... }; ``` ## Composition with Freeze -For freeze functionality, deploy a separate [Freeze](/contracts/conditions/freeze) condition and compose via [AndCondition](/contracts/conditions/combinators): - -```solidity -// Escrow period only: releaseCondition = escrowPeriod -// Freeze only: releaseCondition = freeze -// Both: releaseCondition = AndCondition([escrowPeriod, freeze]) -``` +Compose this condition with a separate [Freeze](/contracts/conditions/freeze) condition via [AndCondition](/contracts/conditions/combinators) to gate capture on both escrow elapsed **and** not frozen. See [Composition Patterns](/contracts/conditions/freeze#composition-patterns) for the wiring. ## Use Cases -- **Time-lock releases** — 7-day escrow for e-commerce -- **Delayed fund access** — Grace period before receiver can access funds -- **Buyer protection** — Give payers time to freeze or request refunds +- **Time-lock releases**: 7-day escrow for e-commerce +- **Delayed fund access**: Grace period before receiver can access funds +- **Buyer protection**: Give payers time to freeze or request refunds ## Gas -**Cost:** ~20k gas per `record()` call (one `SSTORE` for the timestamp). The `check()` call is a `view` function with one `SLOAD`. +**Cost:** ~20k gas per `run()` call (one `SSTORE` for the timestamp). The `check()` call is a `view` function with one `SLOAD`. ## Next Steps diff --git a/contracts/conditions/freeze.mdx b/contracts/conditions/freeze.mdx index 9297f49..b4b879a 100644 --- a/contracts/conditions/freeze.mdx +++ b/contracts/conditions/freeze.mdx @@ -1,12 +1,12 @@ --- title: "Freeze" -description: "Block payment release when frozen, with configurable freeze/unfreeze authorization" +description: "Block payment capture when frozen, with configurable freeze/unfreeze authorization" icon: "snowflake" --- ## Overview -Freeze is a standalone condition that blocks release when a payment is frozen. It manages freeze/unfreeze state with configurable authorization and optional duration-based auto-expiry. +Freeze is a standalone condition that blocks capture on frozen payments. It manages freeze and unfreeze state with configurable authorization and an optional duration-based auto expiry. **Type:** Per-deployment via [FreezeFactory](/contracts/factories) @@ -19,11 +19,12 @@ Freeze is a standalone condition that blocks release when a payment is frozen. I ## Logic ```solidity -// ICondition — returns false when frozen (blocks release) +// ICondition, returns false when frozen (blocks capture) function check( AuthCaptureEscrow.PaymentInfo calldata paymentInfo, uint256, - address + address, + bytes calldata ) external view returns (bool allowed) { return !isFrozen(paymentInfo); } @@ -46,12 +47,12 @@ const freeze = await freezeFactory.deploy( ## Composition Patterns ```solidity -// Escrow period only: releaseCondition = escrowPeriod -// Freeze only: releaseCondition = freeze -// Both: releaseCondition = AndCondition([escrowPeriod, freeze]) +// Escrow period only: capturePreActionCondition = escrowPeriod +// Freeze only: capturePreActionCondition = freeze +// Both: capturePreActionCondition = AndCondition([escrowPeriod, freeze]) ``` -Use [AndCondition](/contracts/conditions/combinators) to require both escrow period elapsed **and** not frozen before release. +Use [AndCondition](/contracts/conditions/combinators) to require both escrow period elapsed **and** not frozen before capture. ## Freeze Duration @@ -74,9 +75,9 @@ Freeze duration should balance payer protection with receiver UX. Too long and r ## Use Cases -- **Buyer protection** — Payer freezes suspicious payments during escrow -- **Dispute holds** — Arbiter freezes payments pending investigation -- **Compliance** — Compliance officer freezes flagged transactions +- **Buyer protection**: Payer freezes suspicious payments during escrow +- **Dispute holds**: Arbiter freezes payments pending investigation +- **Compliance**: Compliance officer freezes flagged transactions ## Gas @@ -86,7 +87,7 @@ Freeze duration should balance payer protection with receiver UX. Too long and r - Add time-based release restrictions. + Add time-based capture restrictions. Deploy Freeze via FreezeFactory. diff --git a/contracts/conditions/overview.mdx b/contracts/conditions/overview.mdx index bd8baed..a7acdf9 100644 --- a/contracts/conditions/overview.mdx +++ b/contracts/conditions/overview.mdx @@ -4,17 +4,19 @@ description: "Pluggable condition system for flexible payment authorization and icon: "filter" --- -## What Are Conditions? +## What conditions do -Conditions are pluggable contracts that control who can perform actions on a PaymentOperator. Each operator has **5 condition slots** — one per action: +Conditions are swappable contracts that control who can perform actions on a PaymentOperator. Each operator has **5 condition slots**, one per action: | Slot | Controls | |------|----------| -| `AUTHORIZE_CONDITION` | Who can authorize payments | -| `CHARGE_CONDITION` | Who can charge partial amounts | -| `RELEASE_CONDITION` | Who can release from escrow | -| `REFUND_IN_ESCROW_CONDITION` | Who can refund during escrow | -| `REFUND_POST_ESCROW_CONDITION` | Who can refund after release | +| `AUTHORIZE_PRE_ACTION_CONDITION` | Who can create payments | +| `CHARGE_PRE_ACTION_CONDITION` | Who can charge partial amounts | +| `CAPTURE_PRE_ACTION_CONDITION` | Who can capture funds from escrow | +| `VOID_PRE_ACTION_CONDITION` | Who can refund during escrow | +| `REFUND_PRE_ACTION_CONDITION` | Who can refund after capture | + +These are the condition half of the operator's 10 slots. For the full slot layout alongside the post-action hooks, see [PaymentOperator: 10-slot configuration](/contracts/payment-operator#10-slot-configuration). ## ICondition Interface @@ -23,23 +25,25 @@ interface ICondition { function check( AuthCaptureEscrow.PaymentInfo calldata paymentInfo, uint256 amount, - address caller + address caller, + bytes calldata data ) external view returns (bool allowed); } ``` **Parameters:** -- `paymentInfo` — The payment information struct -- `amount` — The amount involved in the action (0 for authorization-only checks like refund request status updates) -- `caller` — The address attempting the action +- `paymentInfo`, The payment information struct +- `amount`, The amount involved in the action (0 for authorization-only checks like refund request status updates) +- `caller`, The address attempting the action +- `data`, Arbitrary data forwarded from the caller (signatures, proofs, attestations) -**Return:** `true` if the caller is authorized, `false` otherwise. +**Return:** `true` if the caller can proceed, `false` otherwise. ## Default Behavior -**Condition slot = `address(0)`** — always returns `true` (allow). The action is unrestricted. +**Condition slot = `address(0)`**: always returns `true` (allow). The action has no restrictions. -This means you only need to set conditions for slots you want to restrict. Leave the rest as `address(0)`. +You only need to set conditions for slots you want to restrict. Leave the rest as `address(0)`. ## Singleton vs Per-Deployment @@ -52,24 +56,24 @@ This means you only need to set conditions for slots you want to restrict. Leave ## Security Rules -**Conditions MUST NOT revert.** Return `false` to deny — never `revert`. The operator converts `false` into a `ConditionNotMet` error. +**Conditions MUST NOT revert.** Return `false` to deny, never `revert`. The operator converts `false` into a `ConditionNotMet` error. - Conditions should be `view` or `pure` to prevent reentrancy attacks - Never make external state-changing calls inside a condition -- Test thoroughly — edge cases in authorization logic can lead to locked funds +- Cover edge cases in tests. Bugs in authorization logic can lock funds ## Configuration Patterns Conditions compose to create flexible authorization policies. Here are common patterns: -### Open Authorization, Restricted Release +### Open Authorization, Restricted Capture ```solidity config = { - authorizeCondition: ALWAYS_TRUE_CONDITION, // Anyone can authorize - authorizeRecorder: escrowRecorder, // Record time - releaseCondition: releaseCondition, // Restricted + authorizePreActionCondition: ALWAYS_TRUE_CONDITION, // Anyone can authorize + authorizePostActionHook: escrowHook, // Record time + capturePreActionCondition: capturePreActionCondition, // Restricted // ... }; ``` @@ -78,8 +82,8 @@ config = { ```solidity config = { - authorizeCondition: PAYER_CONDITION, // Only payer - releaseCondition: PAYER_CONDITION, // Only payer + authorizePreActionCondition: PAYER_CONDITION, // Only payer + capturePreActionCondition: PAYER_CONDITION, // Only payer // ... }; ``` @@ -88,11 +92,11 @@ config = { ```solidity config = { - authorizeCondition: ARBITER_CONDITION, // Only arbiter - chargeCondition: ARBITER_CONDITION, // Only arbiter - releaseCondition: ARBITER_CONDITION, // Only arbiter - refundInEscrowCondition: ARBITER_CONDITION, // Only arbiter - refundPostEscrowCondition: ARBITER_CONDITION, + authorizePreActionCondition: ARBITER_CONDITION, // Only arbiter + chargePreActionCondition: ARBITER_CONDITION, // Only arbiter + capturePreActionCondition: ARBITER_CONDITION, // Only arbiter + voidPreActionCondition: ARBITER_CONDITION, // Only arbiter + refundPreActionCondition: ARBITER_CONDITION, // ... }; ``` @@ -103,12 +107,12 @@ For complete configuration examples, see the [Examples](/contracts/examples) pag ### Singleton Reuse -Singleton conditions are deployed once and reused by all operators. Reference the existing addresses — don't deploy new instances: +All operators reuse the same singleton conditions. Reference the existing addresses, don't deploy new instances: ```typescript // Good: Reference the singleton address -const config1 = { authorizeCondition: PAYER_CONDITION }; -const config2 = { authorizeCondition: PAYER_CONDITION }; // Same address +const config1 = { authorizePreActionCondition: PAYER_CONDITION }; +const config2 = { authorizePreActionCondition: PAYER_CONDITION }; // Same address ``` ### Stateless Conditions @@ -117,14 +121,14 @@ Prefer stateless conditions when possible: ```solidity // Stateless: No storage reads (pure) -function check(PaymentInfo calldata payment, uint256, address caller) +function check(PaymentInfo calldata payment, uint256, address caller, bytes calldata) external pure returns (bool) { return caller == payment.receiver; // Pure computation } // Stateful: Storage reads cost gas (view) -function check(PaymentInfo calldata payment, uint256, address caller) +function check(PaymentInfo calldata payment, uint256, address caller, bytes calldata) external view returns (bool) { return allowList[caller]; // SLOAD costs gas @@ -134,7 +138,7 @@ function check(PaymentInfo calldata payment, uint256, address caller) ## Next Steps - + Learn about the state recording system. diff --git a/contracts/conditions/payer.mdx b/contracts/conditions/payer.mdx index 51ed10f..cbe29b7 100644 --- a/contracts/conditions/payer.mdx +++ b/contracts/conditions/payer.mdx @@ -8,39 +8,37 @@ icon: "user" PayerCondition is a singleton condition that restricts an action to the payment's payer address. -**Type:** Singleton (deployed once, reused by all operators) +**Type:** Singleton, CREATE2 (deployed once, reused by all operators) -**Address (Base Sepolia):** `0xBAF68176FF94CAdD403EF7FbB776bbca548AC09D` - -**Address (Base Mainnet):** `0xb33D6502EdBbC47201cd1E53C49d703EC0a660b8` +**Address (all supported chains):** `0x586486394C38A2a7d36B16a3FDaF366cd202d823` ## Logic ```solidity -function check(PaymentInfo calldata payment, uint256, address caller) +function check(PaymentInfo calldata payment, uint256, address caller, bytes calldata) external pure returns (bool) { return caller == payment.payer; } ``` -The condition compares `caller` against `payment.payer` — pure computation with no storage reads. +The condition compares `caller` against `payment.payer`, pure computation with no storage reads. ## When to Use | Slot | Use Case | |------|----------| -| `AUTHORIZE_CONDITION` | Let payer create payments (subscriptions, invoices) | -| `REFUND_IN_ESCROW_CONDITION` | Let payer request refunds during escrow | -| `REFUND_POST_ESCROW_CONDITION` | Let payer cancel streams | +| `AUTHORIZE_PRE_ACTION_CONDITION` | Let payer create payments (subscriptions, invoices) | +| `VOID_PRE_ACTION_CONDITION` | Let payer request refunds during escrow | +| `REFUND_PRE_ACTION_CONDITION` | Let payer cancel streams | -Typically paired with [ReceiverCondition](/contracts/conditions/receiver) for release, since payers shouldn't release their own funds in most configurations. +Typically paired with [ReceiverCondition](/contracts/conditions/receiver) for capture, since payers shouldn't capture their own funds in most configurations. ## Gas -**Cost:** Minimal — `pure` function with no storage reads. +**Cost:** Minimal, `pure` function with no storage reads. ## Next Steps diff --git a/contracts/conditions/receiver.mdx b/contracts/conditions/receiver.mdx index 3506bd7..364ae2f 100644 --- a/contracts/conditions/receiver.mdx +++ b/contracts/conditions/receiver.mdx @@ -8,39 +8,37 @@ icon: "store" ReceiverCondition is a singleton condition that restricts an action to the payment's receiver address. -**Type:** Singleton (deployed once, reused by all operators) +**Type:** Singleton, CREATE2 (deployed once, reused by all operators) -**Address (Base Sepolia):** `0x12EDefd4549c53497689067f165c0f101796Eb6D` - -**Address (Base Mainnet):** `0xed02d3E5167BCc9582D851885A89b050AB816a56` +**Address (all supported chains):** `0x321651df4593DA57C413579c5b611D1A90168a3A` ## Logic ```solidity -function check(PaymentInfo calldata payment, uint256, address caller) +function check(PaymentInfo calldata payment, uint256, address caller, bytes calldata) external pure returns (bool) { return caller == payment.receiver; } ``` -The condition compares `caller` against `payment.receiver` — pure computation with no storage reads. +The condition compares `caller` against `payment.receiver`, pure computation with no storage reads. ## When to Use | Slot | Use Case | |------|----------| -| `RELEASE_CONDITION` | Let receiver release funds after escrow | -| `CHARGE_CONDITION` | Let receiver charge partial amounts | -| `REFUND_IN_ESCROW_CONDITION` | Let receiver voluntarily refund | +| `CAPTURE_PRE_ACTION_CONDITION` | Let receiver capture funds after escrow | +| `CHARGE_PRE_ACTION_CONDITION` | Let receiver charge partial amounts | +| `VOID_PRE_ACTION_CONDITION` | Let receiver issue refunds at their discretion | -For release, ReceiverCondition is often composed with [EscrowPeriod](/contracts/conditions/escrow-period) via [AndCondition](/contracts/conditions/combinators) to ensure the escrow window has passed before the receiver can release. +For capture, ReceiverCondition is often composed with [EscrowPeriod](/contracts/conditions/escrow-period) via [AndCondition](/contracts/conditions/combinators) to ensure the escrow window has passed before the receiver can capture. ## Gas -**Cost:** Minimal — `pure` function with no storage reads. +**Cost:** Minimal, `pure` function with no storage reads. ## Next Steps @@ -49,6 +47,6 @@ For release, ReceiverCondition is often composed with [EscrowPeriod](/contracts/ Restrict actions to the payment payer. - Add time-based release restrictions. + Add time-based capture restrictions. diff --git a/contracts/conditions/static-address.mdx b/contracts/conditions/static-address.mdx index 32be52c..020ba6c 100644 --- a/contracts/conditions/static-address.mdx +++ b/contracts/conditions/static-address.mdx @@ -20,7 +20,7 @@ contract StaticAddressCondition is ICondition { DESIGNATED_ADDRESS = _designatedAddress; } - function check(PaymentInfo calldata payment, uint256, address caller) + function check(PaymentInfo calldata payment, uint256, address caller, bytes calldata) external view returns (bool) { return caller == DESIGNATED_ADDRESS; @@ -49,13 +49,13 @@ const arbiterCondition = await staticAddressConditionFactory.write.deploy([arbit // For subscription service provider const providerCondition = await staticAddressConditionFactory.write.deploy([serviceProviderAddress]); -// For DAO governance — same address = same deterministic address (idempotent) +// For DAO governance, same address produces the same deterministic deployment (idempotent) const daoCondition = await staticAddressConditionFactory.write.deploy([daoMultisigAddress]); ``` ## Gas -**Cost:** Minimal — `view` function with a single `immutable` read (compiled as a constant in bytecode, not a storage read). +**Cost:** Minimal. `view` function with a single `immutable` read (compiled as a constant in bytecode, not a storage read). ## Next Steps diff --git a/contracts/examples.mdx b/contracts/examples.mdx index 136798a..195850b 100644 --- a/contracts/examples.mdx +++ b/contracts/examples.mdx @@ -5,12 +5,12 @@ icon: "code" --- -The configuration examples below use simplified pseudo-code (e.g., `new StaticAddressCondition(...)`, `new AndCondition(...)`) to illustrate the logical composition of conditions. In practice, deploy conditions via their respective [factory contracts](/contracts/factories) using viem. See the [SDK quickstart](/sdk/client/quickstart) for executable code. +The configuration examples below use simplified pseudo-code (for example, `new StaticAddressCondition(args)` or `new AndCondition([list])`) to illustrate how conditions compose. In practice, deploy conditions via their respective [factory contracts](/contracts/factories) using viem. See the [Deploy an operator guide](/sdk/deploy-operator) for executable code. ## Example 1: Standard E-Commerce with 7-Day Escrow -**Use Case:** Online marketplace with buyer protection. 7-day escrow period, payer can freeze for 3 days, receiver or arbiter can release after escrow. +**Use Case:** Online marketplace with buyer protection. 7-day escrow period, payer can freeze for 3 days, receiver or arbiter can capture after escrow. ### Complete Configuration @@ -22,34 +22,30 @@ The configuration examples below use simplified pseudo-code (e.g., `new StaticAd ``` - + ```typescript - // Payer can freeze, arbiter can unfreeze (or wait for expiry) - const freezePolicy = await freezePolicyFactory.deploy( - PAYER_CONDITION, // Only payer can freeze - arbiterCondition.address, // Only arbiter can unfreeze - 3 * 24 * 60 * 60 // 3 days (auto-expires) - ); - ``` - - - - ```typescript - // 7-day escrow period (combined recorder + condition) + // 7-day escrow period (combined hook + condition) const escrowPeriod = await escrowPeriodFactory.deploy( 7 * 24 * 60 * 60, // 7 days zeroHash // bytes32(0) = operator-only ); + ``` + - // Freeze condition linked to escrow period + + ```typescript + // Payer can freeze, arbiter can unfreeze (or wait for 3-day expiry), + // linked to the 7-day escrow period. const freeze = await freezeFactory.deploy( - freezePolicy, - escrowPeriod // Link to escrow period for time constraint + PAYER_CONDITION, // freezeCondition: only payer can freeze + arbiterCondition.address, // unfreezeCondition: only arbiter can unfreeze + 3 * 24 * 60 * 60, // freezeDuration: 3 days (auto-expires; 0 = permanent) + escrowPeriod // escrowPeriodContract: restricts freeze() to the escrow window (address(0) = unconstrained) ); ``` - + ```typescript // (Receiver OR Arbiter) AND (EscrowPassed AND NotFrozen) const receiverOrArbiter = await new OrCondition([ @@ -63,7 +59,7 @@ The configuration examples below use simplified pseudo-code (e.g., `new StaticAd freeze // Not frozen ]); - const releaseCondition = await new AndCondition([ + const capturePreActionCondition = await new AndCondition([ receiverOrArbiter, escrowAndFreeze ]); @@ -73,18 +69,18 @@ The configuration examples below use simplified pseudo-code (e.g., `new StaticAd ```typescript const config = { - feeRecipient: arbiterAddress, // Arbiter earns fees for dispute resolution + feeReceiver: arbiterAddress, // Arbiter earns fees for dispute resolution feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: ALWAYS_TRUE_CONDITION, - authorizeRecorder: escrowPeriod, // Same address for recording auth time - chargeCondition: RECEIVER_CONDITION, - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: releaseCondition, - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: arbiterCondition.address, - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: arbiterCondition.address, - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + authorizePreActionCondition: ALWAYS_TRUE_CONDITION, + authorizePostActionHook: escrowPeriod, // Same address for recording auth time + chargePreActionCondition: RECEIVER_CONDITION, + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: capturePreActionCondition, + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: arbiterCondition.address, + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: arbiterCondition.address, + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -115,9 +111,9 @@ sequenceDiagram Note over Buyer,Seller: Day 8 - Freeze Expires Note over Operator: Freeze automatically expires - Note over Buyer,Seller: Day 9 - Release - Seller->>Operator: release(paymentId) - Operator->>Escrow: release funds + Note over Buyer,Seller: Day 9 - Capture + Seller->>Operator: capture(paymentInfo, amount) + Operator->>Escrow: capture funds Escrow->>Seller: 99.95 USDC Escrow->>Operator: 0.05 USDC ``` @@ -126,7 +122,7 @@ sequenceDiagram ## Example 2: Instant Payment (Using Charge) -**Use Case:** Digital goods or services where immediate payment to seller is expected. +**Use Case:** Digital goods or services where the seller expects immediate payment. ### Configuration @@ -135,18 +131,18 @@ sequenceDiagram const arbiterCondition = await new StaticAddressCondition(arbiterAddress); const config = { - feeRecipient: arbiterAddress, // Arbiter earns fees + feeReceiver: arbiterAddress, // Arbiter earns fees feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: '0x0000000000000000000000000000000000000000', // Not used (charge handles auth) - authorizeRecorder: '0x0000000000000000000000000000000000000000', - chargeCondition: RECEIVER_CONDITION, // Only receiver can charge - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: RECEIVER_CONDITION, // Fallback to release remaining - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: arbiterCondition.address, - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: arbiterCondition.address, - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + authorizePreActionCondition: '0x0000000000000000000000000000000000000000', // Not used (charge handles auth) + authorizePostActionHook: '0x0000000000000000000000000000000000000000', + chargePreActionCondition: RECEIVER_CONDITION, // Only receiver can charge + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: RECEIVER_CONDITION, // Fallback to capture remaining + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: arbiterCondition.address, + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: arbiterCondition.address, + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -160,11 +156,11 @@ Buyer approves tokens → Seller calls charge() → Funds transferred in one tx ``` **Trade-offs:** -- ✅ Single transaction - no separate authorize step -- ✅ Instant delivery for digital goods -- ✅ Better UX - seller gets paid immediately -- ❌ No buyer protection escrow period -- ❌ Payment immediately moves to post-escrow state +- Single transaction, no separate `authorize` step +- Instant delivery for digital goods +- Better UX: seller gets paid immediately +- No buyer protection escrow period +- Payment moves to the captured state right away --- @@ -178,41 +174,40 @@ Buyer approves tokens → Seller calls charge() → Funds transferred in one tx // Deploy arbiter condition const arbiterCondition = await new StaticAddressCondition(arbiterAddress); -// Receiver can freeze for 5 days, arbiter can unfreeze -const freezePolicy = await freezePolicyFactory.deploy( - RECEIVER_CONDITION, // Receiver can freeze (product defect) - arbiterCondition.address, // Arbiter can unfreeze (dispute resolution) - 5 * 24 * 60 * 60 // 5 days (auto-expires) -); - // 14-day escrow (shipping + inspection) const escrowPeriod = await escrowPeriodFactory.deploy( 14 * 24 * 60 * 60, // 14 days zeroHash // bytes32(0) = operator-only ); -// Deploy Freeze linked to escrow period -const freeze = await freezeFactory.deploy(freezePolicy, escrowPeriod); +// Receiver freeze (product defect), arbiter unfreeze (dispute resolution), +// linked to the 14-day escrow period. +const freeze = await freezeFactory.deploy( + RECEIVER_CONDITION, // freezeCondition + arbiterCondition.address, // unfreezeCondition + 5 * 24 * 60 * 60, // freezeDuration: 5 days + escrowPeriod // escrowPeriodContract +); -// Receiver OR Arbiter can release (after escrow + not frozen) -const releaseCondition = await new AndCondition([ +// Receiver OR Arbiter can capture (after escrow + not frozen) +const capturePreActionCondition = await new AndCondition([ await new OrCondition([RECEIVER_CONDITION, arbiterCondition.address]), await new AndCondition([escrowPeriod, freeze]) ]); const config = { - feeRecipient: arbiterAddress, // Arbiter earns fees + feeReceiver: arbiterAddress, // Arbiter earns fees feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: ALWAYS_TRUE_CONDITION, - authorizeRecorder: escrowPeriod, // Same address for recording auth time - chargeCondition: '0x0000000000000000000000000000000000000000', - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: releaseCondition, - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: arbiterCondition.address, - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: arbiterCondition.address, - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + authorizePreActionCondition: ALWAYS_TRUE_CONDITION, + authorizePostActionHook: escrowPeriod, // Same address for recording auth time + chargePreActionCondition: '0x0000000000000000000000000000000000000000', + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: capturePreActionCondition, + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: arbiterCondition.address, + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: arbiterCondition.address, + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -246,14 +241,14 @@ sequenceDiagram Arbiter->>Arbiter: Investigates Note over Buyer,Escrow: Day 16 - Resolution - Arbiter->>Operator: refundInEscrow(paymentId) - Operator->>Escrow: void(paymentId) + Arbiter->>Operator: void(paymentInfo, data) + Operator->>Escrow: void(paymentInfo) Escrow->>Buyer: Full refund ``` --- -## Example 4: Service-Based Payments (Milestone Release) +## Example 4: Service-Based Payments (Milestone Capture) **Use Case:** Freelance work with milestone-based releases. Receiver can trigger partial releases. @@ -269,25 +264,25 @@ const escrowPeriod = await escrowPeriodFactory.deploy( zeroHash // bytes32(0) = operator-only ); -// Receiver can release after short escrow -const releaseCondition = await new AndCondition([ +// Receiver can capture after short escrow +const capturePreActionCondition = await new AndCondition([ RECEIVER_CONDITION, // Only receiver escrowPeriod // Escrow period passed ]); const config = { - feeRecipient: arbiterAddress, // Arbiter earns fees + feeReceiver: arbiterAddress, // Arbiter earns fees feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: PAYER_CONDITION, // Only payer authorizes - authorizeRecorder: escrowPeriod, // Record auth time - chargeCondition: RECEIVER_CONDITION, // Receiver can charge partials - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: releaseCondition, - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: arbiterCondition.address, - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: arbiterCondition.address, - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + authorizePreActionCondition: PAYER_CONDITION, // Only payer authorizes + authorizePostActionHook: escrowPeriod, // Record auth time + chargePreActionCondition: RECEIVER_CONDITION, // Receiver can charge partials + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: capturePreActionCondition, + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: arbiterCondition.address, + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: arbiterCondition.address, + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -319,8 +314,8 @@ sequenceDiagram Note over Escrow: 300 USDC remaining Note over Client,Escrow: Day 15 - Final Milestone - Freelancer->>Operator: release(paymentId) - Operator->>Escrow: release remaining + Freelancer->>Operator: capture(paymentInfo, amount) + Operator->>Escrow: capture remaining Escrow->>Freelancer: 300 USDC Note over Escrow: Payment complete ``` @@ -338,18 +333,18 @@ sequenceDiagram const arbiterCondition = await new StaticAddressCondition(arbiterAddress); const config = { - feeRecipient: arbiterAddress, // Arbiter earns all fees + feeReceiver: arbiterAddress, // Arbiter earns all fees feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: arbiterCondition.address, // Arbiter creates payments - authorizeRecorder: '0x0000000000000000000000000000000000000000', - chargeCondition: arbiterCondition.address, // Arbiter charges - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: arbiterCondition.address, // Arbiter releases - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: arbiterCondition.address, // Arbiter refunds - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: arbiterCondition.address, - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + authorizePreActionCondition: arbiterCondition.address, // Arbiter creates payments + authorizePostActionHook: '0x0000000000000000000000000000000000000000', + chargePreActionCondition: arbiterCondition.address, // Arbiter charges + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: arbiterCondition.address, // Arbiter releases + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: arbiterCondition.address, // Arbiter refunds + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: arbiterCondition.address, + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -368,7 +363,7 @@ Factory-level fees still apply (MAX_TOTAL_FEE_RATE and PROTOCOL_FEE_PERCENTAGE). ## Example 6: Receiver-Initiated Refunds -**Use Case:** Receiver can voluntarily offer refunds (e.g., return policy). +**Use Case:** Receiver can offer refunds (for example, a return policy). ### Configuration @@ -382,8 +377,8 @@ const escrowPeriod = await escrowPeriodFactory.deploy( zeroHash // bytes32(0) = operator-only ); -// Receiver OR Arbiter can release -const releaseCondition = await new AndCondition([ +// Receiver OR Arbiter can capture +const capturePreActionCondition = await new AndCondition([ await new OrCondition([RECEIVER_CONDITION, arbiterCondition.address]), escrowPeriod // Escrow period passed ]); @@ -395,18 +390,18 @@ const refundCondition = await new OrCondition([ ]); const config = { - feeRecipient: arbiterAddress, // Arbiter earns fees + feeReceiver: arbiterAddress, // Arbiter earns fees feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: ALWAYS_TRUE_CONDITION, - authorizeRecorder: escrowPeriod, // Record auth time - chargeCondition: '0x0000000000000000000000000000000000000000', - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: releaseCondition, - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: refundCondition, // Receiver OR Arbiter - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: RECEIVER_CONDITION, // Only receiver post-escrow - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + authorizePreActionCondition: ALWAYS_TRUE_CONDITION, + authorizePostActionHook: escrowPeriod, // Record auth time + chargePreActionCondition: '0x0000000000000000000000000000000000000000', + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: capturePreActionCondition, + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: refundCondition, // Receiver OR Arbiter + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: RECEIVER_CONDITION, // Only receiver after capture + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -432,8 +427,8 @@ sequenceDiagram Note over Buyer,Escrow: Day 5 - Return Request Buyer->>Seller: Requests return Note over Seller: Approves return - Seller->>Operator: refundInEscrow(paymentId) - Operator->>Escrow: void(paymentId) + Seller->>Operator: void(paymentInfo, data) + Operator->>Escrow: void(paymentInfo) Escrow->>Buyer: Full refund Note over Buyer,Escrow: Buyer returns product ``` @@ -452,18 +447,18 @@ const providerCondition = await new StaticAddressCondition(serviceProviderAddres // Receiver can charge immediately (no escrow) const config = { - feeRecipient: serviceProviderAddress, // Service provider earns fees + feeReceiver: serviceProviderAddress, // Service provider earns fees feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: PAYER_CONDITION, // Payer sets up subscription - authorizeRecorder: '0x0000000000000000000000000000000000000000', - chargeCondition: providerCondition.address, // Provider charges monthly - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: providerCondition.address,// Provider releases - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: '0x0000000000000000000000000000000000000000', // No refunds (no arbiter) - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: '0x0000000000000000000000000000000000000000', - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + authorizePreActionCondition: PAYER_CONDITION, // Payer sets up subscription + authorizePostActionHook: '0x0000000000000000000000000000000000000000', + chargePreActionCondition: providerCondition.address, // Provider charges monthly + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: providerCondition.address,// Provider releases + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: '0x0000000000000000000000000000000000000000', // Open-access: anyone can call void() (`address(0)` = default-allow) + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: '0x0000000000000000000000000000000000000000', + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -501,8 +496,8 @@ sequenceDiagram Escrow->>Provider: 100 USDC Note over User,Escrow: Month 12 - Subscription Ends - Provider->>Operator: release(paymentId) - Operator->>Escrow: release remaining + Provider->>Operator: capture(paymentInfo, amount) + Operator->>Escrow: capture remaining Escrow->>Provider: Remaining funds Note over User,Escrow: Alt: If cancelled early @@ -515,7 +510,7 @@ sequenceDiagram ## Example 8: DAO Treasury Controlled -**Use Case:** DAO manages grant releases via multisig governance. No arbiter needed - DAO is the authority. +**Use Case:** DAO manages grant releases via multisig governance. No arbiter needed: the DAO is the authority. ### Configuration @@ -524,18 +519,18 @@ sequenceDiagram const daoCondition = await new StaticAddressCondition(DAO_MULTISIG_ADDRESS); const config = { - feeRecipient: DAO_MULTISIG_ADDRESS, // DAO treasury earns fees + feeReceiver: DAO_MULTISIG_ADDRESS, // DAO treasury earns fees feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: daoCondition.address, // DAO authorizes grants - authorizeRecorder: '0x0000000000000000000000000000000000000000', - chargeCondition: '0x0000000000000000000000000000000000000000', - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: daoCondition.address, // DAO must approve releases - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: daoCondition.address, // DAO can refund if needed - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: '0x0000000000000000000000000000000000000000', // No post-escrow refunds - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + authorizePreActionCondition: daoCondition.address, // DAO authorizes grants + authorizePostActionHook: '0x0000000000000000000000000000000000000000', + chargePreActionCondition: '0x0000000000000000000000000000000000000000', + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: daoCondition.address, // DAO must approve releases + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: daoCondition.address, // DAO can refund if needed + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: '0x0000000000000000000000000000000000000000', // Open-access: anyone can call refund() (`address(0)` = default-allow) + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -563,9 +558,9 @@ sequenceDiagram Note over DAO: Review and vote DAO->>DAO: Multisig approval - Note over Grantee,Escrow: Release - DAO->>Operator: release(paymentId) - Operator->>Escrow: release funds + Note over Grantee,Escrow: Capture + DAO->>Operator: capture(paymentInfo, amount) + Operator->>Escrow: capture funds Escrow->>Grantee: 50000 USDC Note over Grantee: Grant complete ``` @@ -588,26 +583,26 @@ sequenceDiagram // Deploy condition for platform address const platformCondition = await new StaticAddressCondition(PLATFORM_ADDRESS); -// Time-proportional charge condition (custom — not provided out of the box, +// Time-proportional charge condition (custom, not provided shipped with the SDK, // this is a hypothetical custom condition you would implement yourself) const timeProportionalCondition = await new TimeProportionalCondition(); const config = { - feeRecipient: PLATFORM_ADDRESS, // Platform earns fees + feeReceiver: PLATFORM_ADDRESS, // Platform earns fees feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: PAYER_CONDITION, // Payer authorizes stream - authorizeRecorder: '0x0000000000000000000000000000000000000000', - chargeCondition: new AndCondition([ + authorizePreActionCondition: PAYER_CONDITION, // Payer authorizes stream + authorizePostActionHook: '0x0000000000000000000000000000000000000000', + chargePreActionCondition: new AndCondition([ RECEIVER_CONDITION, timeProportionalCondition // Can only charge proportional to time ]), - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: RECEIVER_CONDITION, // Receiver releases remaining - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: PAYER_CONDITION, // Payer can cancel stream - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: '0x0000000000000000000000000000000000000000', // No refunds after charged - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: RECEIVER_CONDITION, // Receiver releases remaining + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: PAYER_CONDITION, // Payer can cancel stream + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: '0x0000000000000000000000000000000000000000', // Open-access: anyone can call refund() (`address(0)` = default-allow) + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -661,22 +656,22 @@ sequenceDiagram ### Configuration ```typescript -// No arbiter - receiver controls release, platform earns fees +// No arbiter - receiver controls capture, platform earns fees const platformCondition = await new StaticAddressCondition(PLATFORM_ADDRESS); const config = { - feeRecipient: PLATFORM_ADDRESS, // Platform earns fees + feeReceiver: PLATFORM_ADDRESS, // Platform earns fees feeCalculator: feeCalculatorAddress, // Operator fee calculator - authorizeCondition: PAYER_CONDITION, // Payer creates invoice payment - authorizeRecorder: '0x0000000000000000000000000000000000000000', - chargeCondition: RECEIVER_CONDITION, // Receiver charges on delivery - chargeRecorder: '0x0000000000000000000000000000000000000000', - releaseCondition: RECEIVER_CONDITION, // Receiver releases on payment terms - releaseRecorder: '0x0000000000000000000000000000000000000000', - refundInEscrowCondition: RECEIVER_CONDITION,// Receiver can refund (invoice error) - refundInEscrowRecorder: '0x0000000000000000000000000000000000000000', - refundPostEscrowCondition: '0x0000000000000000000000000000000000000000', // No post-delivery refunds - refundPostEscrowRecorder: '0x0000000000000000000000000000000000000000' + authorizePreActionCondition: PAYER_CONDITION, // Payer creates invoice payment + authorizePostActionHook: '0x0000000000000000000000000000000000000000', + chargePreActionCondition: RECEIVER_CONDITION, // Receiver charges on delivery + chargePostActionHook: '0x0000000000000000000000000000000000000000', + capturePreActionCondition: RECEIVER_CONDITION, // Receiver releases on payment terms + capturePostActionHook: '0x0000000000000000000000000000000000000000', + voidPreActionCondition: RECEIVER_CONDITION,// Receiver can refund (invoice error) + voidPostActionHook: '0x0000000000000000000000000000000000000000', + refundPreActionCondition: '0x0000000000000000000000000000000000000000', // Open-access: anyone can call refund() (`address(0)` = default-allow) + refundPostActionHook: '0x0000000000000000000000000000000000000000' }; const operator = await operatorFactory.deployOperator(config); @@ -701,8 +696,8 @@ sequenceDiagram Note over CompanyA: Receives goods Note over CompanyA,Platform: Day 30 - Settlement - CompanyB->>Operator: release(paymentId) - Operator->>Escrow: release funds + CompanyB->>Operator: capture(paymentInfo, amount) + Operator->>Escrow: capture funds Escrow->>CompanyB: 99900 USDC Escrow->>Platform: 100 USDC Note over CompanyB: Invoice settled @@ -739,13 +734,13 @@ Before deploying, verify: - [ ] Freeze policy suits your use case - [ ] Escrow period is appropriate for delivery time -- [ ] Release condition prevents premature releases +- [ ] Capture condition prevents premature captures - [ ] Refund conditions allow arbiter intervention - [ ] Fee rates are competitive and sustainable - [ ] Protocol fee percentage is reasonable - [ ] Tested on testnet with same configuration -- [ ] Arbiter address is controlled (preferably multisig) -- [ ] Condition contracts are verified on block explorer +- [ ] A trusted party controls the arbiter address (preferably multisig) +- [ ] Verify condition contracts on the block explorer --- @@ -754,7 +749,7 @@ Before deploying, verify: ```typescript import { createTestClient, http, parseUnits, keccak256, toHex } from 'viem'; import { baseSepolia } from 'viem/chains'; -import { PaymentOperatorABI } from '@x402r/core/abis'; +import { paymentOperatorAbi } from '@x402r/core'; // Deploy on Base Sepolia first const operatorAddress = await factory.write.deployOperator([config]); @@ -768,18 +763,18 @@ const testClient = createTestClient({ // 1. Authorize await walletClient.writeContract({ address: operatorAddress, - abi: PaymentOperatorABI, + abi: paymentOperatorAbi, functionName: 'authorize', args: [paymentInfo, parseUnits('100', 6), tokenCollectorAddress, collectorData], }); -// 2. Try to release immediately (should fail if escrow configured) +// 2. Try to capture immediately (should fail if escrow configured) // Expect revert with ConditionNotMet try { await walletClient.writeContract({ address: operatorAddress, - abi: PaymentOperatorABI, - functionName: 'release', + abi: paymentOperatorAbi, + functionName: 'capture', args: [paymentInfo, parseUnits('100', 6)], }); } catch (e) { @@ -790,11 +785,11 @@ try { await testClient.increaseTime({ seconds: 7 * 24 * 60 * 60 }); await testClient.mine({ blocks: 1 }); -// 4. Release after escrow +// 4. Capture after escrow await walletClient.writeContract({ address: operatorAddress, - abi: PaymentOperatorABI, - functionName: 'release', + abi: paymentOperatorAbi, + functionName: 'capture', args: [paymentInfo, parseUnits('100', 6)], }); @@ -822,12 +817,12 @@ Research competitors' escrow periods and fees: Test your configuration handles: -- Immediate release attempts +- Immediate capture attempts - Freeze during escrow - Freeze expiry - Refunds in both states - Fee distribution -- Multiple partial charges +- Partial charges followed by capture diff --git a/contracts/factories.mdx b/contracts/factories.mdx index dd8e5ff..bc5ea8b 100644 --- a/contracts/factories.mdx +++ b/contracts/factories.mdx @@ -8,7 +8,7 @@ icon: "industry" x402r uses the factory pattern with CREATE2 for gas-efficient, deterministic contract deployments. Factories enable on-demand instance creation with predictable addresses. -## Why Factories? +## Why factories @@ -19,17 +19,17 @@ x402r uses the factory pattern with CREATE2 for gas-efficient, deterministic con - Multiple instances can share immutable configuration: + Many instances can share immutable configuration: - Lower deployment costs - Consistent behavior across instances - Centralized ownership control - Calling factory with same parameters returns existing contract: - - Safe to call multiple times + Calling a factory with the same parameters returns the existing contract: + - Safe to call again - No duplicate deployments - - Automatic deduplication + - Built-in deduplication @@ -46,26 +46,26 @@ Deploys PaymentOperator instances with deterministic addresses. ### Contract Address -All factories use unified CREATE3 addresses (same on every chain). +All factories use universal CREATE2 addresses (same on every chain). -**PaymentOperatorFactory:** `0x4D9BC2Ba2D0d9AFb6B63E3afBbfC95143E6E8Da9` +**PaymentOperatorFactory:** `0xa0d4734842df1690a5B33Cb21828c946e39D55a2` ### Configuration Structure ```solidity struct OperatorConfig { - address feeRecipient; // Who receives operator fees + address feeReceiver; // Who receives operator fees address feeCalculator; // Operator fee calculator (IFeeCalculator) - address authorizeCondition; - address authorizeRecorder; - address chargeCondition; - address chargeRecorder; - address releaseCondition; - address releaseRecorder; - address refundInEscrowCondition; - address refundInEscrowRecorder; - address refundPostEscrowCondition; - address refundPostEscrowRecorder; + address authorizePreActionCondition; + address authorizePostActionHook; + address chargePreActionCondition; + address chargePostActionHook; + address capturePreActionCondition; + address capturePostActionHook; + address voidPreActionCondition; + address voidPostActionHook; + address refundPreActionCondition; + address refundPostActionHook; } ``` @@ -78,10 +78,10 @@ function deployOperator( ``` **Parameters (in config):** -- `feeRecipient` - Who receives operator fees (arbiter, service provider, treasury, etc.) -- `authorizeCondition` through `refundPostEscrowRecorder` - 10-slot configuration +- `feeReceiver` - Who receives operator fees (arbiter, service provider, or treasury) +- `authorizePreActionCondition` through `refundPostActionHook` - 10-slot configuration -**Note:** `maxFeeBps` and `protocolFeePct` are set at factory level (shared across all operators) +**Note:** the factory sets `maxFeeBps` and `protocolFeePct` (shared across all operators) **Returns:** Address of deployed operator (or existing if already deployed) @@ -98,8 +98,8 @@ function computeAddress( **Usage:** ```typescript const config = { - feeRecipient: arbiterAddress, - authorizeCondition: ALWAYS_TRUE_CONDITION, + feeReceiver: arbiterAddress, + authorizePreActionCondition: ALWAYS_TRUE_CONDITION, // ... rest of config }; @@ -121,9 +121,9 @@ assert(deployedAddress === predictedAddress); import { createWalletClient, http, getContract, zeroAddress } from 'viem'; import { base } from 'viem/chains'; import { privateKeyToAccount } from 'viem/accounts'; -import { PaymentOperatorABI } from '@x402r/core/abis'; +import { paymentOperatorAbi } from '@x402r/core'; -const FACTORY_ADDRESS = '0x...'; // Replace with actual factory address +const FACTORY_ADDRESS = '0xa0d4734842df1690a5B33Cb21828c946e39D55a2'; const account = privateKeyToAccount('0x...'); const walletClient = createWalletClient({ @@ -134,7 +134,7 @@ const walletClient = createWalletClient({ const factory = getContract({ address: FACTORY_ADDRESS, - abi: PaymentOperatorABI, + abi: paymentOperatorAbi, client: walletClient }); @@ -142,26 +142,26 @@ const factory = getContract({ const arbiterConditionHash = await staticAddressConditionFactory.write.deploy([arbiterAddress]); const arbiterConditionAddress = /* get from receipt */; -// Deploy release condition: arbiter AND escrow period passed -const releaseConditionHash = await andConditionFactory.write.deploy([ +// Deploy capture condition: arbiter AND escrow period passed +const capturePreActionConditionHash = await andConditionFactory.write.deploy([ [arbiterConditionAddress, escrowPeriodAddress] ]); -const releaseConditionAddress = /* get from receipt */; +const captureConditionAddress = /* get from receipt */; // Define configuration const config = { - feeRecipient: arbiterAddress, // Arbiter earns fees + feeReceiver: arbiterAddress, // Arbiter earns fees feeCalculator: feeCalculatorAddress, - authorizeCondition: ALWAYS_TRUE_CONDITION, - authorizeRecorder: escrowPeriodAddress, - chargeCondition: zeroAddress, // Default allow - chargeRecorder: zeroAddress, // No recording - releaseCondition: releaseConditionAddress, - releaseRecorder: zeroAddress, - refundInEscrowCondition: arbiterConditionAddress, - refundInEscrowRecorder: zeroAddress, - refundPostEscrowCondition: arbiterConditionAddress, - refundPostEscrowRecorder: zeroAddress + authorizePreActionCondition: ALWAYS_TRUE_CONDITION, + authorizePostActionHook: escrowPeriodAddress, + chargePreActionCondition: zeroAddress, // Default allow + chargePostActionHook: zeroAddress, // No recording + capturePreActionCondition: captureConditionAddress, + capturePostActionHook: zeroAddress, + voidPreActionCondition: arbiterConditionAddress, + voidPostActionHook: zeroAddress, + refundPreActionCondition: arbiterConditionAddress, + refundPostActionHook: zeroAddress }; // Deploy operator @@ -179,17 +179,17 @@ console.log("Deployed marketplace operator at:", operatorAddress); const providerCondition = await new StaticAddressCondition(serviceProviderAddress); const config = { - feeRecipient: serviceProviderAddress, // Provider earns fees - authorizeCondition: PAYER_CONDITION, - authorizeRecorder: zeroAddress, - chargeCondition: providerCondition.address, - chargeRecorder: zeroAddress, - releaseCondition: providerCondition.address, - releaseRecorder: zeroAddress, - refundInEscrowCondition: zeroAddress, // No refunds - refundInEscrowRecorder: zeroAddress, - refundPostEscrowCondition: zeroAddress, - refundPostEscrowRecorder: zeroAddress + feeReceiver: serviceProviderAddress, // Provider earns fees + authorizePreActionCondition: PAYER_CONDITION, + authorizePostActionHook: zeroAddress, + chargePreActionCondition: providerCondition.address, + chargePostActionHook: zeroAddress, + capturePreActionCondition: providerCondition.address, + capturePostActionHook: zeroAddress, + voidPreActionCondition: zeroAddress, // No refunds + voidPostActionHook: zeroAddress, + refundPreActionCondition: zeroAddress, + refundPostActionHook: zeroAddress }; const hash = await factory.write.deployOperator([config]); @@ -204,11 +204,11 @@ If you call `deployOperator()` with the same configuration twice, the factory re ## Escrow Period Factory -Deploys `EscrowPeriod` contracts - combined recorder and condition for time-based release logic. +Deploys `EscrowPeriod` contracts - combined hook and condition for time-based capture logic. ### Contract Address -**EscrowPeriodFactory:** `0x15DB06aADEB3a39D47756Bf864a173cc48bafe24` +**EscrowPeriodFactory:** `0xe72D2014ebC48F1d92521e8629574918E8030548` ### Deployment Method @@ -220,7 +220,7 @@ function deploy( ``` **Parameters:** -- `escrowPeriod` - Duration in seconds (e.g., `7 * 24 * 60 * 60` for 7 days) +- `escrowPeriod` - Duration in seconds (for example, `7 * 24 * 60 * 60` for 7 days) - `authorizedCodehash` - Runtime codehash of authorized caller (`bytes32(0)` = operator-only) **Returns:** Address of deployed EscrowPeriod contract @@ -228,22 +228,22 @@ function deploy( ### How It Works The factory deploys a single **EscrowPeriod** contract that: -- Extends `AuthorizationTimeRecorder` (implements `IRecorder`) +- Extends `AuthorizationTimeRecorderHook` (implements `IHook`) - Implements `ICondition` -- Records authorization timestamp when used as recorder +- Records authorization timestamp when used as hook - Checks if escrow period has passed when used as condition **Architecture:** ```mermaid flowchart LR - EP[EscrowPeriod] -->|extends| ATR[AuthorizationTimeRecorder] + EP[EscrowPeriod] -->|extends| ATR[AuthorizationTimeRecorderHook] EP -->|implements| IC[ICondition] - ATR -->|implements| IR[IRecorder] + ATR -->|implements| IR[IHook] ``` -Use the SAME `EscrowPeriod` address for both `AUTHORIZE_RECORDER` and `RELEASE_CONDITION` slots on the operator. For freeze functionality, deploy a separate `Freeze` condition and compose via `AndCondition([escrowPeriod, freeze])`. +Use the SAME `EscrowPeriod` address for both `AUTHORIZE_POST_ACTION_HOOK` and `CAPTURE_PRE_ACTION_CONDITION` slots on the operator. For freeze functionality, deploy a separate `Freeze` condition and compose via `AndCondition([escrowPeriod, freeze])`. ### Example Deployment @@ -268,13 +268,13 @@ const escrowPeriodAddress = receipt.logs[0].address; console.log("EscrowPeriod:", escrowPeriodAddress); -// Use SAME address for both recorder and condition +// Use SAME address for both hook and condition const config = { - authorizeCondition: ALWAYS_TRUE_CONDITION, - authorizeRecorder: escrowPeriodAddress, // Record auth time + authorizePreActionCondition: ALWAYS_TRUE_CONDITION, + authorizePostActionHook: escrowPeriodAddress, // Record auth time // ... - releaseCondition: escrowPeriodAddress, // Check escrow passed - releaseRecorder: zeroAddress, // No additional recording needed + capturePreActionCondition: escrowPeriodAddress, // Check escrow passed + capturePostActionHook: zeroAddress, // No additional recording needed // ... }; ``` @@ -293,11 +293,11 @@ const config = { ## Freeze Factory -Deploys `Freeze` condition contracts that block release when a payment is frozen. +Deploys `Freeze` condition contracts that block capture when the payer freezes a payment. ### Contract Address -**FreezeFactory:** `0xdf129EFFE040c3403aca597c0F0bb704859a78Fd` +**FreezeFactory:** `0xeC092cf1215DB44af0Abe87c1157E304FEa5d0Eb` ### Deployment Method @@ -311,8 +311,8 @@ function deploy( ``` **Parameters:** -- `freezeCondition` - ICondition that authorizes freeze calls (e.g., PayerCondition) -- `unfreezeCondition` - ICondition that authorizes unfreeze calls (e.g., PayerCondition, ArbiterCondition) +- `freezeCondition` - ICondition that gates freeze calls (for example, PayerCondition) +- `unfreezeCondition` - ICondition that gates unfreeze calls (for example, PayerCondition or ArbiterCondition) - `freezeDuration` - How long freeze lasts in seconds (`0` = permanent until unfrozen) - `escrowPeriodContract` - Address of EscrowPeriod contract (`address(0)` = freeze unconstrained by time) @@ -335,34 +335,28 @@ const freeze = await freezeFactory.write.deploy([ escrowPeriod // Link to EscrowPeriod (or zeroAddress for unconstrained) ]); -// Step 3: Compose with EscrowPeriod for release condition -const releaseCondition = await andConditionFactory.write.deploy([ +// Step 3: Compose with EscrowPeriod for capture condition +const capturePreActionCondition = await andConditionFactory.write.deploy([ [escrowPeriod, freeze] ]); // Use in operator config const config = { // ... - releaseCondition: releaseCondition, + capturePreActionCondition: capturePreActionCondition, // ... }; ``` ### Condition Singletons -Use these pre-deployed condition contracts: - -| Condition | Address (all chains) | Description | -|-----------|---------------------|-------------| -| PayerCondition | `0x33F5F1154A02d0839266EFd23Fd3b85a3505bB4B` | Only payer can call | -| ReceiverCondition | `0xF41974A853940Ff4c18d46B6565f973c1180E171` | Only receiver can call | -| AlwaysTrueCondition | `0xb295df7E7f786fd84D614AB26b1f2e86026C3483` | Anyone can call | +Reference the pre-deployed condition singletons (PayerCondition, ReceiverCondition, AlwaysTrueCondition) by their canonical addresses. The full address registry lives on [Periphery Overview: Condition Singletons](/contracts/periphery/overview#condition-singletons), identical across every supported chain. ### Example Deployments - Payer can freeze, arbiter can unfreeze (or auto-expires after 3 days): + Payer can freeze, arbiter can unfreeze (or it expires after 3 days): ```typescript const freeze = await freezeFactory.deploy( @@ -375,7 +369,7 @@ Use these pre-deployed condition contracts: - Receiver can freeze, arbiter can unfreeze (or auto-expires after 5 days): + Receiver can freeze, arbiter can unfreeze (or it expires after 5 days): ```typescript const freeze = await freezeFactory.deploy( @@ -437,7 +431,7 @@ Freeze duration should balance payer protection with receiver UX. Too long and r ## Factory Ownership -All factories are owned by a multisig wallet for security. +A multisig wallet owns all factories for security. ### Owner Capabilities @@ -447,7 +441,7 @@ Factory owners can: - Transfer ownership (2-step process) Factory owners **cannot:** -- Modify deployed instances +- Change deployed instances - Pause or stop operations - Access funds in deployed operators @@ -470,7 +464,7 @@ Approximate gas costs for factory deployments (Base Sepolia): | Operation | Gas Cost | USD (at 0.1 gwei, $3000 ETH) | |-----------|----------|------------------------------| | Deploy PaymentOperator | ~2.5M gas | ~$0.75 | -| Deploy EscrowPeriod (condition + recorder) | ~1.8M gas | ~$0.54 | +| Deploy EscrowPeriod (condition + hook) | ~1.8M gas | ~$0.54 | | Deploy Freeze | ~1.0M gas | ~$0.30 | | Predict address (view call) | 0 gas | $0.00 | @@ -488,19 +482,19 @@ Each factory uses different salt strategies: **PaymentOperatorFactory:** ```solidity -bytes32 key = keccak256(abi.encodePacked( - config.feeRecipient, +bytes32 key = keccak256(abi.encode( + config.feeReceiver, config.feeCalculator, - config.authorizeCondition, - config.authorizeRecorder, - config.chargeCondition, - config.chargeRecorder, - config.releaseCondition, - config.releaseRecorder, - config.refundInEscrowCondition, - config.refundInEscrowRecorder, - config.refundPostEscrowCondition, - config.refundPostEscrowRecorder + config.authorizePreActionCondition, + config.authorizePostActionHook, + config.chargePreActionCondition, + config.chargePostActionHook, + config.capturePreActionCondition, + config.capturePostActionHook, + config.voidPreActionCondition, + config.voidPostActionHook, + config.refundPreActionCondition, + config.refundPostActionHook )); ``` @@ -518,37 +512,7 @@ bytes32 salt = keccak256(abi.encodePacked("freeze", key)); ### Cross-Chain Addresses -Same configuration on different chains = same address: - -```typescript -import { createPublicClient } from 'viem'; -import { baseSepolia, optimismSepolia } from 'viem/chains'; - -// Deploy on Base Sepolia -const baseFactory = getContract({ - address: FACTORY_ADDRESS, - abi: PaymentOperatorFactory.abi, - client: baseWalletClient -}); - -const baseHash = await baseFactory.write.deployOperator([config]); -const baseReceipt = await baseWalletClient.waitForTransactionReceipt({ hash: baseHash }); -const addressBaseSepolia = baseReceipt.logs[0].address; - -// Deploy on Optimism Sepolia with identical params -const opFactory = getContract({ - address: FACTORY_ADDRESS, - abi: PaymentOperatorFactory.abi, - client: opWalletClient -}); - -const opHash = await opFactory.write.deployOperator([config]); -const opReceipt = await opWalletClient.waitForTransactionReceipt({ hash: opHash }); -const addressOptimismSepolia = opReceipt.logs[0].address; - -// Addresses are identical! -console.assert(addressBaseSepolia === addressOptimismSepolia); -``` +Because the factory uses CREATE2, the same configuration produces the same operator address on any chain where the factory itself lives at the canonical address. As supported chains expand beyond Base, an operator deployed with identical config will land at the same address on each new chain without the integrator needing per-chain bookkeeping. This enables: - Consistent addressing across chains @@ -577,14 +541,14 @@ Don't deploy new PayerCondition/ReceiverCondition - use existing singletons: ```typescript // ✅ Good: Reuse singleton const config = { - authorizeCondition: PAYER_CONDITION, // Pre-deployed singleton + authorizePreActionCondition: PAYER_CONDITION, // Pre-deployed singleton // ... }; // ❌ Bad: Deploy new instance const payerCondition = await new PayerCondition(); const config = { - authorizeCondition: payerCondition.address, // Wastes gas + authorizePreActionCondition: payerCondition.address, // Wastes gas // ... }; ``` @@ -598,11 +562,11 @@ Deploy on testnet with same configuration before mainnet: const testHash = await testnetFactory.write.deployOperator([config]); // ... test thoroughly ... -// Deploy on mainnet with identical config (same address!) +// Deploy on mainnet with identical config (same address) const mainnetHash = await mainnetFactory.write.deployOperator([config]); ``` -### 4. Document Your Config +### 4. Document your config Keep a record of your deployed configurations: diff --git a/contracts/fees.mdx b/contracts/fees.mdx index 5e08280..0ff5358 100644 --- a/contracts/fees.mdx +++ b/contracts/fees.mdx @@ -6,7 +6,7 @@ icon: "coins" ## Overview -x402r uses an **additive modular** fee system: `totalFee = protocolFee + operatorFee`. Each layer is independently configurable, and fees are split between a shared protocol recipient and a per-operator fee recipient. +x402r uses an **additive modular** fee system: `totalFee = protocolFee + operatorFee`. Each layer is independently configurable, and the operator splits fees between a shared protocol recipient and a per-operator fee recipient. ## Fee Architecture @@ -26,7 +26,7 @@ flowchart TD ACC --> DIST["distributeFees()"] DIST --> PR["protocolFeeRecipient (on ProtocolFeeConfig)"] - DIST --> FR["FEE_RECIPIENT + DIST --> FR["FEE_RECEIVER (on Operator)"] ``` @@ -35,7 +35,7 @@ flowchart TD | Layer | Configured By | Mutability | Recipient | |-------|--------------|------------|-----------| | **Protocol Fee** | `ProtocolFeeConfig` (shared) | Swappable calculator with 7-day timelock | `protocolFeeRecipient` on ProtocolFeeConfig | -| **Operator Fee** | `FEE_CALCULATOR` (per-operator) | Immutable — set at deploy time | `FEE_RECIPIENT` on operator | +| **Operator Fee** | `FEE_CALCULATOR` (per-operator) | Immutable: set at deploy time | `FEE_RECEIVER` on operator | ### Example Calculation @@ -44,7 +44,7 @@ For a 1000 USDC payment with 50 bps protocol fee + 250 bps operator fee: | Component | Rate | Amount | Goes To | |-----------|------|--------|---------| | Protocol Fee | 50 bps (0.5%) | 5.00 USDC | `protocolFeeRecipient` | -| Operator Fee | 250 bps (2.5%) | 25.00 USDC | `FEE_RECIPIENT` | +| Operator Fee | 250 bps (2.5%) | 25.00 USDC | `FEE_RECEIVER` | | **Total Fee** | **300 bps (3%)** | **30.00 USDC** | | | **Receiver Gets** | | **970.00 USDC** | Payment receiver | @@ -67,11 +67,11 @@ interface IFeeCalculator { } ``` -This enables flexible fee models — static rates, volume-based tiers, per-token pricing, or any custom logic. +This enables flexible fee models, static rates, volume-based tiers, per-token pricing, or any custom logic. ## StaticFeeCalculator -The simplest implementation — returns a fixed basis points value for every payment: +The simplest implementation, returns a fixed basis points value for every payment: ```solidity contract StaticFeeCalculator is IFeeCalculator { @@ -104,7 +104,7 @@ const sameAddress = await staticFeeCalculatorFactory.write.deploy([250]); ## Fee Locking -Fees are **locked at authorization time** to prevent protocol fee changes from breaking already-authorized payments. +The operator **locks fees at authorization time** so later protocol fee changes don't break already-authorized payments. ```solidity struct AuthorizedFees { @@ -117,11 +117,11 @@ mapping(bytes32 paymentInfoHash => AuthorizedFees) public authorizedFees; **Flow:** 1. `authorize()` calculates fees and stores them in `authorizedFees[hash]` -2. `release()` uses the stored fees — not the current calculator rates +2. `capture()` uses the stored fees, not the current calculator rates 3. Protocol fee timelocks can't break already-authorized payments -`charge()` calculates fees inline since it authorizes and captures atomically — there's no gap where fees could change. +`charge()` calculates fees inline since it authorizes and captures atomically, there's no gap where fees could change. ## Fee Bounds Validation @@ -139,13 +139,13 @@ This ensures payers always know the fee range they're agreeing to. ## Fee Distribution -Fees accumulate in the operator contract and are distributed via `distributeFees()`: +Fees accumulate in the operator contract. Call `distributeFees()` to disburse them: ```solidity // Anyone can call to distribute fees for a token operator.distributeFees(usdcAddress); // Protocol share → protocolFeeRecipient -// Operator share → FEE_RECIPIENT +// Operator share → FEE_RECEIVER ``` **How it works:** @@ -153,11 +153,11 @@ operator.distributeFees(usdcAddress); 2. Protocol share = `accumulatedProtocolFees[token]` (tracked per-token) 3. Operator share = remaining balance 4. Transfer protocol share to `protocolFeeRecipient` -5. Transfer operator share to `FEE_RECIPIENT` +5. Transfer operator share to `FEE_RECEIVER` 6. Reset accumulated tracking to 0 -`distributeFees()` is permissionless — anyone can trigger distribution. This prevents fees from being stuck in the operator. +`distributeFees()` is permissionless, anyone can trigger distribution. This stops fees from accumulating indefinitely in the operator. ## ProtocolFeeConfig @@ -201,18 +201,18 @@ await protocolFeeConfig.executeRecipient(); ``` -Operator fees are **immutable** — set at deploy time via `IFeeCalculator` and `FEE_RECIPIENT`. Only protocol fees can be changed (with 7-day timelock). Already-authorized payments use locked fee rates regardless. +Operator fees are **immutable**: set at deploy time via `IFeeCalculator` and `FEE_RECEIVER`. Only protocol fees support updates (with 7-day timelock). Already-authorized payments use locked fee rates regardless. ### Disabling Protocol Fees Set the protocol fee calculator to `address(0)` to disable protocol fees entirely. The operator will calculate 0 bps for the protocol layer. -## FEE_RECIPIENT Roles +## FEE_RECEIVER Roles -The operator's `FEE_RECIPIENT` varies by use case: +The operator's `FEE_RECEIVER` varies by use case: -| Use Case | FEE_RECIPIENT | Description | +| Use Case | FEE_RECEIVER | Description | |----------|---------------|-------------| | Marketplace | Arbiter address | Arbiter earns fees for dispute resolution | | Subscription | Service provider | Provider earns fees for service delivery | diff --git a/contracts/gas-costs.mdx b/contracts/gas-costs.mdx index 486f1e1..beace94 100644 --- a/contracts/gas-costs.mdx +++ b/contracts/gas-costs.mdx @@ -1,91 +1,90 @@ --- title: "Gas Costs" -description: "Simulated gas measurements for every on-chain operation, with per-plugin overhead breakdown" +description: "Foundry-measured gas costs for every on-chain x402r operation, with per-plugin overhead breakdown" icon: "gas-pump" --- ## Overview -x402r adds escrow, refund windows, and dispute resolution on top of the [Commerce Payments Protocol](https://github.com/base/commerce-payments). Here you'll find the **measured gas cost** of every on-chain operation so you can evaluate the overhead. +x402r adds escrow, refund windows, and dispute resolution on top of the [Commerce Payments Protocol](https://github.com/base/commerce-payments). Below you'll find the **measured gas cost** of every on-chain operation so you can weigh the overhead. -All numbers are from Foundry simulations (`forge test`) with optimizer enabled (200 runs, via IR). The benchmark test is at [`test/gas/GasBenchmark.t.sol`](https://github.com/BackTrackCo/x402r-contracts/blob/main/test/gas/GasBenchmark.t.sol). +All numbers come from Foundry simulations (`forge test --gas-report`) with optimizer enabled (200 runs, via IR), pinned to [x402r-contracts @ `bb188db`](https://github.com/BackTrackCo/x402r-contracts/commit/bb188dbc0251f9a3af7da57906d5c59e2b2a14d0) (snapshot: 2026-05-20). The benchmark lives at [`test/gas/GasBenchmark.t.sol`](https://github.com/BackTrackCo/x402r-contracts/blob/bb188db/test/gas/GasBenchmark.t.sol). Numbers are per-transaction and warm where the test measures warm (so they reflect typical second-and-beyond payments on the same operator); cold-vs-warm splits are reported alongside the warm number where relevant. -The buyer never pays gas. They only sign an off-chain ERC-3009 authorization. All on-chain transactions are submitted by the facilitator, merchant, or other parties. +The buyer never pays gas. They only sign an off-chain ERC-3009 or Permit2 authorization. The facilitator, merchant, or another party submits every on-chain transaction. ## What you'll pay on Base | Role | Operations | Gas | Cost on Base | |------|-----------|-----|-------------| -| **Facilitator** | `authorize()` | 181,544 | < $0.005 | -| **Merchant** | `release()` | 150,262 | < $0.005 | -| **Happy path total** | authorize + release | 331,806 | **< $0.01** | +| **Facilitator** | `authorize()` | 182,440 | < $0.005 | +| **Merchant** | `capture()` | 150,049 | < $0.005 | +| **Happy path total** | authorize + capture | 332,489 | **< $0.01** | -Disputes are rare and add < $0.005 with off-chain resolution — see [Dispute Path](#dispute-path) below. +Disputes are rare and add < $0.005 with off-chain resolution, see [Dispute Path](#dispute-path) below. ## Happy Path -The happy path has **2 on-chain transactions**: `authorize` (at purchase time) and `release` (after the escrow period expires). +The happy path has **2 on-chain transactions**: `authorize` (at checkout) and `capture` (after the escrow period expires). With operator fees enabled, the `distributeFees()` call adds a third settle-time write to claim accumulated protocol fees, batched across many payments. | Operation | Gas | vs transfer | Who Calls | When | |-----------|-----|------------|-----------|------| -| `authorize()` | 181,544 | 17.6x | Facilitator | At purchase (HTTP 402 settlement) | -| `release()` | 150,262 | 14.6x | Anyone | After escrow period expires | +| `authorize()` | 182,440 | 17.8x | Facilitator | At checkout (HTTP 402 settlement) | +| `capture()` | 150,049 | 14.6x | Anyone | After escrow period expires | +| `distributeFees()` | 57,007 | 5.6x | Owner | Periodically, batched across payments | -The **vs transfer** column shows multiples of a cold ERC-20 `transfer()` (10,305 gas) — the absolute floor for moving tokens on-chain. +The **vs transfer** column shows multiples of a cold ERC-20 `transfer()` (10,263 gas), the absolute floor for moving tokens on-chain. -In production, the merchant typically calls `release()`, but the function has no caller restriction beyond the configured release condition (EscrowPeriod + Freeze). After the escrow period passes and the payment isn't frozen, anyone can trigger it. +In production, the merchant typically calls `capture()`, but the function has no caller restriction beyond the configured capture condition (EscrowPeriod + Freeze). After the escrow period passes and the payment isn't frozen, anyone can trigger it. An escrow authorization is inherently more work than a raw ERC-20 transfer: it validates payment info, checks fee bounds, locks fees, transfers tokens into escrow, and records state. The per-plugin section below shows exactly where the gas goes. -**Facilitators: set a gas limit.** The facilitator pays gas for `authorize()`, but the operator chooses which conditions and recorders are configured. Each plugin slot adds cost, and custom plugins can run arbitrary computation. Simulate the transaction with `eth_estimateGas` before submitting and reject operators whose `authorize()` exceeds a reasonable threshold (e.g., 300,000 gas). The full x402r configuration uses ~181,000 — anything significantly above that warrants investigation. +**Facilitators: set a gas limit.** The facilitator pays gas for `authorize()`, but the operator chooses which conditions and hooks to run. Each plugin slot adds cost, and custom plugins can run arbitrary computation. Simulate the transaction with `eth_estimateGas` before submitting and reject operators whose `authorize()` exceeds a reasonable threshold (for example, 300,000 gas). The full x402r configuration uses around 182,000 gas; anything well above that warrants investigation. ## Per-Plugin Gas Costs -The PaymentOperator is configured with pluggable conditions (checked before an action) and recorders (called after). You choose which plugins to use. Here's the marginal cost of each, measured by diffing adjacent configurations. +The PaymentOperator runs with pluggable conditions (checked before an action) and hooks (called after). You choose which plugins to use. Here's the marginal cost of each, measured by diffing adjacent configurations through the `PaymentOperator` entry point. ### authorize() | Configuration | Gas | Marginal Cost | Plugin | |---------------|-----|---------------|--------| -| Commerce Payments escrow (no operator) | 78,353 | — | Raw `AuthCaptureEscrow.authorize()` — validates payment, escrows tokens via `PreApprovalPaymentCollector` | -| + PaymentOperator layer | 117,250 | **+38,897** | Operator dispatch, plugin slot checks, access control — all conditions, recorders, and fee calculator set to `address(0)` | -| + Fee calculation | 135,961 | **+18,711** | `StaticFeeCalculator` — calculates protocol + operator fees, validates bounds, locks fees in `authorizedFees[hash]` | -| + EscrowPeriod recorder | 162,744 | **+26,783** | `EscrowPeriod.record()` — stores `authorizationTime[hash] = block.timestamp` (cold SSTORE to cross-contract slot) | +| PaymentOperator, no plugins | 119,018 |: | `bareOperator.authorize()`: operator dispatch, plugin slot checks, escrow `authorize()` call | +| + Fee calculation | 146,738 | **+27,720** | `StaticFeeCalculator`: calculates protocol + operator fees, validates bounds, locks fees in `authorizedFees[hash]` | +| + EscrowPeriod hook | 182,440 | **+35,702** | `EscrowPeriod.run()`: stores `authorizationTime[hash] = block.timestamp` (cold SSTORE to cross-contract slot) | -The EscrowPeriod recorder is the single most expensive plugin on `authorize` because it writes to a new storage slot in the EscrowPeriod contract. +The EscrowPeriod hook is the single most expensive plugin on `authorize` because it writes to a new storage slot in the EscrowPeriod contract. -### release() +### capture() | Configuration | Gas | Marginal Cost | Plugin | |---------------|-----|---------------|--------| -| Commerce Payments escrow (no operator) | 66,365 | — | Raw `AuthCaptureEscrow.capture()` — validates authorization, distributes tokens to receiver | -| + PaymentOperator layer | 77,926 | **+11,561** | Operator dispatch, plugin slot checks, access control — all conditions, recorders, and fee calculator set to `address(0)` | -| + Fee retrieval | 116,980 | **+39,054** | Reads locked fees from `authorizedFees[hash]`, calculates protocol share, accumulates in `accumulatedProtocolFees[token]` | -| + ReceiverCondition | 121,430 | **+4,450** | Pure calldata comparison: `caller == paymentInfo.receiver` — no storage reads | -| + EscrowPeriod condition | 122,520 | **+5,540** | Cross-contract SLOAD: reads `authorizationTime[hash]`, compares against `block.timestamp` | -| + Freeze + AndCondition | 142,961 | **+20,441** | AndCondition combinator loop + `Freeze.check()` reads `frozenUntil[hash]` + internal `isDuringEscrowPeriod()` | +| PaymentOperator, no plugins | 78,074 |: | `bareOperator.capture()`: operator dispatch + escrow `capture()` call | +| + Fee retrieval and distribution | 117,033 | **+38,959** | Reads locked fees from `authorizedFees[hash]`, calculates protocol share, accumulates in `accumulatedProtocolFees[token]` | +| + ReceiverCondition | 121,529 | **+4,496** | Pure calldata comparison: `caller == paymentInfo.receiver`, no storage reads | +| + EscrowPeriod condition | 126,888 | **+5,359** | Cross-contract SLOAD: reads `authorizationTime[hash]`, compares against `block.timestamp` | +| + Freeze + AndCondition | 147,298 | **+20,410** | AndCondition combinator loop + `Freeze.check()` reads `frozenUntil[hash]` + internal `isDuringEscrowPeriod()` | -**Simple conditions are nearly free.** `ReceiverCondition` and `PayerCondition` cost ~4,500 gas — they only compare calldata fields. Cross-contract conditions like `EscrowPeriod` cost ~5,500 due to a cold SLOAD. The `Freeze` condition is the most expensive single condition (+20,441) because of the AndCondition combinator overhead, its own `frozenUntil` storage read, and an internal escrow period check. +**Simple conditions are close to free.** `ReceiverCondition` and `PayerCondition` cost around 4,500 gas; they only compare calldata fields. Cross-contract conditions like `EscrowPeriod` cost around 5,400 because of a cold SLOAD. The `Freeze` condition is the most expensive single condition (+20,410) because of the AndCondition combinator overhead, its own `frozenUntil` storage read, and an internal escrow period check. ## Dispute Path -These operations only happen when a payment is disputed. Most payments never touch this path. +These operations only happen when a buyer disputes a payment. Most payments never touch this path. ### Off-chain resolution -The refund request, evidence submission, and arbiter approval can all happen off-chain. The only on-chain steps are `freeze()` (to lock the payment during the escrow window) and `refundInEscrow()` (to return funds). The arbiter never submits a transaction — their approval is an EIP-712 signature that anyone can relay. +The refund request, evidence submission, and arbiter approval can all happen off-chain. The only on-chain steps are `freeze()` (to lock the payment during the escrow window) and `void()` (to return funds). The arbiter never submits a transaction; their approval is an EIP-712 signature that anyone can relay. | On-chain step | Gas | vs transfer | Who Calls | |--------------|-----|------------|-----------| -| `freeze()` | 44,651 | 4.3x | Buyer | -| `refundInEscrow()` | 65,924 | 6.4x | Anyone | -| **Total** | **110,575** | **10.7x** | | +| `freeze()` | 45,831 | 4.5x | Buyer | +| `void()` | 68,947 | 6.7x | Anyone | +| **Total** | **114,778** | **11.2x** | | Total dispute cost on Base with off-chain resolution: **< $0.005**. @@ -95,19 +94,19 @@ If the parties choose to handle the dispute fully on-chain instead: | Operation | Gas | vs transfer | Who Calls | Notes | |-----------|-----|------------|-----------|-------| -| `authorize()` | 181,544 | 17.6x | Facilitator | Already paid during happy path | -| `freeze()` | 44,651 | 4.3x | Buyer | Locks payment during escrow window | -| `release()` | 150,262 | 14.6x | Anyone | Already paid during happy path | -| `requestRefund()` | 421,689 | 40.9x | Buyer | Creates refund request with multi-index storage | -| `submitEvidence()` | 135,597 | 13.2x | Any party | Stores IPFS CID on-chain | -| `approveWithSignature()` | 89,935 | 8.7x | Anyone | Relays arbiter's off-chain EIP-712 signature | -| `refundPostEscrow()` | 54,467 | 5.3x | Anyone | Pulls funds from merchant wallet via ReceiverRefundCollector | -| **Total** | **1,078,145** | **104.6x** | | | +| `authorize()` | 182,445 | 17.8x | Facilitator | Already paid during happy path | +| `freeze()` | 45,818 | 4.5x | Buyer | Locks payment during escrow window | +| `capture()` | 145,549 | 14.2x | Anyone | Already paid during happy path | +| `requestRefund()` | 418,174 | 40.7x | Buyer | Creates refund request with multi-index storage | +| `submitEvidence()` | 132,431 | 12.9x | Any party | Stores IPFS CID on-chain | +| `deny()` | 11,096 | 1.1x | Arbiter | Terminal status update on the request | +| `refund()` | 57,482 | 5.6x | Anyone | Pulls funds from merchant wallet via ReceiverRefundCollector | +| **Total** | **992,995** | **96.7x** | | | -This total includes the happy path steps (`authorize` + `release`) since those have already been paid. The dispute-only overhead is 746,339 gas (< $0.02 on Base). +This total includes the happy path steps (`authorize` + `capture`) since those already ran. The dispute-only overhead is 665,001 gas (< $0.02 on Base). -`requestRefund()` at 421,689 gas is the most expensive operation because it writes to **multiple storage mappings** for indexing: +`requestRefund()` at 418,174 gas is the most expensive operation because it writes to **five storage mappings** for indexing: - Refund request data (status, amount, payment hash) - Payer index (`payerRefundRequests[payer][n]`) @@ -122,26 +121,44 @@ This indexing enables efficient off-chain queries but costs more gas upfront. On | Scenario | Gas | vs transfer | Cost on Base | |----------|-----|------------|-------------| -| ERC-20 transfer (baseline) | 10,305 | 1x | < $0.001 | -| Commerce Payments escrow, no operator (authorize + capture) | 144,718 | 14.0x | < $0.005 | -| + PaymentOperator layer, no plugins | 195,176 | 18.9x | < $0.005 | -| + fees | 252,941 | 24.5x | < $0.005 | -| + fees + simple condition | 257,391 | 25.0x | < $0.005 | -| **+ fees + EscrowPeriod + Freeze (x402r full)** | **331,806** | **32.2x** | **< $0.01** | -| x402r dispute (off-chain optimized) | 110,575 | 10.7x | < $0.005 | -| x402r dispute (fully on-chain, 7 txns) | 1,078,145 | 104.6x | < $0.05 | +| ERC-20 transfer (cold baseline) | 10,263 | 1x | < $0.001 | +| PaymentOperator, no plugins (`authorize` + `capture`) | 197,092 | 19.2x | < $0.005 | +| + fees | 263,771 | 25.7x | < $0.005 | +| + fees + simple condition | 268,267 | 26.1x | < $0.005 | +| **+ fees + EscrowPeriod + Freeze (x402r full)** | **332,489** | **32.4x** | **< $0.01** | +| x402r dispute (off-chain optimized) | 114,778 | 11.2x | < $0.005 | +| x402r dispute (fully on-chain, 7 txns) | 992,995 | 96.7x | < $0.05 | -The full x402r happy path uses ~32x the gas of a single ERC-20 transfer — but on Base L2, the absolute cost stays under a penny. The overhead comes from escrow validation, fee locking, cross-contract storage writes, and condition checks, all detailed in the per-plugin breakdown above. +The full x402r happy path uses ~32x the gas of a single ERC-20 transfer, but on Base L2, the absolute cost stays under a penny. The overhead comes from escrow validation, fee locking, cross-contract storage writes, and condition checks, all detailed in the per-plugin breakdown above. -All numbers above assume one payment per transaction. Batching multiple operations in a single transaction (via a multicall contract) can reduce per-payment costs by 37–80% due to warm EVM access — contract addresses and shared storage only need to be loaded once. The benchmark test includes warm measurements for reference. +All numbers above assume one payment per transaction. Batching operations in a single transaction (via a multicall contract) can reduce per-payment costs by 37 to 80 percent thanks to warm EVM access; contract addresses and shared storage only load once. The benchmark test includes warm measurements for reference. +## Reproducing these numbers + +```bash +git clone https://github.com/BackTrackCo/x402r-contracts +cd x402r-contracts +git checkout bb188dbc0251f9a3af7da57906d5c59e2b2a14d0 +forge test --match-path test/gas/GasBenchmark.t.sol -vv +``` + +Each test logs its measured gas via `console.log` and the `--gas-report` summary tables list aggregate per-function statistics. + + +A scheduled CI job in [x402r-contracts](https://github.com/BackTrackCo/x402r-contracts) re-runs `GasBenchmark.t.sol` and flags drift past threshold, so the numbers on this page are checked against the live benchmark rather than left to manual regen. When the benchmark moves, the pinned commit and figures above are refreshed. + + +## Reference: upstream escrow costs + +The escrow layer these numbers build on is Base's audited [Commerce Payments Protocol](https://github.com/base/commerce-payments). For the baseline cost of the underlying `AuthCaptureEscrow` lifecycle (`authorize`, `capture`, `void`, `refund`) independent of x402r's condition and hook plugins, see the upstream contracts and their own benchmarks in the [commerce-payments repository](https://github.com/base/commerce-payments). The x402r figures above are the upstream escrow cost plus the per-plugin overhead detailed in the breakdown. + - How protocol and operator fees are calculated and distributed + How the operator calculates and distributes protocol and operator fees - How conditions, recorders, and escrow fit together + How conditions, hooks, and escrow fit together diff --git a/contracts/hooks/authorization-time.mdx b/contracts/hooks/authorization-time.mdx new file mode 100644 index 0000000..1991213 --- /dev/null +++ b/contracts/hooks/authorization-time.mdx @@ -0,0 +1,62 @@ +--- +title: "AuthorizationTimeRecorderHook" +description: "Records authorization timestamp for time-based conditions" +icon: "clock" +--- + +## Overview + +AuthorizationTimeRecorderHook stores `block.timestamp` at the moment the operator authorizes a payment. Time-based conditions like [EscrowPeriod](/contracts/conditions/escrow-period) read this timestamp to gate later actions. + + +[EscrowPeriod](/contracts/conditions/escrow-period) **extends** AuthorizationTimeRecorderHook and adds an `ICondition` implementation. For escrow enforcement, use EscrowPeriod directly instead of deploying AuthorizationTimeRecorderHook on its own. + + +## State + +```solidity +mapping(bytes32 paymentInfoHash => uint256 authorizedAt) public authorizationTimes; +``` + +## Methods + +```solidity +// Called after authorize() +function run( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256 /* amount */, + address /* caller */, + bytes calldata /* data */ +) external { + bytes32 hash = _verifyAndHash(paymentInfo); + authorizationTimes[hash] = block.timestamp; +} + +// View function +function getAuthorizationTime( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo +) external view returns (uint256) { + return authorizationTimes[escrow.getHash(paymentInfo)]; +} +``` + +`amount`, `caller`, and `data` are unused; they exist to satisfy `IHook.run`. + +## When to Use + +Use AuthorizationTimeRecorderHook directly only if you need authorization timestamps **without** escrow period enforcement. For most use cases, [EscrowPeriod](/contracts/conditions/escrow-period) is the better choice since it includes this hook plus time-lock condition logic. + +## Gas + +**Cost:** ~20k gas per `run()` call (one `SSTORE` for the timestamp). + +## Next Steps + + + + Combined hook + condition for escrow enforcement. + + + Index payments for on-chain queries. + + diff --git a/contracts/hooks/combinator.mdx b/contracts/hooks/combinator.mdx new file mode 100644 index 0000000..f0ea711 --- /dev/null +++ b/contracts/hooks/combinator.mdx @@ -0,0 +1,50 @@ +--- +title: "HookCombinator" +description: "Chain hooks into a single operator slot for composite state tracking" +icon: "layer-group" +--- + +## Overview + +HookCombinator chains hooks into one, invoking each in sequence. Each operator slot accepts only one hook address, so use HookCombinator when you need more than one hook on the same action. + +## Deployment + +Deploy via HookCombinatorFactory: + +```typescript +const comboAddress = await hookCombinatorFactory.write.deploy([ + [escrowPeriodAddress, paymentIndexRecorderHookAddress] // Records auth time + payment index +]); + +config.authorizePostActionHook = comboAddress; +``` + +## Behavior + +- The combinator invokes hooks in the order provided +- **If any hook reverts, all revert**: the entire recording is atomic +- Each hook receives the same `paymentInfo`, `amount`, `caller`, and `data` parameters + +## Limits + + +**Max 10 hooks per combinator.** Each extra hook adds ~1k gas overhead for the delegation call. + + +## Gas + +**Cost:** Sum of all individual hook costs + ~1k gas overhead per hook for delegation. + +Example: EscrowPeriod (~20k) + PaymentIndexRecorderHook (~20k) + ~2k overhead = ~42k gas total. + +## Next Steps + + + + Record authorization timestamps. + + + Index payments for on-chain queries. + + diff --git a/contracts/hooks/custom.mdx b/contracts/hooks/custom.mdx new file mode 100644 index 0000000..7a0d4c2 --- /dev/null +++ b/contracts/hooks/custom.mdx @@ -0,0 +1,137 @@ +--- +title: "Custom Hooks" +description: "Build your own hook contracts for specialized state tracking" +icon: "wrench" +--- + +## Overview + +You can build custom hooks for specialized tracking beyond what the built-in hooks provide. Use the `IHook` interface and extend `BaseHook` for operator access control. + +## `IHook` interface + +```solidity +interface IHook { + function run( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256 amount, + address caller, + bytes calldata data + ) external; +} +``` + +`data` is forwarded verbatim from the action call (signatures, proofs, attestations). Subclasses ignore parameters they do not need. + +## Extending BaseHook + +Extend `BaseHook` and call `_verifyAndHash(paymentInfo)` to enforce caller and payment-existence checks: + +```solidity +contract MyHook is BaseHook { + constructor(address escrow, bytes32 authorizedCodehash) + BaseHook(escrow, authorizedCodehash) + {} + + function run( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256 amount, + address /* caller */, + bytes calldata /* data */ + ) external override { + bytes32 hash = _verifyAndHash(paymentInfo); + // Your recording logic here, keyed by `hash` + } +} +``` + +`authorizedCodehash` is the runtime codehash of an optional trusted caller (e.g. `HookCombinator`). Pass `bytes32(0)` to gate solely on `msg.sender == paymentInfo.operator`. + +## Example: CaptureCountHook + +Tracks the number and total amount of captures per payment: + +```solidity +contract CaptureCountHook is BaseHook { + mapping(bytes32 => uint256) public captureCount; + mapping(bytes32 => uint256) public totalCaptured; + + constructor(address escrow, bytes32 authorizedCodehash) + BaseHook(escrow, authorizedCodehash) + {} + + function run( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256 amount, + address /* caller */, + bytes calldata /* data */ + ) external override { + bytes32 hash = _verifyAndHash(paymentInfo); + captureCount[hash]++; + totalCaptured[hash] += amount; + } + + function getStats(bytes32 paymentHash) + external view + returns (uint256 count, uint256 total) + { + return (captureCount[paymentHash], totalCaptured[paymentHash]); + } +} +``` + +## Testing + +Test custom hooks with Forge: + +```solidity +contract CaptureCountHookTest is Test { + CaptureCountHook hook; + + function setUp() public { + hook = new CaptureCountHook(address(escrow), bytes32(0)); + } + + function test_incrementsOnRun() public { + vm.prank(paymentInfo.operator); + hook.run(paymentInfo, 100e6, address(0), ""); + bytes32 hash = escrow.getHash(paymentInfo); + (uint256 count, uint256 total) = hook.getStats(hash); + assertEq(count, 1); + assertEq(total, 100e6); + } + + function test_tracksMultipleCaptures() public { + vm.startPrank(paymentInfo.operator); + hook.run(paymentInfo, 50e6, address(0), ""); + hook.run(paymentInfo, 30e6, address(0), ""); + vm.stopPrank(); + bytes32 hash = escrow.getHash(paymentInfo); + (uint256 count, uint256 total) = hook.getStats(hash); + assertEq(count, 2); + assertEq(total, 80e6); + } +} +``` + +## Security Checklist + +- [ ] Extends `BaseHook` and uses `_verifyAndHash` for caller and payment-existence checks +- [ ] Returns early instead of reverting on business-logic edge cases (a reverting hook permanently bricks the surrounding action) +- [ ] Gas-efficient storage layout +- [ ] Full test coverage across the public surface + + +Unlike conditions, hooks **do mutate state**. `BaseHook._verifyAndHash` enforces `msg.sender == paymentInfo.operator` (or matches `AUTHORIZED_CODEHASH`) plus payment existence in escrow. Calling `_verifyAndHash` is mandatory. + + +## Next Steps + + + + Compare recording strategies. + + + Build custom condition contracts. + + diff --git a/contracts/hooks/overview.mdx b/contracts/hooks/overview.mdx new file mode 100644 index 0000000..ddb0e3a --- /dev/null +++ b/contracts/hooks/overview.mdx @@ -0,0 +1,105 @@ +--- +title: "Hooks Overview" +description: "State recording system for tracking payment lifecycle events" +icon: "database" +--- + +## What are hooks + +Hooks are pluggable contracts that update state **after** an action successfully executes on a PaymentOperator. Each operator has **5 hook slots**, one per action: + +| Slot | Records after | +|------|---------------| +| `AUTHORIZE_POST_ACTION_HOOK` | Authorization (for example, timestamp) | +| `CHARGE_POST_ACTION_HOOK` | Charge event | +| `CAPTURE_POST_ACTION_HOOK` | Capture from escrow | +| `VOID_POST_ACTION_HOOK` | Void | +| `REFUND_POST_ACTION_HOOK` | Refund (after capture) | + +These are the hook half of the operator's 10 slots. For the full slot layout alongside the pre-action conditions, see [PaymentOperator: 10-slot configuration](/contracts/payment-operator#10-slot-configuration). + +## IHook Interface + +```solidity +interface IHook { + function run( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256 amount, + address caller, + bytes calldata data + ) external; +} +``` + +**Parameters:** +- `paymentInfo`, The payment information struct +- `amount`, The amount involved in the action +- `caller`, The address that executed the action (msg.sender on operator) +- `data`, Arbitrary data forwarded from the caller (signatures, proofs, attestations) + +## Default behavior + +**Hook slot = `address(0)`**: no-op (does nothing). The operator records no state for that slot. + +Set hooks only on the slots where you want state tracking. Leave the rest as `address(0)`. + +## BaseHook + +All built-in hooks extend `BaseHook`, which verifies that the caller is an authorized operator. This prevents unauthorized contracts from writing state. + +## Choosing a recording strategy + +Not every payment needs on-chain hooks. Choose based on your use case: + +### Events only (~0 extra gas) + +The operator already emits an event for every action (`AuthorizeExecuted`, `CaptureExecuted`, and the same shape for the rest). If you only need payment history for analytics or display, skip hooks entirely and index events off-chain. + +**Best for:** micropayments, high-volume payments where gas overhead matters, simple UIs. + +### Events plus subgraph (richest queries) + +Index operator events with a subgraph for rich queries (payment history by payer, receiver, status, date range). No on-chain hook gas cost. + +**Best for:** analytics dashboards, payment history, cross-payment queries. + +**Trade-off:** requires subgraph infrastructure (semi-centralized). + +### On-chain hooks (~20k gas per write) + +Use hooks when you need **on-chain reads**: other contracts or conditions that depend on recorded state. [EscrowPeriod](/contracts/conditions/escrow-period) is the most common example. It records authorization time so the capture condition can check if the escrow window has passed. + +**Best for:** escrow enforcement, dispute evidence, decentralized frontends, on-chain composability. + +**Trade-off:** ~20k gas per `SSTORE` operation. + +### Decision table + +| Need | Strategy | Hook slots | +|------|----------|---------------| +| Payment history for UI | Events only | `address(0)` | +| Rich queries, analytics | Events + Subgraph | `address(0)` | +| Time-locked releases | On-chain | [EscrowPeriod](/contracts/conditions/escrow-period) on `AUTHORIZE_POST_ACTION_HOOK` | +| On-chain payment index | On-chain | [PaymentIndexRecorderHook](/contracts/hooks/payment-index) | +| Many data points | On-chain | [HookCombinator](/contracts/hooks/combinator) | + + +For most configurations, you only need a hook on the `AUTHORIZE_POST_ACTION_HOOK` slot (for [EscrowPeriod](/contracts/conditions/escrow-period)). Leave other hook slots as `address(0)`. + + +## Next Steps + + + + Record authorization timestamps. + + + Index payments for on-chain queries. + + + Chain hooks into one slot. + + + Build your own hook. + + diff --git a/contracts/hooks/payment-index.mdx b/contracts/hooks/payment-index.mdx new file mode 100644 index 0000000..a3d7076 --- /dev/null +++ b/contracts/hooks/payment-index.mdx @@ -0,0 +1,88 @@ +--- +title: "PaymentIndexRecorderHook" +description: "Index payments by sequential count for on-chain queries and repeated refund requests" +icon: "list-ol" +--- + +## Overview + +PaymentIndexRecorderHook indexes payments by payer and receiver and stores the full `PaymentInfo` struct keyed by `paymentInfoHash`. Wire it into `AUTHORIZE_POST_ACTION_HOOK` on a `HookCombinator` (or directly when authorize is the only hook slot you use) so each new authorization registers itself for on-chain lookups. + +## When to use + +- You need on-chain payment lookups **without a subgraph** +- Other contracts need to read the full `PaymentInfo` for a hash, or page through every payment by payer / receiver +- You want a single chain-singleton index that aggregates across every operator routing through `HookCombinator` + +**Skip when:** you're using a subgraph for payment queries, since the subgraph can derive indexes from events without the on-chain gas cost. + +## State + +```solidity +mapping(bytes32 paymentInfoHash => AuthCaptureEscrow.PaymentInfo) private paymentInfoStore; +mapping(address payer => mapping(uint256 index => bytes32 hash)) private payerPayments; +mapping(address payer => uint256 count) public payerPaymentCount; +mapping(address receiver => mapping(uint256 index => bytes32 hash)) private receiverPayments; +mapping(address receiver => uint256 count) public receiverPaymentCount; +``` + +## Methods + +```solidity +function run( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256 /* amount */, + address /* caller */, + bytes calldata /* data */ +) external { + bytes32 hash = escrow.getHash(paymentInfo); + paymentInfoStore[hash] = paymentInfo; + payerPayments[paymentInfo.payer][payerPaymentCount[paymentInfo.payer]++] = hash; + receiverPayments[paymentInfo.receiver][receiverPaymentCount[paymentInfo.receiver]++] = hash; +} + +function getPaymentInfo( + bytes32 paymentInfoHash +) external view returns (AuthCaptureEscrow.PaymentInfo memory); + +function getPayerPayments( + address payer, + uint256 offset, + uint256 count +) external view returns (AuthCaptureEscrow.PaymentInfo[] memory, uint256 total); + +function getReceiverPayments( + address receiver, + uint256 offset, + uint256 count +) external view returns (AuthCaptureEscrow.PaymentInfo[] memory, uint256 total); +``` + +`amount`, `caller`, and `data` are unused; they exist to satisfy `IHook.run`. + +## Querying + +```typescript +const info = await paymentIndex.read.getPaymentInfo([paymentInfoHash]) + +const [payerInfos, total] = await paymentIndex.read.getPayerPayments([ + payer, + 0n, + 10n, +]) +``` + +## Gas + +**Cost:** ~175k gas per authorization (payer index + receiver index + full `PaymentInfo` SSTORE). + +## Next Steps + + + + Combine with other hooks in a single slot. + + + Compare recording strategies. + + diff --git a/contracts/hooks/refund-request.mdx b/contracts/hooks/refund-request.mdx new file mode 100644 index 0000000..0eda209 --- /dev/null +++ b/contracts/hooks/refund-request.mdx @@ -0,0 +1,144 @@ +--- +title: "RefundRequest" +description: "Manages refund request lifecycle and approvals independent of operator implementation" +icon: "rotate-left" +--- + +## Overview + +- **Type:** Singleton (one per network) +- **Deployment:** Direct deployment (no factory) +- **Purpose:** Track refund request lifecycle + +## Request Types + + + + **Who can request:** Payer, Receiver, OR Arbiter + + **Typical flow:** + 1. Payer suspects fraud, requests a refund + 2. Arbiter investigates + 3. Arbiter approves or denies the request + 4. If approved, arbiter calls `operator.void()` + + **Use cases:** + - Buyer remorse + - Seller fraud + - Payment error + + + + **Who can request:** Receiver only + + **Typical flow:** + 1. Receiver realizes product defect after capture + 2. Receiver requests a refund + 3. Arbiter investigates + 4. If approved, arbiter calls `operator.refund()` + + **Use cases:** + - Product defects discovered later + - Service not as described + - Voluntary refund by merchant + + + +## Request status states + +Each payment supports one refund request, keyed by `paymentInfoHash`. The payer may only request again after cancelling the prior request. + +```mermaid +stateDiagram-v2 + [*] --> Pending: payer requestRefund() + Pending --> Approved: arbiter (via operator hook on capture/void/refund) + Pending --> Denied: arbiter deny() + Pending --> Refused: arbiter refuse() + Pending --> Cancelled: payer cancelRefundRequest() + Cancelled --> Pending: payer requestRefund() again + + note right of Approved + Status flips to Approved automatically + when the arbiter executes the refund + via operator.void() / operator.refund() + (wired through VOID_POST_ACTION_HOOK). + end note +``` + +## Key methods + +### requestRefund() + +Creates a refund request for this payment. Only the payer can call. + +```solidity +function requestRefund( + AuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint120 amount +) external +``` + +**Parameters:** +- `paymentInfo`: PaymentInfo struct +- `amount`: refund amount the payer is asking for (uint120) + +**Reverts** if a non-cancelled request already exists, if the payment is unknown to the canonical escrow, or if `amount == 0`. + +### cancelRefundRequest() + +Payer cancels their own pending request, freeing the slot to request again. + +```solidity +function cancelRefundRequest(AuthCaptureEscrow.PaymentInfo calldata paymentInfo) external +``` + +**Access:** only the payer. + +### deny() + +Arbiter rejects the claim after reviewing evidence. Terminal state. + +```solidity +function deny(AuthCaptureEscrow.PaymentInfo calldata paymentInfo) external +``` + +### refuse() + +Arbiter refuses to consider the request (spam, out of jurisdiction, invalid). Terminal state. + +```solidity +function refuse(AuthCaptureEscrow.PaymentInfo calldata paymentInfo) external +``` + +### Approval + +The contract has no `updateStatus` or explicit `approve` entrypoint. Approval happens automatically when the arbiter executes the refund through the operator (`operator.void()` or `operator.refund()`), which fires `VOID_POST_ACTION_HOOK` / `REFUND_POST_ACTION_HOOK` and flips status to `Approved`. + +### Query helpers + +- `getRefundRequest(paymentInfo)`: full `RefundRequestData` for this payment +- `hasRefundRequest(paymentInfo)`: boolean +- `getRefundRequestStatus(paymentInfo)`: just the `RequestStatus` +- `getPayerRefundRequests(payer, offset, count)` / `getReceiverRefundRequests(...)` / `getOperatorRefundRequests(...)`: paginated index lookups + +## Usage example + +```typescript +// 1. Payer files refund request +await refundRequest.write.requestRefund([paymentInfo, requestedAmount]) +// Status: Pending + +// 2a. Arbiter denies (terminal): +await refundRequest.write.deny([paymentInfo]) + +// 2b. Arbiter approves by executing the refund via the operator. +// The post-action hook auto-flips the request to Approved. +// Before capture: +await operator.write.void([paymentInfo, '0x']) +// After capture: +await operator.write.refund([paymentInfo, amount, tokenCollector, collectorData]) +``` + + +RefundRequest is the request-tracking layer. Executing the refund is always a separate call into the operator/escrow. + diff --git a/contracts/license.mdx b/contracts/license.mdx index ebd763e..27b7721 100644 --- a/contracts/license.mdx +++ b/contracts/license.mdx @@ -4,15 +4,15 @@ description: "BUSL-1.1 licensing terms, permissions, and restrictions for x402r icon: "scale-balanced" --- -## Why BUSL-1.1? +## Why BUSL-1.1 -We want the code to be fully readable and usable on-chain — you can integrate with deployed x402r contracts, build on top of them, and inspect every line of source. The license protects against forks that compete with or commoditize the protocol (e.g., stripping fees and redeploying), so that protocol fees can continue funding development, audits, and new features for everyone building on x402r. +The code is fully readable and usable on-chain. You can integrate with deployed x402r contracts, build on top of them, and inspect every line of source. The license protects against forks that compete with or commoditize the protocol (for example, stripping fees and redeploying), so that protocol fees can keep funding development, audits, and new features for everyone building on x402r. -BUSL-1.1 protects against that while keeping the code open. After the Change Date, everything converts to MIT and is fully permissionless. This is the same approach used by Uniswap, Aave, and other major DeFi protocols. +BUSL-1.1 protects against that while keeping the code open. After the Change Date, everything converts to MIT and is fully permissionless. Uniswap, Aave, and other major DeFi protocols use the same approach. ## License Terms -All Solidity source files in `x402r-contracts/src/` are licensed under the **Business Source License 1.1**. +The **Business Source License 1.1** covers all Solidity source files in `x402r-contracts/src/`. | Parameter | Value | |-----------|-------| @@ -24,41 +24,41 @@ All Solidity source files in `x402r-contracts/src/` are licensed under the **Bus | **Change Date** | December 9, 2029 | | **Change License** | MIT License | -## What Can You Do? +## What you can do -### On-Chain +### On-chain -You can freely interact with x402r contracts that are already deployed: +You can interact with deployed x402r contracts: -- **Integrate** — call x402r contracts from your own contracts or dApps -- **Build** — create applications, services, and protocols on top of x402r -- **Deploy via factories** — use x402r's official factories (e.g., `PaymentOperatorFactory`, `EscrowPeriodFactory`) to deploy your own operator instances with your own configuration +- **Integrate**: call x402r contracts from your own contracts or dApps +- **Build**: create applications, services, and protocols on top of x402r +- **Deploy via factories**: use x402r's official factories (for example, `PaymentOperatorFactory` and `EscrowPeriodFactory`) to deploy your own operator instances with your own configuration -### Off-Chain +### Off-chain -You can freely work with the source code: +You can work with the source code: - **Read and learn** from the code -- **Fork and modify** for local development and testing +- **Fork and adapt** for local development and testing - **Redistribute** the source code -- **Deploy locally** — spin up Anvil, Hardhat, or any local/test environment for integration testing +- **Deploy locally**: spin up Anvil, Hardhat, or any local or test environment for integration testing -### The One Restriction +### The one restriction -Do not deploy x402r contracts outside of the official factories — whether modified or unmodified. +Do not deploy x402r contracts outside of the official factories, whether modified or unmodified. Deploying through x402r's factories is the intended path and is always allowed. What you cannot do: - Take the source code and deploy your own instances outside of the factories -- Deploy a modified fork (e.g., removing fees, changing parameters) to any production chain -- Remove or modify the license notice +- Deploy a modified fork (for example, removing fees or changing parameters) to any production chain +- Remove or change the license notice Deploying to local chains, testnets, and private forks for **development and testing** is fine. ### Change Date -On **December 9, 2029** (or 4 years after the first public release of each version, whichever comes first), the license automatically converts to the **MIT License** — at which point you can deploy, fork, and do anything you want. +On **December 9, 2029** (or 4 years after the first public release of each version, whichever comes first), the license automatically converts to the **MIT License**, at which point you can deploy, fork, and do anything you want. ## Summary diff --git a/contracts/overview.mdx b/contracts/overview.mdx index 2d773c4..c0df1c2 100644 --- a/contracts/overview.mdx +++ b/contracts/overview.mdx @@ -4,9 +4,9 @@ description: "Introduction to x402r smart contracts and their relationship to co icon: "circle-info" --- -## What is x402r? +## What is x402r -x402r is a smart contract extension for HTTP-native refundable payments. It builds on [commerce-payments](https://github.com/BackTrackCo/commerce-payments) to add dispute resolution, escrow periods, and flexible refund capabilities. +x402r builds on the canonical [Commerce Payments Protocol](https://github.com/base/commerce-payments) to add dispute resolution, escrow periods, and refund capabilities. For the protocol-level introduction and the payer/merchant/arbiter model, see [What is x402r](/). This page covers the contract layer. ## Architecture Layers @@ -14,11 +14,7 @@ See the [system architecture diagrams](https://github.com/BackTrackCo/x402r-cont ## Commerce Payments (Base Layer) -[Commerce Payments](https://github.com/BackTrackCo/commerce-payments) provides the foundational payment infrastructure. - - -x402r uses a [fork of commerce-payments](https://github.com/BackTrackCo/commerce-payments) that adds **partial void** support for handling partially completed orders and partial refunds. - +The audited [Commerce Payments Protocol](https://github.com/base/commerce-payments) provides the foundational payment infrastructure. x402r uses the canonical contracts directly (no fork) at their universal CREATE2 addresses. ### AuthCaptureEscrow @@ -26,18 +22,17 @@ Core escrow contract for holding ERC-20 tokens during payments. **Features:** - Authorization-based deposits (no direct transfers) -- Payment state machine: `NonExistent` → `InEscrow` → `Released` → `Settled` +- Per-payment state queried via `paymentState(hash)` → `(hasCollected, capturableAmount, ...)` - Void/reclaim for failed authorizations -- **Partial void support** (x402r addition) - Refund partial amounts during escrow -- Operator-based access control +- CaptureAuthorizer-based access control **Key Methods:** ```solidity -authorize(paymentId, payer, receiver, amount, token, operator) -charge(paymentId, amount) -release(paymentId) -void(paymentId) -reclaim(paymentId, receiver, amount) +authorize(paymentInfo, amount, tokenCollector, collectorData) +charge(paymentInfo, amount, tokenCollector, collectorData) +capture(paymentInfo, amount, data) +void(paymentInfo, data) +reclaim(paymentInfo, data) ``` ### ERC3009PaymentCollector @@ -73,19 +68,19 @@ x402r extends commerce-payments with flexible payment capabilities: - Fee recipient for protocol and operator fee distribution - Configurable authorization via conditions (not hardcoded roles) - Refund request states: `Pending` → `Approved`/`Denied`/`Cancelled` -- In-escrow refunds (during escrow period) -- Post-escrow refunds (after release) +- Voids (during escrow period) +- Refunds (after capture) - Support for marketplace, subscription, streaming, and custom flows ### 2. Pluggable Condition System **Conditions (ICondition)** - Authorization checks before actions -**Recorders (IRecorder)** - State updates after actions +**Hooks (IHook)** - State updates after actions **10-slot configuration per operator:** -- 5 condition slots (before action): authorize, charge, release, refundInEscrow, refundPostEscrow -- 5 recorder slots (after action): state tracking for each action +- 5 condition slots (before action): `authorize`, `charge`, `capture`, `void`, `refund` +- 5 hook slots (after action): state tracking for each action **Benefits:** - Configure operator behavior without redeploying @@ -95,14 +90,14 @@ x402r extends commerce-payments with flexible payment capabilities: ### 3. Time-Based Escrow & Freeze Policies -**EscrowPeriod** - Combined recorder and condition that tracks authorization time and enforces escrow period +**EscrowPeriod** - Combined hook and condition that tracks authorization time and enforces escrow period -**Freeze** - Standalone condition that blocks release when payment is frozen (with configurable freeze/unfreeze authorization) +**Freeze** - Standalone condition that blocks capture while a freeze remains active (with configurable freeze/unfreeze authorization) **Key features:** -- Configurable escrow periods (e.g., 7 days, 14 days) +- Configurable escrow periods (for example, 7 days or 14 days) - Payer-initiated freezes to stop suspicious releases -- Time-limited freeze durations (e.g., 3 days) +- Time-limited freeze durations (for example, 3 days) - MEV protection via private mempool support - Composable via `AndCondition([escrowPeriod, freeze])` @@ -112,15 +107,15 @@ x402r extends commerce-payments with flexible payment capabilities: ### 5. Factory Pattern -**PaymentOperatorFactory** - Deploys operators with deterministic CREATE3 addresses +**PaymentOperatorFactory** - Deploys operators with deterministic CREATE2 addresses **EscrowPeriodFactory** - Deploys EscrowPeriod contracts **FreezeFactory** - Deploys Freeze condition contracts -Plus factories for: StaticFeeCalculator, StaticAddressCondition, AndCondition, OrCondition, NotCondition, RecorderCombinator. +Plus factories for: StaticFeeCalculator, StaticAddressCondition, AndCondition, OrCondition, NotCondition, HookCombinator. -All factories use **unified CREATE3 addresses** — same address on every supported chain. +All factories use **universal CREATE2 addresses**: same address on every supported chain. **Benefits:** - Predictable addresses for off-chain address generation @@ -134,13 +129,13 @@ All factories use **unified CREATE3 addresses** — same address on every suppor | Feature | Commerce Payments | x402r | |---------|------------------|-------| | **Refunds** | Manual void/reclaim | Structured refund requests with configurable approval | -| **Escrow Period** | Not enforced | Configurable time-lock before release | +| **Escrow Period** | Not enforced | Configurable time-lock before capture | | **Dispute Resolution** | Not built-in | Arbiter workflow via conditions, signatures, and evidence | | **Authorization** | Operator-based only | Pluggable conditions (access, time, signature, combinators) | | **Freeze Mechanism** | Not available | Configurable freeze during escrow period | -| **Deployment** | Direct deployment | Factory pattern with unified CREATE3 (same address every chain) | +| **Deployment** | Direct deployment | Factory pattern with universal CREATE2 (same address every chain) | | **Fees** | Not enforced | Additive protocol + operator fees with 7-day timelock | -| **Multi-chain** | Per-chain deployment | Unified CREATE3 addresses across 11 chains | +| **Multi-chain** | Per-chain deployment | Universal CREATE2 addresses on supported chains | ## Use Cases @@ -163,7 +158,7 @@ All factories use **unified CREATE3 addresses** — same address on every suppor Most contracts are immutable to prevent rug pulls and ensure trustlessness: - **PaymentOperator** - Cannot pause or upgrade -- **EscrowPeriod** - Cannot modify escrow period +- **EscrowPeriod** - Cannot change escrow period - **Freeze** - Cannot change freeze rules after deployment Protocol fee configuration is mutable via `ProtocolFeeConfig` (with 7-day timelock). Operator fees are immutable. @@ -173,21 +168,21 @@ Protocol fee configuration is mutable via `ProtocolFeeConfig` (with 7-day timelo - Condition singletons deployed once, reused everywhere - CREATE2 for deterministic addresses (no registry lookups) - Minimal storage in operators (conditions are stateless) -- Recorder pattern separates state from logic +- Hook pattern separates state from logic -- Conditions can be combined with And/Or/Not logic +- Conditions compose with And/Or/Not logic - Operators can share condition implementations - Factories enable on-demand instance deployment -- Stateless conditions work across multiple operators +- Stateless conditions work across many operators - Reentrancy guards on all state changes - 7-day timelock on protocol fee changes - Two-step ownership transfers -- Comprehensive event logging for monitoring +- Detailed event logging for monitoring ## Architecture Overview diff --git a/contracts/payment-operator.mdx b/contracts/payment-operator.mdx index 8e4a827..2357aee 100644 --- a/contracts/payment-operator.mdx +++ b/contracts/payment-operator.mdx @@ -10,15 +10,15 @@ The main payment operator contract with pluggable conditions for flexible author - **Type:** Operator instance (one per fee recipient + configuration) - **Deployment:** Via PaymentOperatorFactory -- **Immutability:** Cannot be paused or upgraded -- **Configuration:** 10 slots for conditions and recorders +- **Immutability:** No pause switch, no upgrade path +- **Configuration:** 10 slots for conditions and hooks - **Use Cases:** Marketplace, subscriptions, streaming, grants, custom flows ## Immutable Fields ```solidity address public immutable ESCROW; // AuthCaptureEscrow address -address public immutable FEE_RECIPIENT; // Operator fee recipient +address public immutable FEE_RECEIVER; // Operator fee recipient ProtocolFeeConfig public immutable PROTOCOL_FEE_CONFIG; // Shared protocol fee config IFeeCalculator public immutable FEE_CALCULATOR; // Operator fee calculator ``` @@ -36,21 +36,21 @@ mapping(bytes32 paymentInfoHash => AuthorizedFees) public authorizedFees; ## 10-Slot Configuration -1. **AUTHORIZE_CONDITION** - Who can authorize payments -2. **CHARGE_CONDITION** - Who can charge partial amounts -3. **RELEASE_CONDITION** - Who can release from escrow -4. **REFUND_IN_ESCROW_CONDITION** - Who can refund during escrow -5. **REFUND_POST_ESCROW_CONDITION** - Who can refund after release +1. **AUTHORIZE_PRE_ACTION_CONDITION** - Who can authorize payments +2. **CHARGE_PRE_ACTION_CONDITION** - Who can charge partial amounts +3. **CAPTURE_PRE_ACTION_CONDITION** - Who can capture funds from escrow +4. **VOID_PRE_ACTION_CONDITION** - Who can refund during escrow +5. **REFUND_PRE_ACTION_CONDITION** - Who can refund after capture **Default:** `address(0)` = always allow - -1. **AUTHORIZE_RECORDER** - Record authorization (e.g., timestamp) -2. **CHARGE_RECORDER** - Record charge event -3. **RELEASE_RECORDER** - Record release -4. **REFUND_IN_ESCROW_RECORDER** - Record in-escrow refund -5. **REFUND_POST_ESCROW_RECORDER** - Record post-escrow refund + +1. **AUTHORIZE_POST_ACTION_HOOK** - Record authorization (for example, timestamp) +2. **CHARGE_POST_ACTION_HOOK** - Record charge event +3. **CAPTURE_POST_ACTION_HOOK** - Record capture +4. **VOID_POST_ACTION_HOOK** - Record void +5. **REFUND_POST_ACTION_HOOK** - Record refund **Default:** `address(0)` = no recording (no-op) @@ -77,17 +77,17 @@ function authorize( - `collectorData` - Data to pass to the token collector **Flow:** -1. Check `AUTHORIZE_CONDITION` (if set) -2. Validate fee bounds compatibility +1. Check `AUTHORIZE_PRE_ACTION_CONDITION` (if set) +2. Check fee bounds compatibility 3. Store fees at authorization time (prevents protocol fee changes from breaking capture) 4. Call `escrow.authorize()` -5. Call `AUTHORIZE_RECORDER` (if set) -6. Emit `AuthorizationCreated` +5. Call `AUTHORIZE_POST_ACTION_HOOK` (if set) +6. Emit `AuthorizeExecuted` -**Access:** Controlled by `AUTHORIZE_CONDITION` (default: anyone) +**Access:** Controlled by `AUTHORIZE_PRE_ACTION_CONDITION` (default: anyone) -**Authorization Expiry:** The `PaymentInfo` struct includes an `authorizationExpiry` field (from base commerce-payments). Set this to `type(uint48).max` for no expiry, or specify a timestamp to allow the payer to reclaim funds after expiry. This is useful for subscription-based payments where you want to limit the authorization window. +**Authorization Expiry:** The `PaymentInfo` struct includes an `authorizationExpiry` field (from base commerce-payments). Set this to `type(uint48).max` for no expiry, or specify a timestamp to let the payer reclaim funds after expiry. Subscription-based payments use this to bound the authorization window. ### charge() @@ -110,43 +110,45 @@ function charge( - `collectorData` - Data to pass to the token collector **Flow:** -1. Check `CHARGE_CONDITION` (if set) -2. Validate fee bounds compatibility +1. Check `CHARGE_PRE_ACTION_CONDITION` (if set) +2. Check fee bounds compatibility 3. Call `escrow.charge()` - funds go directly to receiver 4. Accumulate protocol fees for later distribution -5. Call `CHARGE_RECORDER` (if set) +5. Call `CHARGE_POST_ACTION_HOOK` (if set) 6. Emit `ChargeExecuted` -**Access:** Controlled by `CHARGE_CONDITION` (default: anyone) +**Access:** Controlled by `CHARGE_PRE_ACTION_CONDITION` (default: anyone) -Unlike `authorize()`, funds go directly to receiver without escrow hold. Refunds are only possible via `refundPostEscrow()`. +Unlike `authorize()`, funds go directly to receiver without escrow hold. Refunds are only possible via `refund()`. -### release() +### capture() Releases funds from escrow to receiver (capture). ```solidity -function release( +function capture( AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint256 amount + uint256 amount, + bytes calldata data ) external nonReentrant ``` **Parameters:** - `paymentInfo` - Payment info struct -- `amount` - Amount to release +- `amount` - Amount to capture +- `data` - Optional pass-through data for the pre/post action plugins **Flow:** -1. Check `RELEASE_CONDITION` (if set) +1. Check `CAPTURE_PRE_ACTION_CONDITION` (if set) 2. Use fees stored at authorization time 3. Call `escrow.capture()` 4. Accumulate protocol fees for later distribution -5. Call `RELEASE_RECORDER` (if set) -6. Emit `ReleaseExecuted` +5. Call `CAPTURE_POST_ACTION_HOOK` (if set) +6. Emit `CaptureExecuted` -**Access:** Controlled by `RELEASE_CONDITION` +**Access:** Controlled by `CAPTURE_PRE_ACTION_CONDITION` **Marketplace example:** Receiver OR StaticAddressCondition(arbiter) + escrow passed @@ -154,42 +156,46 @@ function release( **DAO example:** StaticAddressCondition(daoMultisig) -### refundInEscrow() +### void() -Refunds payment while still in escrow (partial void). +Returns all escrowed funds to the payer before capture. Full-only: `escrow.void()` empties the authorization in one transaction. ```solidity -function refundInEscrow( +function void( AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint120 amount + bytes calldata data ) external nonReentrant ``` **Parameters:** - `paymentInfo` - Payment info struct -- `amount` - Amount to return to payer +- `data` - Optional pass-through data for the pre/post action plugins **Flow:** -1. Check `REFUND_IN_ESCROW_CONDITION` (if set) -2. Call `escrow.partialVoid()` to return funds to payer -3. Call `REFUND_IN_ESCROW_RECORDER` (if set) -4. Emit `RefundInEscrowExecuted` +1. Check `VOID_PRE_ACTION_CONDITION` (if set) +2. Call `escrow.void()` to return escrowed funds to payer +3. Call `VOID_POST_ACTION_HOOK` (if set) +4. Emit `VoidExecuted` -**Access:** Controlled by `REFUND_IN_ESCROW_CONDITION` +**Access:** Controlled by `VOID_PRE_ACTION_CONDITION` -**Marketplace example:** StaticAddressCondition(arbiter) - disputes +**Marketplace example:** StaticAddressCondition(arbiter) for disputes **Return policy example:** Receiver OR StaticAddressCondition(arbiter) **DAO example:** StaticAddressCondition(daoMultisig) -**Subscription example:** address(0) - no refunds +**Subscription example:** address(0), no voids allowed -### refundPostEscrow() + +For a partial return, call `capture()` for the amount to keep. Then `void()` the unused authorization, or let the payer reclaim it after `captureDeadline`. + -Refunds payment after it has been released (captured). +### refund() + +Refunds a payment after capture (after the receiver has the funds). ```solidity -function refundPostEscrow( +function refund( AuthCaptureEscrow.PaymentInfo calldata paymentInfo, uint256 amount, address tokenCollector, @@ -201,20 +207,20 @@ function refundPostEscrow( - `paymentInfo` - Payment info struct - `amount` - Amount to refund to payer - `tokenCollector` - Address of the token collector that will source the refund -- `collectorData` - Data to pass to the token collector (e.g., signatures) +- `collectorData` - Data to pass to the token collector (for example, signatures) **Flow:** -1. Check `REFUND_POST_ESCROW_CONDITION` (if set) +1. Check `REFUND_PRE_ACTION_CONDITION` (if set) 2. Call `escrow.refund()` - token collector enforces permission -3. Call `REFUND_POST_ESCROW_RECORDER` (if set) -4. Emit `RefundPostEscrowExecuted` +3. Call `REFUND_POST_ACTION_HOOK` (if set) +4. Emit `RefundExecuted` -**Access:** Controlled by `REFUND_POST_ESCROW_CONDITION`. Permission is also enforced by the token collector (e.g., receiver must have approved it, or collectorData contains receiver's signature). +**Access:** Controlled by `REFUND_PRE_ACTION_CONDITION`. The token collector also enforces permission (for example, the receiver must have approved it, or `collectorData` contains the receiver's signature). **Marketplace example:** StaticAddressCondition(arbiter) - post-delivery disputes **Return policy example:** Receiver - voluntary returns -**Most configurations:** address(0) - no post-escrow refunds +**Most configurations:** address(0) - no refunds ## Fee System (Modular, Additive) @@ -231,23 +237,17 @@ ProtocolFeeConfig public immutable PROTOCOL_FEE_CONFIG; IFeeCalculator public immutable FEE_CALCULATOR; // Fee recipients -address public immutable FEE_RECIPIENT; // Operator fee recipient +address public immutable FEE_RECEIVER; // Operator fee recipient // Protocol fee recipient is on ProtocolFeeConfig // Fee tracking for accurate distribution mapping(address token => uint256) public accumulatedProtocolFees; ``` -**Example Fee Calculation (Additive):** - -For a 1000 USDC payment: -- **Protocol Fee:** 3 bps (0.03%) = 0.30 USDC -> goes to `protocolFeeRecipient` -- **Operator Fee:** 2 bps (0.02%) = 0.20 USDC -> goes to `FEE_RECIPIENT` -- **Total Fee:** 5 bps (0.05%) = 0.50 USDC -- **Receiver Gets:** 999.50 USDC +Fees are additive: `totalFee = protocolFee + operatorFee`, split between `protocolFeeRecipient` and the operator's `FEE_RECEIVER`. For a worked example with concrete amounts, see the [Fee System](/contracts/fees#example-calculation). **Fee Locking:** -Fees are calculated and stored at `authorize()` time in `authorizedFees[hash]`. This prevents protocol fee changes from breaking capture of already-authorized payments. +The operator calculates fees at `authorize()` time and stores them in `authorizedFees[hash]`. This stops later protocol fee changes from breaking capture of already-authorized payments. ```solidity struct AuthorizedFees { @@ -257,7 +257,7 @@ struct AuthorizedFees { mapping(bytes32 paymentInfoHash => AuthorizedFees) public authorizedFees; ``` -**FEE_RECIPIENT** can be: +**FEE_RECEIVER** can be: - Arbiter (marketplace with disputes) - Service Provider (subscriptions) - Platform Treasury (platform-controlled) @@ -265,28 +265,18 @@ mapping(bytes32 paymentInfoHash => AuthorizedFees) public authorizedFees; ### Fee Distribution -Fees accumulate in the operator contract and are distributed via `distributeFees(token)`: +Fees accumulate in the operator contract. Call `distributeFees(token)` to disburse them: ```solidity // Anyone can call to distribute fees for a token operator.distributeFees(usdcAddress); // Protocol share -> protocolFeeRecipient -// Operator share -> FEE_RECIPIENT +// Operator share -> FEE_RECEIVER ``` ### Protocol Fee Changes (7-day Timelock) -Protocol fee calculator changes require a 7-day timelock on `ProtocolFeeConfig`: - -```solidity -// Step 1: Queue new calculator -protocolFeeConfig.queueCalculator(newCalculatorAddress); - -// Step 2: Wait 7 days - -// Step 3: Execute -protocolFeeConfig.executeCalculator(); -``` +Protocol fee calculator and recipient changes require a 7-day timelock on `ProtocolFeeConfig`. See [Fee System: 7-day timelock](/contracts/fees#calculator-changes-7-day-timelock) for the full queue, wait, execute workflow. Protocol fee changes require 7-day timelock. Operator fees are immutable (set at deploy time). @@ -297,7 +287,7 @@ Protocol fee changes require 7-day timelock. Operator fees are immutable (set at - **ReentrancyGuardTransient** - EIP-1153 transient storage for gas-efficient reentrancy protection - **Ownership** - Solady's Ownable with 2-step transfer - **Timelock** - 7-day delay on protocol fee changes (operator fees are immutable) -- **Immutable Core** - Escrow, conditions, and fee configuration cannot be changed +- **Immutable Core** - Escrow, conditions, and fee configuration stay fixed after deployment ## Next Steps diff --git a/contracts/periphery/auth-capture-escrow.mdx b/contracts/periphery/auth-capture-escrow.mdx index aaf32bc..3d18a00 100644 --- a/contracts/periphery/auth-capture-escrow.mdx +++ b/contracts/periphery/auth-capture-escrow.mdx @@ -1,131 +1,138 @@ --- title: "Commerce Payments" -description: "AuthCaptureEscrow and ERC3009PaymentCollector — the base layer from commerce-payments" +description: "AuthCaptureEscrow and the canonical token collectors that form the auth-capture base layer" icon: "vault" --- -x402r builds on the [Commerce Payments Protocol](https://github.com/base/commerce-payments). Two contracts from this stack form the base layer: **AuthCaptureEscrow** (holds funds) and **ERC3009PaymentCollector** (collects funds via signed authorizations). +x402r builds on the canonical [Commerce Payments Protocol](https://github.com/base/commerce-payments) (no fork). Three contracts from this stack form the base layer: - -x402r uses a [fork of commerce-payments](https://github.com/BackTrackCo/commerce-payments) that adds **partial void** support for handling partially completed orders and partial refunds. - +- **AuthCaptureEscrow**: singleton escrow that holds funds and gates lifecycle actions on the `captureAuthorizer` (committed on-chain as `PaymentInfo.operator`). +- **ERC3009PaymentCollector**: collects funds via signed ERC-3009 `receiveWithAuthorization`. +- **Permit2PaymentCollector**: collects funds via Uniswap Permit2 `permitTransferFrom`. + +All three sit at universal CREATE2 addresses (same address on every supported chain). + +| Contract | Canonical address | +|---|---| +| `AuthCaptureEscrow` | `0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff` | +| `ERC3009PaymentCollector` | `0x0E3dF9510de65469C4518D7843919c0b8C7A7757` | +| `Permit2PaymentCollector` | `0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26` | ## AuthCaptureEscrow Core escrow contract for holding ERC-20 tokens during the payment lifecycle. -- **Type:** Singleton (one per network) -- **Access:** Operator-based (only registered operators can manage payments) -- **Address:** `0xe050bB89eD43BB02d71343063824614A7fb80B77` (all chains) - ### Payment State Machine ```mermaid stateDiagram-v2 [*] --> NonExistent NonExistent --> InEscrow: authorize() - InEscrow --> Released: release() - InEscrow --> Settled: void() / refundInEscrow() - Released --> Settled: reclaim() / refundPostEscrow() + InEscrow --> Captured: capture() + InEscrow --> Settled: void() + Captured --> Settled: reclaim() / refund() Settled --> [*] note right of InEscrow - Funds locked in escrow - Payer can reclaim after expiry + Funds locked in escrow. + Payer can reclaim after captureDeadline. end note - note right of Released - Funds transferred to receiver - Can still refund post-escrow + note right of Captured + Funds transferred to receiver. + Can still refund within refundDeadline. end note note right of Settled - Terminal state - No further actions possible + Terminal state. + No further actions possible. end note ``` -### Key Methods +### Key methods #### authorize() -Locks tokens in escrow. Called by operator. +Pulls funds into escrow via the token collector. Only the `captureAuthorizer` (typically a facilitator EOA, or a smart contract acting as captureAuthorizer) can call it. ```solidity function authorize( - bytes32 paymentId, - address payer, - address receiver, + PaymentInfo calldata paymentInfo, uint256 amount, - address token, - address operator -) external onlyOperator + address tokenCollector, + bytes calldata collectorData +) external ``` -**Requires:** Payer has approved escrow contract for `amount` of `token` - - -The base escrow contract uses individual parameters (paymentId, payer, receiver, etc.) while the PaymentOperator wraps them in a `PaymentInfo` struct. The operator translates between the two formats internally. - +`tokenCollector` is `ERC3009PaymentCollector` or `Permit2PaymentCollector` depending on `assetTransferMethod` in the scheme `extra`. `collectorData` carries the raw ERC-3009 signature or the ABI-encoded Permit2 signature. -#### release() +#### charge() -Releases tokens to receiver. Called by operator. +Single-shot atomic settlement: pulls funds and transfers directly to the receiver, no escrow hold. ```solidity -function release( - bytes32 paymentId -) external onlyOperator returns (uint256 amount) +function charge( + PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData +) external ``` -**State change:** `InEscrow` -> `Released` +#### capture() + +Releases escrowed funds to the receiver, minus fees. + +```solidity +function capture( + PaymentInfo calldata paymentInfo, + uint256 amount, + uint16 feeBps, + address feeReceiver +) external +``` #### void() -Returns tokens to payer (full refund). Called by operator. +Returns all escrowed funds to the payer. Full-only: `void()` empties the authorization in one transaction. ```solidity -function void( - bytes32 paymentId -) external onlyOperator +function void(PaymentInfo calldata paymentInfo) external ``` -**State change:** `InEscrow` -> `Settled` - #### reclaim() -Takes tokens back from receiver to give to payer. Called by operator. +Payer-only: gated by `onlySender(paymentInfo.payer)`. The payer can pull funds back out of escrow after `captureDeadline` if the captureAuthorizer never captured. No third party (including the operator or arbiter) can call `reclaim` on the payer's behalf. ```solidity -function reclaim( - bytes32 paymentId, - address from, - uint256 amount -) external onlyOperator +function reclaim(PaymentInfo calldata paymentInfo) external ``` -**State change:** `Released` -> `Settled` - -**Requires:** Receiver has approved escrow for `amount` +#### refund() -#### partialVoid() - -Returns partial amount to payer (x402r addition). +Returns funds to the payer after capture, sourced via a token collector (typically pulled from the merchant's balance). ```solidity -function partialVoid( - bytes32 paymentId, - uint256 amount -) external onlyOperator +function refund( + PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData +) external ``` -**Use case:** Partial refunds for partially fulfilled orders +### Access control -### Security Features +Lifecycle actions (`authorize`, `charge`, `capture`, `void`, `refund`) check `msg.sender` against `PaymentInfo.operator` (the captureAuthorizer). Anyone can call `reclaim` after `captureDeadline`. -- **Operator whitelist** - Only registered operators can manage payments -- **Reentrancy protection** - All state changes protected -- **Event logging** - Complete audit trail +The escrow has no global "operator whitelist." Access is per-payment, governed by the signed `PaymentInfo`. + +### Security features + +- **Replay prevention**: each payment has a unique nonce derived from `(chainId, escrowAddress, paymentInfoHash)`, consumed on-chain at settlement +- **Fee bounds enforcement**: the client signs `minFeeBps` / `maxFeeBps` / `feeReceiver` in `PaymentInfo`; the escrow rejects out-of-bounds captures/charges +- **Expiry ordering**: contract enforces `preApprovalExpiry <= authorizationExpiry <= refundExpiry` +- **Reentrancy protection** on all state-changing entry points --- @@ -133,35 +140,42 @@ function partialVoid( Collects ERC-20 tokens into escrow using the client's off-chain ERC-3009 signature. The payer never submits a transaction. -- **Type:** Singleton (one per network) -- **Address:** `0xcE66Ab399EDA513BD12760b6427C87D6602344a7` (all chains) - -### How It Works +### How it works -The operator calls the token collector during `authorize()` or `charge()`, passing the client's signature as `collectorData`. The collector executes `receiveWithAuthorization` (ERC-3009) to pull tokens from the payer into escrow. +The escrow calls the token collector during `authorize()` or `charge()`, passing the client's signature as `collectorData`. The collector executes `receiveWithAuthorization` (ERC-3009) to pull tokens from the payer. ### Features -- **ERC-3009 `receiveWithAuthorization()`** - Gasless token transfers via signed messages -- **EIP-6492 support** - Handles smart wallet clients with deployment bytecode in signatures -- **Nonce-based replay protection** - Each authorization can only be used once -- **Deadline-based expiry** - `validBefore` timestamp prevents stale authorizations +- **ERC-3009 `receiveWithAuthorization()`**: gasless token transfers via signed messages +- **EIP-6492 support**: handles smart wallet clients with deployment bytecode in signatures (via `ERC6492SignatureHandler`) +- **Nonce-based replay protection**: each authorization can run only once; the nonce is the payer-agnostic `PaymentInfo` hash +- **Deadline-based expiry**: `validBefore` (typically `now + maxTimeoutSeconds`) blocks stale authorizations -### ERC-3009 Signature +### ERC-3009 signature The client signs an EIP-712 typed data message with primary type `ReceiveWithAuthorization`: ```typescript const authorization = { from: payerAddress, // Who is paying - to: tokenCollectorAddress, // ERC3009PaymentCollector + to: tokenCollectorAddress, // ERC3009PaymentCollector (canonical) value: amount, // Amount in token decimals validAfter: 0, // Earliest valid time (0 = immediately) validBefore: deadline, // Latest valid time - nonce: derivedNonce // Deterministic nonce from payment params -}; + nonce: derivedNonce, // Payer-agnostic PaymentInfo hash +} ``` -The escrow scheme uses `receiveWithAuthorization` (not `transferWithAuthorization`). The token collector is the `to` address, which then routes tokens to the escrow contract. +The auth-capture scheme uses `receiveWithAuthorization` (not `transferWithAuthorization`). The token collector is the `to` address, which then routes tokens into the escrow. + +--- + +## Permit2PaymentCollector + +Collects ERC-20 tokens through Uniswap Permit2 `permitTransferFrom`. The operator selects this collector when `assetTransferMethod === "permit2"` in the scheme `extra`. Any ERC-20 the payer has approved Permit2 for becomes spendable through this collector. + +The client signs a Permit2 `PermitTransferFrom`; the deterministic nonce binds the merchant address, removing the need for a separate witness struct. + +See the [auth-capture wire format](/x402-integration/auth-capture/wire-format) for the full Permit2 wire format. diff --git a/contracts/periphery/overview.mdx b/contracts/periphery/overview.mdx index 454046b..195fad4 100644 --- a/contracts/periphery/overview.mdx +++ b/contracts/periphery/overview.mdx @@ -4,52 +4,56 @@ description: "Supporting contracts that extend the PaymentOperator: escrow, refu icon: "puzzle-piece" --- -## What Are Periphery Contracts? +## What are periphery contracts -Periphery contracts support the [PaymentOperator](/contracts/payment-operator) but are not the operator itself. They handle escrow storage, token collection, refund workflows, and evidence submission. +Periphery contracts support the [PaymentOperator](/contracts/payment-operator) without being the operator itself. They handle escrow storage, token collection, refund workflows, and evidence submission. ## Contract Map | Contract | Role | Type | |----------|------|------| -| [Commerce Payments](/contracts/periphery/auth-capture-escrow) | AuthCaptureEscrow + ERC3009PaymentCollector (base layer) | Singleton | -| [RefundRequest](/contracts/periphery/refund-request) | Tracks refund request lifecycle and approvals | Singleton | -| [RefundRequestEvidence](/contracts/periphery/refund-request-evidence) | On-chain evidence submission for disputes | Singleton | -| [ReceiverRefundCollector](/contracts/periphery/receiver-refund-collector) | Pulls funds from receiver for post-escrow refunds | Singleton | +| [Commerce Payments](/contracts/periphery/auth-capture-escrow) | AuthCaptureEscrow + ERC3009PaymentCollector + Permit2PaymentCollector (base layer) | Singleton | +| [RefundRequestEvidence](/contracts/periphery/refund-request-evidence) | On-chain evidence submission tied to RefundRequest | Singleton | +| [ReceiverRefundCollector](/contracts/periphery/receiver-refund-collector) | Pulls funds from receiver for refunds (after capture) | Singleton | + + +**RefundRequest** is a hook plugin, see [Hooks: RefundRequest](/contracts/hooks/refund-request). + ## Contract Addresses -All periphery contracts use **unified CREATE3 addresses** — the same address on every supported chain. +All periphery contracts use **universal CREATE2 addresses**: the same address on every supported chain. | Contract | Address | |----------|---------| -| AuthCaptureEscrow | `0xe050bB89eD43BB02d71343063824614A7fb80B77` | -| ERC3009PaymentCollector | `0xcE66Ab399EDA513BD12760b6427C87D6602344a7` | -| ProtocolFeeConfig | `0x7e868A42a458fa2443b6259419aA6A8a161E08c8` | -| ReceiverRefundCollector | `0xE5500a38BE45a6C598420fbd7867ac85EC451A07` | -| RefundRequestEvidence | `0xF97aAB816b7cbe53025454ad05b03cf5C361F1BA` | +| AuthCaptureEscrow | `0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff` | +| ERC3009PaymentCollector | `0x0E3dF9510de65469C4518D7843919c0b8C7A7757` | +| Permit2PaymentCollector | `0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26` | +| ProtocolFeeConfig | `0xBe2d24614F339a1eB103A399F93AA2a39Ca815Bc` | +| ReceiverRefundCollector | `0x88C9826dFA17Ad9d3a726015C667dD995394D341` | +| RefundRequestEvidence | `0x4089A5A853e9eF35f504B842795fB272dF69c739` | ### Factories | Factory | Address | |---------|---------| -| PaymentOperatorFactory | `0x4D9BC2Ba2D0d9AFb6B63E3afBbfC95143E6E8Da9` | -| EscrowPeriodFactory | `0x15DB06aADEB3a39D47756Bf864a173cc48bafe24` | -| FreezeFactory | `0xdf129EFFE040c3403aca597c0F0bb704859a78Fd` | -| StaticFeeCalculatorFactory | `0x6CDdBdB46e2d7Caae31A6b213B59a1412d7f16Ac` | -| StaticAddressConditionFactory | `0xfB09350b200fda7dDd06565F5296A0CA625311d5` | -| AndConditionFactory | `0x5a1F3b6d030D25a2B86aAE469Ae1216ef3be308D` | -| OrConditionFactory | `0x101B2fac8cdC6348E541A0ef087275dA62AA13A0` | -| NotConditionFactory | `0x1D58f97843579356863d3393ebe24feEd76ceefF` | -| RecorderCombinatorFactory | `0xACf2b5e21CFc14135C9cD43ebE96a481F184C1A1` | +| PaymentOperatorFactory | `0xa0d4734842df1690a5B33Cb21828c946e39D55a2` | +| EscrowPeriodFactory | `0xe72D2014ebC48F1d92521e8629574918E8030548` | +| FreezeFactory | `0xeC092cf1215DB44af0Abe87c1157E304FEa5d0Eb` | +| StaticFeeCalculatorFactory | `0x97F99AB01F86b480f751B7b81166Dbe1F113e6C3` | +| StaticAddressConditionFactory | `0x77B379390750E1d3F802cC220926694D2454903E` | +| AndConditionFactory | `0x2B07d750C639b65a26e43F1FDCE404b21DCf16D9` | +| OrConditionFactory | `0x0519a37c0A996DD5F1e81e07b4aD3B24C257BC90` | +| NotConditionFactory | `0xb9c3223D059C3cAbD482bB54f3d7cD52DE70A9ae` | +| HookCombinatorFactory | `0x30B5373FD791D2d7b28C3B8020EB68b032f3f960` | ### Condition Singletons | Condition | Address | |-----------|---------| -| PayerCondition | `0x33F5F1154A02d0839266EFd23Fd3b85a3505bB4B` | -| ReceiverCondition | `0xF41974A853940Ff4c18d46B6565f973c1180E171` | -| AlwaysTrueCondition | `0xb295df7E7f786fd84D614AB26b1f2e86026C3483` | +| PayerCondition | `0x586486394C38A2a7d36B16a3FDaF366cd202d823` | +| ReceiverCondition | `0x321651df4593DA57C413579c5b611D1A90168a3A` | +| AlwaysTrueCondition | `0x2ef2A6162aEF9Df1022ff51c011af94D99AB4904` | All addresses are available programmatically via `@x402r/core`'s `getChainConfig(chainId)`. See [SDK Overview](/sdk/overview) for details. @@ -61,7 +65,7 @@ All addresses are available programmatically via `@x402r/core`'s `getChainConfig AuthCaptureEscrow and ERC3009PaymentCollector. - + Refund request lifecycle and approvals. diff --git a/contracts/periphery/receiver-refund-collector.mdx b/contracts/periphery/receiver-refund-collector.mdx index 66640fa..4c1eb2f 100644 --- a/contracts/periphery/receiver-refund-collector.mdx +++ b/contracts/periphery/receiver-refund-collector.mdx @@ -1,29 +1,29 @@ --- title: "ReceiverRefundCollector" -description: "Pulls funds from receiver for post-escrow refunds" +description: "Pulls funds from receiver for refunds" icon: "arrow-rotate-left" --- ## Overview - **Type:** Singleton (one per network) -- **Purpose:** Collect tokens from the receiver to refund the payer after escrow release -- **Address:** `0xE5500a38BE45a6C598420fbd7867ac85EC451A07` (all chains) +- **Purpose:** Collect tokens from the receiver to refund the payer after capture +- **Address:** `0x88C9826dFA17Ad9d3a726015C667dD995394D341` (all chains) ## Features -- **Post-escrow refunds** - Pulls funds from the receiver's wallet after funds have already been released -- **Receiver approval required** - The receiver must have approved the collector contract or provided a signature -- **Operator integration** - Called by `operator.refundPostEscrow()` via the token collector interface +- **Refunds (after capture)** - Pulls funds from the receiver's wallet after the escrow has already released them +- **Receiver approval required** - The receiver must approve the collector contract or supply a signature +- **Operator integration** - `operator.refund()` invokes the collector through the token collector interface -## How It Works +## How it works -After funds have been released to the receiver (state: `Released`), refunds require pulling tokens back from the receiver's wallet. The `ReceiverRefundCollector` handles this by: +Once the escrow has released funds to the receiver (state: `Captured`), refunding the payer requires pulling tokens back out of the receiver's wallet. The `ReceiverRefundCollector` handles that flow: -1. Operator calls `refundPostEscrow(paymentInfo, amount, receiverRefundCollector, collectorData)` +1. Operator calls `refund(paymentInfo, amount, receiverRefundCollector, collectorData)` 2. The collector transfers tokens from the receiver to the escrow contract 3. The escrow contract returns tokens to the payer -The receiver must have pre-approved the `ReceiverRefundCollector` for token transfers, or the `collectorData` must contain a valid receiver signature authorizing the refund. +The receiver must pre-approve the `ReceiverRefundCollector` for token transfers, or `collectorData` must carry a valid receiver signature authorizing the refund. diff --git a/contracts/periphery/refund-request-evidence.mdx b/contracts/periphery/refund-request-evidence.mdx index b799de7..ab92352 100644 --- a/contracts/periphery/refund-request-evidence.mdx +++ b/contracts/periphery/refund-request-evidence.mdx @@ -8,17 +8,17 @@ icon: "file-lines" - **Type:** Singleton (one per network) - **Purpose:** Store evidence for refund disputes on-chain -- **Address:** `0xF97aAB816b7cbe53025454ad05b03cf5C361F1BA` (all chains) +- **Address:** `0x4089A5A853e9eF35f504B842795fB272dF69c739` (all chains) ## Features - **IPFS CID storage** - Stores content hashes on-chain for evidence trails -- **EIP-712 signature approval** - Arbiter can approve refunds via off-chain signatures (gas-free for arbiters) -- **Evidence indexing** - Evidence is indexed by payment and submitting party +- **EIP-712 signature approval** - Arbiter approves refunds with off-chain signatures (gas-free for arbiters) +- **Evidence indexing** - The contract indexes evidence by payment and submitting party - **Multi-party submission** - Both payer and receiver can submit evidence -## How It Works +## How it works -When a refund is disputed, parties submit evidence (documents, screenshots, logs) to IPFS and record the CID on-chain. The arbiter reviews evidence off-chain and submits an EIP-712 approval signature that anyone can relay. +When a payer or receiver disputes a refund, each side submits evidence (documents, screenshots, logs) to IPFS and records the CID on-chain. The arbiter reviews evidence off-chain and produces an EIP-712 approval signature that anyone can relay. -This keeps dispute resolution costs low — the arbiter never needs to submit an on-chain transaction. +This keeps dispute resolution costs low, since the arbiter never has to submit an on-chain transaction. diff --git a/contracts/periphery/refund-request.mdx b/contracts/periphery/refund-request.mdx deleted file mode 100644 index 0b6dc7c..0000000 --- a/contracts/periphery/refund-request.mdx +++ /dev/null @@ -1,154 +0,0 @@ ---- -title: "RefundRequest" -description: "Manages refund request lifecycle and approvals independent of operator implementation" -icon: "rotate-left" ---- - -## Overview - -- **Type:** Singleton (one per network) -- **Deployment:** Direct deployment (no factory) -- **Purpose:** Track refund request lifecycle - -## Request Types - - - - **Who can request:** Payer, Receiver, OR Arbiter - - **Typical flow:** - 1. Payer suspects fraud, requests refund - 2. Arbiter investigates - 3. Arbiter approves or denies request - 4. If approved, arbiter calls `operator.refundInEscrow()` - - **Use cases:** - - Buyer remorse - - Seller fraud - - Payment error - - - - **Who can request:** Receiver only - - **Typical flow:** - 1. Receiver realizes product defect after release - 2. Receiver requests refund - 3. Arbiter investigates - 4. If approved, arbiter calls `operator.refundPostEscrow()` - - **Use cases:** - - Product defects discovered later - - Service not as described - - Voluntary refund by merchant - - - -## Request Status States - -```mermaid -stateDiagram-v2 - [*] --> Pending - Pending --> Approved: Arbiter approves - Pending --> Denied: Arbiter denies - Pending --> Cancelled: Requester cancels - Approved --> [*]: Arbiter executes refund via operator - - note right of Approved - Arbiter must call - operator.refundInEscrow() - or operator.refundPostEscrow() - end note -``` - -## Key Methods - -### requestRefund() - -Creates a new refund request. - -```solidity -function requestRefund( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint120 amount, - uint256 nonce -) external -``` - -**Parameters:** -- `paymentInfo` - Payment info struct -- `amount` - Amount being requested for refund -- `nonce` - Record index (from PaymentIndexRecorder) identifying which charge/action - -**Access Control:** Only the payer who made the authorization can request - - -Each refund request is keyed by `(paymentInfoHash, nonce)` where nonce is the record index. This allows multiple refund requests per payment (one per charge/action). - - -### updateStatus() - -Approve or deny a refund request. - -```solidity -function updateStatus( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint256 nonce, - RequestStatus newStatus -) external -``` - -**Parameters:** -- `paymentInfo` - Payment info struct -- `nonce` - Record index identifying which refund request -- `newStatus` - The new status (`Approved` or `Denied`) - -**Access:** Receiver can always approve/deny. While in escrow, anyone passing the operator's `REFUND_IN_ESCROW_CONDITION` can also approve/deny. - -**Valid transitions:** -- `Pending` -> `Approved` -- `Pending` -> `Denied` - -### cancelRefundRequest() - -Payer cancels their own request. - -```solidity -function cancelRefundRequest( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint256 nonce -) external -``` - -**Parameters:** -- `paymentInfo` - Payment info struct -- `nonce` - Record index identifying which refund request - -**Access:** Only the payer who created the request - -**Valid transition:** -- `Pending` -> `Cancelled` - -## Usage Example - -```typescript -// 1. Payer requests refund (nonce 0 = first action on this payment) -await refundRequest.requestRefund(paymentInfo, requestedAmount, 0); -// Status: Pending - -// 2. Receiver (or arbiter) reviews and approves -await refundRequest.updateStatus( - paymentInfo, - 0, // nonce - RequestStatus.Approved -); -// Status: Approved - -// 3. Execute refund via operator (separate transaction) -await operator.refundInEscrow(paymentInfo, refundAmount); -// Funds returned to payer -``` - - -RefundRequest is **advisory only**. Approval does not automatically execute refunds - the authorized party must call the operator's refund function. - diff --git a/contracts/recorders/authorization-time.mdx b/contracts/recorders/authorization-time.mdx deleted file mode 100644 index bb2b1cf..0000000 --- a/contracts/recorders/authorization-time.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "AuthorizationTimeRecorder" -description: "Records authorization timestamp for time-based conditions" -icon: "clock" ---- - -## Overview - -AuthorizationTimeRecorder stores the `block.timestamp` when a payment is authorized. This timestamp is used by time-based conditions like [EscrowPeriod](/contracts/conditions/escrow-period). - - -[EscrowPeriod](/contracts/conditions/escrow-period) **extends** AuthorizationTimeRecorder and adds `ICondition` implementation. For escrow enforcement, use EscrowPeriod directly instead of deploying AuthorizationTimeRecorder separately. - - -## State - -```solidity -mapping(bytes32 paymentInfoHash => uint256 authorizedAt) public authorizationTimes; -``` - -## Methods - -```solidity -// Called after authorize() -function record( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint256 amount, - address caller -) external { - bytes32 hash = escrow.getHash(paymentInfo); - authorizationTimes[hash] = block.timestamp; -} - -// View function -function getAuthorizationTime( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo -) external view returns (uint256) { - return authorizationTimes[escrow.getHash(paymentInfo)]; -} -``` - -## When to Use - -Use AuthorizationTimeRecorder directly only if you need authorization timestamps **without** escrow period enforcement. For most use cases, [EscrowPeriod](/contracts/conditions/escrow-period) is the better choice since it includes this recorder plus time-lock condition logic. - -## Gas - -**Cost:** ~20k gas per `record()` call (one `SSTORE` for the timestamp). - -## Next Steps - - - - Combined recorder + condition for escrow enforcement. - - - Index payments for on-chain queries. - - diff --git a/contracts/recorders/combinator.mdx b/contracts/recorders/combinator.mdx deleted file mode 100644 index 66f177d..0000000 --- a/contracts/recorders/combinator.mdx +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: "RecorderCombinator" -description: "Chain multiple recorders into a single operator slot for composite state tracking" -icon: "layer-group" ---- - -## Overview - -RecorderCombinator chains multiple recorders into one, calling each in sequence. Since each operator slot accepts only one recorder address, use RecorderCombinator when you need multiple recorders for the same action. - -## Deployment - -Deploy via RecorderCombinatorFactory: - -```typescript -const comboAddress = await recorderCombinatorFactory.write.deploy([ - [escrowPeriodAddress, paymentIndexRecorderAddress] // Records auth time + payment index -]); - -config.authorizeRecorder = comboAddress; -``` - -## Behavior - -- Recorders are called in the order provided -- **If any recorder reverts, all revert** — the entire recording is atomic -- Each recorder receives the same `paymentInfo`, `amount`, and `caller` parameters - -## Limits - - -**Max 10 recorders per combinator.** Each additional recorder adds ~1k gas overhead for the delegation call. - - -## Gas - -**Cost:** Sum of all individual recorder costs + ~1k gas overhead per recorder for delegation. - -Example: EscrowPeriod (~20k) + PaymentIndexRecorder (~20k) + ~2k overhead = ~42k gas total. - -## Next Steps - - - - Record authorization timestamps. - - - Index payments for on-chain queries. - - diff --git a/contracts/recorders/custom.mdx b/contracts/recorders/custom.mdx deleted file mode 100644 index 7ae30cf..0000000 --- a/contracts/recorders/custom.mdx +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: "Custom Recorders" -description: "Build your own recorder contracts for specialized state tracking" -icon: "wrench" ---- - -## Overview - -You can create custom recorders for specialized tracking beyond what the built-in recorders provide. Implement the `IRecorder` interface and extend `BaseRecorder` for operator access control. - -## IRecorder Interface - -```solidity -interface IRecorder { - function record( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint256 amount, - address caller - ) external; -} -``` - -## Extending BaseRecorder - -Extend `BaseRecorder` to ensure only authorized operators can call `record()`: - -```solidity -contract MyRecorder is BaseRecorder { - constructor(address _escrow) BaseRecorder(_escrow) {} - - function record( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint256 amount, - address caller - ) external onlyAuthorizedOperator { - // Your recording logic here - } -} -``` - -## Example: ReleaseCountRecorder - -Tracks the number and total amount of releases per payment: - -```solidity -contract ReleaseCountRecorder is BaseRecorder { - mapping(bytes32 => uint256) public releaseCount; - mapping(bytes32 => uint256) public totalReleased; - - constructor(address _escrow) BaseRecorder(_escrow) {} - - function record( - PaymentInfo calldata payment, - uint256 amount, - address caller - ) external onlyAuthorizedOperator { - bytes32 hash = escrow.getHash(payment); - releaseCount[hash]++; - totalReleased[hash] += amount; - } - - // Enable tracking partial releases - function getStats(bytes32 paymentHash) - external view - returns (uint256 count, uint256 total) - { - return (releaseCount[paymentHash], totalReleased[paymentHash]); - } -} -``` - -## Testing - -Test custom recorders with Forge: - -```solidity -contract ReleaseCountRecorderTest is Test { - ReleaseCountRecorder recorder; - - function setUp() public { - recorder = new ReleaseCountRecorder(address(escrow)); - } - - function test_incrementsOnRecord() public { - recorder.record(paymentInfo, 100e6, caller); - bytes32 hash = escrow.getHash(paymentInfo); - (uint256 count, uint256 total) = recorder.getStats(hash); - assertEq(count, 1); - assertEq(total, 100e6); - } - - function test_tracksMultipleReleases() public { - recorder.record(paymentInfo, 50e6, caller); - recorder.record(paymentInfo, 30e6, caller); - bytes32 hash = escrow.getHash(paymentInfo); - (uint256 count, uint256 total) = recorder.getStats(hash); - assertEq(count, 2); - assertEq(total, 80e6); - } -} -``` - -## Security Checklist - -- [ ] Extends `BaseRecorder` for operator access control -- [ ] Handles edge cases (zero amounts, duplicate records) -- [ ] Gas-efficient storage layout -- [ ] Comprehensive test coverage - - -Unlike conditions, recorders **do modify state**. Ensure proper access control via `BaseRecorder` to prevent unauthorized writes. - - -## Next Steps - - - - Compare recording strategies. - - - Build custom condition contracts. - - diff --git a/contracts/recorders/overview.mdx b/contracts/recorders/overview.mdx deleted file mode 100644 index cf073a3..0000000 --- a/contracts/recorders/overview.mdx +++ /dev/null @@ -1,101 +0,0 @@ ---- -title: "Recorders Overview" -description: "State recording system for tracking payment lifecycle events" -icon: "database" ---- - -## What Are Recorders? - -Recorders are pluggable contracts that update state **after** an action successfully executes on a PaymentOperator. Each operator has **5 recorder slots** — one per action: - -| Slot | Records After | -|------|---------------| -| `AUTHORIZE_RECORDER` | Authorization (e.g., timestamp) | -| `CHARGE_RECORDER` | Charge event | -| `RELEASE_RECORDER` | Release from escrow | -| `REFUND_IN_ESCROW_RECORDER` | In-escrow refund | -| `REFUND_POST_ESCROW_RECORDER` | Post-escrow refund | - -## IRecorder Interface - -```solidity -interface IRecorder { - function record( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint256 amount, - address caller - ) external; -} -``` - -**Parameters:** -- `paymentInfo` — The payment information struct -- `amount` — The amount involved in the action -- `caller` — The address that executed the action (msg.sender on operator) - -## Default Behavior - -**Recorder slot = `address(0)`** — no-op (does nothing). No state is recorded. - -This means you only need to set recorders for slots where you want state tracking. Leave the rest as `address(0)`. - -## BaseRecorder - -All built-in recorders extend `BaseRecorder`, which verifies that the caller is an authorized operator. This prevents unauthorized contracts from writing state. - -## Choosing a Recording Strategy - -Not every payment needs on-chain recorders. Choose based on your use case: - -### Events Only (~0 Extra Gas) - -The operator already emits events (`AuthorizationCreated`, `ReleaseExecuted`, etc.) for every action. If you only need payment history for analytics or display, skip recorders entirely and index events off-chain. - -**Best for:** Micropayments, high-volume payments where gas overhead matters, simple UIs. - -### Events + Subgraph (Best Queries) - -Index operator events with a subgraph for rich queries (payment history by payer, receiver, status, date range). No on-chain recorder gas cost. - -**Best for:** Analytics dashboards, payment history, multi-payment queries. - -**Trade-off:** Requires subgraph infrastructure (semi-centralized). - -### On-Chain Recorders (~20k Gas per Write) - -Use recorders when you need **on-chain reads** — other contracts or conditions that depend on recorded state. [EscrowPeriod](/contracts/conditions/escrow-period) is the most common example: it records authorization time so the release condition can check if the escrow window has passed. - -**Best for:** Escrow enforcement, dispute evidence, decentralized frontends, on-chain composability. - -**Trade-off:** ~20k gas per `SSTORE` operation. - -### Decision Table - -| Need | Strategy | Recorder Slots | -|------|----------|---------------| -| Payment history for UI | Events only | `address(0)` | -| Rich queries, analytics | Events + Subgraph | `address(0)` | -| Time-locked releases | On-chain | [EscrowPeriod](/contracts/conditions/escrow-period) on `AUTHORIZE_RECORDER` | -| On-chain payment index | On-chain | [PaymentIndexRecorder](/contracts/recorders/payment-index) | -| Multiple data points | On-chain | [RecorderCombinator](/contracts/recorders/combinator) | - - -For most configurations, you only need a recorder on the `AUTHORIZE_RECORDER` slot (for [EscrowPeriod](/contracts/conditions/escrow-period)). Leave other recorder slots as `address(0)`. - - -## Next Steps - - - - Record authorization timestamps. - - - Index payments for on-chain queries. - - - Chain multiple recorders into one slot. - - - Build your own recorder. - - diff --git a/contracts/recorders/payment-index.mdx b/contracts/recorders/payment-index.mdx deleted file mode 100644 index 6931645..0000000 --- a/contracts/recorders/payment-index.mdx +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: "PaymentIndexRecorder" -description: "Index payments by sequential count for on-chain queries and multiple refund requests" -icon: "list-ol" ---- - -## Overview - -PaymentIndexRecorder records a sequential index for each payment action, enabling multiple refund requests per payment. Each time `record()` is called, the index increments by 1. - -## When to Use - -- You need on-chain payment lookups **without a subgraph** -- You need to support **multiple refund requests** per payment (each keyed by nonce) -- Other contracts need to read the payment index on-chain - -**Skip when:** You're using a subgraph for payment queries — the subgraph can derive indexes from events without the on-chain gas cost. - -## State - -```solidity -mapping(bytes32 paymentInfoHash => uint256 count) public paymentIndex; -``` - -## Methods - -```solidity -function record( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo, - uint256 amount, - address caller -) external { - bytes32 hash = escrow.getHash(paymentInfo); - paymentIndex[hash]++; -} - -function getPaymentIndex( - AuthCaptureEscrow.PaymentInfo calldata paymentInfo -) external view returns (uint256) { - return paymentIndex[escrow.getHash(paymentInfo)]; -} -``` - -## Integration with RefundRequest - -The `nonce` parameter in `RefundRequest.requestRefund()` corresponds to the payment index. This allows one refund request per charge/action: - -```typescript -// After two charges on the same payment: -// paymentIndex = 2 - -// Refund request for first charge -await refundRequest.requestRefund(paymentInfo, amount1, 0); // nonce 0 - -// Refund request for second charge -await refundRequest.requestRefund(paymentInfo, amount2, 1); // nonce 1 -``` - -## Gas - -**Cost:** ~20k gas per `record()` call (one `SSTORE` for the counter increment). - -## Next Steps - - - - Combine with other recorders in a single slot. - - - Compare recording strategies. - - diff --git a/docs.json b/docs.json index d4c2b4a..8e9758d 100644 --- a/docs.json +++ b/docs.json @@ -17,24 +17,20 @@ "group": "Getting Started", "pages": [ "index", - "x402-integration/overview", - "x402-integration/escrow-scheme", - "x402-integration/comparison" + "x402-integration/overview" ] }, { - "group": "Roadmap", + "group": "auth-capture Scheme", "pages": [ - "roadmap" + "x402-integration/auth-capture/index", + "x402-integration/auth-capture/wire-format", + "x402-integration/auth-capture/verification-and-settlement", + "x402-integration/auth-capture/payment-info" ] - } - ] - }, - { - "tab": "Smart Contracts", - "groups": [ + }, { - "group": "Overview", + "group": "Contracts", "pages": [ "contracts/overview", "contracts/architecture", @@ -42,17 +38,13 @@ "contracts/payment-operator", "contracts/factories", "contracts/fees", - "contracts/gas-costs", - "contracts/examples", - "contracts/audits", - "contracts/license" + "contracts/examples" ] }, { "group": "Periphery", "pages": [ "contracts/periphery/overview", - "contracts/periphery/refund-request", "contracts/periphery/refund-request-evidence", "contracts/periphery/receiver-refund-collector" ] @@ -72,13 +64,14 @@ ] }, { - "group": "Recorders", + "group": "Hooks", "pages": [ - "contracts/recorders/overview", - "contracts/recorders/authorization-time", - "contracts/recorders/payment-index", - "contracts/recorders/combinator", - "contracts/recorders/custom" + "contracts/hooks/overview", + "contracts/hooks/authorization-time", + "contracts/hooks/payment-index", + "contracts/hooks/combinator", + "contracts/hooks/refund-request", + "contracts/hooks/custom" ] } ] @@ -90,33 +83,48 @@ "group": "Getting Started", "pages": [ "sdk/overview", + "sdk/create-client", "sdk/deploy-operator" ] }, { - "group": "Marketplace", + "group": "Merchants", "pages": [ - "sdk/merchant", - "sdk/payer", - "sdk/arbiter" + "sdk/merchant/getting-started", + "sdk/merchant/quickstart", + "sdk/merchant/refund-handling" ] }, { "group": "Delivery Protection", "pages": [ "sdk/delivery-protection", + "sdk/helpers/forward-to-arbiter", "sdk/delivery-merchant", "sdk/delivery-arbiter" ] }, { - "group": "Reference", + "group": "Tools", "pages": [ - "sdk/create-client", + "sdk/cli", "sdk/examples" ] } ] + }, + { + "tab": "Resources", + "groups": [ + { + "group": "Resources", + "pages": [ + "contracts/gas-costs", + "contracts/audits", + "contracts/license" + ] + } + ] } ] }, @@ -160,5 +168,27 @@ "x": "https://x.com/x402rorg", "github": "https://github.com/BackTrackCo" } - } -} + }, + "redirects": [ + { + "source": "/x402-integration/escrow-scheme", + "destination": "/x402-integration/auth-capture" + }, + { + "source": "/x402-integration/auth-capture-scheme", + "destination": "/x402-integration/auth-capture" + }, + { + "source": "/contracts/periphery/refund-request", + "destination": "/contracts/hooks/refund-request" + }, + { + "source": "/contracts/recorders/:slug", + "destination": "/contracts/hooks/:slug" + }, + { + "source": "/x402-integration/comparison", + "destination": "/x402-integration/auth-capture" + } + ] +} \ No newline at end of file diff --git a/index.mdx b/index.mdx index d02c959..6bfe861 100644 --- a/index.mdx +++ b/index.mdx @@ -6,15 +6,15 @@ icon: "house" **x402r** is a refundable payments protocol extension for [x402](https://www.x402.org/). It enables secure, reversible transactions with built-in buyer protection through smart contract escrow on Base. -## Why x402r? +## Why x402r Standard x402 payments are immediate and irreversible. x402r adds: -- **Escrow deposits** — Funds held in smart contracts until conditions are met -- **Refund windows** — Configurable time periods for buyers to request refunds -- **Dispute resolution** — Arbiter system for handling contested transactions +- **Escrow deposits**: smart contracts hold funds until conditions clear +- **Refund windows**: configurable time periods for buyers to request refunds +- **Dispute resolution**: arbiter system for handling contested transactions -## How It Works +## How it works ```mermaid sequenceDiagram @@ -27,7 +27,7 @@ sequenceDiagram Escrow-->>Merchant: Payment notification alt Happy path - Merchant->>Escrow: Release funds + Merchant->>Escrow: Capture funds Escrow->>Merchant: Transfer else Refund requested Client->>Escrow: Request refund @@ -38,21 +38,15 @@ sequenceDiagram end ``` -## Who Is This For? +## Who this is for - - Request refunds, freeze suspicious payments, and track payment state. - - Release funds, process refunds, and manage escrow periods. - - - Resolve disputes and approve/deny refund requests. + Capture funds, process refunds, and manage escrow periods. -## Get Started +## Get started @@ -75,29 +69,17 @@ x402r consists of these core components: | Component | Purpose | |-----------|---------| -| **PaymentOperator** | Manages payment authorization, release, charge, and refunds with pluggable conditions | +| **PaymentOperator** | Manages payment authorization, capture, charge, void, and refunds with pluggable conditions | | **AuthCaptureEscrow** | Holds ERC-20 tokens during the payment lifecycle (from commerce-payments) | -| **Conditions & Recorders** | Pluggable authorization checks (before action) and state updates (after action) | -| **EscrowPeriod & Freeze** | Time-based release and freeze policies for buyer protection | +| **Conditions & Hooks** | Pluggable authorization checks (before action) and state updates (after action) | +| **EscrowPeriod & Freeze** | Time-based capture and freeze policies for buyer protection | | **RefundRequest** | Handles refund request lifecycle and approvals | -All protocol contracts use unified CREATE3 addresses — same address on every supported chain. - -## Supported Networks - -| Network | Chain ID | Status | -|---------|----------|--------| -| Base | 8453 | Supported | -| Ethereum | 1 | Supported | -| Polygon | 137 | Supported | -| Arbitrum One | 42161 | Supported | -| Optimism | 10 | Supported | -| Celo | 42220 | Supported | -| Avalanche C-Chain | 43114 | Supported | -| Monad | 143 | Supported | -| Linea | 59144 | Supported | -| Base Sepolia | 84532 | Testnet | -| Ethereum Sepolia | 11155111 | Testnet | +All protocol contracts use universal CREATE2 addresses, same address on every supported chain. + +## Supported networks + +Today, the supported chains in `@x402r/core` are **Base** and **Base Sepolia**. More EVMs land as canonical `base/commerce-payments@v1.0.0` coverage extends. See [Network support](/sdk/overview#network-support) for chain IDs and USDC token addresses. ## Resources diff --git a/roadmap.mdx b/roadmap.mdx deleted file mode 100644 index 29575ba..0000000 --- a/roadmap.mdx +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: "Roadmap" -description: "Current progress and upcoming features for the x402r protocol and SDK" -icon: "map" ---- - -## Protocol - -### V0 Demo (Completed) - -- Client SDK wrapper and MCP tool -- NPM package published -- Refund request encryption (Lit Protocol) -- Basic refund flow (cancel, replace, approve/reject) - -### Protocol V2 (Completed) - -- Switched from proxy pattern to escrow scheme (commerce-payments) -- PaymentOperator with pluggable conditions and recorders -- Partial refund support (partial void) -- EscrowPeriod and Freeze contracts -- Factory pattern with unified CREATE3 deterministic deployment -- RefundRequestEvidence for on-chain evidence submission -- Deployed across 11 chains with unified CREATE3 addresses (same address everywhere) - -### Escrow Scheme Spec (In Progress) - -- [Proposal submitted to coinbase/x402 (Issue #1011)](https://github.com/coinbase/x402/issues/1011) -- [Spec PR submitted (PR #1425)](https://github.com/coinbase/x402/pull/1425) -- Reference implementation: [x402r-scheme](https://github.com/BackTrackCo/x402r-scheme) - -### Developer Experience (In Progress) - -- Documentation restructure and accuracy fixes -- LLM-friendly docs with MCP integration -- Simple "API Down" arbiter template for first merchants -- CI/CD pipeline for SDK and contracts - -### Protocol Extensions (Future) - -- Bond-based disputes -- Multiple arbiter support per operator -- Post-escrow arbitration handling -- Reputation system for clients, merchants, and arbiters -- Arbiter marketplace -- Token wrapper for enforced refund protection - ---- - -## SDK - -### Phase 0: Operator Deployment (Completed) - -- Fixed all SDK ABIs to match contracts -- Factory deployment helpers (`deployMarketplaceOperator`) -- Condition composition (AND/OR/NOT combinators) -- Deterministic address computation (CREATE2) -- Deployed all contracts on Base Sepolia and Base Mainnet - -### Phase 1: MVP Examples (Completed) - -- 8 examples (deploy-operator, facilitator, server-express, server-hono, merchant-cli, client-cli, arbiter-cli, shared) -- Full e2e dispute resolution flow - -### Phase 2: Core SDK (Completed) - -- `@x402r/client` — Refund requests, freeze, escrow period queries, subscriptions -- `@x402r/merchant` — Release, charge, refundInEscrow, refundPostEscrow, refund handling -- `@x402r/arbiter` — Decision submission, batch operations, registry, AI hooks -- `@x402r/helpers` — `refundable()` helper for payment options -- `@x402r/core` — Types, ABIs, config, deploy utilities -- 310+ tests across all packages - -### Phase 3: Client UX (Upcoming) - -- Pre-payment info extraction (`getOperatorInfo` — discover arbiter, escrow period from operator address) -- Combined freeze + refund (`freezeAndRequestRefund` — single call) -- Condition awareness for clients - -### Phase 4: Subgraph Integration (Upcoming) - -- Deploy subgraph for payment event indexing -- Implement 8 stubbed methods (`getPaymentState`, `getMyPayments`, etc.) -- Historical payment listing for all roles - -### Future SDK Work - -- Evidence/metadata system with pluggable backends (IPFS, Arweave) -- Encrypted communication channels (XMTP) -- Session-based billing patterns -- Multi-arbiter support -- Dedicated Express/Hono middleware - ---- - -## Contract Status - -All contracts use **unified CREATE3 addresses** — same address on every supported chain (11 chains: Base, Ethereum, Polygon, Arbitrum, Optimism, Celo, Avalanche, Monad, Linea, Base Sepolia, Ethereum Sepolia). - -| Contract | Status | -|----------|--------| -| AuthCaptureEscrow | Deployed | -| ERC3009PaymentCollector | Deployed | -| PaymentOperatorFactory | Deployed | -| EscrowPeriodFactory | Deployed | -| FreezeFactory | Deployed | -| StaticFeeCalculatorFactory | Deployed | -| All condition/combinator factories | Deployed | -| ProtocolFeeConfig | Deployed | -| RefundRequestEvidence | Deployed | -| ReceiverRefundCollector | Deployed | -| Condition singletons (Payer, Receiver, AlwaysTrue) | Deployed | - - -All contract addresses are available in `@x402r/core` via `getChainConfig(chainId)`. See the [SDK Overview](/sdk/overview) page for details. - - -## Get Involved - - - - Follow development and contribute. - - - Get in touch with the team. - - diff --git a/sdk/arbiter.mdx b/sdk/arbiter.mdx deleted file mode 100644 index cdc4d92..0000000 --- a/sdk/arbiter.mdx +++ /dev/null @@ -1,175 +0,0 @@ ---- -title: "Arbiter Guide" -description: "Review refund requests, approve or deny refunds, and distribute fees." -icon: "scale-balanced" ---- - -### Prerequisites - -* A wallet with ETH on Base Sepolia for gas ([faucet](https://www.alchemy.com/faucets/base-sepolia)) -* Node.js 18+ and npm -* A marketplace operator where your address is configured as the arbiter (see [Deploy an Operator](/sdk/deploy-operator)) - - -There are pre-configured [arbiter examples](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/arbiter) and a full [dispute resolution scenario](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/scenarios/dispute-resolution.ts) in the SDK repo. - - -### 1. Install Dependencies - - -```bash npm -npm install @x402r/sdk -``` -```bash pnpm -pnpm add @x402r/sdk -``` -```bash bun -bun add @x402r/sdk -``` - - -### 2. Create an Arbiter Client - -```typescript -import { createPublicClient, createWalletClient, http } from 'viem' -import { baseSepolia } from 'viem/chains' -import { privateKeyToAccount } from 'viem/accounts' -import { createArbiterClient } from '@x402r/sdk' - -const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`) - -const arbiter = createArbiterClient({ - publicClient: createPublicClient({ chain: baseSepolia, transport: http() }), - walletClient: createWalletClient({ - account, - chain: baseSepolia, - transport: http(), - }), - operatorAddress: '0x...', // from deploy result - refundRequestAddress: '0x...', // from deploy result - refundRequestEvidenceAddress: '0x...', // from deploy result - escrowPeriodAddress: '0x...', - freezeAddress: '0x...', -}) -``` - -### 3. Check for Pending Refund Requests - -```typescript -import type { PaymentInfo } from '@x402r/sdk' - -const paymentInfo: PaymentInfo = { /* ... */ } - -const hasRefund = await arbiter.refund?.has(paymentInfo) -if (hasRefund) { - const request = await arbiter.refund?.get(paymentInfo) - console.log('Amount:', request?.amount) - console.log('Status:', request?.status) // 0 = Pending, 1 = Approved, 2 = Denied, 3 = Cancelled, 4 = Refused -} -``` - -To list all refund requests for your operator: - -```typescript -const requests = await arbiter.refund?.getOperatorRequests( - arbiter.config.operatorAddress, - 0n, // offset - 10n, // count -) -console.log('Pending requests:', requests) -``` - -### 4. Review Evidence - -Both payers and merchants can submit evidence as IPFS CIDs. Read all entries before making a decision: - -```typescript -const count = await arbiter.evidence?.count(paymentInfo) -console.log('Evidence entries:', count) - -const batch = await arbiter.evidence?.getBatch(paymentInfo, 0n, count!) - -for (const entry of batch!.entries) { - console.log('CID:', entry.cid) - console.log('Submitter:', entry.submitter) - console.log('Timestamp:', entry.timestamp) -} -``` - -### 5. Approve a Refund - - -`refundInEscrow()` auto-approves the pending RefundRequest. There is no undo. - - -```typescript -const tx = await arbiter.payment.refundInEscrow(paymentInfo, request!.amount) -console.log('Refund approved:', tx) - -// Verify -const approved = await arbiter.refund?.get(paymentInfo) -console.log('Approved amount:', approved?.approvedAmount) -console.log('Status:', approved?.status) // 1 = Approved -``` - -### 6. Deny a Refund Request - - -`deny()` = you reviewed and rejected the claim. `refuse()` = you decline to rule (e.g. conflict of interest). - - -```typescript -const denyTx = await arbiter.refund?.deny(paymentInfo) -console.log('Refund denied:', denyTx) -``` - -Or decline to rule entirely: - -```typescript -const refuseTx = await arbiter.refund?.refuse(paymentInfo) -console.log('Declined to rule:', refuseTx) -``` - -### 7. Unfreeze a Payment - -If the payer froze the payment during the dispute, unfreeze it after resolution: - -```typescript -const frozen = await arbiter.freeze?.isFrozen(paymentInfo) -if (frozen) { - const tx = await arbiter.freeze?.unfreeze(paymentInfo) - console.log('Unfrozen:', tx) -} -``` - -### 8. Distribute Accumulated Fees - -Protocol fees accumulate on the operator when payments are released: - -```typescript -import { getChainConfig } from '@x402r/sdk' - -const config = getChainConfig(84532) - -const fees = await arbiter.operator.getAccumulatedProtocolFees(config.usdc) -console.log('Accumulated fees:', fees) - -if (fees > 0n) { - const tx = await arbiter.operator.distributeFees(config.usdc) - console.log('Fees distributed:', tx) -} -``` - -## Next Steps - - - - Automated evaluation for every transaction. - - - How your address gets configured as arbiter on an operator. - - - Full dispute resolution scenario end-to-end. - - diff --git a/sdk/arbiter/ai-integration.mdx b/sdk/arbiter/ai-integration.mdx deleted file mode 100644 index 333b400..0000000 --- a/sdk/arbiter/ai-integration.mdx +++ /dev/null @@ -1,221 +0,0 @@ ---- -title: "AI Integration" -description: "Automate dispute resolution with AI using the Arbiter SDK's evaluation hooks" -icon: "robot" ---- - -The Arbiter SDK includes built-in support for AI-powered dispute resolution through evaluation hooks and a webhook handler. You can plug in any AI model -- LLMs, rule engines, or hybrid systems -- to automatically evaluate and decide on refund requests. - -## Core Concepts - -The AI integration is built around three types: - -1. **CaseEvaluationContext** -- the data your AI receives for each case -2. **DecisionResult** -- the decision your AI returns -3. **createWebhookHandler** -- wires your AI evaluation function to the arbiter - -## Case Evaluation Context - -When a case needs evaluation, the handler provides a structured context: - -```typescript -interface CaseEvaluationContext { - /** The payment information struct */ - paymentInfo: PaymentInfo; - - /** The record index (nonce) identifying which charge */ - nonce: bigint; - - /** Current payment state */ - paymentState: PaymentState; - - /** Current refund request status */ - refundStatus: number; - - /** Unique hash of the payment */ - paymentInfoHash: `0x${string}`; - - /** Amount being requested for refund */ - refundAmount?: bigint; - - /** Optional evidence/metadata */ - evidence?: unknown; -} -``` - -## Decision Result - -Your AI evaluation function returns a `DecisionResult`: - -```typescript -interface DecisionResult { - /** The decision: approve or deny */ - decision: 'approve' | 'deny'; - - /** Optional reasoning for the decision */ - reasoning?: string; - - /** Optional partial refund amount */ - refundAmount?: bigint; - - /** Confidence score (0-1) */ - confidence?: number; -} -``` - -## Create a Webhook Handler - -Use `createWebhookHandler` to connect your evaluation function to the arbiter: - -```typescript -import { createWebhookHandler } from '@x402r/arbiter'; -import type { CaseEvaluationContext, DecisionResult } from '@x402r/arbiter'; - -const handler = createWebhookHandler({ - arbiter, - evaluationHook: async (context: CaseEvaluationContext): Promise => { - // Your AI evaluation logic here - const result = await myAIModel.evaluate(context); - - return { - decision: result.shouldApprove ? 'approve' : 'deny', - reasoning: result.explanation, - confidence: result.confidence, - }; - }, - autoSubmitDecision: true, // Auto-submit approve/deny on-chain - confidenceThreshold: 0.9, // Only auto-submit if confidence >= 0.9 -}); -``` - - -Setting `autoSubmitDecision: true` calls `approveRefundRequest` or `denyRefundRequest` on-chain automatically. This submits the decision only -- executing the actual refund transfer via `executeRefundInEscrow` is a separate step you handle after approval. - - -## Webhook Handler Configuration - -```typescript -interface WebhookHandlerConfig { - /** X402rArbiter instance */ - arbiter: X402rArbiter; - - /** Your evaluation function */ - evaluationHook: ArbiterHook; - - /** Auto-submit decisions on-chain (default: false) */ - autoSubmitDecision?: boolean; - - /** Minimum confidence for auto-submission (default: 0.8) */ - confidenceThreshold?: number; -} -``` - -The handler returns a `WebhookResult` that extends `DecisionResult`: - -```typescript -interface WebhookResult extends DecisionResult { - /** Transaction hash if auto-submitted */ - txHash?: `0x${string}`; - - /** Whether the decision was submitted on-chain */ - executed: boolean; -} -``` - -## Watch and Auto-Evaluate Pattern - -The most common pattern combines `watchNewCases` with the webhook handler to automatically evaluate incoming refund requests: - -```typescript -import { X402rArbiter, createWebhookHandler } from '@x402r/arbiter'; -import type { CaseEvaluationContext, DecisionResult, RefundRequestEventLog } from '@x402r/arbiter'; -import { PaymentState, RequestStatus } from '@x402r/core'; -import type { PaymentInfo } from '@x402r/core'; - -// Step 1: Create the webhook handler with your AI evaluation -const handler = createWebhookHandler({ - arbiter, - evaluationHook: async (context: CaseEvaluationContext): Promise => { - return evaluateWithAI(context); - }, - autoSubmitDecision: true, - confidenceThreshold: 0.85, -}); - -// Step 2: Watch for new cases and feed them to the handler -const { unsubscribe } = arbiter.watchNewCases(async (event: RefundRequestEventLog) => { - const paymentInfoHash = event.args.paymentInfoHash!; - const nonce = event.args.nonce ?? 0n; - - console.log(`[NEW CASE] ${paymentInfoHash} (nonce: ${nonce})`); - - // Build the evaluation context - // NOTE: You need to reconstruct the full PaymentInfo from your database or event logs - const paymentInfo = await lookupPaymentInfo(paymentInfoHash); - - const context: CaseEvaluationContext = { - paymentInfo, - nonce, - paymentState: PaymentState.InEscrow, - refundStatus: RequestStatus.Pending, - paymentInfoHash, - refundAmount: event.args.amount, - }; - - // Evaluate and optionally auto-submit - const result = await handler(context); - - console.log(`[DECISION] ${result.decision} (confidence: ${result.confidence})`); - console.log(`[REASONING] ${result.reasoning}`); - - if (result.executed) { - console.log(`[ON-CHAIN] Decision submitted: ${result.txHash}`); - - // If approved, execute the refund transfer - if (result.decision === 'approve') { - const { txHash } = await arbiter.executeRefundInEscrow( - paymentInfo, - result.refundAmount // partial refund if specified - ); - console.log(`[REFUND] Executed: ${txHash}`); - } - } else { - console.log('[SKIPPED] Confidence below threshold, requires manual review'); - } -}); - -// Graceful shutdown -process.on('SIGINT', () => { - unsubscribe(); - process.exit(); -}); -``` - -## Evaluation Patterns - -The `evaluationHook` function receives a `CaseEvaluationContext` and returns a `DecisionResult`. Three common patterns: - -- **LLM-based** — Send the structured context to an LLM (GPT-4, Claude, etc.) with a system prompt that outputs JSON `{decision, reasoning, confidence}`. Sanitize inputs to prevent prompt injection. -- **Rule-based** — Apply deterministic rules (amount thresholds, blocklists) for predictable, high-confidence decisions. Best for clear-cut cases. -- **Hybrid** — Apply hard rules first; if no rule matches with high confidence, fall back to LLM evaluation. Deny and flag for manual review when confidence is low. - - -AI-powered dispute resolution handles financial decisions. Always implement prompt injection protection, input validation, and confidence thresholds before deploying to production. - - -## Next Steps - - - - Watch for new cases to evaluate in real-time. - - - Process queued AI decisions in batches. - - - Approve/deny individual cases and execute refunds. - - - Register your AI arbiter for on-chain discovery. - - diff --git a/sdk/arbiter/batch-operations.mdx b/sdk/arbiter/batch-operations.mdx deleted file mode 100644 index 7f32132..0000000 --- a/sdk/arbiter/batch-operations.mdx +++ /dev/null @@ -1,192 +0,0 @@ ---- -title: "Batch Operations" -description: "Process multiple refund decisions efficiently with the Arbiter SDK" -icon: "layer-group" ---- - -The Arbiter SDK provides batch operations for processing multiple refund requests in a single call. Both `batchApprove` and `batchDeny` accept an array of `{ paymentInfo, nonce }` objects. - - -Batch items are processed **sequentially**, not atomically. If one item fails mid-batch, all previously processed items will **not** be rolled back. Design your error handling accordingly. - - -## Batch Approve - -Approve multiple refund requests in one call: - -```typescript -const items = [ - { paymentInfo: paymentInfo1, nonce: 0n }, - { paymentInfo: paymentInfo2, nonce: 0n }, - { paymentInfo: paymentInfo3, nonce: 1n }, -]; - -const results = await arbiter.batchApprove(items); - -for (const { txHash } of results) { - console.log('Approved:', txHash); -} -``` - -## Batch Deny - -Deny multiple refund requests in one call: - -```typescript -const items = [ - { paymentInfo: paymentInfo4, nonce: 0n }, - { paymentInfo: paymentInfo5, nonce: 0n }, -]; - -const results = await arbiter.batchDeny(items); - -for (const { txHash } of results) { - console.log('Denied:', txHash); -} -``` - -## Empty Batch Handling - -Both batch methods safely handle empty arrays and return an empty results array: - -```typescript -const results = await arbiter.batchApprove([]); -console.log(results.length); // 0 -``` - -## Item Format - -Each item in the batch array must include both the `paymentInfo` struct and the `nonce`: - -```typescript -interface BatchItem { - /** The full PaymentInfo struct identifying the payment */ - paymentInfo: PaymentInfo; - /** The record index (nonce) from PaymentIndexRecorder */ - nonce: bigint; -} -``` - - -The `nonce` identifies which specific charge record the refund request targets. For most single-charge payments, this is `0n`. - - -## Example: Triage and Batch Process Pending Cases - -Fetch all pending cases, evaluate each one, then batch approve and deny: - -```typescript -import { X402rArbiter } from '@x402r/arbiter'; -import { RequestStatus } from '@x402r/core'; -import type { PaymentInfo, RefundRequestData } from '@x402r/core'; - -async function triageAndProcess( - arbiter: X402rArbiter, - receiverAddress: `0x${string}`, - lookupPaymentInfo: (hash: `0x${string}`) => Promise -) { - // Step 1: Fetch pending cases - const { keys, total } = await arbiter.getPendingRefundRequests(0n, 100n, receiverAddress); - console.log(`Processing ${total} pending cases`); - - const toApprove: Array<{ paymentInfo: PaymentInfo; nonce: bigint }> = []; - const toDeny: Array<{ paymentInfo: PaymentInfo; nonce: bigint }> = []; - - // Step 2: Evaluate each case - for (const key of keys) { - const request = await arbiter.getRefundRequestByKey(key); - - if (request.status !== RequestStatus.Pending) { - continue; - } - - const paymentInfo = await lookupPaymentInfo(request.paymentInfoHash); - const item = { paymentInfo, nonce: request.nonce }; - - if (shouldApprove(request)) { - toApprove.push(item); - } else { - toDeny.push(item); - } - } - - // Step 3: Batch process decisions - const approveResults = await arbiter.batchApprove(toApprove); - const denyResults = await arbiter.batchDeny(toDeny); - - console.log(`Approved: ${approveResults.length}, Denied: ${denyResults.length}`); - - return { approved: approveResults, denied: denyResults }; -} - -function shouldApprove(request: RefundRequestData): boolean { - // Your decision logic here - return request.amount < BigInt('10000000'); // Auto-approve < 10 USDC -} -``` - -## Example: Batch Approve with Refund Execution - -After batch approving, execute refunds individually for each approved payment: - -```typescript -import { X402rArbiter } from '@x402r/arbiter'; -import type { PaymentInfo } from '@x402r/core'; - -async function batchApproveAndExecute( - arbiter: X402rArbiter, - items: Array<{ paymentInfo: PaymentInfo; nonce: bigint }> -) { - // Step 1: Batch approve all items - const approveResults = await arbiter.batchApprove(items); - console.log(`Approved ${approveResults.length} refund requests`); - - // Step 2: Execute refunds individually - const executeResults: Array<{ txHash: `0x${string}` }> = []; - - for (const { paymentInfo } of items) { - try { - const { txHash } = await arbiter.executeRefundInEscrow(paymentInfo); - executeResults.push({ txHash }); - console.log('Refund executed:', txHash); - } catch (error) { - console.error(`Failed to execute refund for ${paymentInfo.payer}:`, error); - } - } - - return { - approved: approveResults, - executed: executeResults, - }; -} -``` - -## Performance Considerations - - -Each item in a batch results in a separate on-chain transaction. Gas costs scale linearly with the number of items. Plan batch sizes around your RPC provider's rate limits. - - -| Factor | Detail | -|--------|--------| -| **Transaction ordering** | Items are processed sequentially to ensure correct nonce ordering. | -| **Gas costs** | Each item is a separate transaction. Batch methods save on SDK overhead, not gas. | -| **Partial failures** | If one transaction fails, previous ones remain on-chain. Handle partial failures in your logic. | -| **Rate limiting** | Large batches may hit RPC rate limits. Consider adding delays for 50+ item batches. | - -## Next Steps - - - - Automate decisions with AI evaluation hooks. - - - Watch for new cases in real-time. - - - Individual approve/deny methods and executeRefundInEscrow. - - - Review the complete arbiter setup guide. - - diff --git a/sdk/arbiter/decision-submission.mdx b/sdk/arbiter/decision-submission.mdx deleted file mode 100644 index 696479e..0000000 --- a/sdk/arbiter/decision-submission.mdx +++ /dev/null @@ -1,254 +0,0 @@ ---- -title: "Decision Submission" -description: "Submit decisions on refund requests and execute refunds with the Arbiter SDK" -icon: "gavel" ---- - -The Arbiter SDK provides methods for reviewing refund requests, making decisions, and executing refunds for disputed payments. - -## Approve a Refund Request - -Approve a pending refund request. This updates the on-chain status but does not transfer funds. - -```typescript -const { txHash } = await arbiter.approveRefundRequest(paymentInfo, 0n); -console.log('Refund approved:', txHash); -``` - - -Approving a refund request updates the request status to `Approved` but does not transfer funds. You must call `executeRefundInEscrow()` separately to move funds back to the payer. - - -## Deny a Refund Request - -Deny a pending refund request: - -```typescript -const { txHash } = await arbiter.denyRefundRequest(paymentInfo, 0n); -console.log('Refund denied:', txHash); -``` - -## Execute Refund in Escrow - -After approving a refund request, execute the actual fund transfer back to the payer: - -```typescript -// Full refund (defaults to paymentInfo.maxAmount) -const { txHash } = await arbiter.executeRefundInEscrow(paymentInfo); -console.log('Full refund executed:', txHash); - -// Partial refund -const partialAmount = BigInt('500000'); // 0.5 USDC -const { txHash: partialTx } = await arbiter.executeRefundInEscrow(paymentInfo, partialAmount); -console.log('Partial refund executed:', partialTx); -``` - - -When no `amount` is provided, `executeRefundInEscrow` defaults to `paymentInfo.maxAmount`, issuing a full refund. - - -## Check If a Refund Request Exists - -Verify whether a refund request has been submitted for a given payment and nonce: - -```typescript -const hasRequest = await arbiter.hasRefundRequest(paymentInfo, 0n); - -if (!hasRequest) { - console.log('No refund request found for this payment'); - return; -} -``` - -## Get Refund Request Data - -Retrieve the full refund request data, including amount and status: - -```typescript -import { RequestStatus } from '@x402r/core'; - -const request = await arbiter.getRefundRequest(paymentInfo, 0n); - -console.log('Payment hash:', request.paymentInfoHash); -console.log('Nonce:', request.nonce); -console.log('Refund amount:', request.amount); -console.log('Status:', RequestStatus[request.status]); -``` - -The `RefundRequestData` type contains: - -```typescript -interface RefundRequestData { - paymentInfoHash: `0x${string}`; - nonce: bigint; - amount: bigint; - status: RequestStatus; -} -``` - -## Get Refund Request Status - -Query the current status of a specific refund request: - -```typescript -import { RequestStatus } from '@x402r/core'; - -const status = await arbiter.getRefundStatus(paymentInfo, 0n); - -switch (status) { - case RequestStatus.Pending: - console.log('Awaiting decision'); - break; - case RequestStatus.Approved: - console.log('Already approved'); - break; - case RequestStatus.Denied: - console.log('Already denied'); - break; - case RequestStatus.Cancelled: - console.log('Cancelled by payer'); - break; -} -``` - -## Get Pending Refund Requests (Paginated) - -Retrieve a paginated list of refund request keys for a receiver. You can optionally filter by receiver address: - -```typescript -// Get the first 10 pending requests for a specific receiver -const { keys, total } = await arbiter.getPendingRefundRequests( - 0n, // offset - 10n, // count - '0xReceiverAddress...' // optional: defaults to the arbiter's wallet address -); - -console.log(`${total} total cases, showing first ${keys.length}`); - -// Look up each request by its composite key -for (const key of keys) { - const request = await arbiter.getRefundRequestByKey(key); - console.log(`Amount: ${request.amount}, Status: ${RequestStatus[request.status]}`); -} -``` - -## Get Refund Request Count - -Get the total number of refund requests for a receiver: - -```typescript -const count = await arbiter.getRefundRequestCount('0xReceiverAddress...'); -console.log(`Total refund requests: ${count}`); - -// Defaults to the arbiter's wallet address if not specified -const myCount = await arbiter.getRefundRequestCount(); -console.log(`My refund requests: ${myCount}`); -``` - -## Get Refund Request by Composite Key - -Look up a specific refund request using its `keccak256(paymentInfoHash, nonce)` composite key: - -```typescript -const request = await arbiter.getRefundRequestByKey(compositeKey); - -console.log('Payment hash:', request.paymentInfoHash); -console.log('Amount:', request.amount); -console.log('Status:', RequestStatus[request.status]); -``` - -## Check If a Payment Is Frozen - -Verify whether a payment is currently frozen by a Freeze condition contract: - -```typescript -const frozen = await arbiter.isFrozen(paymentInfo, freezeAddress); - -if (frozen) { - console.log('Payment is frozen - dispute in progress'); -} else { - console.log('Payment is not frozen'); -} -``` - -## Complete Decision Workflow - -This example shows the full arbiter workflow: fetching pending cases, reviewing them, making a decision, and executing the refund. - -```typescript -import { X402rArbiter } from '@x402r/arbiter'; -import { RequestStatus } from '@x402r/core'; -import type { PaymentInfo } from '@x402r/core'; - -async function processAllPendingCases(arbiter: X402rArbiter, receiverAddress: `0x${string}`) { - // Step 1: Get all pending refund requests - const { keys, total } = await arbiter.getPendingRefundRequests(0n, 50n, receiverAddress); - console.log(`Found ${total} pending cases`); - - for (const key of keys) { - // Step 2: Retrieve request details - const request = await arbiter.getRefundRequestByKey(key); - - // Step 3: Skip if already decided - if (request.status !== RequestStatus.Pending) { - console.log(`Skipping ${request.paymentInfoHash} - already ${RequestStatus[request.status]}`); - continue; - } - - // Step 4: Apply your decision logic - const shouldApprove = await evaluateCase(request); - - if (shouldApprove) { - // Step 5a: Approve and execute the refund - // NOTE: You need the full PaymentInfo struct to call these methods. - // Retrieve it from your application's database or event logs. - const paymentInfo = await lookupPaymentInfo(request.paymentInfoHash); - - const { txHash: approveTx } = await arbiter.approveRefundRequest(paymentInfo, request.nonce); - console.log(`Approved: ${approveTx}`); - - const { txHash: executeTx } = await arbiter.executeRefundInEscrow(paymentInfo); - console.log(`Refund executed: ${executeTx}`); - } else { - // Step 5b: Deny the refund - const paymentInfo = await lookupPaymentInfo(request.paymentInfoHash); - - const { txHash } = await arbiter.denyRefundRequest(paymentInfo, request.nonce); - console.log(`Denied: ${txHash}`); - } - } -} -``` - -## Decision Flow Diagram - -```mermaid -flowchart TD - A[Get Pending Requests] --> B[getRefundRequestByKey] - B --> C{Status Pending?} - C -->|No| D[Skip - Already Decided] - C -->|Yes| E[Evaluate Case] - E --> F{Decision} - F -->|Approve| G[approveRefundRequest] - F -->|Deny| H[denyRefundRequest] - G --> I[executeRefundInEscrow] - I --> J[Funds Returned to Payer] - H --> K[Merchant Keeps Funds] -``` - -## Next Steps - - - - Process multiple cases at once. - - - Automate decisions with AI evaluation hooks. - - - Register as an arbiter for on-chain discovery. - - - RefundRequest contract and state machine details. - - diff --git a/sdk/arbiter/quickstart.mdx b/sdk/arbiter/quickstart.mdx deleted file mode 100644 index dc24b32..0000000 --- a/sdk/arbiter/quickstart.mdx +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: "Arbiter SDK" -description: "Dispute resolution SDK for reviewing cases and making decisions (experimental)" -icon: "rocket" ---- - - -The Arbiter SDK is experimental. The dispute resolution system design is actively evolving. - - -The `@x402r/arbiter` package provides methods for arbiters to resolve disputes: reviewing refund requests, approving or denying them, and executing refunds. - -## Setup - -```typescript -import { X402rArbiter } from '@x402r/arbiter'; -import { getNetworkConfig } from '@x402r/core'; - -const config = getNetworkConfig('eip155:84532')!; - -const arbiter = new X402rArbiter({ - publicClient, - walletClient, - operatorAddress: '0x...', - refundRequestAddress: config.refundRequest, - arbiterRegistryAddress: config.arbiterRegistry, -}); -``` - -## Available Methods - -The Arbiter SDK currently supports: - -- **`approveRefundRequest()`** — Approve a pending refund request -- **`denyRefundRequest()`** — Deny a pending refund request -- **`executeRefundInEscrow()`** — Execute an approved refund to transfer funds back -- **`getPendingRefundRequests()`** — List refund requests awaiting decision -- **`getRefundRequestByKey()`** — Get details of a specific refund request -- **`registerArbiter()`** — Register in the on-chain ArbiterRegistry -- **`isArbiterRegistered()`** — Check registration status -- **`submitEvidence()`** — Attach evidence (IPFS CID) to a refund request -- **`getEvidence()`** — Retrieve a single evidence entry by index -- **`getAllEvidence()`** — Retrieve all evidence for a refund request - -## Try It Now - -The easiest way to try arbiter features is with the **arbiter-cli** example, which provides a command-line interface for all arbiter operations: - - - CLI tool for arbiters to review cases, make decisions, and manage registry. - - -## Next Steps - - - - See all working examples including the full payment flow. - - - Deploy a PaymentOperator with arbiter support. - - diff --git a/sdk/arbiter/registry.mdx b/sdk/arbiter/registry.mdx deleted file mode 100644 index 1855c8d..0000000 --- a/sdk/arbiter/registry.mdx +++ /dev/null @@ -1,171 +0,0 @@ ---- -title: "Arbiter Registry" -description: "Register, update, and discover arbiters using the on-chain ArbiterRegistry" -icon: "clipboard-list" ---- - -The ArbiterRegistry is an on-chain contract that allows arbiters to register themselves, publish metadata URIs, and be discovered by merchants and clients. - -## Setup - -To use registry methods, provide the `arbiterRegistryAddress` when creating the arbiter instance: - -```typescript -import { X402rArbiter } from '@x402r/arbiter'; -import { getNetworkConfig } from '@x402r/core'; - -const config = getNetworkConfig('eip155:84532')!; - -const arbiter = new X402rArbiter({ - publicClient, - walletClient, - operatorAddress: '0x...', - refundRequestAddress: config.refundRequest, - arbiterRegistryAddress: config.arbiterRegistry, -}); -``` - -## Register as an Arbiter - -Register your address in the on-chain registry with a URI pointing to your metadata or API endpoint: - -```typescript -const { txHash } = await arbiter.registerArbiter( - 'https://arbiter.example.com/api/disputes' -); -console.log('Registered:', txHash); -``` - -The URI can point to: -- An API endpoint for receiving dispute notifications -- A JSON metadata file describing your arbitration services -- An IPFS hash with your arbiter profile - -## Update Your URI - -Change your registered URI: - -```typescript -const { txHash } = await arbiter.updateArbiterUri( - 'https://new-arbiter.example.com/api' -); -console.log('URI updated:', txHash); -``` - -## Deregister - -Remove yourself from the registry: - -```typescript -const { txHash } = await arbiter.deregisterArbiter(); -console.log('Deregistered:', txHash); -``` - -## Query the Registry - -### Check if an Address is Registered - -```typescript -const isRegistered = await arbiter.isArbiterRegistered('0xArbiterAddress...'); -console.log('Is registered:', isRegistered); -``` - -### Get an Arbiter's URI - -```typescript -const uri = await arbiter.getArbiterUri('0xArbiterAddress...'); -console.log('URI:', uri); // empty string if not registered -``` - -### Get Total Arbiter Count - -```typescript -const count = await arbiter.getArbiterCount(); -console.log('Total arbiters:', count); -``` - -### List Arbiters (Paginated) - -```typescript -const { arbiters, uris, total } = await arbiter.listArbiters(0n, 10n); -console.log(`Showing ${arbiters.length} of ${total} arbiters`); - -for (let i = 0; i < arbiters.length; i++) { - console.log(`${arbiters[i]}: ${uris[i]}`); -} -``` - -## Method Reference - -| Method | Parameters | Returns | Description | -|--------|-----------|---------|-------------| -| `registerArbiter` | `uri: string` | `{ txHash }` | Register with a URI | -| `updateArbiterUri` | `newUri: string` | `{ txHash }` | Update registered URI | -| `deregisterArbiter` | (none) | `{ txHash }` | Remove from registry | -| `getArbiterUri` | `arbiter: Address` | `string` | Get arbiter's URI | -| `isArbiterRegistered` | `arbiter: Address` | `boolean` | Check registration | -| `getArbiterCount` | (none) | `bigint` | Total registered count | -| `listArbiters` | `offset: bigint, count: bigint` | `ArbiterList` | Paginated list | - -## ArbiterList Type - -```typescript -interface ArbiterList { - arbiters: readonly `0x${string}`[]; // Arbiter addresses - uris: readonly string[]; // Corresponding URIs - total: bigint; // Total registered count -} -``` - -## Complete Example - -```typescript -import { X402rArbiter } from '@x402r/arbiter'; -import { getNetworkConfig } from '@x402r/core'; - -async function main() { - const config = getNetworkConfig('eip155:84532')!; - - const arbiter = new X402rArbiter({ - publicClient, - walletClient, - operatorAddress: '0x...', - arbiterRegistryAddress: config.arbiterRegistry, - }); - - // Register - await arbiter.registerArbiter('https://my-arbiter.example.com/api'); - - // Verify - const isRegistered = await arbiter.isArbiterRegistered( - walletClient.account!.address - ); - console.log('Registered:', isRegistered); - - // Browse all arbiters - const { arbiters, uris, total } = await arbiter.listArbiters(0n, 100n); - console.log(`${total} arbiters registered:`); - for (let i = 0; i < arbiters.length; i++) { - console.log(` ${arbiters[i]} → ${uris[i]}`); - } -} - -main().catch(console.error); -``` - -## Next Steps - - - - Approve and deny refund requests. - - - Automate dispute resolution with AI. - - - Complete arbiter setup guide. - - - Understand arbiter conditions in contracts. - - diff --git a/sdk/arbiter/subscriptions.mdx b/sdk/arbiter/subscriptions.mdx deleted file mode 100644 index e2436ae..0000000 --- a/sdk/arbiter/subscriptions.mdx +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: "Arbiter Events" -description: "Subscribe to dispute events and build real-time arbiter dashboards" -icon: "bell" ---- - -The Arbiter SDK provides three subscription methods for monitoring dispute activity in real-time. Each returns an object with an `unsubscribe` function you call to stop watching. - -## Watch New Cases - -Subscribe to `RefundRequested` events -- these are new refund requests that need your attention: - -```typescript -import type { RefundRequestEventLog } from '@x402r/core'; - -const { unsubscribe } = arbiter.watchNewCases((event: RefundRequestEventLog) => { - console.log('New refund request!'); - console.log('Payment hash:', event.args.paymentInfoHash); - console.log('Payer:', event.args.payer); - console.log('Receiver:', event.args.receiver); - console.log('Amount:', event.args.amount); - console.log('Nonce:', event.args.nonce); - console.log('Block:', event.blockNumber); -}); - -// Later: stop watching -unsubscribe(); -``` - -## Watch Decisions - -Subscribe to `RefundRequestStatusUpdated` events -- these fire when a refund request is approved or denied: - -```typescript -import type { RefundRequestEventLog } from '@x402r/core'; - -const { unsubscribe } = arbiter.watchDecisions((event: RefundRequestEventLog) => { - console.log('Decision made!'); - console.log('Payment hash:', event.args.paymentInfoHash); - console.log('New status:', event.args.status); - console.log('Transaction:', event.transactionHash); -}); -``` - -## Watch Freeze Events - -Subscribe to `PaymentFrozen` and `PaymentUnfrozen` events from a Freeze condition contract: - -```typescript -import type { FreezeEventLog } from '@x402r/core'; - -const freezeAddress = '0xFreezeContractAddress...' as `0x${string}`; - -const { unsubscribe } = arbiter.watchFreezeEvents( - freezeAddress, - (event: FreezeEventLog) => { - if (event.eventName === 'PaymentFrozen') { - console.log('Payment frozen:', event.args.paymentInfoHash); - console.log('Frozen by:', event.args.caller); - } else if (event.eventName === 'PaymentUnfrozen') { - console.log('Payment unfrozen:', event.args.paymentInfoHash); - } - } -); -``` - -## Event Type Reference - -| Method | Contract Event | Fires When | -|--------|---------------|------------| -| `watchNewCases` | `RefundRequested` | A payer submits a new refund request | -| `watchDecisions` | `RefundRequestStatusUpdated` | An arbiter or receiver approves/denies a request | -| `watchFreezeEvents` | `PaymentFrozen` / `PaymentUnfrozen` | A payment is frozen or unfrozen | - -## Event Log Types - -Both `watchNewCases` and `watchDecisions` emit `RefundRequestEventLog` events: - -```typescript -interface RefundRequestEventLog { - eventName: 'RefundRequested' | 'RefundRequestStatusUpdated' | 'RefundRequestCancelled'; - args: { - paymentInfoHash?: `0x${string}`; - payer?: `0x${string}`; - receiver?: `0x${string}`; - amount?: bigint; - nonce?: bigint; - status?: number; - }; - address: `0x${string}`; - blockNumber: bigint; - transactionHash: `0x${string}`; - logIndex: number; -} -``` - -The `watchFreezeEvents` method emits `FreezeEventLog` events: - -```typescript -interface FreezeEventLog { - eventName: 'PaymentFrozen' | 'PaymentUnfrozen'; - args: { - paymentInfoHash?: `0x${string}`; - caller?: `0x${string}`; - }; - address: `0x${string}`; - blockNumber: bigint; - transactionHash: `0x${string}`; - logIndex: number; -} -``` - - -All subscription methods use viem's `watchContractEvent` under the hood. For reliable real-time delivery, configure your `publicClient` with a [WebSocket transport](https://viem.sh/docs/clients/transports/websocket). - - -## Next Steps - - - - Automate case evaluation with AI hooks. - - - Review the complete arbiter setup guide. - - - Process multiple queued cases efficiently. - - - See how clients subscribe to the same events. - - diff --git a/sdk/cli.mdx b/sdk/cli.mdx new file mode 100644 index 0000000..92839fb --- /dev/null +++ b/sdk/cli.mdx @@ -0,0 +1,202 @@ +--- +title: "CLI" +description: "One-shot command-line tool for paying x402 endpoints. Wallet-agnostic with zero provider dependencies." +icon: "terminal" +--- + +`@x402r/cli` makes a single x402 payment from the command line. You point it at an address, provide a signer, and get back the response body plus a settlement transaction hash. + +The CLI carries zero provider SDK dependencies. Raw private keys, JSON-RPC signers (Privy, Turnkey, Fireblocks, Safe), and custom signer modules all work through the same interface. + +### Install + + +```bash npm +npx @x402r/cli pay [options] +``` +```bash pnpm +pnpm dlx @x402r/cli pay [options] +``` +```bash bun +bunx @x402r/cli pay [options] +``` + + +No project install required. Pin the version (for example, `@x402r/cli@0.2.0`) for reproducible scripted workflows. + +### Usage + +```bash +x402r pay [signer flags] [--chain ] [--rpc ] [--max-amount N] [--json] +``` + +If the endpoint does not return HTTP 402, the CLI short-circuits and prints the response body with exit code 0. The CLI sends no payment. + +### Signer configuration + +Configure exactly one signer source. CLI flags take precedence over environment variables. If the CLI finds zero or more than one source, it exits with code 6. + +| Source | Flag | Environment variable | +|--------|------|---------| +| Raw private key | `--key 0x...` | `PRIVATE_KEY` | +| Remote JSON-RPC | `--signer-url ` and `--signer-address 0x...` | `SIGNER_URL` and `SIGNER_ADDRESS` | +| Custom module | `--signer-module ` | `SIGNER_MODULE` | + +Environment variable names use no `X402R_` prefix to match Foundry, Hardhat, and x402-reference conventions. + +### Request options + +| Flag | Description | +|------|-------------| +| `--chain ` | Select a specific `accepts[]` entry when the merchant offers more than one chain. Required when more than one option exists. | +| `--asset-transfer-method ` | Select the token-collection path when the chosen `accepts[]` entry supports both EIP-3009 and Permit2. Required when more than one option exists. | +| `--rpc ` | Override the RPC endpoint for on-chain reads. Required for chain IDs not in `viem/chains`. | +| `--max-amount ` | Refuse to pay more than `n` atomic token units. Exits with code 3 if the price exceeds this. | +| `--json` | Emit a single JSON envelope to stdout instead of plain text. | + +### Supported chains + +The CLI reads the chain from the 402 response's `accepts[].network` field. Any EVM chain known to `viem/chains` works, including Base and Base Sepolia. For chain IDs `viem/chains` does not recognize, pass `--rpc ` with an RPC endpoint. + +### Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Network error | +| 2 | Malformed 402 response or unusable `accepts[]` | +| 3 | Price exceeds `--max-amount` | +| 4 | Signature rejected | +| 5 | Settlement failed (merchant error after payment, or facilitator error) | +| 6 | Signer resolution failed (none, more than one, or incomplete configuration) | + +### Examples + +#### Raw private key + +```bash +PRIVATE_KEY=0xabc123... npx @x402r/cli pay https://api.example.com/paid-endpoint +``` + +#### JSON-RPC signer + +Any endpoint that speaks `eth_signTypedData_v4` works: Privy wallet RPC, Turnkey, Fireblocks, Safe, a local `cast wallet` endpoint, or a hardware wallet behind an RPC bridge. + +```bash +npx @x402r/cli pay https://api.example.com/paid-endpoint \ + --signer-url https://signer.example/rpc \ + --signer-address 0x586486394C38A2a7d36B16a3FDaF366cd202d823 +``` + +#### Custom module (Privy) + +```javascript privy-signer.js +import { PrivyClient } from "@privy-io/server-auth"; +import { createViemAccount } from "@privy-io/server-auth/viem"; + +export default async function () { + const privy = new PrivyClient( + process.env.PRIVY_APP_ID, + process.env.PRIVY_APP_SECRET + ); + return createViemAccount({ + walletId: process.env.PRIVY_WALLET_ID, + address: process.env.PRIVY_WALLET_ADDRESS, + privy, + }); +} +``` + +```bash +npx @x402r/cli pay https://api.example.com/paid-endpoint \ + --signer-module ./privy-signer.js +``` + +#### Custom module (Coinbase CDP) + +```javascript cdp-signer.js +import { CdpClient } from "@coinbase/cdp-sdk"; +import { toAccount } from "viem/accounts"; + +export default async function () { + const cdp = new CdpClient(); + const acct = await cdp.evm.getOrCreateAccount({ + name: process.env.CDP_ACCOUNT_NAME, + }); + return toAccount(acct); +} +``` + +```bash +npx @x402r/cli pay https://api.example.com/paid-endpoint \ + --signer-module ./cdp-signer.js +``` + +### JSON output + +With `--json`, the CLI writes a single JSON envelope to stdout: + +```json +{ + "body": "", + "status": 200, + "tx": "0x...", + "elapsedMs": 1234, + "signer": { "kind": "key", "address": "0x..." } +} +``` + +The CLI drops the `signer` field when the endpoint returned a non-402 response (the CLI sent no payment). + +### Signer module contract + +A custom signer module must default-export a factory function with the signature `() => Promise`. The returned object must be a viem `Account` with at least `address` and `signTypedData`. The CLI only needs typed-data signatures; the facilitator broadcasts the transaction. + +### Programmatic usage + +You can also use the `pay` function and `resolveSigner` directly from TypeScript: + +```typescript +import { pay } from "@x402r/cli"; +import type { PayResult } from "@x402r/cli"; + +const result: PayResult = await pay({ + url: "https://api.example.com/paid-endpoint", + key: process.env.PRIVATE_KEY, + json: true, +}); + +console.log(result.body); +console.log(result.tx); +``` + + +The programmatic API uses the same `PayFlags` interface as the CLI binary. All options (chain, rpc, maxAmount, signer flags) are available. + + +### Exports + +The `@x402r/cli` package exports: + +| Export | Type | Description | +|--------|------|-------------| +| `pay` | function | Execute a one-shot payment against an endpoint | +| `resolveSigner` | function | Resolve a signer from flags and environment variables | +| `CliError` | class | Base error class with typed exit codes | +| `NetworkError` | class | Exit code 1 | +| `Malformed402Error` | class | Exit code 2 | +| `MaxAmountExceededError` | class | Exit code 3 | +| `SignatureRejectedError` | class | Exit code 4 | +| `SettlementError` | class | Exit code 5 | +| `SignerResolutionError` | class | Exit code 6 | + +## Next steps + + + + Accept payments and manage escrow releases. + + + Runnable examples for every SDK operation. + + diff --git a/sdk/client/escrow-management.mdx b/sdk/client/escrow-management.mdx deleted file mode 100644 index 5f86fd3..0000000 --- a/sdk/client/escrow-management.mdx +++ /dev/null @@ -1,238 +0,0 @@ ---- -title: "Escrow Management" -description: "Manage escrow periods and freeze payments with the Client SDK" -icon: "lock" ---- - -The Client SDK provides methods to interact with the escrow system, including freezing payments during disputes and querying escrow period timing. All methods documented on this page are **fully functional**. - -## Freeze Operations - -Freezing a payment pauses the escrow timer, preventing the merchant from releasing funds while a dispute is being resolved. Freeze operations interact with the `Freeze` contract. - -### freezePayment - -Freeze a payment to pause the escrow timer. Only the payer can freeze a payment. - -```typescript -const freezeAddress = '0x...'; // Freeze contract address - -const { txHash } = await client.freezePayment(paymentInfo, freezeAddress); -console.log(`Payment frozen: ${txHash}`); -``` - -#### Signature - -```typescript -freezePayment( - paymentInfo: PaymentInfo, - freezeAddress: `0x${string}` -): Promise<{ txHash: `0x${string}` }> -``` - - -Freezing is useful when: -- You need more time to resolve a dispute with the merchant -- You are waiting for additional information before deciding on a refund -- The merchant is unresponsive to your refund request - - -### unfreezePayment - -Unfreeze a previously frozen payment. The receiver (merchant) or arbiter can unfreeze a payment once the dispute is resolved. - -```typescript -const { txHash } = await client.unfreezePayment(paymentInfo, freezeAddress); -console.log(`Payment unfrozen: ${txHash}`); -``` - -#### Signature - -```typescript -unfreezePayment( - paymentInfo: PaymentInfo, - freezeAddress: `0x${string}` -): Promise<{ txHash: `0x${string}` }> -``` - -### isFrozen - -Check whether a payment is currently frozen. - -```typescript -const frozen = await client.isFrozen(paymentInfo, freezeAddress); - -if (frozen) { - console.log('Payment is frozen - escrow timer paused'); -} else { - console.log('Payment is not frozen - escrow timer running'); -} -``` - -#### Signature - -```typescript -isFrozen( - paymentInfo: PaymentInfo, - freezeAddress: `0x${string}` -): Promise -``` - -## Escrow Period Operations - -These methods interact with the `EscrowPeriod` contract to query timing information about a payment's escrow window. - -### getAuthorizationTime - -Get the timestamp (in seconds) when a payment was authorized on-chain. This is the starting point of the escrow period. - -```typescript -const escrowPeriodAddress = '0x...'; // EscrowPeriod contract address - -const authTime = await client.getAuthorizationTime(paymentInfo, escrowPeriodAddress); -const authDate = new Date(Number(authTime) * 1000); - -console.log(`Payment authorized at: ${authDate.toISOString()}`); -``` - -#### Signature - -```typescript -getAuthorizationTime( - paymentInfo: PaymentInfo, - escrowPeriodAddress: `0x${string}` -): Promise -``` - -### isDuringEscrowPeriod - -Check whether a payment is still within its escrow period. Returns `true` if the escrow period has not yet passed, meaning the payment can still be refunded. - -```typescript -const duringEscrow = await client.isDuringEscrowPeriod( - paymentInfo, - escrowPeriodAddress -); - -if (duringEscrow) { - console.log('Still in escrow period - refund is possible'); -} else { - console.log('Escrow period has passed - funds can be fully released'); -} -``` - -#### Signature - -```typescript -isDuringEscrowPeriod( - paymentInfo: PaymentInfo, - escrowPeriodAddress: `0x${string}` -): Promise -``` - - -The method name is `isDuringEscrowPeriod`, **not** `isEscrowPeriodPassed`. It returns `true` when the escrow period is still **active** (refund window is open), and `false` when it has passed. - - -## Understanding Escrow Timing - -| Condition | Escrow Timer | Can Request Refund | Can Release | -|-----------|--------------|-------------------|-------------| -| Normal (unfrozen, period active) | Running | Yes | Partial only | -| Frozen | Paused | Yes | No | -| Escrow period passed (unfrozen) | Stopped | No | Full amount | - - -The escrow period length is configured at the contract level when the `EscrowPeriod` condition is deployed. Common values are 7 days, 14 days, or 30 days. You can calculate the remaining time from `getAuthorizationTime` and the configured period. - - -## Example: Freeze and Request Refund - -A common pattern is to freeze a payment before submitting a refund request, ensuring the merchant cannot release funds while the request is pending. - -```typescript -import { X402rClient } from '@x402r/client'; -import type { PaymentInfo } from '@x402r/core'; - -async function freezeAndRequestRefund( - client: X402rClient, - paymentInfo: PaymentInfo, - freezeAddress: `0x${string}`, - refundAmount: bigint -) { - // Step 1: Check if already frozen - const alreadyFrozen = await client.isFrozen(paymentInfo, freezeAddress); - - if (!alreadyFrozen) { - // Freeze the payment first - const { txHash: freezeTx } = await client.freezePayment( - paymentInfo, - freezeAddress - ); - console.log(`Payment frozen: ${freezeTx}`); - } - - // Step 2: Check if refund request already exists - const nonce = 0n; - const hasRequest = await client.hasRefundRequest(paymentInfo, nonce); - - if (!hasRequest) { - // Submit the refund request - const { txHash: refundTx } = await client.requestRefund( - paymentInfo, - refundAmount, - nonce - ); - console.log(`Refund requested: ${refundTx}`); - } - - // Step 3: Watch for resolution - const { unsubscribe } = client.watchFreezeEvents( - freezeAddress, - (event) => { - if (event.eventName === 'PaymentUnfrozen') { - console.log('Payment was unfrozen - dispute may be resolved'); - unsubscribe(); - } - } - ); -} -``` - -## Freeze / Unfreeze Flow - -```mermaid -sequenceDiagram - participant P as Payer - participant F as Freeze Contract - participant M as Merchant / Arbiter - - Note over F: Escrow timer running - P->>F: freezePayment() - Note over F: Timer paused - - alt Dispute resolved favorably - M->>F: unfreezePayment() - Note over F: Timer resumes - else Payer cancels dispute - P->>F: unfreezePayment() - Note over F: Timer resumes - end -``` - -## Next Steps - - - - Watch for freeze and unfreeze events in real-time. - - - Request refunds while payment is in escrow. - - - Planned query methods and current workarounds. - - - Full setup guide for the Client SDK. - - diff --git a/sdk/client/payment-queries.mdx b/sdk/client/payment-queries.mdx deleted file mode 100644 index 38d3103..0000000 --- a/sdk/client/payment-queries.mdx +++ /dev/null @@ -1,120 +0,0 @@ ---- -title: "Payment Queries" -description: "Query payment states and details with the Client SDK" -icon: "magnifying-glass" ---- - -The Client SDK provides five methods for querying payment state and history. These read directly from the escrow contract and on-chain event logs. - -## getPaymentState - -Derive the lifecycle state of a payment from the escrow contract (amounts and expiry). - -```typescript -import { PaymentState } from '@x402r/core'; - -const state = await client.getPaymentState(paymentInfo); - -// PaymentState enum: -// 0 = NonExistent - Payment has never been authorized -// 1 = InEscrow - Funds locked, capturableAmount > 0 -// 2 = Released - Funds released to receiver, may still be refundable -// 3 = Settled - No funds in escrow or refundable -// 4 = Expired - Authorization expired, payer can reclaim -``` - -```typescript -getPaymentState(paymentInfo: PaymentInfo): Promise -``` - -## paymentExists - -Check whether a payment has been collected by reading the escrow's `hasCollectedPayment` flag. - -```typescript -const exists = await client.paymentExists(paymentInfoHash); -if (exists) { - console.log('Payment found'); -} -``` - -```typescript -paymentExists(paymentInfoHash: `0x${string}`): Promise -``` - -## isInEscrow - -Check if a payment currently has capturable funds in escrow. - -```typescript -const inEscrow = await client.isInEscrow(paymentInfoHash); -if (inEscrow) { - console.log('Payment has funds in escrow'); -} -``` - -```typescript -isInEscrow(paymentInfoHash: `0x${string}`): Promise -``` - -## getPaymentDetails - -Retrieve the full `PaymentInfo` struct by scanning `AuthorizationCreated` events for the given hash. - -```typescript -const details = await client.getPaymentDetails(paymentInfoHash); - -console.log('Payer:', details.payer); -console.log('Receiver:', details.receiver); -console.log('Amount:', details.maxAmount); -``` - -```typescript -getPaymentDetails( - paymentInfoHash: `0x${string}`, - fromBlock?: bigint -): Promise -``` - - -This method scans event logs. Pass `fromBlock` to limit the scan range if your RPC limits `eth_getLogs` responses (Base Sepolia typically caps at 10,000 blocks). - - -## getPayerPayments - -List all payments where the connected wallet is the payer, by scanning `AuthorizationCreated` events. - -```typescript -const payments = await client.getPayerPayments(); - -for (const { hash, paymentInfo } of payments) { - console.log(`Payment ${hash}: ${paymentInfo.maxAmount}`); -} -``` - -```typescript -getPayerPayments( - fromBlock?: bigint -): Promise> -``` - - -Like `getPaymentDetails`, this scans event logs. Pass `fromBlock` to limit the range for large histories. - - -## Next Steps - - - - Request and manage refunds. - - - Freeze payments and query escrow periods. - - - Full setup guide for the Client SDK. - - - Known constraints including event log scanning limits. - - diff --git a/sdk/client/quickstart.mdx b/sdk/client/quickstart.mdx deleted file mode 100644 index f451a5f..0000000 --- a/sdk/client/quickstart.mdx +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: "Client SDK" -description: "Payer-side SDK for refunds, freezes, and escrow management (experimental)" -icon: "rocket" ---- - - -The Client SDK is experimental. APIs will change as the refund and dispute system design evolves. - - -The `@x402r/client` package provides payer-side methods for interacting with X402r payments: requesting refunds, freezing payments, and querying escrow state. - -## Setup - -```typescript -import { X402rClient } from '@x402r/client'; -import { getNetworkConfig } from '@x402r/core'; - -const networkConfig = getNetworkConfig('eip155:84532')!; - -const client = new X402rClient({ - publicClient, - walletClient, - operatorAddress: '0x...', // Your PaymentOperator address - refundRequestAddress: networkConfig.refundRequest, - escrowAddress: networkConfig.authCaptureEscrow, -}); -``` - -## Available Methods - -The Client SDK currently supports: - -- **`requestRefund()`** — Submit a refund request for a payment in escrow -- **`getRefundStatus()`** — Check the status of a refund request -- **`freezePayment()`** — Freeze a payment to prevent release during a dispute -- **`isFrozen()`** — Check if a payment is frozen -- **`isDuringEscrowPeriod()`** — Check if a payment is still in its escrow window -- **`getAuthorizationTime()`** — Get when a payment was authorized -- **`getPaymentState()`** — Derive the lifecycle state of a payment -- **`paymentExists()`** — Check if a payment has been authorized -- **`isInEscrow()`** — Check if a payment has capturable funds -- **`getPaymentDetails()`** — Retrieve full PaymentInfo from event logs -- **`getPayerPayments()`** — List all payments for the connected wallet -- **`submitEvidence()`** — Attach evidence (IPFS CID) to a refund request -- **`getEvidence()`** — Retrieve a single evidence entry by index -- **`getAllEvidence()`** — Retrieve all evidence for a refund request - -## Try It Now - -The easiest way to try client features is with the **client-cli** example, which provides a command-line interface for all client operations: - - - CLI tool for payers to `pay`, `preview-fee`, request refunds, freeze payments, and check status. - - -## Next Steps - - - - See all working examples including the full payment flow. - - - Deploy a PaymentOperator to test client operations against. - - diff --git a/sdk/client/refund-operations.mdx b/sdk/client/refund-operations.mdx deleted file mode 100644 index 7acd7f3..0000000 --- a/sdk/client/refund-operations.mdx +++ /dev/null @@ -1,118 +0,0 @@ ---- -title: "Refund Operations" -description: "Request and manage refunds with the Client SDK - submit, cancel, and track refund requests" -icon: "rotate-left" ---- - -The Client SDK provides complete refund management capabilities for payers. All refund methods interact directly with the `RefundRequest` contract on-chain. - - -**About the `nonce` parameter:** Every refund method requires a `nonce: bigint` parameter. This is the record index from the `PaymentIndexRecorder` and identifies which charge within a payment you are requesting a refund for. For the first (and most common) charge, use `0n`. - - -## requestRefund - -Submit a refund request for a payment that is in escrow. The request goes on-chain and is visible to the merchant and any assigned arbiter. - -```typescript -const { txHash } = await client.requestRefund( - paymentInfo, - BigInt('1000000'), // amount to refund (e.g., 1 USDC with 6 decimals) - 0n // nonce: first charge -); - -console.log(`Refund requested: ${txHash}`); -``` - -## cancelRefundRequest - -Cancel a pending refund request that you submitted. Only the original requester (payer) can cancel, and only while the request status is `Pending`. - -```typescript -const { txHash } = await client.cancelRefundRequest(paymentInfo, 0n); -console.log(`Refund request cancelled: ${txHash}`); -``` - -## Query Refund State - -These methods read on-chain state for refund requests. None require a wallet client. - -### Check existence and status - -```typescript -// Check if a refund request exists -const hasRequest = await client.hasRefundRequest(paymentInfo, 0n); - -// Get just the status -const status = await client.getRefundStatus(paymentInfo, 0n); -// Returns: RequestStatus.Pending | Approved | Denied | Cancelled -``` - -### Get full refund request data - -```typescript -// By paymentInfo + nonce -const request = await client.getRefundRequest(paymentInfo, 0n); -console.log(request.amount, request.status); - -// By composite key (from getMyRefundRequests) -const request2 = await client.getRefundRequestByKey(compositeKey); -``` - -### List your refund requests - -```typescript -// Get total count -const count = await client.getMyRefundRequestCount(); - -// Get paginated keys, then fetch details -const { keys, total } = await client.getMyRefundRequests(0n, 10n); - -for (const key of keys) { - const request = await client.getRefundRequestByKey(key); - console.log(`Amount: ${request.amount}, Status: ${request.status}`); -} -``` - -## Refund Request Lifecycle - -```mermaid -stateDiagram-v2 - [*] --> Pending: requestRefund() - Pending --> Approved: Merchant/Arbiter approves - Pending --> Denied: Merchant/Arbiter denies - Pending --> Cancelled: cancelRefundRequest() - Approved --> [*]: Funds returned - Denied --> [*] - Cancelled --> [*] -``` - -## Method Reference - -| Method | Parameters | Returns | -|--------|-----------|---------| -| `requestRefund` | `paymentInfo, amount, nonce` | `{ txHash }` | -| `cancelRefundRequest` | `paymentInfo, nonce` | `{ txHash }` | -| `hasRefundRequest` | `paymentInfo, nonce` | `boolean` | -| `getRefundStatus` | `paymentInfo, nonce` | `RequestStatus` | -| `getRefundRequest` | `paymentInfo, nonce` | `RefundRequestData` | -| `getRefundRequestByKey` | `compositeKey` | `RefundRequestData` | -| `getMyRefundRequests` | `offset, count` | `{ keys, total }` | -| `getMyRefundRequestCount` | none | `bigint` | - -## Next Steps - - - - Freeze payments and check escrow period timing. - - - Watch for refund status updates in real-time. - - - Query payment state and details. - - - Full setup guide for the Client SDK. - - diff --git a/sdk/client/subscriptions.mdx b/sdk/client/subscriptions.mdx deleted file mode 100644 index cac10ce..0000000 --- a/sdk/client/subscriptions.mdx +++ /dev/null @@ -1,237 +0,0 @@ ---- -title: "Client Events" -description: "Subscribe to real-time payment, refund, and freeze events as a payer" -icon: "bell" ---- - -The Client SDK provides methods to subscribe to blockchain events in real-time using viem's `watchContractEvent` under the hood. All subscription methods return an object with an `unsubscribe` function that you should call when you no longer need the watcher. - -## watchPaymentState - -Watch for state changes on a specific payment. This subscribes to `ReleaseExecuted`, `RefundInEscrowExecuted`, and `RefundPostEscrowExecuted` events on the `PaymentOperator` contract. - -```typescript -const { unsubscribe } = client.watchPaymentState( - paymentInfoHash, - (event) => { - console.log(`Payment event: ${event.eventName}`); - - switch (event.eventName) { - case 'ReleaseExecuted': - console.log('Funds released to merchant'); - console.log('Amount:', event.args.amount); - break; - case 'RefundInEscrowExecuted': - console.log('Funds refunded from escrow'); - break; - case 'RefundPostEscrowExecuted': - console.log('Funds refunded after escrow period'); - break; - } - } -); - -// Stop watching when done -unsubscribe(); -``` - -### Signature - -```typescript -watchPaymentState( - paymentInfoHash: `0x${string}`, - callback: (event: PaymentOperatorEventLog) => void -): { unsubscribe: () => void } -``` - -### PaymentOperatorEventLog Type - -```typescript -interface PaymentOperatorEventLog { - eventName: - | 'ReleaseExecuted' - | 'RefundInEscrowExecuted' - | 'RefundPostEscrowExecuted' - | 'AuthorizationCreated' - | 'ChargeExecuted'; - args: { - paymentInfoHash?: `0x${string}`; - payer?: `0x${string}`; - receiver?: `0x${string}`; - amount?: bigint; - }; - address: `0x${string}`; - blockNumber: bigint; - transactionHash: `0x${string}`; - logIndex: number; -} -``` - -## watchRefundRequests - -Watch for refund request lifecycle events. This subscribes to `RefundRequested`, `RefundRequestStatusUpdated`, and `RefundRequestCancelled` events on the `RefundRequest` contract. - -```typescript -const { unsubscribe } = client.watchRefundRequests((event) => { - switch (event.eventName) { - case 'RefundRequested': - console.log('New refund request submitted'); - console.log('Payment:', event.args.paymentInfoHash); - console.log('Amount:', event.args.amount); - break; - case 'RefundRequestStatusUpdated': - console.log('Refund status changed:', event.args.status); - // 1 = Approved, 2 = Denied - break; - case 'RefundRequestCancelled': - console.log('Refund request cancelled'); - break; - } -}); - -// Stop watching when done -unsubscribe(); -``` - -### Signature - -```typescript -watchRefundRequests( - callback: (event: RefundRequestEventLog) => void -): { unsubscribe: () => void } -``` - - -Requires `refundRequestAddress` to be configured on the client. Throws an error if not set. - - -### RefundRequestEventLog Type - -```typescript -interface RefundRequestEventLog { - eventName: - | 'RefundRequested' - | 'RefundRequestStatusUpdated' - | 'RefundRequestCancelled'; - args: { - paymentInfoHash?: `0x${string}`; - payer?: `0x${string}`; - receiver?: `0x${string}`; - amount?: bigint; - nonce?: bigint; - status?: number; - }; - address: `0x${string}`; - blockNumber: bigint; - transactionHash: `0x${string}`; - logIndex: number; -} -``` - -## watchMyPayments - -Watch for new payment authorizations where the connected wallet is the payer. This subscribes to `AuthorizationCreated` events on the `PaymentOperator` contract, filtered by the wallet's address. - -```typescript -const { unsubscribe } = client.watchMyPayments((event) => { - console.log('New payment authorized!'); - console.log('Event:', event.eventName); // 'AuthorizationCreated' - console.log('Hash:', event.args.paymentInfoHash); - console.log('Receiver:', event.args.receiver); - console.log('Amount:', event.args.amount); -}); - -// Stop watching when done -unsubscribe(); -``` - -### Signature - -```typescript -watchMyPayments( - callback: (event: PaymentOperatorEventLog) => void -): { unsubscribe: () => void } -``` - - -Requires a `walletClient` with an account to be configured, since the events are filtered by the payer address. - - -## watchFreezeEvents - -Watch for freeze and unfreeze events on a specific `Freeze` contract. This subscribes to `PaymentFrozen` and `PaymentUnfrozen` events. - -```typescript -const freezeAddress = '0x...'; // Freeze contract address - -const { unsubscribe } = client.watchFreezeEvents( - freezeAddress, - (event) => { - if (event.eventName === 'PaymentFrozen') { - console.log('Payment frozen:', event.args.paymentInfoHash); - console.log('Frozen by:', event.args.caller); - } else if (event.eventName === 'PaymentUnfrozen') { - console.log('Payment unfrozen:', event.args.paymentInfoHash); - console.log('Unfrozen by:', event.args.caller); - } - } -); - -// Stop watching when done -unsubscribe(); -``` - -### Signature - -```typescript -watchFreezeEvents( - freezeAddress: `0x${string}`, - callback: (event: FreezeEventLog) => void -): { unsubscribe: () => void } -``` - -### FreezeEventLog Type - -```typescript -interface FreezeEventLog { - eventName: 'PaymentFrozen' | 'PaymentUnfrozen'; - args: { - paymentInfoHash?: `0x${string}`; - caller?: `0x${string}`; - }; - address: `0x${string}`; - blockNumber: bigint; - transactionHash: `0x${string}`; - logIndex: number; -} -``` - -## Event Types Reference - -| Method | Events Watched | Contract | Use Case | -|--------|---------------|----------|----------| -| `watchPaymentState` | `ReleaseExecuted`, `RefundInEscrowExecuted`, `RefundPostEscrowExecuted` | PaymentOperator | Track a single payment's lifecycle | -| `watchRefundRequests` | `RefundRequested`, `RefundRequestStatusUpdated`, `RefundRequestCancelled` | RefundRequest | Monitor refund request workflow | -| `watchMyPayments` | `AuthorizationCreated` (filtered by payer) | PaymentOperator | Track new payments for your wallet | -| `watchFreezeEvents` | `PaymentFrozen`, `PaymentUnfrozen` | Freeze | Monitor dispute freeze activity | - - -All subscription methods use viem's `watchContractEvent` under the hood. For reliable real-time delivery, configure your `publicClient` with a [WebSocket transport](https://viem.sh/docs/clients/transports/websocket). - - -## Next Steps - - - - Review the complete client setup. - - - Request and manage refunds. - - - Freeze payments and check escrow timing. - - - Learn about the merchant side of X402r. - - diff --git a/sdk/concepts.mdx b/sdk/concepts.mdx deleted file mode 100644 index 3b66c61..0000000 --- a/sdk/concepts.mdx +++ /dev/null @@ -1,206 +0,0 @@ ---- -title: "Core Concepts" -description: "Understand the X402r payment lifecycle, escrow, and dispute resolution" -icon: "lightbulb" ---- - -## Payment States - -Every payment in X402r goes through a defined lifecycle represented by the `PaymentState` enum: - -```typescript -import { PaymentState } from '@x402r/core'; - -// PaymentState values: -PaymentState.NonExistent // 0 - Payment doesn't exist -PaymentState.InEscrow // 1 - Funds held in escrow -PaymentState.Released // 2 - Funds released to merchant -PaymentState.Settled // 3 - Payment fully settled -PaymentState.Expired // 4 - Payment expired -``` - -```mermaid -stateDiagram-v2 - [*] --> InEscrow: authorize() - InEscrow --> Released: release() - InEscrow --> Settled: refundInEscrow() - InEscrow --> Expired: escrow period passes - Released --> Settled: settle() - Expired --> [*] - Settled --> [*] -``` - -## Payment Info - -The `PaymentInfo` struct uniquely identifies a payment and contains all its parameters: - -```typescript -interface PaymentInfo { - operator: `0x${string}`; // PaymentOperator contract address - payer: `0x${string}`; // Payer's address - receiver: `0x${string}`; // Merchant's address - token: `0x${string}`; // Payment token (e.g., USDC) - maxAmount: bigint; // Maximum payment amount - preApprovalExpiry: bigint; // Pre-approval expiry timestamp (0n if not used) - authorizationExpiry: bigint; // When payer can reclaim funds - refundExpiry: bigint; // Deadline for refund requests - minFeeBps: number; // Minimum fee in basis points - maxFeeBps: number; // Maximum fee in basis points - feeReceiver: `0x${string}`; // Address that receives fees - salt: bigint; // Unique salt for this payment -} -``` - - -Use `computePaymentInfoHash()` from `@x402r/core` to compute the unique hash of a payment. Use `parsePaymentInfo()` to deserialize a JSON PaymentInfo back into the typed struct. - - -## Escrow Period - -The **EscrowPeriod** contract tracks when a payment was authorized and enforces a configurable waiting period before funds can be released. During the escrow period: - -- **Payers** can request refunds or freeze the payment -- **Merchants** can refund but cannot release -- **After the period** — merchants can release funds to themselves - -```typescript -import { X402rClient } from '@x402r/client'; - -// Check when payment was authorized -const authTime = await client.getAuthorizationTime(paymentInfo, escrowPeriodAddress); - -// Check if still within escrow period -const inEscrow = await client.isDuringEscrowPeriod(paymentInfo, escrowPeriodAddress); -if (!inEscrow) { - console.log('Escrow period has passed - funds can be released'); -} -``` - -## Refund Requests - -When a payer wants a refund, they create a refund request that goes through approval: - -```typescript -import { RequestStatus } from '@x402r/core'; - -// RequestStatus values: -RequestStatus.Pending // 0 - Awaiting decision -RequestStatus.Approved // 1 - Approved by merchant/arbiter -RequestStatus.Denied // 2 - Denied by merchant/arbiter -RequestStatus.Cancelled // 3 - Cancelled by payer -``` - -### Refund Flow - -```mermaid -sequenceDiagram - participant P as Payer - participant R as RefundRequest Contract - participant M as Merchant - participant A as Arbiter - - P->>R: requestRefund(paymentInfo, amount, nonce) - R-->>M: RefundRequested event - - alt Merchant approves - M->>R: approveRefundRequest(paymentInfo, nonce) - M->>R: refundInEscrow(paymentInfo, amount) - else Merchant denies - M->>R: denyRefundRequest(paymentInfo, nonce) - else Escalate to arbiter - A->>R: approveRefundRequest(paymentInfo, nonce) - A->>R: executeRefundInEscrow(paymentInfo, amount) - end -``` - -### The Nonce Parameter - -All refund methods require a `nonce` parameter. This is the record index from the PaymentIndexRecorder that identifies which charge the refund request applies to. For the first charge, use `0n`. - -```typescript -// Request refund for the first charge -await client.requestRefund(paymentInfo, amount, 0n); - -// Check status for the first charge -const status = await client.getRefundStatus(paymentInfo, 0n); -``` - -## Freeze / Unfreeze - -The **Freeze** contract allows payers to freeze a payment during the escrow period, preventing release until the freeze expires or is lifted: - -```typescript -// Payer freezes payment (requires payer authorization) -await client.freezePayment(paymentInfo, freezeAddress); - -// Merchant unfreezes payment (requires receiver authorization) -await merchant.unfreezePayment(paymentInfo, freezeAddress); - -// Check frozen status -const frozen = await client.isFrozen(paymentInfo, freezeAddress); -``` - - -Freezing a payment does not automatically escalate to an arbiter. It pauses the release condition to allow time for dispute resolution. - - -## Roles and Permissions - -| Role | Can Do | -|------|--------| -| **Payer** | Request refunds, freeze payments, cancel requests, query escrow state | -| **Merchant** | Release payments, charge, approve/deny refunds, unfreeze payments | -| **Arbiter** | Approve/deny disputed refunds, execute refunds, batch operations, registry | - -## Contract Architecture - -```mermaid -flowchart TB - subgraph PO[PaymentOperator] - direction LR - auth[authorize] - rel[release] - chg[charge] - ref[refundInEscrow] - refp[refundPostEscrow] - end - - subgraph Components[Supporting Contracts] - direction LR - subgraph RR[RefundRequest] - rr1[Request/Cancel] - rr2[Approve/Deny] - end - subgraph EP[EscrowPeriod] - ep1[Track auth time] - ep2[Check period] - end - subgraph FR[Freeze] - fr1[Freeze/Unfreeze] - fr2[Check frozen] - end - subgraph Cond[Conditions] - c1[Access Control] - c2[Combinators] - end - end - - PO --> RR - PO --> EP - PO --> FR - PO --> Cond -``` - -## Next Steps - - - - Deploy your own PaymentOperator with all supporting contracts. - - - Working examples for merchants, clients, and arbiters. - - - Understand the underlying contract architecture. - - diff --git a/sdk/create-client.mdx b/sdk/create-client.mdx index 5df2a78..0a9b979 100644 --- a/sdk/create-client.mdx +++ b/sdk/create-client.mdx @@ -51,27 +51,28 @@ Type narrowing is a DX convenience, not a security boundary. On-chain [condition | `publicClient` | `PublicClient` | Yes | viem public client for reads | | `walletClient` | `WalletClient` | No | Required for writes. Role presets throw without it. | | `operatorAddress` | `Address` | Yes | Your deployed PaymentOperator | -| `chainId` | `number` | No | Auto-detected from `publicClient.chain` | +| `chainId` | `number` | No | Resolves from `publicClient.chain` when omitted | +| `network` | `string` | No | EIP-155 network ID (for example, `'eip155:84532'`). Alternative to `chainId`. | | `escrowPeriodAddress` | `Address` | No | Activates `escrow` group | | `refundRequestAddress` | `Address` | No | Activates `refund` group | | `refundRequestEvidenceAddress` | `Address` | No | Activates `evidence` group (requires `refundRequestAddress`) | | `freezeAddress` | `Address` | No | Activates `freeze` group | -| `paymentIndexRecorderAddress` | `Address` | No | Activates `query` group | -| `paymentStore` | `PaymentStore` | No | Pluggable storage for payment lookups | +| `paymentIndexRecorderHookAddress` | `Address` | No | Activates `query` group | +| `paymentStore` | `PaymentStore` | No | Custom storage layer for payment lookups | | `eventFromBlock` | `bigint` | No | Starting block for event-based payment lookups | ### Action Groups -| Group | Methods | Requirement | +| Group | Methods | Required config | |-------|---------|-------------| | `payment` | 9 | Always available | | `operator` | 8 | Always available | | `watch` | 4 | Always available | | `escrow` | 3 | `escrowPeriodAddress` | -| `refund` | 15 | `refundRequestAddress` | +| `refund` | 14 | `refundRequestAddress` | | `evidence` | 4 | `refundRequestEvidenceAddress` | | `freeze` | 3 | `freezeAddress` | -| `query` | 3 | `paymentIndexRecorderAddress` | +| `query` | 3 | `paymentIndexRecorderHookAddress` | Groups without their required address are `undefined` on the client. Use optional chaining: @@ -89,23 +90,86 @@ import { createX402r, queryActions } from '@x402r/sdk' const client = createX402r({ publicClient, operatorAddress: '0x...' }) const extended = client.extend( - queryActions('0xRecorderAddress', { eventFromBlock: 100000n }) + queryActions('0xHookAddress', { eventFromBlock: 100000n }) ) // extended.query is now defined const payments = await extended.query.getPayerPayments(payerAddress) ``` -## Next Steps +### ERC-8004 plugin + +The `erc8004Actions` plugin adds `identity`, `reputation`, and `discovery` action groups for on-chain agent identity and reputation: + +```typescript +import { createX402r, erc8004Actions } from '@x402r/sdk' + +const client = createX402r({ publicClient, walletClient, operatorAddress: '0x...' }) + +const extended = client.extend(erc8004Actions()) + +// Identity +await extended.identity.register('https://my-agent.example.com') +await extended.identity.verifyAgentId(42n, '0xAgentAddress...') +await extended.identity.resolveAgent(42n) +await extended.identity.isRegistered('0xAgentAddress...') + +// Verify + reputation in one call +const result = await extended.identity.check(42n, '0xAgentAddress...', [ + '0xReviewer1...', + '0xReviewer2...', +]) +console.log('Verified:', result.verified) +console.log('Reputation:', result.reputation) // ReputationSummary | null + +// Reputation +await extended.reputation.rate(42n, 85) +await extended.reputation.getSummary(42n, ['0xReviewer...']) + +// Discovery +await extended.discovery.resolveServiceEndpoint(42n, 'arbiter') +``` + +#### `identity.check()` + +Verifies an agent's on-chain identity and optionally fetches their reputation summary in a single call. Both the verification and reputation lookup run in parallel. + +```typescript +const { verified, reputation } = await extended.identity.check( + agentId, + agentAddress, + reviewerAddresses, // optional +) +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `agentId` | `bigint` | The agent's on-chain ID | +| `address` | `Address` | The address claiming to own the agent ID | +| `reviewers` | `readonly Address[]` | Optional list of reviewer addresses for reputation lookup | + +Returns `CheckAgentResult`: + +```typescript +interface CheckAgentResult { + verified: boolean + reputation: ReputationSummary | null +} +``` + +If you omit `reviewers` or pass an empty array, `reputation` is `null` and only on-chain verification runs. + + +For standalone helpers that extract identity data from x402 extension responses without a client instance, use the `extractArbiterIdentity`, `extractReputationRegistrations`, and `fetchArbiterIdentity` exports from `@x402r/sdk`. + + +## Next steps - - Accept payments and release funds. + + Accept payments and capture funds. Get the addresses for your client config. - - Conditions and recorders that control on-chain access. - diff --git a/sdk/delivery-arbiter.mdx b/sdk/delivery-arbiter.mdx index 66b3290..bae01d0 100644 --- a/sdk/delivery-arbiter.mdx +++ b/sdk/delivery-arbiter.mdx @@ -11,116 +11,122 @@ icon: "shield-check" * Operator and escrow addresses from the [Merchant Setup](/sdk/delivery-merchant) -There is a full [AI garbage detector example](https://github.com/BackTrackCo/arbiter-examples) that implements this pattern with heuristic + LLM evaluation. +The [AI garbage detector example](https://github.com/BackTrackCo/arbiter-examples) implements this pattern end-to-end with heuristic plus LLM evaluation. -### 1. Install Dependencies - - -```bash npm -npm install @x402r/sdk @x402r/helpers -``` -```bash pnpm -pnpm add @x402r/sdk @x402r/helpers -``` -```bash bun -bun add @x402r/sdk @x402r/helpers -``` - - -### 2. Create the Arbiter Client - -```typescript -import { createPublicClient, createWalletClient, http } from 'viem' -import { baseSepolia } from 'viem/chains' -import { privateKeyToAccount } from 'viem/accounts' -import { createArbiterClient } from '@x402r/sdk' -import { fromNetworkId } from '@x402r/core' - -const account = privateKeyToAccount(process.env.ARBITER_PRIVATE_KEY as `0x${string}`) - -const arbiter = createArbiterClient({ - publicClient: createPublicClient({ chain: baseSepolia, transport: http() }), - walletClient: createWalletClient({ - account, - chain: baseSepolia, - transport: http(), - }), - operatorAddress: process.env.OPERATOR_ADDRESS as `0x${string}`, - escrowPeriodAddress: process.env.ESCROW_PERIOD_ADDRESS as `0x${string}`, -}) -``` - -### 3. Handle the Verify Endpoint - -The merchant's `forwardToArbiter()` hook POSTs to `/verify`. Use `parseForwardedPayload()` to extract typed `PaymentInfo` with BigInt fields restored: - -```typescript -import { parseForwardedPayload } from '@x402r/helpers' -import express from 'express' - -const app = express() -app.use(express.json()) - -app.post('/verify', async (req, res) => { - const { responseBody, paymentInfo, network, transaction } = - parseForwardedPayload(req.body) - - const chainId = fromNetworkId(network) // "eip155:84532" -> 84532 - - // Your evaluation logic - const passed = await evaluate(responseBody) - - if (passed) { - const amounts = await arbiter.payment.getAmounts(paymentInfo) - await arbiter.payment.release(paymentInfo, amounts.capturableAmount) - res.json({ verdict: 'PASS' }) - } else { - // Do nothing. Funds auto-refund after escrow expires. - res.json({ verdict: 'FAIL' }) - } -}) - -app.listen(3001) -``` - -### 4. Implement Your Evaluation Logic - -The `evaluate()` function is where your logic lives. It could be: - -- **Heuristic checks:** HTTP status code, response size, content-type validation -- **AI evaluation:** send response body to an LLM and ask "is this a valid response?" -- **Schema validation:** check if the response matches an expected JSON schema - -```typescript -async function evaluate(responseBody: string): Promise { - // Example: reject empty or error responses - if (!responseBody || responseBody.length < 10) return false - if (responseBody.includes('"error"')) return false - - // Example: LLM evaluation - // const result = await llm.evaluate(responseBody) - // return result.verdict === 'PASS' - - return true -} -``` - -### 5. What Happens on Failure - - -If your service goes down, no payments get evaluated and funds stay in escrow until timeout. The escrow period protects payers, but add uptime monitoring and alerting. - + + + + ```bash npm + npm install @x402r/sdk @x402r/helpers + ``` + ```bash pnpm + pnpm add @x402r/sdk @x402r/helpers + ``` + ```bash bun + bun add @x402r/sdk @x402r/helpers + ``` + + + + + The role-narrowed `createArbiterClient` exposes `payment.voidPayment`, `payment.getState`, and `payment.getAmounts`. Capturing requires the full surface, so use `createX402r()` directly: + + ```typescript + import { createPublicClient, createWalletClient, http } from 'viem' + import { baseSepolia } from 'viem/chains' + import { privateKeyToAccount } from 'viem/accounts' + import { createX402r } from '@x402r/sdk' + + const account = privateKeyToAccount(process.env.ARBITER_PRIVATE_KEY as `0x${string}`) + + const arbiter = createX402r({ + publicClient: createPublicClient({ chain: baseSepolia, transport: http() }), + walletClient: createWalletClient({ + account, + chain: baseSepolia, + transport: http(), + }), + operatorAddress: process.env.OPERATOR_ADDRESS as `0x${string}`, + escrowPeriodAddress: process.env.ESCROW_PERIOD_ADDRESS as `0x${string}`, + }) + ``` + + + + The merchant's `forwardToArbiter()` hook POSTs `{ responseBody, transaction, paymentInfoWire }` to `/verify`. The `paymentInfoWire` is the JSON-safe wire form of `PaymentInfo`; call `PaymentInfo.fromWire(...)` to recover the `bigint`-typed struct expected by SDK actions. + + ```typescript + import { PaymentInfo } from '@x402r/sdk' + import express from 'express' + + const app = express() + app.use(express.json()) + + app.post('/verify', async (req, res) => { + const { responseBody, transaction, paymentInfoWire } = req.body + + if (!paymentInfoWire) { + res.status(400).json({ error: 'missing_payment_info' }) + return + } + + const paymentInfo = PaymentInfo.fromWire(paymentInfoWire) + + const passed = await evaluate(responseBody) + + if (passed) { + const amounts = await arbiter.payment.getAmounts(paymentInfo) + await arbiter.payment.capture(paymentInfo, amounts.capturableAmount) + res.json({ verdict: 'PASS' }) + } else { + // Arbiter can refund immediately without waiting for escrow expiry. + await arbiter.payment.voidPayment(paymentInfo) + res.json({ verdict: 'FAIL' }) + } + }) + + app.listen(3001) + ``` + + + + The `evaluate()` function is where your logic lives. It can run: + + - **Heuristic checks**: HTTP status code, response size, content-type validation + - **AI judgment**: send response body to an LLM and ask "is this a valid response?" + - **Schema validation**: check if the response matches an expected JSON schema + + ```typescript + async function evaluate(responseBody: string): Promise { + // Reject empty or error responses + if (!responseBody || responseBody.length < 10) return false + if (responseBody.includes('"error"')) return false + + // LLM evaluation + // const result = await llm.evaluate(responseBody) + // return result.verdict === 'PASS' + + return true + } + ``` + + + + With delivery protection, the arbiter can call `voidPayment()` immediately on a FAIL verdict. You do not need to wait for escrow expiry. The receiver (merchant) can also trigger a voluntary refund at any time. + + + If your service goes down, no payments get evaluated and funds stay in escrow until timeout. The escrow period protects payers, but add uptime monitoring and alerting. + + + ## Next Steps - + Deploy the operator and configure forwardToArbiter(). - - For human-reviewed disputes instead of automated evaluation. - Runnable examples for every SDK operation. diff --git a/sdk/delivery-merchant.mdx b/sdk/delivery-merchant.mdx index 28ab7f7..3ac4af0 100644 --- a/sdk/delivery-merchant.mdx +++ b/sdk/delivery-merchant.mdx @@ -7,63 +7,73 @@ icon: "store" ### Prerequisites * A deployed delivery protection operator (see [Deploy an Operator](/sdk/deploy-operator#delivery-protection-operator)) -* An arbiter service URL (see [Arbiter Setup](/sdk/delivery-arbiter)) - -### 1. Install Dependencies - - -```bash npm -npm install @x402r/helpers -``` -```bash pnpm -pnpm add @x402r/helpers -``` -```bash bun -bun add @x402r/helpers -``` - - -### 2. Configure forwardToArbiter() - -Add the `forwardToArbiter()` hook to your x402 resource server. After every payment settlement, it POSTs the HTTP response body to your arbiter service: - -```typescript -import { forwardToArbiter } from '@x402r/helpers' - -const resourceServer = new x402ResourceServer(facilitatorConfig) -registerCommerceEvmScheme(resourceServer) - -resourceServer.onAfterSettle( - forwardToArbiter('http://your-arbiter:3001', { - onError: (err) => console.error('Arbiter unreachable:', err), - }) -) -``` - -The hook POSTs to `{arbiterUrl}/verify` with: - -```json -{ - "responseBody": "the HTTP response body as a string", - "transaction": "0xsettlement_tx_hash", - "paymentPayload": { - "x402Version": 1, - "scheme": "commerce", - "accepted": { "network": "eip155:84532", ... }, - "payload": { "paymentInfo": { ... }, ... } - } -} -``` - -The arbiter uses `parseForwardedPayload()` from `@x402r/helpers` to extract `paymentInfo` and `network` from the nested structure. - - -`forwardToArbiter()` is fire-and-forget. If the arbiter service is unreachable, funds stay in escrow until timeout. Add monitoring for arbiter availability. - - -### 3. Share Addresses with the Arbiter - -The arbiter service needs `operatorAddress` and `escrowPeriodAddress` from your [deployment](/sdk/deploy-operator#delivery-protection-operator) to create its SDK client. Share these via config, environment variables, or a shared registry. +* An arbiter service endpoint (see [Arbiter Setup](/sdk/delivery-arbiter)) + + + + + ```bash npm + npm install @x402r/helpers + ``` + ```bash pnpm + pnpm add @x402r/helpers + ``` + ```bash bun + bun add @x402r/helpers + ``` + + + + + Add the `forwardToArbiter()` hook to your x402 resource server. After every successful `auth-capture` settlement, it POSTs to your arbiter service fire-and-forget: + + ```typescript + import { forwardToArbiter } from '@x402r/helpers' + import { AuthCaptureEvmScheme } from '@x402r/evm/auth-capture/server' + + const resourceServer = new x402ResourceServer(facilitatorConfig) + .register(networkId, new AuthCaptureEvmScheme()) + .onAfterSettle( + forwardToArbiter('http://your-arbiter:3001', { + onError: (err) => console.error('Arbiter unreachable:', err), + }), + ) + ``` + + The hook POSTs to `{arbiterUrl}/verify` with: + + ```json + { + "responseBody": "the HTTP response body as a string", + "transaction": "0xsettlement_tx_hash", + "paymentInfoWire": { + "operator": "0x...", + "payer": "0x...", + "receiver": "0x...", + "token": "0x...", + "maxAmount": "10000", + "preApprovalExpiry": 1740758554, + "authorizationExpiry": 1740762154, + "refundExpiry": 1741276954, + "minFeeBps": 0, + "maxFeeBps": 500, + "feeReceiver": "0x...", + "salt": "0x..." + } + } + ``` + + The helper reconstructs `PaymentInfoWire` from the verified `SettleResultContext`. The arbiter consumes `req.body.paymentInfoWire` and runs it through `PaymentInfo.fromWire(...)` to recover the `bigint`-typed struct expected by SDK actions. See [forwardToArbiter() docs](/sdk/helpers/forward-to-arbiter) for the full payload shape. + + + `forwardToArbiter()` is fire-and-forget. If the arbiter service is unreachable, funds stay in escrow until timeout. Add monitoring for arbiter availability. + + + + + The arbiter service needs `operatorAddress` and `escrowPeriodAddress` from your [deployment](/sdk/deploy-operator#delivery-protection-operator) to construct its SDK client. Share these via config, environment variables, or a shared registry. + + ## Next Steps diff --git a/sdk/delivery-protection.mdx b/sdk/delivery-protection.mdx index a466e2d..7c15d2d 100644 --- a/sdk/delivery-protection.mdx +++ b/sdk/delivery-protection.mdx @@ -4,19 +4,20 @@ description: "Automated quality verification for every transaction." icon: "shield-check" --- -In the delivery protection model, the arbiter evaluates every transaction automatically. Only the arbiter can release funds. If the arbiter does not release, funds auto-refund to the payer after escrow expires. +In the delivery protection model, the arbiter evaluates every transaction. The arbiter or a satisfied payer can capture funds. If the arbiter issues a FAIL verdict, it can trigger an immediate refund without waiting for escrow expiry. If nobody acts, funds return to the payer once escrow expires. -This is different from the [marketplace model](/sdk/overview) where the merchant releases funds and the arbiter only gets involved when a payer files a dispute. +This differs from the [marketplace model](/sdk/overview) where the merchant releases funds and the arbiter only gets involved when a payer files a dispute. | | Marketplace | Delivery Protection | |---|---|---| -| Who releases funds | Merchant (after escrow) | Arbiter only | +| Who releases funds | Merchant (after escrow) | Arbiter or payer | +| Refund during escrow | Receiver or arbiter | Escrow expiry, receiver, or arbiter | | Dispute process | Payer files refund request | No disputes needed | | Arbiter involvement | Only on disputes | Every transaction | -| Contracts needed | Operator + EscrowPeriod + RefundRequest + Evidence + Freeze | Operator + EscrowPeriod + StaticAddressCondition | +| Contracts deployed | ~8 (Operator, EscrowPeriod, RefundRequest, Evidence, Freeze, etc.) | 6 (Operator, EscrowPeriod, SAC, 2x OrCondition, HookCombinator) | | Deploy preset | `deployMarketplaceOperator()` | `deployDeliveryProtectionOperator()` | -Use this when every response needs automated quality checks: AI content verification, garbage detection, schema validation. +Use this when every response needs programmatic quality checks: AI content verification, garbage detection, schema validation. diff --git a/sdk/deploy-operator.mdx b/sdk/deploy-operator.mdx index ca8f7a1..285efc9 100644 --- a/sdk/deploy-operator.mdx +++ b/sdk/deploy-operator.mdx @@ -1,12 +1,40 @@ --- -title: "Deploy an Operator" +title: "Deploy an operator" description: "Deploy a PaymentOperator with escrow, freeze, and dispute resolution in one call" icon: "rocket" --- -## Marketplace Operator +The `@x402r/core` package includes deployment presets that handle the full lifecycle of deploying a PaymentOperator and all its supporting contracts. -A marketplace operator deployment includes: EscrowPeriod, Freeze, StaticAddressCondition (arbiter), OrCondition (receiver OR arbiter for refunds), optional StaticFeeCalculator, and the PaymentOperator itself. All deployed via CREATE3 factories. +## Presets + +The SDK ships two deployment presets. Pick the one that matches your use case: + +| Preset | Use case | Freeze | Fees | RefundRequest | +|--------|----------|--------|------|---------------| +| `deployMarketplaceOperator` | General marketplace with dispute resolution | Yes (optional) | Yes (optional) | Yes | +| `deployDeliveryProtectionOperator` | Garbage detection / delivery verification | No | No | No | + +All contracts ship via CREATE2 factories, so identical configurations produce identical addresses across deployments. + +## Marketplace operator + +A complete marketplace operator deployment includes: + +1. **EscrowPeriod**: Records authorization time, enforces waiting period before capture +2. **Freeze**: Allows payer to freeze payment during escrow, receiver to unfreeze +3. **ReceiverCondition**: Gates voids to the merchant (receiver) +4. **RefundRequest (`IHook`)**: Wired as `voidPostActionHook`, flips pending refund requests to `Approved` during `voidPayment()` +5. **StaticFeeCalculator**: Optional operator fee (basis points) +6. **PaymentOperator**: The main contract tying everything together + +## Deploy your operator + +**Prerequisites:** +- Node.js 20+, pnpm 9.15+ +- A private key with Base Sepolia ETH ([get Sepolia ETH](https://www.coinbase.com/faucets/base-ethereum-sepolia-faucet)) + +Call `deployMarketplaceOperator` from `@x402r/core` with a viem wallet client. Because every contract uses CREATE2, deploys are idempotent: re-running with the same parameters reuses any existing contract at the predicted address and skips it. ```typescript import { createPublicClient, createWalletClient, http } from 'viem'; @@ -26,109 +54,134 @@ const walletClient = createWalletClient({ transport: http(), }) -const result = await deployMarketplaceOperator(walletClient, publicClient, { - chainId: 84532, // Base Sepolia - feeRecipient: account.address, // receives operator fees - arbiter: '0xArbiterAddress...', // dispute resolver - escrowPeriodSeconds: 604_800n, // 7 days - freezeDurationSeconds: 259_200n, // 3 days max freeze - operatorFeeBps: 100n, // 1% fee (optional) -}) +const result = await deployMarketplaceOperator( + walletClient, + publicClient, + { + chainId: 84532, // Base Sepolia + feeReceiver: account.address, // receives operator fees + arbiter: '0xArbiterAddress...', // dispute resolver + escrowPeriodSeconds: 604800n, // 7 days + freezeDurationSeconds: 259200n, // 3 days max freeze + operatorFeeBps: 100n, // 1% fee (optional) + } +); -console.log('Operator:', result.operatorAddress) -console.log('EscrowPeriod:', result.escrowPeriodAddress) -console.log('RefundRequest:', result.refundRequestAddress) -console.log('Freeze:', result.freezeAddress) -console.log('New deployments:', result.summary.newCount) -console.log('Existing (reused):', result.summary.existingCount) +console.log('Operator:', result.operatorAddress); +console.log('EscrowPeriod:', result.escrowPeriodAddress); +console.log('Freeze:', result.freezeAddress); +console.log('New deployments:', result.summary.newCount); +console.log('Existing (reused):', result.summary.existingCount); ``` -## Configuration Options +## Configuration options | Option | Type | Description | |--------|------|-------------| -| `feeRecipient` | `Address` | Address that receives operator fees | +| `chainId` | `number` | Target chain ID (for example, `84532` for Base Sepolia) | +| `feeReceiver` | `Address` | Address that receives operator fees | | `arbiter` | `Address` | Arbiter address for dispute resolution | -| `escrowPeriodSeconds` | `bigint` | Escrow waiting period (e.g., `604800n` for 7 days) | +| `escrowPeriodSeconds` | `bigint` | Escrow waiting period (for example, `604800n` for 7 days) | | `freezeDurationSeconds` | `bigint` | How long freezes last. Default: `0n` (permanent until unfrozen) | | `operatorFeeBps` | `bigint` | Fee in basis points. Default: `0n` (no fee). `100n` = 1% | +| `authorizedCodehash` | `Hex` | Optional. Restricts which contract codehashes can record. Defaults to `bytes32(0)` (no restriction) | - - - ```typescript - interface MarketplaceOperatorDeployment { - operatorAddress: Address - escrowPeriodAddress: Address - freezeAddress: Address | null - refundRequestAddress: Address - refundRequestEvidenceAddress: Address - refundInEscrowConditionAddress: Address // OR(Receiver, Arbiter) - feeCalculatorAddress: Address | null // null if no fee - operatorConfig: OperatorConfig - deployments: DeployResult[] - summary: { - newCount: number - existingCount: number - txHashes: `0x${string}`[] - } - } - ``` +## Deployment result - Redeploying with the same parameters is idempotent (CREATE3). It detects existing contracts and skips them. Check `summary` for what was new vs reused. - +```typescript +interface MarketplaceOperatorDeployment { + operatorAddress: Address // The PaymentOperator + escrowPeriodAddress: Address // EscrowPeriod hook/condition + freezeAddress: Address | null // Freeze condition (null if disabled) + refundRequestAddress: Address // RefundRequest contract + refundRequestEvidenceAddress: Address // RefundRequestEvidence contract + voidConditionAddress: Address // OR(Receiver, Arbiter) + feeCalculatorAddress: Address | null // null if no fee + operatorConfig: OperatorConfig // Full operator slot configuration + deployments: DeployResult[] // Per-contract deploy details + summary: { + newCount: number // Newly deployed contracts + existingCount: number // Reused existing contracts + txHashes: `0x${string}`[] // All deployment tx hashes + } +} +``` - - Compute addresses without deploying: + +Because all contracts use CREATE2, redeploying with the same parameters is idempotent. The tooling skips any contract that already exists at the predicted address. The `summary` tells you what was new vs reused. + - ```typescript - import { previewMarketplaceOperator } from '@x402r/core' +## Preview addresses (no deploy) - const preview = await previewMarketplaceOperator(publicClient, { - chainId: 84532, - feeRecipient: '0xYourAddress...', - arbiter: '0xArbiterAddress...', - escrowPeriodSeconds: 604_800n, - }) +```typescript +import { previewMarketplaceOperator } from '@x402r/core' - console.log('Operator will be at:', preview.operatorAddress) - console.log('EscrowPeriod will be at:', preview.escrowPeriodAddress) - ``` - +const preview = await previewMarketplaceOperator(publicClient, { + chainId: 84532, + feeReceiver: account.address, + arbiter: '0xArbiterAddress...', + escrowPeriodSeconds: 604800n, +}) - - | Slot | Contract | Purpose | - |------|----------|---------| - | `AUTHORIZE_CONDITION` | UsdcTvlLimit | Safety limit on authorization | - | `AUTHORIZE_RECORDER` | EscrowPeriod | Records authorization timestamp | - | `CHARGE_CONDITION` | (none) | No restrictions on charge | - | `RELEASE_CONDITION` | EscrowPeriod | Blocks release during escrow period | - | `REFUND_IN_ESCROW_CONDITION` | OR(Receiver, Arbiter) | Receiver or arbiter can approve | - | `REFUND_POST_ESCROW_CONDITION` | Receiver | Only receiver after escrow | - | `FEE_CALCULATOR` | StaticFeeCalculator | Fixed percentage fee | - | `FEE_RECIPIENT` | Your address | Receives fees | - - +console.log('Operator will be at:', preview.operatorAddress) +console.log('EscrowPeriod will be at:', preview.escrowPeriodAddress) +``` + +## Marketplace operator slot configuration -Deployment is supported on all [supported chains](/sdk/overview#supported-chains). Pass the numeric `chainId` in the options. +The deployed marketplace operator has the following slot configuration: + +| Slot | Contract | Purpose | +|---|---|---| +| `AUTHORIZE_PRE_ACTION_CONDITION` | (none) | Default: anyone with a valid signature can authorize | +| `AUTHORIZE_POST_ACTION_HOOK` | EscrowPeriod | Records authorization timestamp | +| `CHARGE_PRE_ACTION_CONDITION` | (none) | No restrictions on charge | +| `CAPTURE_PRE_ACTION_CONDITION` | EscrowPeriod (or AND(EscrowPeriod, Freeze) if freeze enabled) | Blocks capture during escrow period | +| `VOID_PRE_ACTION_CONDITION` | OR(Receiver, Arbiter) | Receiver or arbiter can approve | +| `VOID_POST_ACTION_HOOK` | RefundRequest | Tracks refund request state | +| `REFUND_PRE_ACTION_CONDITION` | Receiver | Only receiver after escrow | +| `FEE_CALCULATOR` | StaticFeeCalculator | Fixed percentage fee (if configured) | +| `FEE_RECEIVER` | Your address | Receives fees | + +--- + +## Network support + +The deploy presets target the chains in `@x402r/core`'s `x402rChains` (Base and Base Sepolia today). See [Network support](/sdk/overview#network-support) for chain IDs, EIP-155 IDs, and token addresses. -Deployment requires gas fees. Ensure your wallet has ETH on the target network. On Base Sepolia, you can get testnet ETH from [Base network faucets](https://docs.base.org/base-chain/tools/network-faucets). +Deployment requires gas fees. Ensure your wallet has ETH on the target network. On Base Sepolia, you can fund a wallet from [Base network faucets](https://docs.base.org/base-chain/tools/network-faucets). ## Delivery Protection Operator -For automated quality verification (AI garbage detection, schema validation), use the simpler delivery protection preset. No RefundRequest, Evidence, or Freeze contracts. The arbiter is the only address that can release funds. +For programmatic quality verification (AI garbage detection, schema validation), use the delivery protection preset. No RefundRequest, Evidence, or Freeze contracts. The arbiter or payer can capture funds, and the arbiter can issue immediate refunds without waiting for escrow expiry. ```typescript +import { createPublicClient, createWalletClient, http } from 'viem'; +import { baseSepolia } from 'viem/chains'; +import { privateKeyToAccount } from 'viem/accounts'; import { deployDeliveryProtectionOperator } from '@x402r/core' +const publicClient = createPublicClient({ + chain: baseSepolia, + transport: http(), +}) + +const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`) +const walletClient = createWalletClient({ + account, + chain: baseSepolia, + transport: http(), +}) + const deployment = await deployDeliveryProtectionOperator( walletClient, publicClient, { chainId: 84532, arbiter: '0xArbiterServiceAddress', - feeRecipient: account.address, + feeReceiver: account.address, escrowPeriodSeconds: 300n, // 5 minutes }, ) @@ -136,37 +189,88 @@ const deployment = await deployDeliveryProtectionOperator( console.log('Operator:', deployment.operatorAddress) console.log('EscrowPeriod:', deployment.escrowPeriodAddress) console.log('ArbiterCondition:', deployment.arbiterConditionAddress) +console.log('ReleaseCondition:', deployment.captureConditionAddress) +console.log('AuthorizeHook:', deployment.authorizeHookAddress) ``` | Option | Type | Description | |--------|------|-------------| | `chainId` | `number` | Target chain | -| `arbiter` | `Address` | Only address that can call `release()` | -| `feeRecipient` | `Address` | Receives protocol fees | -| `escrowPeriodSeconds` | `bigint` | Verification window before auto-refund | - - - | Slot | Contract | Purpose | - |------|----------|---------| - | `RELEASE_CONDITION` | StaticAddressCondition(arbiter) | Only arbiter can release | - | `AUTHORIZE_RECORDER` | EscrowPeriod | Records authorization time | - | `REFUND_IN_ESCROW_CONDITION` | EscrowPeriod | Anyone can refund after escrow expires | - | `REFUND_POST_ESCROW_CONDITION` | Receiver | Receiver can refund post-escrow | - +| `arbiter` | `Address` | Arbiter address for capture and refund decisions | +| `feeReceiver` | `Address` | Receives protocol fees | +| `escrowPeriodSeconds` | `bigint` | Verification window before automatic refund | +| `authorizedCodehash` | `Hex` | Override the default `hookCombinatorCodehash`. Optional | +| `paymentIndexRecorderHookAddress` | `Address` | Override the default PaymentIndexRecorderHook. Pass `zeroAddress` to skip on-chain payment indexing. Optional | +| `allowArbiterRefund` | `boolean` | Lets the arbiter refund immediately during escrow. Default: `false` | + + + + ```typescript + interface DeliveryProtectionOperatorDeployment { + operatorAddress: Address + escrowPeriodAddress: Address + arbiterConditionAddress: Address + captureConditionAddress: Address // OrCondition([arbiter, payer]) + voidConditionAddress: Address // OrCondition([escrowPeriod, receiver, arbiter]) + authorizeHookAddress: Address // HookCombinator([escrowPeriod, paymentIndexRecorderHook]) + paymentIndexRecorderHookAddress: Address + operatorConfig: OperatorConfig + deployments: DeployResult[] + summary: { + newCount: number + existingCount: number + txHashes: `0x${string}`[] + } + } + ``` + + Deploys 6 contracts by default: EscrowPeriod, StaticAddressCondition(arbiter), OrCondition(release), OrCondition(refund), HookCombinator, and the Operator. If you pass `paymentIndexRecorderHookAddress: zeroAddress`, the HookCombinator is skipped (5 contracts). + + Redeploying with the same parameters is idempotent (CREATE2). The tooling reuses any contract that already exists at the predicted address. + + + + Compute addresses without deploying: + + ```typescript + import { previewDeliveryProtectionOperator } from '@x402r/core' + + const preview = await previewDeliveryProtectionOperator(publicClient, { + chainId: 84532, + arbiter: '0xArbiterServiceAddress', + feeReceiver: account.address, + escrowPeriodSeconds: 300n, + }) + + console.log('Operator will be at:', preview.operatorAddress) + console.log('EscrowPeriod will be at:', preview.escrowPeriodAddress) + console.log('AuthorizeHook will be at:', preview.authorizeHookAddress) + ``` + + + + | Slot | Contract | Purpose | + |------|----------|---------| + | `CAPTURE_PRE_ACTION_CONDITION` | OrCondition([SAC(arbiter), PayerCondition]) | Arbiter or satisfied payer can capture | + | `AUTHORIZE_POST_ACTION_HOOK` | HookCombinator([EscrowPeriod, PaymentIndexRecorderHook]) | Records authorization time and indexes payments on-chain | + | `VOID_PRE_ACTION_CONDITION` | OrCondition([EscrowPeriod, ReceiverCondition, SAC(arbiter)]) | Escrow expiry, receiver voluntary refund, or arbiter immediate refund | + | `REFUND_PRE_ACTION_CONDITION` | ReceiverCondition | Only receiver after escrow | + + ## Next Steps - - Accept payments, release funds from escrow. - - - Request refunds, freeze payments, submit evidence. + + Accept payments, capture funds from escrow. - Runnable examples for every SDK operation. + See working merchant and client examples. + + + Forward escrow settlements to an arbiter service. - On-chain architecture, conditions, and recorders. + On-chain architecture, conditions, and hooks. diff --git a/sdk/examples.mdx b/sdk/examples.mdx index bde5978..9625352 100644 --- a/sdk/examples.mdx +++ b/sdk/examples.mdx @@ -4,57 +4,61 @@ description: "Runnable examples for every SDK operation." icon: "code" --- -The [x402r-sdk repo](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples) includes runnable examples for every role. Each example starts a local Anvil fork, deploys contracts, and runs. No wallet or testnet funds needed. +The [x402r-sdk repository](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples) ships runnable example scripts organized by role plus end-to-end scenarios. -### Running +## Examples -```bash + + + Request a refund, freeze a payment, submit on-chain evidence (a placeholder CID; the integrator owns IPFS pinning). Three TypeScript scripts. + + + Capture from escrow and charge directly. TypeScript scripts plus README. + + + Approve a refund, review on-chain evidence, distribute protocol fees. + + + End-to-end runners: happy-path capture, dispute resolution, atomic charge, partial refund flow. Wires payer + merchant + arbiter together against a local Anvil fork. + + + Shared setup utilities: Anvil-fork bootstrap, constants, common types. + + + +## Running examples + + +All examples run against a local Anvil fork seeded by `shared/anvil-setup.ts`. You do not need a mainnet wallet or funding. + + + +```bash pnpm git clone https://github.com/BackTrackCo/x402r-sdk.git cd x402r-sdk pnpm install && pnpm build ``` - -Then run any example: - -```bash -# Per-action examples -pnpm example:payer:request-refund -pnpm example:merchant:charge -pnpm example:arbiter:approve-refund - -# Multi-role scenarios -pnpm scenario:release -pnpm scenario:dispute +```bash bun +git clone https://github.com/BackTrackCo/x402r-sdk.git +cd x402r-sdk +bun install && bun run build ``` + -### Payer - -| Example | What it does | -|---------|-------------| -| [`payer/request-refund.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/payer/request-refund.ts) | Request a refund for a payment in escrow | -| [`payer/submit-evidence.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/payer/submit-evidence.ts) | Submit an IPFS evidence CID for a dispute | -| [`payer/freeze-payment.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/payer/freeze-payment.ts) | Freeze a payment to block release during investigation | - -### Merchant - -| Example | What it does | -|---------|-------------| -| [`merchant/charge-payment.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/merchant/charge-payment.ts) | Charge an authorized payment (no escrow) | -| [`merchant/release-escrow.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/merchant/release-escrow.ts) | Release remaining funds after escrow expires | - -### Arbiter - -| Example | What it does | -|---------|-------------| -| [`arbiter/approve-refund.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/arbiter/approve-refund.ts) | Approve a payer's refund request | -| [`arbiter/review-evidence.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/arbiter/review-evidence.ts) | Review all submitted evidence for a dispute | -| [`arbiter/distribute-fees.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/arbiter/distribute-fees.ts) | Distribute accumulated protocol fees | +The SDK uses pnpm workspaces (`pnpm@10.23.0`). The `npm` runtime is fine for application code that consumes published `@x402r/*` packages, but the workspace clone above expects pnpm or a workspace-aware install. -### Scenarios +See each example directory's README on GitHub for the exact run command for that script. -Full multi-role integration tests running against a local Anvil fork. +## Next steps -| Scenario | What it does | -|----------|-------------| -| [`scenarios/happy-path-release.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/scenarios/happy-path-release.ts) | Authorize, wait for escrow, release (2 roles: payer + merchant) | -| [`scenarios/dispute-resolution.ts`](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/scenarios/dispute-resolution.ts) | Full dispute lifecycle with evidence and arbitration (3 roles) | + + + Walk through `deployMarketplaceOperator()` and `deployDeliveryProtectionOperator()`. + + + Forward `auth-capture` settlements to an arbiter service. + + + Browse every example. + + diff --git a/sdk/facilitator/getting-started.mdx b/sdk/facilitator/getting-started.mdx deleted file mode 100644 index 9063130..0000000 --- a/sdk/facilitator/getting-started.mdx +++ /dev/null @@ -1,146 +0,0 @@ ---- -title: "Facilitator Quickstart" -description: "Run your own x402r facilitator service to verify and settle escrow payments" -icon: "server" ---- - -A facilitator is a service that verifies payment signatures and settles escrow transactions on-chain. This guide walks you through running your own facilitator on Base Sepolia. - - -The full source code for this example is available on [GitHub](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/facilitator/basic). - - -## Prerequisites - -- Node.js 20+ -- A wallet private key with Base Sepolia ETH - - -Never commit private keys to source control. Use environment variables or a secrets manager. - - -## Setup - - - - ```bash - mkdir facilitator && cd facilitator - npm init -y - npm install @x402/core @x402/evm @x402r/evm express dotenv viem - ``` - - - - Create a `.env` file in the project root: - - ```bash - PRIVATE_KEY=0xYourPrivateKey - PORT=4022 - ``` - - - - Create `index.ts`. This is identical to a standard x402 facilitator, with two additions: the `@x402r/evm` import and the `registerEscrowScheme()` call. - - ```typescript - import "dotenv/config"; - import express from "express"; - import { x402Facilitator } from "@x402/core/facilitator"; - import { PaymentPayload, PaymentRequirements } from "@x402/core/types"; - import { toFacilitatorEvmSigner } from "@x402/evm"; - import { registerEscrowScheme } from "@x402r/evm/escrow/facilitator"; - import { createWalletClient, http, publicActions } from "viem"; - import { privateKeyToAccount } from "viem/accounts"; - import { baseSepolia } from "viem/chains"; - - if (!process.env.PRIVATE_KEY) { - console.error("PRIVATE_KEY environment variable is required"); - process.exit(1); - } - - const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); - const viemClient = createWalletClient({ - account, chain: baseSepolia, transport: http(), - }).extend(publicActions); - const evmSigner = toFacilitatorEvmSigner({ - address: account.address, - getCode: (args) => viemClient.getCode(args), - readContract: (args) => viemClient.readContract({ ...args, args: args.args || [] }), - verifyTypedData: (args) => viemClient.verifyTypedData(args as any), - writeContract: (args) => viemClient.writeContract({ ...args, args: args.args || [] }), - sendTransaction: (args) => viemClient.sendTransaction(args), - waitForTransactionReceipt: (args) => viemClient.waitForTransactionReceipt(args), - }); - - // Standard x402 facilitator setup - const facilitator = new x402Facilitator(); - - // x402r: Register the escrow scheme to handle refundable payments - registerEscrowScheme(facilitator, { - signer: evmSigner, - networks: "eip155:84532", - }); - - const app = express(); - app.use(express.json()); - - app.post("/verify", async (req, res) => { - const { paymentPayload, paymentRequirements } = req.body as { - paymentPayload: PaymentPayload; - paymentRequirements: PaymentRequirements; - }; - res.json(await facilitator.verify(paymentPayload, paymentRequirements)); - }); - - app.post("/settle", async (req, res) => { - const { paymentPayload, paymentRequirements } = req.body; - res.json(await facilitator.settle( - paymentPayload as PaymentPayload, - paymentRequirements as PaymentRequirements, - )); - }); - - app.get("/supported", async (_req, res) => { - res.json(facilitator.getSupported()); - }); - - const PORT = process.env.PORT || "4022"; - app.listen(parseInt(PORT), () => { - console.log(`Facilitator listening on http://localhost:${PORT}`); - }); - ``` - - - - ```bash - npx tsx index.ts - ``` - - You should see: - ``` - Facilitator account: 0x... - Facilitator listening on http://localhost:4022 - ``` - - - -## How it works - -- **`x402Facilitator`** is the core facilitator class from `@x402/core` that routes verify and settle requests to registered scheme handlers. -- **`registerEscrowScheme`** adds x402r escrow support to the facilitator, enabling it to verify escrow payment signatures and settle them on-chain. -- **`toFacilitatorEvmSigner`** adapts a viem wallet client into the signer interface the facilitator expects for on-chain interactions. -- The three endpoints (`/verify`, `/settle`, `/supported`) match the interface that `HTTPFacilitatorClient` on the merchant side expects. - -## Next Steps - - - - Set up a merchant server that uses your facilitator. - - - Understand the payment lifecycle and escrow flow. - - - Deploy a PaymentOperator contract for your merchant. - - diff --git a/sdk/helpers/forward-to-arbiter.mdx b/sdk/helpers/forward-to-arbiter.mdx new file mode 100644 index 0000000..28d7404 --- /dev/null +++ b/sdk/helpers/forward-to-arbiter.mdx @@ -0,0 +1,153 @@ +--- +title: "forwardToArbiter()" +sidebarTitle: "forwardToArbiter helper" +description: "Forward settlement data to an arbiter service for quality evaluation" +icon: "arrow-right" +--- + +The `forwardToArbiter()` function creates an `onAfterSettle` hook that forwards the response body and reconstructed `PaymentInfoWire` to an arbiter service. It runs fire-and-forget so it never blocks the response to the client. + +- Only fires for successful **`auth-capture`** scheme settlements +- POSTs `{ responseBody, transaction, paymentInfoWire }` to `{arbiterUrl}/verify` +- The hook catches errors internally so an unreachable arbiter cannot break the payment flow + +## Usage + +```typescript +import { forwardToArbiter } from '@x402r/helpers' +import { AuthCaptureEvmScheme } from '@x402r/evm/auth-capture/server' + +const resourceServer = new x402ResourceServer(facilitatorClient) + .register(networkId, new AuthCaptureEvmScheme()) + .onAfterSettle( + forwardToArbiter('http://arbiter:3001'), + ) +``` + +## Function signature + +```typescript +function forwardToArbiter( + arbiterUrl: string, + options?: ForwardToArbiterOptions, +): (context: SettleResultContext) => Promise +``` + +### Parameters + +| Parameter | Type | Description | +|---|---|---| +| `arbiterUrl` | `string` | Base endpoint of your arbiter service (for example, `http://arbiter:3001`) | +| `options` | `ForwardToArbiterOptions` | Optional configuration (see below) | + +### Options + +```typescript +interface ForwardToArbiterOptions { + /** Custom error handler. Defaults to `console.warn`. */ + onError?: (error: unknown) => void +} +``` + +## Payload shape + +When an `auth-capture` settlement succeeds, the hook POSTs the following JSON to `{arbiterUrl}/verify`: + +```typescript +{ + responseBody: string // UTF-8 encoded response body + transaction: string // Settlement transaction hash + paymentInfoWire: { + operator: `0x${string}` // from extra.captureAuthorizer + payer: `0x${string}` // recovered at settlement + receiver: `0x${string}` // from requirements.payTo + token: `0x${string}` // from requirements.asset + maxAmount: string // from requirements.amount + preApprovalExpiry: number // authorization.validBefore (EIP-3009) or permit2Authorization.deadline (Permit2) + authorizationExpiry: number // from extra.captureDeadline + refundExpiry: number // from extra.refundDeadline + minFeeBps: number // from extra.minFeeBps + maxFeeBps: number // from extra.maxFeeBps + feeReceiver: `0x${string}` // from extra.feeRecipient + salt: string // from payload.salt + } +} +``` + +The helper reconstructs `PaymentInfoWire` from the verified `SettleResultContext` using the `reconstructPaymentInfoWire()` helper. The arbiter consumes `req.body.paymentInfoWire` and runs it through `PaymentInfo.fromWire(...)` (from `@x402r/sdk` or `@x402r/core`) to get the `bigint`-typed `PaymentInfo` struct expected by SDK actions. + +## Error handling + +By default, the hook logs fetch errors with `console.warn`. Override this with a custom handler: + +```typescript +import { forwardToArbiter } from '@x402r/helpers' +import { AuthCaptureEvmScheme } from '@x402r/evm/auth-capture/server' + +const resourceServer = new x402ResourceServer(facilitatorClient) + .register(networkId, new AuthCaptureEvmScheme()) + .onAfterSettle( + forwardToArbiter('http://arbiter:3001', { + onError: (err) => sentry.captureException(err), + }), + ) +``` + +The hook wraps each error in an `X402rError` carrying the arbiter endpoint and request details for easier debugging. + +## Skipped scenarios + +The hook returns without making a request when: + +- The settlement was not successful (`context.result.success === false`) +- The scheme is not `auth-capture` +- No response body is available in the transport context + +## Address re-exports + +The `@x402r/helpers` package re-exports chain-invariant address constants from `@x402r/core` for convenience: + +```typescript +import { + authCaptureEscrow, + tokenCollector, + protocolFeeConfig, + receiverRefundCollector, + factories, + conditions, + getChainConfig, + supportedChainIds, +} from '@x402r/helpers' +``` + +Plus the `@x402r/evm` wire-format types and guards: + +```typescript +import { + type AuthCaptureExtra, + type AuthCapturePayload, + type Eip3009Payload, + type Permit2Payload, + type PaymentInfoStruct, + isAuthCaptureExtra, + isAuthCapturePayload, + isEip3009Payload, + isPermit2Payload, +} from '@x402r/helpers' +``` + +And the `x402rDefaults` builder for hand-constructing `extra` in `PaymentRequirements`: + +```typescript +import { type X402rDefaultsInput, x402rDefaults } from '@x402r/helpers' +``` + +`x402rDefaults(input)` returns an `AuthCaptureExtra` populated with sensible defaults, useful when you want to build `PaymentRequirements` outside the merchant client. + +## Next steps + + + + See working merchant server examples. + + diff --git a/sdk/helpers/refundable.mdx b/sdk/helpers/refundable.mdx deleted file mode 100644 index 1d5fbfe..0000000 --- a/sdk/helpers/refundable.mdx +++ /dev/null @@ -1,167 +0,0 @@ ---- -title: "refundable()" -description: "Configure HTTP 402 responses with escrow-backed refundable payment options" -icon: "wrench" ---- - -The `@x402r/helpers` package provides the `refundable()` function — a framework-agnostic helper that adds escrow configuration to x402 payment options. - - -This function lives in `@x402r/helpers`, not `@x402r/merchant`. Install it separately: -```bash -npm install @x402r/helpers @x402r/core -``` - - -## Usage - -```typescript -import { refundable } from '@x402r/helpers'; - -const option = refundable( - { - scheme: 'escrow', - network: 'eip155:84532', - payTo: '0xMerchantAddress...', - price: '$0.01', - }, - '0xOperatorAddress...' -); -``` - -This returns the original payment option with an `extra` field populated with escrow addresses and fee bounds. - -## Function Signature - -```typescript -function refundable( - option: T, - operatorAddress: `0x${string}`, - options?: RefundableOptions -): T & { extra: EscrowExtra } -``` - -### Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `option` | `PaymentOption` | Base payment option (must include `network`) | -| `operatorAddress` | `Address` | Your PaymentOperator contract address | -| `options` | `RefundableOptions` | Optional overrides (see below) | - -### Options & Defaults - -| Option | Default | Description | -|--------|---------|-------------| -| `escrowAddress` | From network config | AuthCaptureEscrow contract address | -| `tokenCollector` | From network config | ERC3009PaymentCollector contract address | -| `minFeeBps` | `0` | Minimum acceptable fee (0% = accept zero fees) | -| `maxFeeBps` | `1000` | Maximum acceptable fee (1000 bps = 10%) | - -### Return Value - -The function returns the original option object with an `extra` field added: - -```typescript -interface EscrowExtra { - escrowAddress: `0x${string}`; - operatorAddress: `0x${string}`; - tokenCollector: `0x${string}`; - minFeeBps: number; - maxFeeBps: number; -} -``` - -## Examples - -### Basic usage (defaults) - -```typescript -const option = refundable({ - scheme: 'escrow', - network: 'eip155:84532', - payTo: '0xMerchant...', - price: '$0.01', -}, '0xOperator...'); - -// Result includes: -// option.extra.escrowAddress → from network config -// option.extra.operatorAddress → '0xOperator...' -// option.extra.tokenCollector → from network config -// option.extra.minFeeBps → 0 -// option.extra.maxFeeBps → 1000 -``` - -### Custom fee bounds - -```typescript -const option = refundable({ - scheme: 'escrow', - network: 'eip155:84532', - payTo: '0xMerchant...', - price: '$10.00', -}, '0xOperator...', { - maxFeeBps: 500, // Accept up to 5% fee -}); -``` - -### Custom escrow address - -```typescript -const option = refundable({ - scheme: 'escrow', - network: 'eip155:84532', - payTo: '0xMerchant...', - price: '$100.00', -}, '0xOperator...', { - escrowAddress: '0xCustomEscrow...', - tokenCollector: '0xCustomCollector...', -}); -``` - -## Supported Networks - -The function resolves addresses from the network config for all supported networks. See `getNetworkConfig()` for the full list (Base Sepolia, Base, Ethereum, Ethereum Sepolia, Arbitrum Sepolia, Polygon, Arbitrum, Optimism, Avalanche, Celo, Monad). - -```typescript -refundable({ network: 'eip155:84532', ... }, '0x...'); // Base Sepolia -refundable({ network: 'eip155:8453', ... }, '0x...'); // Base Mainnet -refundable({ network: 'eip155:1', ... }, '0x...'); // Ethereum -``` - -## Integration with x402 - -Use `refundable()` when constructing your 402 Payment Required response: - -```typescript -import { refundable } from '@x402r/helpers'; - -// In your server handler: -app.get('/api/resource', (req, res) => { - res.status(402).json({ - x402Version: 2, - accepts: [ - refundable({ - scheme: 'escrow', - network: 'eip155:84532', - payTo: merchantAddress, - price: '$1.00', - }, operatorAddress), - ], - }); -}); -``` - -## Next Steps - - - - Deploy a PaymentOperator to use with refundable(). - - - See merchant server examples using refundable(). - - - Understand the escrow scheme. - - diff --git a/sdk/limitations.mdx b/sdk/limitations.mdx deleted file mode 100644 index 7e3a23c..0000000 --- a/sdk/limitations.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "Current Limitations" -description: "Known limitations and constraints in the current SDK" -icon: "circle-info" ---- - -The SDK provides full coverage of core payment flows including authorization, release, charge, refund, dispute resolution, and evidence submission. This page documents the known limitations. - -## API Constraints - -### EIP-155 Network Identifiers - -Network configuration requires EIP-155 format strings, not chain ID numbers: - -```typescript -// Correct -const config = getNetworkConfig('eip155:84532'); - -// Incorrect - will return undefined -const config = getNetworkConfig(84532); -``` - -### PaymentInfo Must Be Complete - -All SDK methods require a complete `PaymentInfo` object. You cannot query by hash alone: - -```typescript -// Works - full PaymentInfo -const status = await client.getRefundStatus(paymentInfo, 0n); - -// Not supported - hash-only queries require the full struct -// const state = await client.getPaymentStateByHash(hash); -``` - -### Event Log Scanning Limits - -`getPayerPayments()`, `getReceiverPayments()`, and `getPaymentDetails()` scan `AuthorizationCreated` events using `eth_getLogs`. Base Sepolia RPCs typically limit responses to 10,000 blocks. Pass a `fromBlock` parameter for large ranges: - -```typescript -// Scan only recent blocks to avoid RPC limits -const payments = await client.getPayerPayments(recentBlockNumber); -const details = await client.getPaymentDetails(hash, recentBlockNumber); -``` - -### No Express/Hono Middleware - -The `refundable()` helper in `@x402r/helpers` is framework-agnostic. There is no dedicated Express or Hono middleware — use `refundable()` directly when constructing payment options. - -## Getting Updates - - - - Return to SDK documentation. - - - Working examples for each role. - - - Watch for new SDK releases. - - diff --git a/sdk/merchant.mdx b/sdk/merchant.mdx deleted file mode 100644 index c1c9864..0000000 --- a/sdk/merchant.mdx +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: "Merchant Guide" -description: "Accept a payment into escrow, check state, and release funds." -icon: "store" ---- - -### Prerequisites - -* A wallet with ETH on Base Sepolia for gas ([faucet](https://www.alchemy.com/faucets/base-sepolia)) -* Node.js 18+ and npm -* A deployed operator with escrow support (see [Deploy an Operator](/sdk/deploy-operator)) - - -There are pre-configured [examples in the x402r-sdk repo](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples), including [merchant examples](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/merchant) and full [scenario scripts](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/scenarios). - - -### 1. Install Dependencies - - -```bash npm -npm install @x402r/sdk -``` -```bash pnpm -pnpm add @x402r/sdk -``` -```bash bun -bun add @x402r/sdk -``` - - -### 2. Create a Merchant Client - -```typescript -import { createPublicClient, createWalletClient, http } from 'viem' -import { baseSepolia } from 'viem/chains' -import { privateKeyToAccount } from 'viem/accounts' -import { createMerchantClient } from '@x402r/sdk' - -const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`) - -const merchant = createMerchantClient({ - publicClient: createPublicClient({ chain: baseSepolia, transport: http() }), - walletClient: createWalletClient({ - account, - chain: baseSepolia, - transport: http(), - }), - operatorAddress: '0x...', // from deploy result - escrowPeriodAddress: '0x...', // from deploy result - refundRequestAddress: '0x...', // from deploy result - freezeAddress: '0x...', // from deploy result - refundRequestEvidenceAddress: '0x...', // from deploy result -}) -``` - -### 3. Check Payment State - -```typescript -import type { PaymentInfo } from '@x402r/sdk' - -// paymentInfo comes from the facilitator callback or your payment records -const paymentInfo: PaymentInfo = { /* ... */ } - -const amounts = await merchant.payment.getAmounts(paymentInfo) -console.log('Collected:', amounts.hasCollectedPayment) // true -console.log('Capturable:', amounts.capturableAmount) // 1000000n -console.log('Refundable:', amounts.refundableAmount) // 1000000n - -const inEscrow = await merchant.escrow?.isDuringEscrow(paymentInfo) -console.log('In escrow:', inEscrow) // true -``` - -### 4. Release Funds After Escrow - - -`release()` reverts if called during escrow. Check `escrow.isDuringEscrow()` first. Pass a smaller amount to release partially. - - -```typescript -const releaseTx = await merchant.payment.release(paymentInfo, 1_000_000n) -console.log('Released:', releaseTx) - -// Verify -const after = await merchant.payment.getAmounts(paymentInfo) -console.log('Capturable after release:', after.capturableAmount) // 0n -``` - -### 5. Handle Refund Requests (Optional) - -If a payer disputes, check for pending refund requests: - -```typescript -const hasRefund = await merchant.refund?.has(paymentInfo) - -if (hasRefund) { - const request = await merchant.refund?.get(paymentInfo) - console.log('Refund amount:', request?.amount) - console.log('Status:', request?.status) // 0 = Pending - - // Approve by executing refundInEscrow (recorder auto-approves) - const refundTx = await merchant.payment.refundInEscrow( - paymentInfo, - request!.amount, - ) - console.log('Refunded:', refundTx) -} -``` - -## Next Steps - - - - Full deployment config, slot details, and preview addresses. - - - How escrow, capture, and void work under the hood. - - - Full scenario scripts to copy from. - - diff --git a/sdk/merchant/getting-started.mdx b/sdk/merchant/getting-started.mdx index 9c75abb..146beb7 100644 --- a/sdk/merchant/getting-started.mdx +++ b/sdk/merchant/getting-started.mdx @@ -1,20 +1,17 @@ --- title: "Merchant Server Quickstart" +sidebarTitle: "Express server setup" description: "Accept escrow-backed refundable payments on your Express server in 5 minutes" icon: "store" --- This guide walks you through setting up an Express server that accepts x402r escrow-backed payments. By the end, you'll have a paid API endpoint protected by the x402 payment middleware with refundable escrow support. - -The full source code for this example is available on [GitHub](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/servers/express). - - ## Prerequisites - Node.js 20+ - A deployed PaymentOperator contract ([Deploy Operator](/sdk/deploy-operator)) -- A running facilitator service ([Facilitator Quickstart](/sdk/facilitator/getting-started)) +- A running facilitator service - Base Sepolia ETH for testing ## Setup @@ -24,7 +21,7 @@ The full source code for this example is available on [GitHub](https://github.co ```bash mkdir merchant-server && cd merchant-server npm init -y - npm install express @x402/core @x402/express @x402r/evm @x402r/helpers dotenv + npm install express @x402/core @x402/express @x402r/evm dotenv ``` @@ -32,8 +29,10 @@ The full source code for this example is available on [GitHub](https://github.co Create a `.env` file in the project root: ```bash - ADDRESS=0xYourMerchantAddress - OPERATOR_ADDRESS=0xYourPaymentOperatorAddress + # Replace ADDRESS with your merchant address. + ADDRESS=0x321651df4593DA57C413579c5b611D1A90168a3A + # Replace OPERATOR_ADDRESS with the operator you deployed. + OPERATOR_ADDRESS=0xa0d4734842df1690a5B33Cb21828c946e39D55a2 FACILITATOR_URL=http://localhost:4022 ``` @@ -45,8 +44,8 @@ The full source code for this example is available on [GitHub](https://github.co import "dotenv/config"; import express from "express"; import { paymentMiddleware, x402ResourceServer } from "@x402/express"; - import { EscrowServerScheme } from "@x402r/evm/escrow/server"; - import { refundable } from "@x402r/helpers"; + import { AuthCaptureEvmScheme } from "@x402r/evm/auth-capture/server"; + import { getChainConfig } from "@x402r/core"; import { HTTPFacilitatorClient } from "@x402/core/server"; const address = process.env.ADDRESS as `0x${string}`; @@ -63,30 +62,44 @@ The full source code for this example is available on [GitHub](https://github.co } const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); + const networkId = "eip155:84532"; + const app = express(); + const now = Math.floor(Date.now() / 1000); + app.use( paymentMiddleware( { "GET /weather": { accepts: [ - refundable( - { - scheme: "escrow", - price: "$0.01", - network: "eip155:84532", - payTo: address, + { + scheme: "auth-capture", + price: "$0.01", + network: networkId, + payTo: address, + maxTimeoutSeconds: 60, + extra: { + name: "USDC", + version: "2", + captureAuthorizer: operatorAddress, + captureDeadline: now + 60 * 60, // capture within 1 hour + refundDeadline: now + 24 * 60 * 60, // refund window 24 hours + feeRecipient: operatorAddress, + minFeeBps: 0, + maxFeeBps: 500, + // assetTransferMethod defaults to "eip3009" + // autoCapture defaults to false (two-phase) }, - operatorAddress, - ), + }, ], description: "Weather data", mimeType: "application/json", }, }, new x402ResourceServer(facilitatorClient).register( - "eip155:84532", - new EscrowServerScheme() as never, + networkId, + new AuthCaptureEvmScheme(), ), ), ); @@ -119,22 +132,26 @@ The full source code for this example is available on [GitHub](https://github.co curl http://localhost:4021/weather ``` - Without a valid payment header, the server responds with HTTP 402 and the escrow payment requirements: + Without a valid payment header, the server responds with HTTP 402 and the auth-capture payment requirements: ```json { "x402Version": 2, "accepts": [{ - "scheme": "escrow", + "scheme": "auth-capture", "price": "$0.01", "network": "eip155:84532", "payTo": "0x...", + "maxTimeoutSeconds": 60, "extra": { - "escrowAddress": "0x...", - "operatorAddress": "0x...", - "tokenCollector": "0x...", + "name": "USDC", + "version": "2", + "captureAuthorizer": "0x...", + "captureDeadline": 1740758554, + "refundDeadline": 1741276954, + "feeRecipient": "0x...", "minFeeBps": 0, - "maxFeeBps": 1000 + "maxFeeBps": 500 } }] } @@ -144,24 +161,21 @@ The full source code for this example is available on [GitHub](https://github.co ## How it works -- **`refundable()`** wraps a standard x402 payment option with escrow configuration (contract addresses, fee bounds) from the network config. -- **`EscrowServerScheme`** registers the escrow payment scheme with the x402 resource server so it can validate escrow-backed payments. -- **`paymentMiddleware`** intercepts requests, checks for a valid payment header, and returns 402 if no payment is provided. +- **`extra` config** declares the captureAuthorizer, capture/refund deadlines, fee recipient, and fee bounds. The canonical `AuthCaptureEscrow` and token collector addresses are universal CREATE2 deploys, so routes do not need to repeat them. +- **`AuthCaptureEvmScheme`** registers the auth-capture payment scheme with the x402 resource server so it can verify auth-capture-backed payments. +- **`paymentMiddleware`** intercepts requests, checks for a valid payment header, and returns 402 when the caller has not provided one. - **`HTTPFacilitatorClient`** connects to the facilitator service that verifies and settles payments on-chain. ## Next Steps - - Configure escrow options and fee bounds. + + Forward escrow settlements to an arbiter service. - Release payments, handle refunds, and manage escrow. + Capture payments, handle refunds, and manage escrow. Deploy your own PaymentOperator contract. - - Run your own facilitator service. - diff --git a/sdk/merchant/payment-operations.mdx b/sdk/merchant/payment-operations.mdx deleted file mode 100644 index e5bdd20..0000000 --- a/sdk/merchant/payment-operations.mdx +++ /dev/null @@ -1,268 +0,0 @@ ---- -title: "Payment Operations" -description: "Release funds, charge payments, process refunds, and query escrow state with the Merchant SDK" -icon: "coins" ---- - -The `X402rMerchant` class provides methods for managing the full payment lifecycle: releasing escrowed funds, charging directly for subscriptions, processing refunds, and querying operator configuration. - -## Payment Operations - -### Release Funds from Escrow - -Use `release()` to transfer escrowed funds to the receiver (merchant). The `amount` parameter is **required** and specifies the exact amount to release in token units. - -```typescript -import { X402rMerchant } from '@x402r/merchant'; - -// Release 10 USDC (6 decimals) from escrow -const { txHash } = await merchant.release(paymentInfo, BigInt('10000000')); -console.log('Released:', txHash); -``` - -For partial releases, specify a smaller amount. The remaining funds stay in escrow and can be released or refunded later. - -```typescript -// Release 3 USDC of a 10 USDC escrow -const { txHash } = await merchant.release(paymentInfo, BigInt('3000000')); -console.log('Partial release:', txHash); - -// Check what remains -const { capturableAmount } = await merchant.getPaymentAmounts(paymentInfo); -console.log('Remaining in escrow:', capturableAmount); // 7000000n -``` - - -The `amount` parameter is always required. There is no default "release all" behavior. Always query `getPaymentAmounts()` first to determine the available capturable amount. - - -### Refund While in Escrow - -Use `refundInEscrow()` to return escrowed funds to the payer before release. The `amount` parameter is **required**. - -```typescript -// Full refund of 10 USDC -const { txHash } = await merchant.refundInEscrow(paymentInfo, BigInt('10000000')); -console.log('Refunded from escrow:', txHash); -``` - -```typescript -// Partial refund: return 2 USDC, keep 8 USDC in escrow -const { txHash } = await merchant.refundInEscrow(paymentInfo, BigInt('2000000')); -console.log('Partial refund:', txHash); -``` - -### Charge Directly - -Use `charge()` for non-escrow flows such as subscriptions or session-based payments. This pulls funds directly from the payer via a token collector (e.g., ERC-3009 `transferWithAuthorization`). - -```typescript -const tokenCollectorAddress: `0x${string}` = '0xTokenCollector...'; -const collectorData: `0x${string}` = '0xSignatureOrCalldata...'; - -const { txHash } = await merchant.charge( - paymentInfo, - BigInt('5000000'), // 5 USDC - tokenCollectorAddress, // token collector contract - collectorData // authorization data (e.g., ERC-3009 signature) -); -console.log('Charged:', txHash); -``` - - -The `charge()` method is designed for recurring payments and session-based billing where funds are not pre-escrowed. The token collector contract handles the actual token transfer. - - -### Refund After Release (Post-Escrow) - -Use `refundPostEscrow()` to refund funds that have already been released to the receiver. This requires a token collector to source the refund from the merchant's balance. - -```typescript -const tokenCollectorAddress: `0x${string}` = '0xTokenCollector...'; -const collectorData: `0x${string}` = '0xSignatureOrCalldata...'; - -const { txHash } = await merchant.refundPostEscrow( - paymentInfo, - BigInt('5000000'), // 5 USDC to refund - tokenCollectorAddress, // token collector that sources the refund - collectorData // authorization data -); -console.log('Post-escrow refund:', txHash); -``` - - -Post-escrow refunds require the merchant to have sufficient token balance. The token collector pulls funds from the merchant to return to the payer. - - -## Query Methods - -### Get Payment Amounts - -Use `getPaymentAmounts()` to query the current capturable and refundable amounts for a payment. This method reads directly from the escrow contract. - -```typescript -const { capturableAmount, refundableAmount } = await merchant.getPaymentAmounts(paymentInfo); - -console.log('Capturable:', capturableAmount); // Funds available to release -console.log('Refundable:', refundableAmount); // Funds available to refund - -if (capturableAmount > 0n) { - // Release available funds - const { txHash } = await merchant.release(paymentInfo, capturableAmount); - console.log('Released all capturable funds:', txHash); -} -``` - - -`getPaymentAmounts()` requires the `escrowAddress` to be configured when creating the `X402rMerchant` instance. - - -### Get Operator Configuration - -Use `getOperatorConfig()` to retrieve all 14 immutable slot addresses from the PaymentOperator contract. This includes the escrow address, fee configuration, all 5 condition slots, and all 5 recorder slots. - -```typescript -const config = await merchant.getOperatorConfig(); - -// Core state -console.log('Escrow:', config.escrow); -console.log('Fee recipient:', config.feeRecipient); -console.log('Fee calculator:', config.feeCalculator); -console.log('Protocol fee config:', config.protocolFeeConfig); - -// Condition slots (address(0) = always allow) -console.log('Authorize condition:', config.authorizeCondition); -console.log('Charge condition:', config.chargeCondition); -console.log('Release condition:', config.releaseCondition); -console.log('Refund in-escrow condition:', config.refundInEscrowCondition); -console.log('Refund post-escrow condition:', config.refundPostEscrowCondition); - -// Recorder slots (address(0) = no-op) -console.log('Authorize recorder:', config.authorizeRecorder); -console.log('Charge recorder:', config.chargeRecorder); -console.log('Release recorder:', config.releaseRecorder); -console.log('Refund in-escrow recorder:', config.refundInEscrowRecorder); -console.log('Refund post-escrow recorder:', config.refundPostEscrowRecorder); -``` - -### Get Fee Structure - -Use `getFeeStructure()` to retrieve the fee-related addresses for the operator. This is a lighter alternative to `getOperatorConfig()` when you only need fee information. - -```typescript -const fees = await merchant.getFeeStructure(); - -console.log('Fee calculator:', fees.feeCalculator); -console.log('Protocol fee config:', fees.protocolFeeConfig); -console.log('Fee recipient:', fees.feeRecipient); -``` - -The returned `FeeStructure` contains three fields: - -| Field | Type | Description | -|-------|------|-------------| -| `feeCalculator` | `0x${string}` | Contract that computes fee amounts | -| `protocolFeeConfig` | `0x${string}` | Protocol-level fee configuration | -| `feeRecipient` | `0x${string}` | Address that receives the operator's fee share | - -### Get Release Conditions - -Use `getReleaseConditions()` to check which condition contract governs release operations. A zero address means releases are always allowed. - -```typescript -const releaseCondition = await merchant.getReleaseConditions(); - -const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; -if (releaseCondition === ZERO_ADDRESS) { - console.log('No release conditions configured - releases always allowed'); -} else { - console.log('Release condition contract:', releaseCondition); -} -``` - -### Get Payment State - -Use `getPaymentState()` to derive the lifecycle state of a payment from the escrow contract. - -```typescript -import { PaymentState } from '@x402r/core'; - -const state = await merchant.getPaymentState(paymentInfo); -// PaymentState: NonExistent, InEscrow, Released, Settled, or Expired -``` - -```typescript -getPaymentState(paymentInfo: PaymentInfo): Promise -``` - -### Get Receiver Payments - -Use `getReceiverPayments()` to list all payments where the connected wallet is the receiver, by scanning `AuthorizationCreated` events. - -```typescript -const payments = await merchant.getReceiverPayments(); - -for (const { hash, paymentInfo } of payments) { - console.log(`Payment ${hash}: ${paymentInfo.maxAmount}`); -} -``` - -```typescript -getReceiverPayments( - fromBlock?: bigint -): Promise> -``` - - -This method scans event logs. Pass `fromBlock` to limit the scan range if your RPC limits `eth_getLogs` responses (Base Sepolia typically caps at 10,000 blocks). - - -### Get Payment Details - -Use `getPaymentDetails()` to retrieve the full `PaymentInfo` struct by scanning `AuthorizationCreated` events for a given hash. - -```typescript -const details = await merchant.getPaymentDetails(paymentInfoHash); -console.log('Payer:', details.payer); -console.log('Amount:', details.maxAmount); -``` - -```typescript -getPaymentDetails( - paymentInfoHash: `0x${string}`, - fromBlock?: bigint -): Promise -``` - -## Release vs Refund Decision Flow - -```mermaid -flowchart TD - A[Payment in Escrow] --> B{Check getPaymentAmounts} - B --> C{capturableAmount > 0?} - C -->|Yes| D{Has refund request?} - C -->|No| E[Nothing to release] - D -->|No| F[Safe to release] - D -->|Yes| G{Approve refund?} - F --> H["release(paymentInfo, amount)"] - G -->|Yes| I["refundInEscrow(paymentInfo, amount)"] - G -->|No| J[Deny request, then release] - J --> H -``` - -## Next Steps - - - - Process incoming refund requests with approve/deny workflows. - - - Watch for real-time payment and refund events. - - - Understand the underlying PaymentOperator contract methods. - - - Mark payment options as refundable with your operator. - - diff --git a/sdk/merchant/quickstart.mdx b/sdk/merchant/quickstart.mdx index 84aa102..8ada49d 100644 --- a/sdk/merchant/quickstart.mdx +++ b/sdk/merchant/quickstart.mdx @@ -1,239 +1,276 @@ --- title: "Merchant SDK" -description: "Release funds, charge payments, process refunds, and query escrow state with @x402r/merchant" +sidebarTitle: "Merchant client" +description: "Capture funds, charge payments, process refunds, and query escrow state" icon: "rocket" --- -The `@x402r/merchant` package provides everything merchants need for the post-payment lifecycle: releasing escrowed funds, charging directly, processing refunds, and querying operator state. +The `@x402r/sdk` package covers the merchant's post-payment lifecycle: capturing escrowed funds, charging directly, processing refunds, and querying operator state. -**Looking for server setup?** The [Merchant Server Quickstart](/sdk/merchant/getting-started) shows how to accept escrow payments via Express middleware. This page covers the `X402rMerchant` class for managing payments after they arrive. +**Looking for server setup?** The [Merchant Server Quickstart](/sdk/merchant/getting-started) shows how to accept escrow payments via Express middleware. This page covers the `createMerchantClient` factory for managing payments after they arrive. ## Installation -```bash -npm install @x402r/merchant @x402r/helpers @x402r/core viem + +```bash npm +npm install @x402r/sdk viem ``` +```bash pnpm +pnpm add @x402r/sdk viem +``` +```bash bun +bun add @x402r/sdk viem +``` + ## Setup -Create viem clients as described in [Installation](/sdk/overview), then: - ```typescript -import { X402rMerchant } from '@x402r/merchant'; -import { getNetworkConfig } from '@x402r/core'; - -const config = getNetworkConfig('eip155:84532')!; - -const merchant = new X402rMerchant({ - publicClient, - walletClient, - operatorAddress: '0x...', // Your PaymentOperator address - escrowAddress: config.authCaptureEscrow, - refundRequestAddress: config.refundRequest, -}); +import { createMerchantClient } from '@x402r/sdk' +import { createPublicClient, createWalletClient, http } from 'viem' +import { baseSepolia } from 'viem/chains' +import { privateKeyToAccount } from 'viem/accounts' + +const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`) + +const merchant = createMerchantClient({ + publicClient: createPublicClient({ chain: baseSepolia, transport: http() }), + walletClient: createWalletClient({ + account, + chain: baseSepolia, + transport: http(), + }), + operatorAddress: '0x...', + escrowPeriodAddress: '0x...', + refundRequestAddress: '0x...', + refundRequestEvidenceAddress: '0x...', + freezeAddress: '0x...', +}) ``` -## Release Funds from Escrow +## payment.capture -Use `release()` to transfer escrowed funds to the receiver (merchant). The `amount` parameter is **required** and specifies the exact amount to release in token units. +Transfer escrowed funds to the receiver. Specify a smaller amount than `paymentInfo.maxAmount` for a partial capture; the rest stays in escrow. ```typescript -// Release 10 USDC (6 decimals) from escrow -const { txHash } = await merchant.release(paymentInfo, BigInt('10000000')); -console.log('Released:', txHash); +const tx = await merchant.payment.capture(paymentInfo, 10_000_000n) ``` -For partial releases, specify a smaller amount. The remaining funds stay in escrow and can be released or refunded later. +**Parameters** -```typescript -// Release 3 USDC of a 10 USDC escrow -const { txHash } = await merchant.release(paymentInfo, BigInt('3000000')); -console.log('Partial release:', txHash); +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | +| `amount` | `bigint` | Atomic units to capture (must be ≤ `paymentInfo.maxAmount`) | +| `data` | `Hex` _(optional)_ | Pass-through data for the operator's pre/post action plugins | -// Check what remains -const { capturableAmount } = await merchant.getPaymentAmounts(paymentInfo); -console.log('Remaining in escrow:', capturableAmount); // 7000000n -``` +**Returns** `Promise`, the settlement transaction hash. -The `amount` parameter is always required. There is no default "release all" behavior. Always query `getPaymentAmounts()` first to determine the available capturable amount. +Always query `payment.getAmounts()` first to determine the available capturable amount. -## Refund While in Escrow +## payment.voidPayment -Use `refundInEscrow()` to return escrowed funds to the payer before release. The `amount` parameter is **required**. +Return all escrowed funds to the payer before capture. Full-only: `void()` empties the authorization in one transaction. For a partial return, capture the share you want to keep first, then void the rest (or let it expire at `captureDeadline`). ```typescript -// Full refund of 10 USDC -const { txHash } = await merchant.refundInEscrow(paymentInfo, BigInt('10000000')); -console.log('Refunded from escrow:', txHash); +const tx = await merchant.payment.voidPayment(paymentInfo) ``` -```typescript -// Partial refund: return 2 USDC, keep 8 USDC in escrow -const { txHash } = await merchant.refundInEscrow(paymentInfo, BigInt('2000000')); -console.log('Partial refund:', txHash); -``` +**Parameters** -## Charge Directly +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | +| `data` | `Hex` _(optional)_ | Pass-through data for the operator's pre/post action plugins | -Use `charge()` for non-escrow flows such as subscriptions or session-based payments. This pulls funds directly from the payer via a token collector (e.g., ERC-3009 `transferWithAuthorization`). +**Returns** `Promise`, the void transaction hash. -```typescript -const tokenCollectorAddress: `0x${string}` = '0xTokenCollector...'; -const collectorData: `0x${string}` = '0xSignatureOrCalldata...'; +## payment.charge -const { txHash } = await merchant.charge( +Non-escrow settlement for subscriptions or session-based payments. Pulls funds directly from the payer via a token collector (no escrow hold). + +```typescript +const tx = await merchant.payment.charge( paymentInfo, - BigInt('5000000'), // 5 USDC - tokenCollectorAddress, // token collector contract - collectorData // authorization data (e.g., ERC-3009 signature) -); -console.log('Charged:', txHash); + 5_000_000n, + '0xTokenCollector...' as `0x${string}`, + '0xSignatureData...' as `0x${string}`, +) ``` - -The `charge()` method is designed for recurring payments and session-based billing where funds are not pre-escrowed. The token collector contract handles the actual token transfer. - +**Parameters** -## Refund After Release (Post-Escrow) +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | +| `amount` | `bigint` | Atomic units to charge | +| `tokenCollector` | `Address` | Canonical token collector for the chosen `assetTransferMethod` | +| `collectorData` | `Hex` | Raw ERC-3009 signature or ABI-encoded Permit2 signature | -Use `refundPostEscrow()` to refund funds that have already been released to the receiver. This requires a token collector to source the refund from the merchant's balance. +**Returns** `Promise`, the charge transaction hash. -```typescript -const tokenCollectorAddress: `0x${string}` = '0xTokenCollector...'; -const collectorData: `0x${string}` = '0xSignatureOrCalldata...'; +## payment.refund + +Refund funds the merchant has already captured. Requires a token collector to pull funds from the merchant's balance. -const { txHash } = await merchant.refundPostEscrow( +```typescript +const tx = await merchant.payment.refund( paymentInfo, - BigInt('5000000'), // 5 USDC to refund - tokenCollectorAddress, // token collector that sources the refund - collectorData // authorization data -); -console.log('Post-escrow refund:', txHash); + 5_000_000n, + '0xTokenCollector...' as `0x${string}`, + '0xSignatureData...' as `0x${string}`, +) ``` - -Post-escrow refunds require the merchant to have sufficient token balance. The token collector pulls funds from the merchant to return to the payer. - +**Parameters** -## Query Methods +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | +| `amount` | `bigint` | Atomic units to refund to the payer | +| `tokenCollector` | `Address` | Token collector that sources the refund (typically `ReceiverRefundCollector`) | +| `collectorData` | `Hex` | Data passed to the collector (for example, the receiver signature) | -### Get Payment Amounts +**Returns** `Promise`, the refund transaction hash. -Use `getPaymentAmounts()` to query the current capturable and refundable amounts for a payment. + +Refunds after capture require the merchant to hold enough token balance and to grant an allowance on the refund collector. + -```typescript -const { capturableAmount, refundableAmount } = await merchant.getPaymentAmounts(paymentInfo); +## payment.getAmounts -console.log('Capturable:', capturableAmount); // Funds available to release -console.log('Refundable:', refundableAmount); // Funds available to refund +Query the current capturable and refundable amounts for a payment. -if (capturableAmount > 0n) { - const { txHash } = await merchant.release(paymentInfo, capturableAmount); - console.log('Released all capturable funds:', txHash); -} +```typescript +const amounts = await merchant.payment.getAmounts(paymentInfo) ``` - -`getPaymentAmounts()` requires the `escrowAddress` to be configured when creating the `X402rMerchant` instance. - +**Parameters** -### Get Payment State +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | -Use `getPaymentState()` to derive the lifecycle state of a payment from the escrow contract. +**Returns** `Promise`: -```typescript -import { PaymentState } from '@x402r/core'; +| Field | Type | Description | +|---|---|---| +| `hasCollectedPayment` | `boolean` | Whether the on-chain escrow holds the payment | +| `capturableAmount` | `bigint` | Atomic units still capturable from escrow | +| `refundableAmount` | `bigint` | Atomic units still refundable | -const state = await merchant.getPaymentState(paymentInfo); -// PaymentState: NonExistent, InEscrow, Released, Settled, or Expired -``` +## payment.getState -### Get Receiver Payments - -Use `getReceiverPayments()` to list all payments where the connected wallet is the receiver. +Returns the payment's lifecycle position as a tuple. The SDK exposes no `PaymentState` enum. ```typescript -const payments = await merchant.getReceiverPayments(); - -for (const { hash, paymentInfo } of payments) { - console.log(`Payment ${hash}: ${paymentInfo.maxAmount}`); -} +const [hasCollectedPayment, capturableAmount, refundableAmount] = + await merchant.payment.getState(paymentInfo) ``` - -This method scans event logs. Pass `fromBlock` to limit the scan range if your RPC limits `eth_getLogs` responses (Base Sepolia typically caps at 10,000 blocks). - +**Parameters** + +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | -### Get Payment Details +**Returns** `Promise`, `[hasCollectedPayment, capturableAmount, refundableAmount]`. -Use `getPaymentDetails()` to retrieve the full `PaymentInfo` struct by scanning `AuthorizationCreated` events for a given hash. +## operator.getConfig + +Retrieve all slot addresses from the PaymentOperator contract. ```typescript -const details = await merchant.getPaymentDetails(paymentInfoHash); -console.log('Payer:', details.payer); -console.log('Amount:', details.maxAmount); +const config = await merchant.operator.getConfig() ``` -### Get Operator Configuration +**Returns** `Promise`, see `packages/core/src/actions/operator/types.ts`. Key fields: -Use `getOperatorConfig()` to retrieve all 14 immutable slot addresses from the PaymentOperator contract. +| Field | Type | Description | +|---|---|---| +| `escrow` | `Address` | Canonical AuthCaptureEscrow | +| `authorizeCondition` / `authorizeHook` | `Address` | Pre/post slots for `authorize` | +| `chargeCondition` / `chargeHook` | `Address` | Pre/post slots for `charge` | +| `captureCondition` / `captureHook` | `Address` | Pre/post slots for `capture` | +| `voidCondition` / `voidHook` | `Address` | Pre/post slots for `void` | +| `refundCondition` / `refundHook` | `Address` | Pre/post slots for `refund` | +| `feeCalculator` | `Address` | Per-operator fee calculator | +| `feeReceiver` | `Address` | Operator fee recipient | +| `protocolFeeConfig` | `Address` | Protocol fee config contract | -```typescript -const config = await merchant.getOperatorConfig(); +## operator.getFeeAddresses -console.log('Escrow:', config.escrow); -console.log('Fee recipient:', config.feeRecipient); -console.log('Fee calculator:', config.feeCalculator); -console.log('Release condition:', config.releaseCondition); +Fetch the fee-related addresses (subset of `getConfig` with both operator and protocol resolved). + +```typescript +const fees = await merchant.operator.getFeeAddresses() ``` -### Get Fee Structure +**Returns** `Promise`: -Use `getFeeStructure()` for just the fee-related addresses — a lighter alternative to `getOperatorConfig()`. +| Field | Type | Description | +|---|---|---| +| `operatorFeeCalculator` | `Address` | Per-operator calculator | +| `protocolFeeConfig` | `Address` | Protocol fee config contract | +| `protocolFeeCalculator` | `Address` | Protocol-level calculator | +| `operatorFeeRecipient` | `Address` | Where operator fees flow | +| `protocolFeeRecipient` | `Address` | Where protocol fees flow | -```typescript -const fees = await merchant.getFeeStructure(); - -console.log('Fee calculator:', fees.feeCalculator); -console.log('Protocol fee config:', fees.protocolFeeConfig); -console.log('Fee recipient:', fees.feeRecipient); -``` +## operator.calculateFees -### Get Release Conditions +Calculate the full fee breakdown for a payment amount. ```typescript -const releaseCondition = await merchant.getReleaseConditions(); -// address(0) means releases are always allowed +const fees = await merchant.operator.calculateFees(paymentInfo, 1_000_000n) ``` -## Release vs Refund Decision Flow +**Parameters** + +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | +| `amount` | `bigint` | Atomic units to compute fees for | + +**Returns** `Promise`: + +| Field | Type | Description | +|---|---|---| +| `protocolFeeBps` | `bigint` | Protocol fee in basis points | +| `operatorFeeBps` | `bigint` | Operator fee in basis points | +| `totalFeeBps` | `bigint` | Sum of the two | +| `protocolFeeAmount` | `bigint` | Atomic units of protocol fee | +| `operatorFeeAmount` | `bigint` | Atomic units of operator fee | +| `totalFeeAmount` | `bigint` | Atomic units of total fee | +| `netAmount` | `bigint` | Amount remaining after fees | + +## Capture vs refund decision flow ```mermaid flowchart TD - A[Payment in Escrow] --> B{Check getPaymentAmounts} + A[Payment in Escrow] --> B{Check payment.getAmounts} B --> C{capturableAmount > 0?} C -->|Yes| D{Has refund request?} - C -->|No| E[Nothing to release] - D -->|No| F[Safe to release] + C -->|No| E[Nothing to capture] + D -->|No| F[Safe to capture] D -->|Yes| G{Approve refund?} - F --> H["release(paymentInfo, amount)"] - G -->|Yes| I["refundInEscrow(paymentInfo, amount)"] - G -->|No| J[Deny request, then release] + F --> H["payment.capture(paymentInfo, amount)"] + G -->|Yes| I["payment.voidPayment(paymentInfo)"] + G -->|No| J[Deny request, then capture] J --> H ``` -## Next Steps +## Next steps - Process incoming refund requests with approve/deny workflows. + Process incoming refund requests with deny workflows. - - Mark payment options as refundable with your operator. + + Forward escrow settlements to an arbiter service. Understand the underlying PaymentOperator contract methods. diff --git a/sdk/merchant/refund-handling.mdx b/sdk/merchant/refund-handling.mdx index 02e8b31..2166425 100644 --- a/sdk/merchant/refund-handling.mdx +++ b/sdk/merchant/refund-handling.mdx @@ -1,325 +1,273 @@ --- -title: "Refund Handling" -description: "Process, approve, deny, and manage refund requests with the Merchant SDK" +title: "Refund handling" +description: "Process, approve, deny, and manage refund requests as a merchant" icon: "rotate-left" --- -The `X402rMerchant` class provides a complete set of methods for handling refund requests from payers. Every refund-related method requires a `nonce: bigint` parameter that identifies which specific charge the refund targets. +The merchant client exposes a read-heavy slice of refund actions plus `freeze.isFrozen`. Writes that change refund-request status (`deny`, `refuse`) and writes that lift a freeze (`unfreeze`) live on `createArbiterClient` or on the full `createX402r()` client. - -The `nonce` parameter corresponds to the record index from the `PaymentIndexRecorder`. For the first charge against a payment, the nonce is `0n`. Each subsequent charge increments the nonce. - +Use `createMerchantClient` for queries below; for executing a refund, see [Capture vs refund decision flow](/sdk/merchant/quickstart#capture-vs-refund-decision-flow). The merchant client's `payment.voidPayment()` flips the request to `Approved` through the `VOID_POST_ACTION_HOOK`. -## Refund Request Queries +## Refund request queries -### Check If a Refund Request Exists +### refund.has -Use `hasRefundRequest()` to check whether a payer has submitted a refund request for a specific payment and nonce. +Check whether a refund request exists for a payment. ```typescript -const hasRequest = await merchant.hasRefundRequest(paymentInfo, 0n); - -if (hasRequest) { - console.log('Refund request exists for this payment'); -} else { - console.log('No refund request submitted'); -} +const hasRequest = await merchant.refund?.has(paymentInfo) ``` -### Get Refund Request Status +**Parameters** -Use `getRefundStatus()` to retrieve the current status of a refund request. Returns a `RequestStatus` enum value. +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | -```typescript -import { RequestStatus } from '@x402r/core'; - -const status = await merchant.getRefundStatus(paymentInfo, 0n); - -switch (status) { - case RequestStatus.Pending: - console.log('Awaiting your decision'); - break; - case RequestStatus.Approved: - console.log('You approved this refund'); - break; - case RequestStatus.Denied: - console.log('You denied this refund'); - break; - case RequestStatus.Cancelled: - console.log('Payer cancelled the request'); - break; -} -``` +**Returns** `Promise`. -### Get Full Refund Request Data +### refund.getStatus -Use `getRefundRequest()` to retrieve the complete refund request data, including the amount and status. +Retrieve the current status of a refund request. ```typescript -import type { RefundRequestData } from '@x402r/core'; - -const request: RefundRequestData = await merchant.getRefundRequest(paymentInfo, 0n); +import { RefundRequestStatus } from '@x402r/sdk' -console.log('Payment hash:', request.paymentInfoHash); -console.log('Nonce:', request.nonce); -console.log('Requested amount:', request.amount); -console.log('Status:', request.status); +const status = await merchant.refund?.getStatus(paymentInfo) ``` -The `RefundRequestData` type contains: +**Parameters** -| Field | Type | Description | -|-------|------|-------------| -| `paymentInfoHash` | `0x${string}` | Hash of the PaymentInfo struct | -| `nonce` | `bigint` | Record index this refund targets | -| `amount` | `bigint` | Amount requested for refund (uint120) | -| `status` | `RequestStatus` | Current status (Pending, Approved, Denied, Cancelled) | +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | -### Get Refund Request by Composite Key +**Returns** `Promise`, `Pending` \| `Approved` \| `Denied` \| `Cancelled` \| `Refused`. -Use `getRefundRequestByKey()` to look up a refund request directly by its composite key (the `keccak256(paymentInfoHash, nonce)` value returned from paginated queries). +### refund.get -```typescript -const request = await merchant.getRefundRequestByKey(compositeKey); +Retrieve the complete refund request data. -console.log('Amount:', request.amount); -console.log('Status:', request.status); +```typescript +const request = await merchant.refund?.get(paymentInfo) ``` -## Paginated Refund Request Listing +**Parameters** -### Get Pending Refund Requests +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | -Use `getPendingRefundRequests()` to retrieve paginated refund request keys for the current receiver address. This method uses the wallet address associated with your `X402rMerchant` instance. +**Returns** `Promise`: -```typescript -// Get the first 10 refund request keys -const { keys, total } = await merchant.getPendingRefundRequests(0n, 10n); - -console.log(`Showing ${keys.length} of ${total} total refund requests`); +| Field | Type | Description | +|---|---|---| +| `paymentInfoHash` | `Hex` | keccak256 of the payment info struct | +| `amount` | `bigint` | Amount the payer requested | +| `approvedAmount` | `bigint` | Amount actually executed (0 until approved) | +| `status` | `RefundRequestStatus` | Lifecycle state | -// Look up each request by its composite key -for (const key of keys) { - const request = await merchant.getRefundRequestByKey(key); - console.log(`Key: ${key}`); - console.log(` Amount: ${request.amount}`); - console.log(` Status: ${request.status}`); -} -``` +### refund.getByKey -For pagination, adjust the `offset` and `count` parameters: +Look up a refund request directly by its payment info hash. ```typescript -// Page through all refund requests, 20 at a time -const pageSize = 20n; -let offset = 0n; -let hasMore = true; +const request = await merchant.refund?.getByKey(paymentInfoHash) +``` -while (hasMore) { - const { keys, total } = await merchant.getPendingRefundRequests(offset, pageSize); +**Parameters** - for (const key of keys) { - const request = await merchant.getRefundRequestByKey(key); - // Process each request... - } +| Name | Type | Description | +|---|---|---| +| `paymentInfoHash` | `Hex` | keccak256 of the payment info struct | - offset += pageSize; - hasMore = offset < total; -} -``` +**Returns** `Promise`. -### Get Refund Request Count +## Paginated refund request listing -Use `getRefundRequestCount()` to get the total number of refund requests targeting the current receiver. +### refund.getReceiverRequests -```typescript -const count = await merchant.getRefundRequestCount(); -console.log(`Total refund requests: ${count}`); +Retrieve paginated refund request keys for this merchant (the receiver). -if (count > 0n) { - const { keys } = await merchant.getPendingRefundRequests(0n, count); - console.log(`Retrieved all ${keys.length} request keys`); +```typescript +const { keys, total } = await merchant.refund?.getReceiverRequests( + receiverAddress, + 0n, + 10n, +) ?? { keys: [], total: 0n } + +for (const hash of keys) { + const request = await merchant.refund?.getByKey(hash) + // ... inspect request.amount, request.status } ``` -## Refund Request Actions +**Parameters** -### Approve a Refund Request +| Name | Type | Description | +|---|---|---| +| `receiver` | `Address` | Receiver address to query (typically the merchant) | +| `offset` | `bigint` | Index offset | +| `count` | `bigint` | Max entries to return | -Use `approveRefundRequest()` to approve a pending refund request. This changes the request status to `Approved`. +**Returns** `Promise<{ keys: readonly Hex[]; total: bigint }>`. To hydrate each entry, call `refund.getByKey(hash)` per key. -```typescript -const { txHash } = await merchant.approveRefundRequest(paymentInfo, 0n); -console.log('Refund approved:', txHash); -``` + +`getOperatorRequests` (paginated across all payments under an operator) lives on `createArbiterClient`, not on the merchant client. + - -Approving a refund request changes its status but does **not** transfer funds. You must also call `refundInEscrow()` or `refundPostEscrow()` to execute the actual token transfer. - +## Refund request actions + +Approving or denying a request through the operator hook is what the merchant does. Terminal `deny` and `refuse` calls on the RefundRequest contract belong to the arbiter role; from a merchant, execute the refund through `payment.voidPayment()` (which flips the request to `Approved`) or signal a refusal off-chain and let the arbiter terminalize it. -### Deny a Refund Request +### payment.voidPayment -Use `denyRefundRequest()` to deny a pending refund request. This changes the request status to `Denied`. +To approve and execute a refund, call `payment.voidPayment()`. The operator's `VOID_POST_ACTION_HOOK` (RefundRequest) flips the request status to `Approved`. ```typescript -const { txHash } = await merchant.denyRefundRequest(paymentInfo, 0n); -console.log('Refund denied:', txHash); +const tx = await merchant.payment.voidPayment(paymentInfo) ``` - -If you deny a request, the payer may escalate to an arbiter for dispute resolution. Consider providing a reason off-chain to reduce escalation risk. - +**Parameters** -## Freeze Management +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | +| `data` | `Hex` _(optional)_ | Pass-through data for pre/post action plugins | -### Check If a Payment Is Frozen +**Returns** `Promise`. -Use `isFrozen()` to check whether a payment has been frozen by the payer or an arbiter. Frozen payments cannot be released until unfrozen. + +`voidPayment()` flips the pending RefundRequest to `Approved`. This action cannot be undone. + -```typescript -const freezeAddress: `0x${string}` = '0xFreezeContract...'; +## Freeze management -const frozen = await merchant.isFrozen(paymentInfo, freezeAddress); +### freeze.isFrozen -if (frozen) { - console.log('Payment is frozen - cannot release until unfrozen'); -} else { - console.log('Payment is not frozen'); -} +Check whether a freeze currently holds a payment. The escrow blocks capture on a frozen payment until the arbiter unfreezes it. + +```typescript +const frozen = await merchant.freeze?.isFrozen(paymentInfo) ``` -### Unfreeze a Payment +**Parameters** -Use `unfreezePayment()` to remove a freeze on a payment. Only the receiver (merchant) or an authorized party can unfreeze. +| Name | Type | Description | +|---|---|---| +| `paymentInfo` | `PaymentInfo` | Full struct identifying the payment | -```typescript -const freezeAddress: `0x${string}` = '0xFreezeContract...'; +**Returns** `Promise`. -const { txHash } = await merchant.unfreezePayment(paymentInfo, freezeAddress); -console.log('Payment unfrozen:', txHash); -``` + +The merchant client exposes `freeze.isFrozen` only. Lifting a freeze (`unfreeze`) is an arbiter-role action; use `createArbiterClient` or `createX402r()`. + -## Complete Refund Workflow +## Complete refund workflow -Here is a full workflow showing how to detect a refund request, review it, make a decision, and execute the refund if approved. +A full workflow that detects a refund request, reviews it, makes a decision, and executes the refund when approved. ```typescript -import { createPublicClient, createWalletClient, http } from 'viem'; -import { baseSepolia } from 'viem/chains'; -import { privateKeyToAccount } from 'viem/accounts'; -import { X402rMerchant } from '@x402r/merchant'; -import { getNetworkConfig, RequestStatus } from '@x402r/core'; +import { createMerchantClient, RefundRequestStatus } from '@x402r/sdk' +import type { PaymentInfo } from '@x402r/sdk' async function handleRefundWorkflow( - merchant: X402rMerchant, + merchant: ReturnType, paymentInfo: PaymentInfo, - nonce: bigint ) { // Step 1: Check if a refund request exists - const hasRequest = await merchant.hasRefundRequest(paymentInfo, nonce); + const hasRequest = await merchant.refund?.has(paymentInfo) if (!hasRequest) { - console.log('No refund request for this payment/nonce'); - return; + console.log('No refund request for this payment') + return } // Step 2: Get the full request data - const request = await merchant.getRefundRequest(paymentInfo, nonce); - console.log(`Refund request: ${request.amount} tokens, status: ${request.status}`); + const request = await merchant.refund?.get(paymentInfo) + console.log('Refund request:', request?.amount, 'status:', request?.status) // Step 3: Only process pending requests - if (request.status !== RequestStatus.Pending) { - console.log('Request already processed'); - return; + if (request?.status !== RefundRequestStatus.Pending) { + console.log('Request already processed') + return } // Step 4: Check if the payment is frozen - const freezeAddress: `0x${string}` = '0xFreezeContract...'; - const frozen = await merchant.isFrozen(paymentInfo, freezeAddress); + const frozen = await merchant.freeze?.isFrozen(paymentInfo) if (frozen) { - console.log('Payment is frozen - resolve dispute before processing refund'); - return; + console.log('Payment is frozen, resolve dispute first') + return } // Step 5: Check available amounts - const { capturableAmount, refundableAmount } = await merchant.getPaymentAmounts(paymentInfo); - console.log(`Available to refund: ${refundableAmount}`); + const amounts = await merchant.payment.getAmounts(paymentInfo) + console.log('Available to refund:', amounts.refundableAmount) // Step 6: Make a decision - const shouldApprove = request.amount <= refundableAmount; + const shouldApprove = request.amount <= amounts.refundableAmount if (shouldApprove) { - // Approve the request - const { txHash: approveTx } = await merchant.approveRefundRequest(paymentInfo, nonce); - console.log('Approved:', approveTx); - - // Execute the refund from escrow - const { txHash: refundTx } = await merchant.refundInEscrow(paymentInfo, request.amount); - console.log('Refund executed:', refundTx); + // Execute the refund (the VOID_POST_ACTION_HOOK flips the request to Approved) + const tx = await merchant.payment.voidPayment(paymentInfo) + console.log('Refund executed:', tx) } else { - // Deny the request - const { txHash: denyTx } = await merchant.denyRefundRequest(paymentInfo, nonce); - console.log('Denied:', denyTx); + // The arbiter can terminalize the request via refund.deny / refund.refuse. + // The merchant can simply leave the request Pending and capture as usual, + // or escalate off-chain to the arbiter. + console.log('Declining; arbiter may deny if escalated') } } ``` -## Refund Request Lifecycle +## Refund request lifecycle ```mermaid sequenceDiagram - participant P as Payer (Client SDK) + participant P as Payer participant R as RefundRequest Contract - participant M as Merchant (Merchant SDK) + participant M as Merchant participant O as PaymentOperator - P->>R: requestRefund(paymentInfo, amount, nonce) + P->>R: refund.request(paymentInfo, amount) R-->>M: RefundRequested event - M->>R: hasRefundRequest(paymentInfo, nonce) + M->>R: refund.has(paymentInfo) R-->>M: true - M->>R: getRefundRequest(paymentInfo, nonce) + M->>R: refund.get(paymentInfo) R-->>M: RefundRequestData M->>M: Review request (policy check) alt Approve - M->>R: approveRefundRequest(paymentInfo, nonce) - M->>O: refundInEscrow(paymentInfo, amount) + M->>O: payment.voidPayment(paymentInfo) O->>P: Funds returned to payer - else Deny - M->>R: denyRefundRequest(paymentInfo, nonce) - Note over P: Payer may escalate to arbiter + else Decline (off-chain) / escalate to arbiter + Note over P,R: Arbiter may terminalize via refund.deny / refund.refuse end ``` -## Method Reference +## Method reference -| Method | Parameters | Returns | Description | -|--------|-----------|---------|-------------| -| `hasRefundRequest` | `paymentInfo, nonce: bigint` | `Promise` | Check if refund request exists | -| `getRefundStatus` | `paymentInfo, nonce: bigint` | `Promise` | Get request status | -| `getRefundRequest` | `paymentInfo, nonce: bigint` | `Promise` | Get full request data | -| `approveRefundRequest` | `paymentInfo, nonce: bigint` | `Promise<{ txHash }>` | Approve a pending request | -| `denyRefundRequest` | `paymentInfo, nonce: bigint` | `Promise<{ txHash }>` | Deny a pending request | -| `getPendingRefundRequests` | `offset: bigint, count: bigint` | `Promise<{ keys, total }>` | Paginated request keys | -| `getRefundRequestCount` | _(none)_ | `Promise` | Total requests for receiver | -| `getRefundRequestByKey` | `compositeKey: hex` | `Promise` | Look up by composite key | -| `unfreezePayment` | `paymentInfo, freezeAddress: hex` | `Promise<{ txHash }>` | Remove payment freeze | -| `isFrozen` | `paymentInfo, freezeAddress: hex` | `Promise` | Check if payment is frozen | +| Method | Parameters | Returns | +|---|---|---| +| `refund.has` | `paymentInfo` | `boolean` | +| `refund.getStatus` | `paymentInfo` | `RefundRequestStatus` | +| `refund.get` | `paymentInfo` | `RefundRequestData` | +| `refund.getByKey` | `paymentInfoHash` | `RefundRequestData` | +| `refund.getStoredPaymentInfo` | `paymentInfoHash` | `PaymentInfo` | +| `refund.getReceiverRequests` | `receiver, offset, count` | `{ keys: readonly Hex[]; total: bigint }` | +| `refund.getCancelCount` | `paymentInfo` | `bigint` (number of cancellations on this RefundRequest) | +| `refund.getCancelledAmount` | `paymentInfo, cancelIndex` | `bigint` (amount cancelled at the given index) | +| `freeze.isFrozen` | `paymentInfo` | `boolean` | +| `payment.voidPayment` | `paymentInfo, data?` | `Hash` (flips the pending RefundRequest to `Approved`) | -## Next Steps +## Next steps - - Watch for refund requests in real-time instead of polling. - - Release funds, charge, and query escrow state. + Capture funds, charge, and query escrow state. - + RefundRequest contract details and state machine. - - How arbiters process refund requests from the other side. - diff --git a/sdk/merchant/subscriptions.mdx b/sdk/merchant/subscriptions.mdx deleted file mode 100644 index 813b31d..0000000 --- a/sdk/merchant/subscriptions.mdx +++ /dev/null @@ -1,202 +0,0 @@ ---- -title: "Merchant Events" -description: "Subscribe to real-time refund, release, and freeze events with the Merchant SDK" -icon: "bell" ---- - -The `X402rMerchant` class provides three subscription methods for watching blockchain events in real-time. Each returns an object with an `unsubscribe` function for cleanup. - -## Watch Refund Requests - -Use `watchRefundRequests()` to subscribe to `RefundRequested` events emitted by the RefundRequest contract. The callback receives a `RefundRequestEventLog` object for each event. - -```typescript -const { unsubscribe } = merchant.watchRefundRequests((event) => { - console.log('Event:', event.eventName); - console.log('Payment hash:', event.args.paymentInfoHash); - console.log('Payer:', event.args.payer); - console.log('Receiver:', event.args.receiver); - console.log('Amount:', event.args.amount); - console.log('Nonce:', event.args.nonce); - console.log('Block:', event.blockNumber); - console.log('Tx hash:', event.transactionHash); -}); - -// Later: stop watching -unsubscribe(); -``` - -The `RefundRequestEventLog` type has the following shape: - -```typescript -interface RefundRequestEventLog { - eventName: 'RefundRequested' | 'RefundRequestStatusUpdated' | 'RefundRequestCancelled'; - args: { - paymentInfoHash?: `0x${string}`; - payer?: `0x${string}`; - receiver?: `0x${string}`; - amount?: bigint; - nonce?: bigint; - status?: number; - }; - address: `0x${string}`; - blockNumber: bigint; - transactionHash: `0x${string}`; - logIndex: number; -} -``` - - -`watchRefundRequests()` requires the `refundRequestAddress` to be configured when creating the `X402rMerchant` instance. - - -### Example: Auto-respond to Small Refund Requests - -```typescript -import { X402rMerchant } from '@x402r/merchant'; -import { RequestStatus } from '@x402r/core'; - -const AUTO_APPROVE_THRESHOLD = BigInt('5000000'); // 5 USDC - -const { unsubscribe } = merchant.watchRefundRequests(async (event) => { - const amount = event.args.amount; - const paymentHash = event.args.paymentInfoHash; - - console.log(`New refund request: ${paymentHash}, amount: ${amount}`); - - if (amount && amount < AUTO_APPROVE_THRESHOLD) { - console.log('Auto-approving small refund request'); - // You would look up the paymentInfo from your database - // and call merchant.approveRefundRequest(paymentInfo, nonce) - } else { - console.log('Queuing for manual review'); - } -}); -``` - -## Watch Releases - -Use `watchReleases()` to subscribe to `ReleaseExecuted` events emitted by the PaymentOperator contract. The callback receives a `PaymentOperatorEventLog` object for each event. - -```typescript -const { unsubscribe } = merchant.watchReleases((event) => { - console.log('Release executed!'); - console.log('Payment hash:', event.args.paymentInfoHash); - console.log('Amount:', event.args.amount); - console.log('Payer:', event.args.payer); - console.log('Receiver:', event.args.receiver); - console.log('Block:', event.blockNumber); - console.log('Tx hash:', event.transactionHash); -}); - -// Later: stop watching -unsubscribe(); -``` - -The `PaymentOperatorEventLog` type has the following shape: - -```typescript -interface PaymentOperatorEventLog { - eventName: 'ReleaseExecuted' | 'RefundInEscrowExecuted' | 'RefundPostEscrowExecuted' - | 'AuthorizationCreated' | 'ChargeExecuted'; - args: { - paymentInfoHash?: `0x${string}`; - payer?: `0x${string}`; - receiver?: `0x${string}`; - amount?: bigint; - }; - address: `0x${string}`; - blockNumber: bigint; - transactionHash: `0x${string}`; - logIndex: number; -} -``` - -### Example: Revenue Tracking - -```typescript -let totalReleased = 0n; - -const { unsubscribe } = merchant.watchReleases((event) => { - const amount = event.args.amount ?? 0n; - totalReleased += amount; - - console.log(`Release: +${amount} tokens`); - console.log(`Total released: ${totalReleased}`); -}); -``` - -## Watch Freeze Events - -Use `watchFreezeEvents()` to subscribe to `PaymentFrozen` and `PaymentUnfrozen` events from a specific Freeze contract. You must provide the Freeze contract address as the first argument. - -```typescript -const freezeAddress: `0x${string}` = '0xFreezeContract...'; - -const { unsubscribe } = merchant.watchFreezeEvents( - freezeAddress, - (event) => { - if (event.eventName === 'PaymentFrozen') { - console.log('Payment FROZEN:', event.args.paymentInfoHash); - console.log('Frozen by:', event.args.caller); - // Alert: a dispute may be in progress - } else if (event.eventName === 'PaymentUnfrozen') { - console.log('Payment UNFROZEN:', event.args.paymentInfoHash); - console.log('Unfrozen by:', event.args.caller); - // The payment can now be released - } - } -); - -// Later: stop watching -unsubscribe(); -``` - -The `FreezeEventLog` type has the following shape: - -```typescript -interface FreezeEventLog { - eventName: 'PaymentFrozen' | 'PaymentUnfrozen'; - args: { - paymentInfoHash?: `0x${string}`; - caller?: `0x${string}`; - }; - address: `0x${string}`; - blockNumber: bigint; - transactionHash: `0x${string}`; - logIndex: number; -} -``` - - -The `freezeAddress` parameter is the address of the Freeze condition contract, not the PaymentOperator. You can retrieve it from your operator config via `merchant.getOperatorConfig()`. - - -## Event Types Reference - -| Method | Event Name | Callback Type | Use Case | -|--------|-----------|---------------|----------| -| `watchRefundRequests` | `RefundRequested` | `RefundRequestEventLog` | Detect incoming refund requests | -| `watchReleases` | `ReleaseExecuted` | `PaymentOperatorEventLog` | Track revenue and release confirmations | -| `watchFreezeEvents` | `PaymentFrozen` / `PaymentUnfrozen` | `FreezeEventLog` | Monitor dispute-related freezes | - - -All subscription methods use viem's `watchContractEvent` under the hood. For reliable real-time delivery, configure your `publicClient` with a [WebSocket transport](https://viem.sh/docs/clients/transports/websocket). - - -## Next Steps - - - - Release funds, charge, and query escrow state. - - - Learn about dispute resolution from the arbiter perspective. - - - Process refund requests with approve/deny workflows. - - - See how clients subscribe to the same events. - - diff --git a/sdk/overview.mdx b/sdk/overview.mdx index 4b43e59..4d704ac 100644 --- a/sdk/overview.mdx +++ b/sdk/overview.mdx @@ -4,19 +4,23 @@ description: "TypeScript SDK for adding escrow, refunds, and dispute resolution icon: "cube" --- -x402 payments are instant and irreversible. x402r adds escrow holds, refund windows, and dispute resolution on top. + +The X402r SDK is in active development. APIs may change between releases. Always test on Base Sepolia before using real funds on mainnet. + Three roles interact with the protocol: -- **Merchants** receive payments into escrow and release funds after delivery +- **Merchants** receive payments into escrow and capture funds after delivery - **Payers** can request refunds, freeze payments, and submit evidence during disputes - **Arbiters** verify transactions or resolve disputes (two models below) ### Two Operator Models -**Marketplace** (`deployMarketplaceOperator`): The merchant releases funds after escrow. If the payer contests, they file a refund request and an arbiter resolves it. Use this for general commerce where most transactions are uncontested. +**Marketplace** (`deployMarketplaceOperator`): The merchant releases funds after escrow. If the payer contests, they file a refund request and an arbiter resolves it. Use this for general commerce where most transactions clear without dispute. -**Delivery Protection** (`deployDeliveryProtectionOperator`): The arbiter evaluates every transaction automatically and is the only address that can release funds. If the arbiter does not release, funds auto-refund after escrow. Use this for AI content verification, schema validation, or automated quality checks. +**Delivery Protection** (`deployDeliveryProtectionOperator`): The arbiter evaluates every transaction. The arbiter or a satisfied payer can capture funds. On a FAIL verdict, the arbiter can trigger an immediate refund. If nobody acts, funds return to the payer once escrow expires. Use this for AI content verification, schema validation, or quality checks. + +See [Deploy an operator](/sdk/deploy-operator) for the full preset feature comparison, slot configurations, and deployment code. ### Packages @@ -32,7 +36,7 @@ bun add @x402r/sdk ```
-`@x402r/sdk` is the only package most developers need. It includes role-scoped client factories, 8 action groups (payment, escrow, refund, evidence, freeze, query, operator, watch), and an `.extend()` plugin system. +`@x402r/sdk` is the only package most developers need. It includes role-scoped client factories, 8 action groups (payment, escrow, refund, evidence, freeze, query, operator, watch), an `.extend()` plugin system, and ERC-8004 helpers that extract on-chain identity and reputation data from x402 extension responses. For low-level access to contract ABIs and deploy utilities: @@ -62,36 +66,69 @@ bun add @x402r/helpers ``` +For one-shot payments from the command line (no project install required): + + +```bash npm +npx @x402r/cli pay [options] +``` +```bash pnpm +pnpm dlx @x402r/cli pay [options] +``` +```bash bun +bunx @x402r/cli pay [options] +``` + + ### Guides - - - Deploy an operator, accept a payment, release funds from escrow. + + + Capture funds from escrow, charge directly, void, and process refunds using `createMerchantClient`. + + + Deploy a PaymentOperator on Base or Base Sepolia and configure plugin slots. - - Check payment state, request a refund, submit evidence. + + Wire merchant settlements into an arbiter that gates capture on response quality. - - Review disputes, approve or deny refunds, distribute fees. + + Wallet-agnostic one-shot payments from the command line or scripts. -### Supported Chains +## Network support -All contracts are deployed to identical addresses on every chain via CREATE3. Only USDC differs. +All x402r-authored contracts use universal CREATE2 addresses: every supported chain resolves to the same address as every other supported chain. + +Today, the supported chains in `@x402r/core` are **Base** and **Base Sepolia**. More EVM chains land as canonical `base/commerce-payments@v1.0.0` coverage extends. | Chain | Chain ID | USDC | -|-------|----------|------| -| Base Sepolia | `84532` | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` | -| Ethereum Sepolia | `11155111` | `0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238` | -| Arbitrum Sepolia | `421614` | `0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d` | -| Ethereum | `1` | `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` | +|---|---|---| | Base | `8453` | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | -| Polygon | `137` | `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359` | -| Arbitrum One | `42161` | `0xaf88d065e77c8cC2239327C5EDb3A432268e5831` | -| Optimism | `10` | `0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85` | -| Celo | `42220` | `0xcebA9300f2b948710d2653dD7B07f33A8B32118C` | -| Avalanche C-Chain | `43114` | `0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E` | -| Monad | `143` | `0x754704Bc059F8C67012fEd69BC8A327a5aafb603` | -| Linea | `59144` | `0x176211869cA2b568f2A7D4EE941E073a821EE1ff` | -| SKALE Base | `1187947933` | `0x85889c8c714505E0c94b30fcfcF64fE3Ac8FCb20` | +| Base Sepolia | `84532` | `0x036CbD53842c5426634e7929541eC2318f3dCF7e` | + +### commerce-payments v1 primitives + +The `base/commerce-payments@v1.0.0` primitives ship at canonical CREATE2 addresses via CreateX permissionless salts. Each contract resolves to the same address on every supported chain. + +| Contract | Address | +|----------|---------| +| `AuthCaptureEscrow` | `0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff` | +| `ERC3009PaymentCollector` | `0x0E3dF9510de65469C4518D7843919c0b8C7A7757` | +| `Permit2PaymentCollector` | `0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26` | + +Salt namespace: `commerce-payments::v1::`. + +Import the addresses from `@x402r/core`: + +```ts +import { authCaptureEscrow, tokenCollector } from '@x402r/core'; + +// AuthCaptureEscrow, canonical across every supported chain. +authCaptureEscrow; +// Primary token collector (currently aliases ERC3009PaymentCollector). +tokenCollector; +``` + +The SDK exposes these primitives on the chains listed in `@x402r/core`'s `x402rChains` (Base + Base Sepolia today). CreateX salts already reserve the CREATE2 addresses on more chains, and the registry enables each one as canonical `base/commerce-payments@v1.0.0` coverage extends. diff --git a/sdk/payer.mdx b/sdk/payer.mdx deleted file mode 100644 index 4289d64..0000000 --- a/sdk/payer.mdx +++ /dev/null @@ -1,150 +0,0 @@ ---- -title: "Payer Guide" -description: "Check payment state, request a refund, freeze a payment, and submit evidence." -icon: "user" ---- - -### Prerequisites - -* A wallet with ETH on Base Sepolia for gas ([faucet](https://www.alchemy.com/faucets/base-sepolia)) -* Node.js 18+ and npm -* An authorized payment on an x402r operator (see [Merchant Guide](/sdk/merchant)) - - -There are pre-configured [examples in the x402r-sdk repo](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples), including [payer examples](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/payer) and a full [dispute resolution scenario](https://github.com/BackTrackCo/x402r-sdk/tree/main/examples/scenarios/dispute-resolution.ts). - - -### 1. Install Dependencies - - -```bash npm -npm install @x402r/sdk -``` -```bash pnpm -pnpm add @x402r/sdk -``` -```bash bun -bun add @x402r/sdk -``` - - -### 2. Create a Payer Client - -```typescript -import { createPublicClient, createWalletClient, http } from 'viem' -import { baseSepolia } from 'viem/chains' -import { privateKeyToAccount } from 'viem/accounts' -import { createPayerClient } from '@x402r/sdk' - -const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`) - -const payer = createPayerClient({ - publicClient: createPublicClient({ chain: baseSepolia, transport: http() }), - walletClient: createWalletClient({ - account, - chain: baseSepolia, - transport: http(), - }), - operatorAddress: '0x...', // your operator address - refundRequestAddress: '0x...', // from deploy result - refundRequestEvidenceAddress: '0x...', - escrowPeriodAddress: '0x...', - freezeAddress: '0x...', -}) -``` - - -All payer actions (refund, freeze, evidence) must happen during the escrow period. Once escrow expires, the merchant can release. - - -### 3. Check Payment State - -```typescript -import type { PaymentInfo } from '@x402r/sdk' - -// paymentInfo is the same struct used during authorization -const paymentInfo: PaymentInfo = { /* ... */ } - -const amounts = await payer.payment.getAmounts(paymentInfo) -console.log('Collected:', amounts.hasCollectedPayment) -console.log('Capturable:', amounts.capturableAmount) -console.log('Refundable:', amounts.refundableAmount) - -const inEscrow = await payer.escrow?.isDuringEscrow(paymentInfo) -console.log('In escrow:', inEscrow) -``` - -### 4. Request a Refund - -Request a refund while the payment is still in escrow: - -```typescript -// Check if a refund request already exists -const hasExisting = await payer.refund?.has(paymentInfo) -if (hasExisting) { - console.log('Refund already requested') -} else { - const tx = await payer.refund?.request(paymentInfo, 1_000_000n) // 1 USDC - console.log('Refund requested:', tx) -} - -// Check refund status -const status = await payer.refund?.getStatus(paymentInfo) -console.log('Status:', status) // 0 = Pending, 1 = Approved, 2 = Denied, 3 = Cancelled, 4 = Refused -``` - -### 5. Freeze a Payment (Optional) - - -`freeze()` blocks the merchant from releasing until the arbiter unfreezes. Only use when you need to prevent a release during investigation. - - -```typescript -const frozen = await payer.freeze?.isFrozen(paymentInfo) -if (!frozen) { - const tx = await payer.freeze?.freeze(paymentInfo) - console.log('Payment frozen:', tx) -} -``` - -### 6. Submit Evidence (Optional) - -Attach evidence to a refund request. Evidence is stored on-chain as IPFS CIDs: - -```typescript -// Upload your evidence to IPFS first, then submit the CID -const tx = await payer.evidence?.submit(paymentInfo, 'QmYourEvidenceCID...') -console.log('Evidence submitted:', tx) - -// Read back evidence -const count = await payer.evidence?.count(paymentInfo) -console.log('Evidence entries:', count) - -for (let i = 0n; i < count!; i++) { - const entry = await payer.evidence?.get(paymentInfo, i) - console.log(` [${i}] CID: ${entry?.cid} from ${entry?.submitter}`) -} -``` - -### 7. Cancel a Refund Request (Optional) - -If the issue is resolved directly with the merchant: - -```typescript -const cancelTx = await payer.refund?.cancel(paymentInfo) -console.log('Refund cancelled:', cancelTx) -``` - -## Next Steps - - - - What happens after you submit a refund request. - - - Escrow timing, capture, and the payment lifecycle. - - - Full dispute resolution scenario end-to-end. - - diff --git a/x402-integration/auth-capture/index.mdx b/x402-integration/auth-capture/index.mdx new file mode 100644 index 0000000..49fe492 --- /dev/null +++ b/x402-integration/auth-capture/index.mdx @@ -0,0 +1,178 @@ +--- +title: "auth-capture Scheme" +description: "Concept, flow diagrams, and captureAuthorizer model for the x402 auth-capture payment scheme" +icon: "file-contract" +--- + +## Overview + +The **`auth-capture` scheme** for x402 v2 uses the audited [Commerce Payments Protocol](https://github.com/base/commerce-payments) (`AuthCaptureEscrow` + token collectors) directly, no fork. The client signs a single signature (ERC-3009 or Permit2). The facilitator submits it, either locking funds in escrow for later capture (two-phase) or sending them directly to the receiver with refund capability (single-shot). + +Unlike `exact`, which has no mechanism for returning funds, `auth-capture` supports returning funds to the client through void, refund, and reclaim. + +## Settlement Paths + +The scheme supports two settlement paths, selected via `extra.autoCapture`: + +| `autoCapture` | Behavior | +|:---|:---| +| `false` (default) | Two-phase. Funds held in escrow. CaptureAuthorizer can capture, void, or refund. Client can reclaim if the capture deadline passes. | +| `true` | Single-shot. Funds sent directly to the receiver. CaptureAuthorizer can refund post-settlement. | + +### Two-phase (`autoCapture: false`, default) + +``` +AUTHORIZE -> RESOURCE DELIVERED -> CAPTURE / VOID -> (REFUND) +``` + + + + The facilitator submits the client's authorization, locking funds in escrow via `AuthCaptureEscrow.authorize()`. The token collector executes the client's signature (ERC-3009 `receiveWithAuthorization` or Permit2 `permitTransferFrom`) to pull tokens into escrow. + + + + Server returns the resource (HTTP 200). + + + + The captureAuthorizer can capture (capture funds to the receiver) or void (return escrowed funds to the client). Capture conditions are policy-defined per captureAuthorizer (time-locked, arbiter-approved, etc.). + + + + If `captureDeadline` passes without capture, the client can reclaim funds directly from the escrow without captureAuthorizer involvement. + + + + After capture, the captureAuthorizer can refund within the `refundDeadline` window. + + + +### Single-shot (`autoCapture: true`) + +``` +CHARGE -> RESOURCE DELIVERED -> (REFUND) +``` + + + + The facilitator submits the client's authorization, sending funds directly to the receiver via `AuthCaptureEscrow.charge()`. No escrow hold. + + + + Server returns the resource (HTTP 200). + + + + The captureAuthorizer can refund within the `refundDeadline` window. + + + +No capture, void, or reclaim, funds are never held in escrow. + +## Visual Flow + +### Exact Payment (Immediate Settlement) + +```mermaid +sequenceDiagram + participant Client + participant Server + participant Receiver + + Client->>Server: 1. Payment + Signature + Server->>Receiver: 2. Immediate Transfer + Server->>Client: 3. Deliver Resource + + Note over Client,Receiver: No recourse after payment - Payment is final +``` + +### auth-capture (Two-phase) + +```mermaid +sequenceDiagram + participant Client + participant Server + participant Facilitator + participant Escrow as AuthCaptureEscrow + participant Receiver + + Client->>Server: GET /resource + Server-->>Client: 402 PaymentRequired + Note over Client: Signs ERC-3009 or Permit2 + + Client->>Server: PaymentPayload with signature + Server->>Facilitator: verify + settle + Facilitator->>Escrow: authorize(paymentInfo, amount, tokenCollector, signature) + Escrow->>Escrow: Lock funds + Facilitator-->>Server: Settlement confirmed + Server-->>Client: 200 OK + resource + + Note over Facilitator,Escrow: Later: captureAuthorizer acts based on policy + + alt Successful completion + Facilitator->>Escrow: capture(paymentInfo, amount, feeBps, feeReceiver) + Escrow->>Receiver: Transfer funds (minus fees) + else Void (full return from escrow) + Facilitator->>Escrow: void(paymentInfo) + Escrow->>Client: Return to payer + else Capture deadline passed + Client->>Escrow: reclaim(paymentInfo) + Escrow->>Client: Return to payer (no captureAuthorizer needed) + end +``` + +### Key Differences + +| Aspect | Exact | auth-capture | +|---|---|---| +| **Settlement** | Immediate on request | Via escrow (two-phase) or direct with refund (single-shot) | +| **Payer Protection** | None (payment final) | Refundable in both paths | +| **Resource Delivery** | After payment clears | Immediately after authorization | +| **Recourse** | No recourse | Reclaim after capture deadline, refund via captureAuthorizer | +| **Fee System** | None | Configurable (min/max bounds, client-signed) | +| **Use Case** | Trusted, low-value, instant | High-value, variable cost, disputes | + +## CaptureAuthorizer + +The **captureAuthorizer** is the address that may call `authorize`, `capture`, `void`, `refund`, or `charge` on a payment. The escrow contract gates those operations on `msg.sender`. In x402's facilitator-submits flow that means either the facilitator's EOA, or any smart contract that ends up calling the escrow (for example, an arbiter contract with dispute logic or a multisig). + +| Use Case | CaptureAuthorizer | +|---|---| +| Session billing | EOA that tracks usage off-chain, captures periodically | +| Time-locked escrow | Contract that releases after a period expires | +| Dispute resolution | Arbiter contract that decides capture vs refund | +| Immediate (exact-like) | Facilitator with `autoCapture: true` for instant settlement | +| Streaming payments | Contract that performs time-proportional captures | + +## vs Exact Scheme + +The `auth-capture` scheme adds an authorization step before settlement (or refundability for single-shot). For simple immediate payments where trust and refundability aren't concerns, the `exact` scheme remains more efficient. + +## Next Steps + + + + PaymentRequirements and PaymentPayload shapes for EIP-3009 and Permit2. + + + + The 13-step verification flow, settlement logic, and error codes. + + + + On-chain struct, expiry ordering, and safety guarantees. + + + + Build your first auth-capture payment flow. + + + +## References + +- [Commerce Payments Protocol](https://blog.base.dev/commerce-payments-protocol) +- [AuthCaptureEscrow Contract](https://github.com/base/commerce-payments) +- [EIP-3009: Transfer With Authorization](https://eips.ethereum.org/EIPS/eip-3009) +- [Uniswap Permit2](https://docs.uniswap.org/contracts/permit2/overview) +- [auth-capture client scheme (`@x402/evm/auth-capture/client`)](https://github.com/x402-foundation/x402/tree/main/typescript/packages/mechanisms/evm/src/auth-capture) +- [x402r auth-capture Scheme Reference Implementation](https://github.com/BackTrackCo/x402r-scheme) diff --git a/x402-integration/auth-capture/payment-info.mdx b/x402-integration/auth-capture/payment-info.mdx new file mode 100644 index 0000000..57352ad --- /dev/null +++ b/x402-integration/auth-capture/payment-info.mdx @@ -0,0 +1,72 @@ +--- +title: "PaymentInfo Struct" +description: "On-chain PaymentInfo struct, expiry ordering, and safety guarantees" +icon: "cube" +--- + +This is the on-chain Solidity struct. The JSON payload omits the `payer` field; the facilitator recovers it from the signature at settlement time. Wire-format `extra` uses spec-level field names; the on-chain struct keeps canonical names so the EIP-712 typehash matches the AuthCaptureEscrow contract byte-for-byte. + +```solidity +struct PaymentInfo { + address operator; // = extra.captureAuthorizer + address payer; // payload-derived + address receiver; // = requirements.payTo + address token; // = requirements.asset + uint120 maxAmount; // = requirements.amount + uint48 preApprovalExpiry; // = now + maxTimeoutSeconds (client-derived) + uint48 authorizationExpiry; // = extra.captureDeadline + uint48 refundExpiry; // = extra.refundDeadline + uint16 minFeeBps; + uint16 maxFeeBps; + address feeReceiver; // = extra.feeRecipient + uint256 salt; // = payload.salt (client-generated, fresh per request) +} +``` + +## Expiry Ordering + +The contract enforces: `preApprovalExpiry <= authorizationExpiry <= refundExpiry` + +| Expiry | Wire field | Enforced At | Effect | +|---|---|---|---| +| `preApprovalExpiry` | derived | `authorize()` / `charge()` | Blocks settlement after this time | +| `authorizationExpiry` | `captureDeadline` | `capture()` | Blocks capture; allows `reclaim()` | +| `refundExpiry` | `refundDeadline` | `refund()` | Blocks refund requests | + +## Safety Guarantees + +The escrow contract enforces invariants on-chain: + + + + The client-signed `maxAmount` caps the settlement amount. Attempts to exceed the limit revert. + + + + Each payment has a unique nonce derived from `(chainId, escrowAddress, paymentInfoHash)`. The nonce is consumed on-chain at settlement. + + + + After `captureDeadline`, the payer can reclaim escrowed funds directly without captureAuthorizer approval. + + + + Min/max fee bounds in `PaymentInfo` are client-signed and enforced on-chain. The captureAuthorizer must respect these limits. + + + + +**CaptureAuthorizer Trust Required:** The captureAuthorizer controls when and how much to capture. Choose with intent and understand the capture policy. See [PaymentOperator](/contracts/payment-operator) for examples. + + +## Next Steps + + + + Where each PaymentInfo field comes from on the wire. + + + + The 13-step verification flow that enforces these invariants. + + diff --git a/x402-integration/auth-capture/verification-and-settlement.mdx b/x402-integration/auth-capture/verification-and-settlement.mdx new file mode 100644 index 0000000..d42940d --- /dev/null +++ b/x402-integration/auth-capture/verification-and-settlement.mdx @@ -0,0 +1,83 @@ +--- +title: "Verification and Settlement" +description: "Facilitator verification flow, settlement logic, EIP-6492 wallets, and error codes" +icon: "shield-check" +--- + +## Verification Logic + +The facilitator performs these checks in order: + +1. **Type guard**: Payload matches `Eip3009Payload` or `Permit2Payload` (includes `signature` and `salt`). +2. **Scheme match**: `requirements.scheme === "auth-capture"` and `payload.accepted.scheme === "auth-capture"`. +3. **Network match**: `payload.accepted.network === requirements.network` and format is `eip155:`. +4. **Extra validation**: All required `extra` fields present. +5. **Method routing**: `extra.assetTransferMethod` (default `"eip3009"`) matches the payload shape. +6. **Deadline ordering**: `refundDeadline >= captureDeadline`, `captureDeadline > now + 6s`, and the payload's `validBefore` (EIP-3009) or `deadline` (Permit2) `<= captureDeadline`. +7. **Time window**: `validBefore` / `deadline > now + 6s` (not expired) and `validAfter <= now` (active, EIP-3009 only). +8. **Spender / collector match**: `authorization.to === EIP3009_TOKEN_COLLECTOR_ADDRESS` (EIP-3009) or `permit2Authorization.spender === PERMIT2_TOKEN_COLLECTOR_ADDRESS` (Permit2). +9. **Token match**: `permit2Authorization.permitted.token === requirements.asset` (Permit2 only, EIP-3009 binds via signing domain). +10. **Signature verify**: Recover signer from EIP-712 (`ReceiveWithAuthorization` or `PermitTransferFrom`); must match payer. +11. **Amount**: Authorization amount matches `requirements.amount`. +12. **Nonce match**: Reconstruct `PaymentInfo` from extra + salt + payer + requirements; recompute the payer-agnostic hash; assert it matches the wire nonce. This transitively enforces equality on every field encoded in `PaymentInfo` (receiver, token, deadlines, fee bounds, feeRecipient). +13. **Simulate**: Call `AuthCaptureEscrow.authorize(...)` or `.charge(...)` via `eth_call` to verify success. + +The `SAFETY_MARGIN_SECONDS` constant is `6`, which is why deadline comparisons use `now + 6s`. + +### EIP-6492 Support + +For smart wallet clients, the signature may be EIP-6492 wrapped (containing deployment bytecode). The facilitator extracts the inner ECDSA signature for verification. The on-chain `ERC6492SignatureHandler` in the token collector handles wallet deployment during settlement. + +## Settlement Logic + +1. **Re-verify** the payload (catch expired/invalid payloads before spending gas). +2. **Determine function**: `extra.autoCapture === true ? "charge" : "authorize"`. +3. **Resolve collector**: `EIP3009_TOKEN_COLLECTOR_ADDRESS` or `PERMIT2_TOKEN_COLLECTOR_ADDRESS` (per `assetTransferMethod`). +4. **Encode `collectorData`**: raw ERC-3009 signature, or ABI-encoded Permit2 signature. +5. **Call escrow**: `AuthCaptureEscrow.(paymentInfo, amount, tokenCollector, collectorData)`. +6. **Wait for receipt**: 60s timeout. +7. **Return result**: tx hash, network, payer. + +## Error Codes + +### Verification Errors + +| Error Code | Description | +|---|---| +| `invalid_payload_format` | Payload doesn't match `Eip3009Payload` or `Permit2Payload`. | +| `unsupported_scheme` | Scheme is not `auth-capture`. | +| `network_mismatch` | Payload network doesn't match requirements. | +| `invalid_network` | Network format is not `eip155:`. | +| `invalid_auth_capture_extra` | Extra is missing required fields. | +| `unsupported_asset_transfer_method` | `assetTransferMethod` is not `"eip3009"` or `"permit2"`. | +| `payload_method_mismatch` | Payload shape doesn't match `assetTransferMethod`. | +| `capture_deadline_expired` | `captureDeadline <= now + 6s`. | +| `invalid_deadline_ordering` | Deadlines violate `now + maxTimeoutSeconds <= captureDeadline <= refundDeadline`. | +| `authorization_expired` | EIP-3009 `validBefore` (or Permit2 `deadline`) `<= now + 6s`. | +| `authorization_not_yet_valid` | EIP-3009 `validAfter > now`. | +| `invalid_auth_capture_signature` | Signature verification failed. | +| `amount_mismatch` | Authorization value doesn't match `requirements.amount`. | +| `token_collector_mismatch` | `to` / `spender` doesn't match the canonical collector for the method. | +| `token_mismatch` | Permit2 `permitted.token` doesn't match `requirements.asset`. | +| `nonce_mismatch` | Wire nonce doesn't match the recomputed payer-agnostic `PaymentInfo` hash. | +| `insufficient_balance` | Payer balance is less than required amount. | +| `simulation_failed` | Settlement simulation reverted with an unmapped error. | + +### Settlement Errors + +| Error Code | Description | +|---|---| +| `verification_failed` | Re-verification before settlement failed. | +| `transaction_reverted` | On-chain transaction reverted after confirmation. | + +## Next Steps + + + + PaymentRequirements and PaymentPayload shapes. + + + + On-chain struct, expiry ordering, and safety guarantees. + + diff --git a/x402-integration/auth-capture/wire-format.mdx b/x402-integration/auth-capture/wire-format.mdx new file mode 100644 index 0000000..e286033 --- /dev/null +++ b/x402-integration/auth-capture/wire-format.mdx @@ -0,0 +1,169 @@ +--- +title: "Wire Format" +description: "PaymentRequirements, PaymentPayload, and Extra-field reference for the auth-capture scheme" +icon: "file-code" +--- + +## PaymentRequirements (402 Response) + +Server sends this to request payment: + +```json +{ + "x402Version": 2, + "accepts": [{ + "scheme": "auth-capture", + "network": "eip155:8453", + "amount": "1000000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0xReceiverAddress", + "maxTimeoutSeconds": 60, + "extra": { + "name": "USDC", + "version": "2", + "captureAuthorizer": "0xCaptureAuthorizerAddress", + "captureDeadline": 1740758554, + "refundDeadline": 1741276954, + "minFeeBps": 0, + "maxFeeBps": 1000, + "feeRecipient": "0xFeeRecipientAddress", + "autoCapture": false, + "assetTransferMethod": "eip3009" + } + }] +} +``` + +A server MAY list more than one `accepts[]` entry with different `assetTransferMethod` values so clients can pick the method matching their token approvals. + +## Signing the payload (client) + +Clients do not hand-build these payloads. The client half of the scheme ships in the x402 monorepo as `AuthCaptureEvmScheme` on the `@x402/evm/auth-capture/client` subpath. Register it on an `x402Client` and it reads the `extra` fields, reconstructs the PaymentInfo struct, derives the payer-agnostic nonce, and emits the ERC-3009 (default) or Permit2 payload shown below. + +```typescript +import { AuthCaptureEvmScheme } from '@x402/evm/auth-capture/client' +import { x402Client, wrapFetchWithPayment } from '@x402/fetch' +import { privateKeyToAccount } from 'viem/accounts' + +const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`) + +const client = new x402Client() +client.register('eip155:*', new AuthCaptureEvmScheme(account)) + +// fetchWithPayment auto-signs any auth-capture 402 it receives +const fetchWithPayment = wrapFetchWithPayment(fetch, client) +``` + +The signer only needs `address` and `signTypedData`, so a bare viem `LocalAccount` works with no `PublicClient`. The scheme selects ERC-3009 or Permit2 from `extra.assetTransferMethod`. + +## PaymentPayload: EIP-3009 (default) + +Client sends this with a signed ERC-3009 authorization: + +```json +{ + "x402Version": 2, + "resource": { + "url": "https://api.example.com/resource", + "method": "GET" + }, + "accepted": { + "scheme": "auth-capture", + "network": "eip155:8453", + "amount": "1000000", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "payTo": "0xReceiverAddress", + "maxTimeoutSeconds": 60, + "extra": { "..." } + }, + "payload": { + "authorization": { + "from": "0xPayerAddress", + "to": "0xEIP3009TokenCollectorAddress", + "value": "1000000", + "validAfter": "0", + "validBefore": "1740675754", + "nonce": "0xf374...3480" + }, + "signature": "0x2d6a...571c", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000abc" + } +} +``` + +## PaymentPayload: Permit2 + +When `extra.assetTransferMethod === "permit2"`, the client signs a Permit2 `PermitTransferFrom`: + +```json +{ + "x402Version": 2, + "resource": { "url": "https://api.example.com/resource", "method": "GET" }, + "accepted": { "scheme": "auth-capture", "...": "..." }, + "payload": { + "permit2Authorization": { + "from": "0xPayerAddress", + "permitted": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "1000000" + }, + "spender": "0xPermit2TokenCollectorAddress", + "nonce": "11021048692073456...", + "deadline": "1740675754" + }, + "signature": "0x2d6a...571c", + "salt": "0x0000000000000000000000000000000000000000000000000000000000000abc" + } +} +``` + +The deterministic nonce binds the merchant address (no witness struct). + +## Field Reference + +### Required Extra Fields + +| Field | Type | Description | +|---|---|---| +| `name` | string | EIP-712 token-domain name (for example, `"USDC"`). Used for ERC-3009 signing only. | +| `version` | string | EIP-712 token-domain version (for example, `"2"`). | +| `captureAuthorizer` | address | Address that may call `authorize`, `capture`, `void`, `refund`, or `charge`. Committed on-chain as `PaymentInfo.operator`. | +| `captureDeadline` | uint48 | Absolute Unix seconds: capture must occur before this. Encoded as `authorizationExpiry`. | +| `refundDeadline` | uint48 | Absolute Unix seconds: refunds allowed until this. Encoded as `refundExpiry`. | +| `feeRecipient` | address | Fee recipient. Set to `address(0)` to let the captureAuthorizer specify any non-zero recipient at capture/charge time. | +| `minFeeBps` | uint16 | Lowest fee in basis points the captureAuthorizer must take. `0` = no floor. | +| `maxFeeBps` | uint16 | Highest fee in basis points the captureAuthorizer can take. | + +### Optional Extra Fields + +| Field | Type | Description | Default | +|---|---|---|---| +| `autoCapture` | `bool` | `true` → facilitator calls `charge()` (atomic). `false` → `authorize()` (two-phase). | `false` | +| `assetTransferMethod` | `"eip3009"` \| `"permit2"` | Which token collector to use. | `"eip3009"` | + + +**Fee Configuration:** The escrow enforces fees on-chain via the `PaymentInfo` struct. The escrow rejects captures/charges that fall outside `[minFeeBps, maxFeeBps]`. If `feeRecipient` is non-zero, the actual fee recipient at capture/charge must match. + + +## Nonce Derivation + +The signature nonce is the payer-agnostic `PaymentInfo` hash. The encoding zeros out the payer; every other field carries the value that will appear on-chain. + +``` +paymentInfoHash = keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, paymentInfoWithZeroPayer)) +nonce = keccak256(abi.encode(chainId, AUTH_CAPTURE_ESCROW_ADDRESS, paymentInfoHash)) +``` + +The `salt` field enforces freshness: each signing call generates a fresh `bytes32` salt, so two payers signing concurrently produce distinct nonces with no collision risk. + +## Next Steps + + + + The 13-step verification flow and error codes. + + + + How wire fields map to the on-chain struct. + + diff --git a/x402-integration/comparison.mdx b/x402-integration/comparison.mdx deleted file mode 100644 index 41da4ae..0000000 --- a/x402-integration/comparison.mdx +++ /dev/null @@ -1,372 +0,0 @@ ---- -title: "Escrow vs Exact" -description: "Detailed comparison of escrow and exact payment schemes" -icon: "scale-balanced" ---- - -## At a Glance - - - - **Immediate settlement** - - Payment happens instantly when request is made. Simple, fast, minimal overhead. - - Best for trusted services and low-value transactions. - - - - **Deferred settlement** - - Funds locked until conditions are met. Authorization separate from capture. - - Best for high-value, usage-based, or disputed transactions. - - - -## Feature Comparison - -| Feature | exact | escrow | -|---------|-------|--------| -| **Settlement Timing** | Immediate | Deferred (conditional) | -| **Payer Protection** | None (payment final) | Refund possible until capture | -| **Receiver Risk** | No risk (paid upfront) | Must wait for release | -| **Gas Cost** | Single transaction | Two transactions (auth + capture) | -| **Complexity** | Minimal (direct transfer) | Higher (operator logic) | -| **Variable Pricing** | Not supported | Supported (authorize max, capture actual) | -| **Dispute Resolution** | Not possible | Supported via operator | -| **Multi-Request Sessions** | Every request needs signature | One auth, multiple captures | -| **Trust Required** | High (payment irreversible) | Lower (escrow protects payer) | - -## When to Use Each - -### Use `exact` when: - - - - For small purchases where the cost of a dispute exceeds the transaction value. - - **Example:** $0.01 API call - not worth the escrow overhead - - - - When you trust the service provider and don't need refund protection. - - **Example:** Paying your own infrastructure or established services - - - - Service is delivered instantly and verifiable on the spot. - - **Example:** Static content, database lookups, instant responses - - - - Single transaction is cheaper than two (auth + capture). - - **Example:** High-frequency micro-transactions where gas matters - - - -### Use `escrow` when: - - - - Significant amounts where you need recourse if service fails. - - **Example:** $500 training job - you want protection if it fails - - - - Usage-based billing where you don't know the exact amount upfront. - - **Example:** LLM API calls charged by tokens (authorize $10, use $6.50, refund $3.50) - - - - Tasks that take hours or days to complete. - - **Example:** Video rendering, data processing, training jobs - - - - Services where quality is subjective or verification is needed. - - **Example:** Freelance work, custom deliverables, SLA-based services - - - - Many small requests under one authorization. - - **Example:** 1,000 API calls at $0.01 each = one $10 auth, periodic captures - - - -## Cost Analysis - -### exact Scheme - - -**Gas Cost: 1 transaction** -- ERC-20 transfer: ~50k gas -- **Total: ~50,000 gas** - -**Example (Base, 0.001 gwei gas price):** -- Gas cost: ~$0.00005 -- For $10 payment: 0.0005% overhead - - -### escrow Scheme - - -**Gas Cost: 2 transactions** -- authorize(): ~150k gas -- capture(): ~80k gas -- **Total: ~230,000 gas** - -**Example (Base, 0.001 gwei gas price):** -- Gas cost: ~$0.00023 -- For $10 payment: 0.0023% overhead - - - -**Amortization:** For multi-request sessions, the auth cost is amortized across many captures. 1 auth + 10 captures is cheaper than 10 exact payments. - - -## Security Comparison - -### exact Scheme - -| Risk | Severity | Mitigation | -|------|----------|-----------| -| Service non-delivery | **High** | None - payment is final | -| Overcharging | **Medium** | Verify amount before signing | -| Malicious server | **High** | Trust required | - -**Trust Model:** You must fully trust the service provider. Once payment is sent, you have no recourse. - -### escrow Scheme - -| Risk | Severity | Mitigation | -|------|----------|-----------| -| Service non-delivery | **Low** | Refund before capture | -| Overcharging | **None** | `maxAmount` enforced on-chain | -| Malicious operator | **Medium** | Choose trusted operators, set expiry | -| Operator disappeared | **Low** | Payer can reclaim after `authorizationExpiry` | - -**Trust Model:** You must trust the operator contract logic, but funds are protected by on-chain invariants. - - -**Operator Selection Critical:** The operator contract controls when funds are released. A malicious or buggy operator can lock funds. Always audit operator code or use verified implementations. - - -## User Experience - -### exact Scheme Flow - -``` -1. Server: "Pay $10" -2. Client: [Signs payment] -3. Client: [Sends request with signature] -4. Server: [Receives payment + delivers service] -5. Done -``` - -**Steps:** 3 (request → sign → deliver) - -**Latency:** Single round-trip - -### escrow Scheme Flow - -``` -1. Server: "Authorize up to $10" -2. Client: [Signs authorization] -3. Client: [Sends request with signature] -4. Server: [Locks $10 in escrow + delivers service] -5. Server: [Calls operator to capture actual amount] -6. Done -``` - -**Steps:** 4 (request → sign → deliver → capture) - -**Latency:** Service delivery same as exact, but capture happens async - - -**Perception:** From the user's perspective, escrow and exact feel the same during the request. The capture happens in the background. - - -## Example Scenarios - -### Scenario 1: Simple API Call - -**Service:** Weather API lookup -**Cost:** $0.01 -**Trust:** High (established provider) -**Decision:** ✅ **Use exact** - -Why: Low value, instant delivery, trusted service. Escrow overhead not worth it. - -### Scenario 2: LLM Agent Session - -**Service:** 100 GPT-4 calls -**Cost:** $0.05-$0.20 per call (variable) -**Trust:** Medium (new provider) -**Decision:** ✅ **Use escrow** - -Why: Variable pricing, multiple requests, moderate trust. Authorize $20 max, capture actual usage. - -### Scenario 3: Training Job - -**Service:** Train ML model on GPU cluster -**Cost:** $500 -**Duration:** 48 hours -**Decision:** ✅ **Use escrow** - -Why: High value, long-running, need verification before payment. Release after job completes successfully. - -### Scenario 4: Freelance Work - -**Service:** Custom logo design -**Cost:** $200 -**Dispute Risk:** High (subjective quality) -**Decision:** ✅ **Use escrow with arbiter** - -Why: Subjective deliverable, need dispute resolution. Arbiter operator releases on approval or mediates disputes. - -### Scenario 5: Micro-Transaction Spam - -**Service:** Rate-limited API (1000 req/sec) -**Cost:** $0.0001 per request -**Volume:** 100,000 requests/day -**Decision:** ✅ **Use escrow with batch captures** - -Why: Too many requests to sign individually. Authorize $10 daily, server batches captures hourly. - -## Performance Comparison - -### Throughput - -| Metric | exact | escrow | -|--------|-------|--------| -| Requests/second | 1000+ | 1000+ (auth), 100+ (capture) | -| On-chain TPS impact | High (every request) | Low (periodic captures) | -| Signature overhead | Per-request | Per-session | - -### Latency - -| Phase | exact | escrow | -|-------|-------|--------| -| Request latency | +50ms (sign + verify) | +50ms (sign + verify) | -| Settlement latency | Immediate | Async (seconds to days) | -| Finality | Instant | After capture | - -## Hybrid Approach - -You can support **both schemes** and let clients choose: - -```json -{ - "x402Version": 2, - "accepts": [ - { - "scheme": "exact", - "network": "eip155:8453", - "amount": "10000000", - "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "payTo": "0xReceiver..." - }, - { - "scheme": "escrow", - "network": "eip155:8453", - "amount": "10000000", - "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "payTo": "0xReceiver...", - "extra": { - "name": "USDC", - "version": "2", - "escrowAddress": "0xe050bB89eD43BB02d71343063824614A7fb80B77", - "operatorAddress": "0xOperator...", - "tokenCollector": "0xcE66Ab399EDA513BD12760b6427C87D6602344a7", - "settlementMethod": "authorize" - } - } - ] -} -``` - -**Client decides based on:** -- Transaction value -- Trust level -- Gas price -- Urgency - -## Migration Strategy - -### From exact to escrow - -Existing `exact` integrations can add `escrow` support: - -1. Deploy operator contract -2. Add `escrow` to `accepts` array -3. Keep `exact` as fallback -4. Clients upgrade when ready - -### From escrow to exact - -If escrow proves unnecessary: - -1. Add `exact` to `accepts` array -2. Monitor which scheme clients prefer -3. Deprecate `escrow` if unused - - -**Start with exact, add escrow when needed.** Don't over-engineer. Most simple services work fine with exact. - - -## Decision Tree - -```mermaid -flowchart TD - A{Transaction over $10?} - A -->|Yes| B[Consider escrow] - A -->|No| C{Variable usage?} - C -->|Yes| D[Use escrow] - C -->|No| E{Trust provider?} - E -->|Yes| F[Use exact] - E -->|No| G[Use escrow] - - B --> H{Check factors} - H -->|Long task| D - H -->|Quick task| F - H -->|Dispute risk| G - - style D fill:#f59e0b,stroke:#d97706,color:#fff - style F fill:#4f46e5,stroke:#4338ca,color:#fff - style G fill:#f59e0b,stroke:#d97706,color:#fff - - style A fill:#64748b,stroke:#475569,color:#fff - style B fill:#64748b,stroke:#475569,color:#fff - style C fill:#64748b,stroke:#475569,color:#fff - style E fill:#64748b,stroke:#475569,color:#fff - style H fill:#64748b,stroke:#475569,color:#fff -``` - -## Next Steps - - - - Learn about x402 and why escrow matters. - - - - Complete technical specification. - - - - Understand operator implementations. - - - - Build your first payment flow. - - diff --git a/x402-integration/escrow-scheme.mdx b/x402-integration/escrow-scheme.mdx deleted file mode 100644 index 0428249..0000000 --- a/x402-integration/escrow-scheme.mdx +++ /dev/null @@ -1,422 +0,0 @@ ---- -title: "Escrow Scheme Specification" -description: "Technical specification for the x402 escrow payment scheme" -icon: "file-contract" ---- - -## Overview - -The **escrow scheme** for x402 v2 uses the [Commerce Payments Protocol](https://github.com/base/commerce-payments) contract stack to enable secure, conditional fund handling. The client signs a single ERC-3009 authorization. The facilitator submits it to an operator, which handles token collection, escrow locking, and fee distribution in one transaction. - - -This spec is based on the [escrow scheme proposal (Issue #1011)](https://github.com/coinbase/x402/issues/1011) and the [spec PR (#1425)](https://github.com/coinbase/x402/pull/1425). It uses audited on-chain escrow contracts from the Commerce Payments Protocol. - - -## Settlement Methods - -The scheme supports two settlement paths: - -| Method | Behavior | -|:-------|:---------| -| `authorize` (default) | Funds held in escrow. Can be captured, voided, reclaimed, or refunded. | -| `charge` | Funds sent directly to receiver. Refundable post-settlement. | - -### Authorize (Default) - -``` -AUTHORIZE -> RESOURCE DELIVERED -> CAPTURE / VOID -> (REFUND) -``` - - - - Client authorization is submitted — funds locked in escrow via `operator.authorize()`. The operator calls the token collector to execute `receiveWithAuthorization` with the client's ERC-3009 signature, then routes funds into the escrow contract. - - - - Server returns the resource (HTTP 200). - - - - The operator can capture (release funds to receiver via `operator.release()`) or void (return escrowed funds to client). Capture conditions are configurable per operator (time-locked, arbiter-approved, etc.). - - - - If `authorizationExpiry` passes without capture, the client can reclaim funds directly from escrow without operator approval. - - - - After capture, the operator can refund within the `refundExpiry` window via `operator.refundPostEscrow()`. - - - -### Charge - -``` -CHARGE -> RESOURCE DELIVERED -> (REFUND) -``` - - - - Client authorization is submitted — funds sent directly to receiver via `operator.charge()`. No escrow hold. - - - - Server returns the resource (HTTP 200). - - - - The operator can refund within the `refundExpiry` window via `operator.refundPostEscrow()`. - - - -No capture, void, or reclaim — funds are never held in escrow. - -## Visual Flow - -### Exact Payment (Immediate Settlement) - -```mermaid -sequenceDiagram - participant Client - participant Server - participant Receiver - - Client->>Server: 1. Payment + Signature - Server->>Receiver: 2. Immediate Transfer - Server->>Client: 3. Deliver Resource - - Note over Client,Receiver: No recourse after payment - Payment is final -``` - -### Escrow Payment (Deferred Settlement) - -```mermaid -sequenceDiagram - participant Client - participant Server - participant Facilitator - participant Operator - participant Escrow - participant Receiver - - Client->>Server: GET /resource - Server-->>Client: 402 PaymentRequired - Note over Client: Signs ERC-3009 authorization - - Client->>Server: PaymentPayload with signature - Server->>Facilitator: verify + settle - Facilitator->>Operator: authorize(paymentInfo, amount, tokenCollector, signature) - Operator->>Escrow: Lock funds in escrow - Facilitator-->>Server: Settlement confirmed - Server-->>Client: 200 OK + resource - - Note over Operator,Escrow: Later: operator releases based on conditions - - alt Successful completion - Operator->>Escrow: release(paymentInfo, amount) - Escrow->>Receiver: Transfer funds (minus fees) - else Void (full refund in escrow) - Operator->>Escrow: void(paymentInfo) - Escrow->>Client: Return to payer - else Authorization expired - Client->>Escrow: reclaim() - Escrow->>Client: Return to payer (no operator needed) - end -``` - -### Key Differences - -| Aspect | Exact | Escrow | -|--------|-------|--------| -| **Settlement** | Immediate on request | Deferred until conditions met | -| **Payer Protection** | None (payment final) | Refundable until capture | -| **Resource Delivery** | After payment clears | Immediately after authorization | -| **Recourse** | No recourse | Reclaim after expiry, refund via operator | -| **Fee System** | None | Configurable (min/max bounds, client-signed) | -| **Use Case** | Trusted, low-value, instant | High-value, variable cost, disputes | - -## Operator Flexibility - -The **operator** is the key abstraction. Different implementations enable different payment patterns: - -| Use Case | Operator Behavior | -|----------|-------------------| -| Session billing | Track usage off-chain, capture periodically | -| Time-locked escrow | Release after period expires | -| Dispute resolution | Arbiter decides release vs refund | -| Immediate (exact-like) | Use `charge()` for instant settlement | -| Streaming payments | Time-proportional captures | - -## Message Format - -### PaymentRequirements (402 Response) - -Server sends this to request payment: - -```json -{ - "x402Version": 2, - "accepts": [{ - "scheme": "escrow", - "network": "eip155:8453", - "amount": "1000000", - "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "payTo": "0xReceiverAddress", - "maxTimeoutSeconds": 60, - "extra": { - "name": "USDC", - "version": "2", - "escrowAddress": "0xe050bB89eD43BB02d71343063824614A7fb80B77", - "operatorAddress": "0xOperatorAddress", - "tokenCollector": "0xcE66Ab399EDA513BD12760b6427C87D6602344a7", - "settlementMethod": "authorize", - "minFeeBps": 0, - "maxFeeBps": 1000, - "feeReceiver": "0xOperatorAddress" - } - }] -} -``` - -### PaymentPayload (Client Response) - -Client sends this with signed authorization: - -```json -{ - "x402Version": 2, - "resource": { - "url": "https://api.example.com/resource", - "method": "GET" - }, - "accepted": { - "scheme": "escrow", - "network": "eip155:8453", - "amount": "1000000", - "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "payTo": "0xReceiverAddress", - "maxTimeoutSeconds": 60, - "extra": { "..." } - }, - "payload": { - "authorization": { - "from": "0xPayerAddress", - "to": "0xcE66Ab399EDA513BD12760b6427C87D6602344a7", - "value": "1000000", - "validAfter": "0", - "validBefore": "1740672154", - "nonce": "0xf374...3480" - }, - "signature": "0x2d6a...571c", - "paymentInfo": { - "operator": "0xOperatorAddress", - "receiver": "0xReceiverAddress", - "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "maxAmount": "1000000", - "preApprovalExpiry": 1740672154, - "authorizationExpiry": 4294967295, - "refundExpiry": 281474976710655, - "minFeeBps": 0, - "maxFeeBps": 1000, - "feeReceiver": "0xOperatorAddress", - "salt": "0x0000...0001" - } - } -} -``` - -## Field Reference - -### Required Extra Fields - -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | EIP-712 domain name for the token (e.g., `"USDC"`) | -| `version` | string | EIP-712 domain version (e.g., `"2"`) | -| `escrowAddress` | address | AuthCaptureEscrow contract address on the specified network | -| `operatorAddress` | address | Operator contract address (stored in PaymentInfo.operator) | -| `tokenCollector` | address | ERC-3009 token collector contract address | - -### Optional Extra Fields - -| Field | Type | Description | Default | -|-------|------|-------------|---------| -| `settlementMethod` | `"authorize"` \| `"charge"` | Settlement path | `"authorize"` | -| `minFeeBps` | uint16 | Minimum fee in basis points | `0` | -| `maxFeeBps` | uint16 | Maximum fee in basis points | `0` | -| `feeReceiver` | address | Address receiving fees | `address(0)` (flexible) | -| `preApprovalExpirySeconds` | uint48 | ERC-3009 signature validity / pre-approval deadline (seconds from now) | `type(uint48).max` | -| `authorizationExpirySeconds` | uint48 | Deadline for capturing escrowed funds (seconds from now) | `type(uint48).max` | -| `refundExpirySeconds` | uint48 | Deadline for refund requests (seconds from now) | `type(uint48).max` | - - -**Fee Configuration:** Fees are enforced on-chain in the PaymentInfo struct. The operator contract cannot charge more than `maxFeeBps` or less than `minFeeBps`. If `feeReceiver` is set, the actual fee recipient at capture/charge must match. - - -## Nonce Derivation - -The ERC-3009 nonce is deterministically derived from the payment parameters: - -``` -nonce = keccak256(abi.encode(chainId, escrowAddress, paymentInfoHash)) -``` - -This ties the off-chain signature to the specific chain, escrow contract, and payment terms — preventing cross-chain or cross-contract replay. The nonce is consumed on-chain at settlement. - -## Verification Logic - -The facilitator performs these checks in order: - -1. **Type guard** — Verify `payload` contains `authorization`, `signature`, and `paymentInfo` fields -2. **Scheme match** — Verify `scheme === "escrow"` -3. **Network match** — Verify network format is `eip155:` and matches between requirements and payload -4. **Extra validation** — Verify `extra` contains required fields (`escrowAddress`, `operatorAddress`, `tokenCollector`) -5. **Time window** — Verify `validBefore > now + 6s` (not expired) and `validAfter <= now` (active) -6. **ERC-3009 signature** — Recover signer from EIP-712 typed data (`ReceiveWithAuthorization` primary type) and verify matches `authorization.from` -7. **Amount** — Verify `authorization.value === requirements.amount` -8. **Recipient match** — Verify `authorization.to === extra.tokenCollector` -9. **Token match** — Verify `paymentInfo.token === requirements.asset` -10. **Receiver match** — Verify `paymentInfo.receiver === requirements.payTo` -11. **Simulate** — Call `operator.authorize(...)` or `operator.charge(...)` via `eth_call` to verify success - -### EIP-6492 Support - -For smart wallet clients, the signature may be EIP-6492 wrapped (containing deployment bytecode). The facilitator extracts the inner ECDSA signature for verification. The on-chain `ERC6492SignatureHandler` in the token collector handles wallet deployment during settlement. - -## Settlement Logic - -Settlement is performed by the facilitator calling the operator: - -1. **Re-verify** the payload (catch expired/invalid payloads before spending gas) -2. **Determine function** — `settlementMethod === "charge" ? "charge" : "authorize"` -3. **Call operator** — `operator.(paymentInfo, amount, tokenCollector, collectorData)` -4. **Wait for receipt** — Confirm transaction success (60s timeout) -5. **Return result** — Transaction hash, network, and payer address - -The operator handles: - -- Calling the token collector to execute `receiveWithAuthorization` with the client's ERC-3009 signature -- Routing funds to escrow (authorize) or directly to receiver (charge) -- Validating fee bounds against the client-signed `PaymentInfo` - -## PaymentInfo Struct - -This is the on-chain Solidity struct. The `payer` field is not included in the JSON payload — it is derived from `authorization.from` at settlement time. - -```solidity -struct PaymentInfo { - address operator; // Operator address - address payer; // Derived from authorization.from (not in payload) - address receiver; // Fund recipient (payTo) - address token; // ERC-20 token address - uint120 maxAmount; // Maximum authorized amount - uint48 preApprovalExpiry; // ERC-3009 validBefore / pre-approval deadline - uint48 authorizationExpiry;// Capture deadline (authorize path only) - uint48 refundExpiry; // Refund request deadline - uint16 minFeeBps; // Minimum acceptable fee (basis points) - uint16 maxFeeBps; // Maximum acceptable fee (basis points) - address feeReceiver; // Fee recipient (address(0) = flexible) - uint256 salt; // Client-provided entropy -} -``` - -### Expiry Ordering - -The contract enforces: `preApprovalExpiry <= authorizationExpiry <= refundExpiry` - -| Expiry | Enforced At | Effect | -|:-------|:------------|:-------| -| `preApprovalExpiry` | `authorize()` / `charge()` | Blocks settlement after this time | -| `authorizationExpiry` | `capture()` | Blocks capture; enables `reclaim()` | -| `refundExpiry` | `refund()` | Blocks refund requests | - -## Safety Guarantees - -The escrow contract enforces invariants on-chain: - - - - Settlement amount is capped by client-signed `maxAmount`. Attempting to exceed the limit reverts the transaction. - - - - Each payment has a unique nonce derived from `(chainId, escrowAddress, paymentInfoHash)`. The nonce is consumed on-chain at settlement. - - - - After `authorizationExpiry`, payer can reclaim escrowed funds directly without operator approval. - - - - Min/max fee bounds in PaymentInfo are client-signed and enforced on-chain. The operator must respect these limits. - - - - -**Operator Trust Required:** The operator contract controls when and how much to release. Choose operators carefully and understand their release conditions. See [Operators](/contracts/overview#payment-operator) for details. - - -## Error Codes - -### Verification Errors - -| Error Code | Description | -|:-----------|:------------| -| `invalid_payload_format` | Payload missing `authorization`, `signature`, or `paymentInfo` | -| `unsupported_scheme` | Scheme is not `escrow` | -| `network_mismatch` | Payload network does not match requirements | -| `invalid_network` | Network format is not `eip155:` | -| `invalid_escrow_extra` | Missing required extra fields (`escrowAddress`, `operatorAddress`, `tokenCollector`) | -| `authorization_expired` | `validBefore <= now + 6s` | -| `authorization_not_yet_valid` | `validAfter > now` | -| `invalid_escrow_signature` | ERC-3009 signature verification failed | -| `amount_mismatch` | `authorization.value !== requirements.amount` | -| `token_collector_mismatch` | `authorization.to !== extra.tokenCollector` | -| `token_mismatch` | `paymentInfo.token !== requirements.asset` | -| `receiver_mismatch` | `paymentInfo.receiver !== requirements.payTo` | -| `insufficient_balance` | Payer balance is less than required amount | -| `simulation_failed` | Settlement simulation via `eth_call` failed | - -### Settlement Errors - -| Error Code | Description | -|:-----------|:------------| -| `verification_failed` | Re-verification before settlement failed | -| `transaction_reverted` | On-chain transaction reverted after confirmation | - -## vs Exact Scheme - -The `escrow` scheme adds an authorization step before settlement. For simple immediate payments where trust is not a concern, the `exact` scheme remains more efficient. - -See [Comparison](/x402-integration/comparison) for detailed trade-offs. - -## Next Steps - - - - Understand why escrow is needed for HTTP payments. - - - - Compare escrow vs exact schemes in detail. - - - - Learn about escrow and operator contracts. - - - - Build your first escrow-based payment flow. - - - -## References - -- [Escrow Scheme Proposal (Issue #1011)](https://github.com/coinbase/x402/issues/1011) -- [Escrow Scheme Specification (PR #1425)](https://github.com/coinbase/x402/pull/1425) -- [Commerce Payments Protocol](https://blog.base.dev/commerce-payments-protocol) -- [AuthCaptureEscrow Contract](https://github.com/base/commerce-payments) -- [EIP-3009: Transfer With Authorization](https://eips.ethereum.org/EIPS/eip-3009) -- [x402r Operator Contracts](https://github.com/BackTrackCo/x402r-contracts) -- [x402r Escrow Scheme](https://github.com/BackTrackCo/x402r-scheme) -- [x402 Protocol](https://github.com/coinbase/x402) diff --git a/x402-integration/overview.mdx b/x402-integration/overview.mdx index 07863cc..941997f 100644 --- a/x402-integration/overview.mdx +++ b/x402-integration/overview.mdx @@ -12,23 +12,23 @@ Think of it as "Stripe for the programmable internet" - agents, robots, and auto ## Payment Schemes -X402 v2 supports multiple payment schemes: +X402 v2 supports two payment schemes: - Immediate settlement - payment happens instantly when request is made. + Immediate settlement - payment clears the moment the client sends the request. **Best for:** Simple purchases, low-value transactions, trusted services - Deferred settlement - funds locked until conditions are met. + Deferred settlement - funds stay locked until conditions clear. **Best for:** High-value transactions, usage-based billing, long-running tasks -## Why Escrow? +## Why Escrow The `exact` scheme works well for immediate-delivery payments, but creates friction for: @@ -39,7 +39,7 @@ The `exact` scheme works well for immediate-delivery payments, but creates frict Client pays $500 → Server crashes → Money lost ``` -**Escrow Solution:** Funds held until work is verified +**Escrow Solution:** Escrow holds funds until the captureAuthorizer verifies the work ``` Client authorizes $500 → Work completes → Operator releases → Server receives @@ -53,7 +53,7 @@ Consider an LLM agent making API calls: - Can't pay exact amount in advance - Server needs guarantee of payment -**Escrow Solution:** Authorize max amount, capture actual usage +**Escrow Solution:** Lock a max amount, capture actual usage ``` Client authorizes $10 → Uses $6.50 → Operator captures $6.50 → Refund $3.50 @@ -66,10 +66,10 @@ Client authorizes $10 → Uses $6.50 → Operator captures $6.50 → Refund $3.5 Client pays for video rendering → 48 hours later → How to verify completion? ``` -**Escrow Solution:** Conditional release with verification +**Escrow Solution:** Conditional capture with verification ``` -Client authorizes → Work progresses → Client verifies → Release on approval +Client authorizes → Work progresses → Client verifies → Capture on approval ``` ### Multi-Request Sessions @@ -80,15 +80,15 @@ Agent makes 1,000 API calls at $0.01 each = 1,000 signatures + 1,000 on-chain transactions ``` -**Escrow Solution:** One authorization, multiple captures +**Escrow Solution:** One authorization, many captures ``` -Client authorizes $10 once → Server tracks usage → Periodic batch capture +Client authorizes $10 once → Server tracks usage → Server batches captures on a schedule ``` ## How x402r Extends X402 -x402r provides the **escrow scheme implementation** for x402: +x402r provides the **auth-capture scheme implementation** for x402: 1. **Base Commerce Payments Integration** - Audited escrow contracts from Base @@ -96,10 +96,10 @@ x402r provides the **escrow scheme implementation** for x402: - On-chain safety guarantees 2. **Operator Contracts** - - Conditional release logic + - Conditional capture logic - Dispute resolution - Fee distribution - - Time-based release + - Time-based capture 3. **Payment Facilitator** - Validates ERC-3009 signatures @@ -113,28 +113,7 @@ x402r provides the **escrow scheme implementation** for x402: ## Payment Flow -```mermaid -sequenceDiagram - participant Client - participant Server - participant Facilitator - participant Operator - participant Escrow - - Client->>Server: GET /resource - Server-->>Client: 402 Payment Required - Note over Client: Signs ERC-3009 authorization - Client->>Server: Payment payload - Server->>Facilitator: Verify & settle - Facilitator->>Operator: authorize(paymentInfo, amount, tokenCollector, signature) - Operator->>Escrow: Lock funds in escrow - Facilitator-->>Server: Settlement confirmed - Server-->>Client: 200 OK + resource - - Note over Operator,Escrow: Later: capture or void - Operator->>Escrow: release(paymentInfo, amount) - Note over Escrow: Funds released to receiver (minus fees) -``` +The auth-capture scheme is two-phase: the facilitator first authorizes (locks the client's signed funds in escrow at the 402 settlement), then later captures to the receiver or voids back to the payer per policy. For the full HTTP and on-chain sequence diagrams, see the [auth-capture flow](/x402-integration/auth-capture) and the [on-chain payment sequence](/contracts/architecture#payment-flow-sequence). ## Use Cases @@ -187,7 +166,7 @@ sequenceDiagram Lock funds in escrow without immediate transfer. Client signs an ERC-3009 authorization allowing the escrow contract to pull tokens. ### Capture -Release authorized funds to the receiver. The operator contract decides when and how much to release based on configured conditions. +Capture authorized funds to the receiver. The operator contract decides when and how much to capture based on configured conditions. ### Void Return funds to payer before capture. Used for full refunds during the escrow period. @@ -197,20 +176,16 @@ Safety valve for payer. If authorization expires without capture, payer can recl ### Operator Smart contract that controls capture/void logic. Different operators enable different payment patterns: -- **Time-locked**: Release after period expires -- **Arbiter-controlled**: Third party decides release +- **Time-locked**: Capture after period expires +- **Arbiter-controlled**: Third party decides capture - **Usage-based**: Capture proportional to consumption - **Immediate**: Behaves like `exact` scheme ## Next Steps - - Complete technical specification for the escrow payment scheme. - - - - Detailed comparison of escrow vs exact schemes. + + Complete technical specification for the auth-capture payment scheme.