From 41a21344a224fe2e031eff1de0cc969fccc8be4d Mon Sep 17 00:00:00 2001 From: OliverDevDS Date: Thu, 9 Apr 2026 01:28:28 -0300 Subject: [PATCH 1/2] feat: add use-refund-protocol skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the Circle Refund Protocol — non-custodial escrow with arbiter-mediated dispute resolution for stablecoin payments on Arc. Covers: pay/withdraw lifecycle, lockup periods, refundByRecipient, refundByArbiter, earlyWithdrawByArbiter (EIP-712), arbiter fund management, debt settlement, updateRefundTo, and 6 antipatterns. --- .../skills/use-refund-protocol/SKILL.md | 537 ++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100755 plugins/circle/skills/use-refund-protocol/SKILL.md diff --git a/plugins/circle/skills/use-refund-protocol/SKILL.md b/plugins/circle/skills/use-refund-protocol/SKILL.md new file mode 100755 index 00000000..4bd20418 --- /dev/null +++ b/plugins/circle/skills/use-refund-protocol/SKILL.md @@ -0,0 +1,537 @@ +# use-refund-protocol + +Build stablecoin payment systems with built-in dispute resolution on Arc. +The Refund Protocol lets payers recover funds in case of fraud, errors, or +chargebacks — without reversing the blockchain — through a non-custodial +arbiter system that holds payments in escrow until the lockup period expires +or a dispute is resolved. + +--- + +## When to use this skill + +Use `use-refund-protocol` when you are building a payment flow that needs: + +- **Merchant refunds** — buyer pays, merchant can voluntarily refund +- **Chargeback protection** — a trusted arbiter can force a refund if fraud is detected +- **Lockup-based settlement** — funds held for N days before recipient can withdraw +- **Early release** — arbiter authorizes early withdrawal with optional fee +- **Institutional treasury** — banks and PSPs managing USDC payment disputes onchain + +Do **not** use this skill for: + +- Immediate, irrevocable payments (use direct USDC transfer) +- AI-validated job deliverables (use `use-agent-economy` with ERC-8183) +- Crosschain transfers (use `bridge-stablecoin` or `use-gateway`) + +--- + +## Core concepts + +### The three roles + +``` +Payer ──── pay() ──────────────► RefundProtocol (escrow) + │ +Recipient ── withdraw() ◄───────────────┤ (after lockup expires) + │ +Arbiter ──── refundByArbiter() ─────────► (dispute resolution) + earlyWithdrawByArbiter() (early release with fee) +``` + +### How lockup works + +Every recipient has a configurable lockup period set by the arbiter. Funds +sit in escrow until the lockup expires. This gives the arbiter a window to +investigate disputes and issue refunds before the recipient can withdraw. + +``` +Day 0 Lockup period (0–180 days) Release + │──────────────────────────────────────────────│ + pay() withdraw() + ↑ refund window ↑ +``` + +A lockup of 0 means funds are available immediately — useful for low-risk +merchant flows. Up to 180 days maximum (`MAX_LOCKUP_SECONDS`). + +--- + +## Repository + +Source: https://github.com/circlefin/refund-protocol + +The contract is deployed by each application — there is no shared instance. +Deploy your own `RefundProtocol` with your arbiter address and USDC token. + +--- + +## Deploying the contract + +### Prerequisites + +Install Foundry: +```bash +curl -L https://foundry.paradigm.xyz | bash +foundryup +git clone https://github.com/circlefin/refund-protocol +cd refund-protocol +git submodule update --init --recursive +``` + +### Deploy on Arc Testnet + +```solidity +// Constructor parameters: +// arbiter — address that can force refunds and authorize early withdrawals +// usdc — 0x3600000000000000000000000000000000000000 (Arc Testnet) +// name — EIP-712 domain name (e.g. "MyApp Refund Protocol") +// version — EIP-712 version (e.g. "1.0") + +RefundProtocol protocol = new RefundProtocol( + arbiterAddress, + 0x3600000000000000000000000000000000000000, + "MyApp Refund Protocol", + "1.0" +); +``` + +```bash +forge create src/RefundProtocol.sol:RefundProtocol \ + --rpc-url https://rpc.testnet.arc.network \ + --private-key $PRIVATE_KEY \ + --constructor-args $ARBITER_ADDRESS \ + 0x3600000000000000000000000000000000000000 \ + "MyApp Refund Protocol" \ + "1.0" +``` + +--- + +## Core flows + +### 1. Basic payment with lockup + +```typescript +import { createPublicClient, createWalletClient, http, parseUnits } from "viem"; +import { arcTestnet } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; + +const USDC = "0x3600000000000000000000000000000000000000" as const; +const REFUND_PROTOCOL = "0xYOUR_DEPLOYED_CONTRACT" as const; +const LOCKUP_SECONDS = 7 * 24 * 3600; // 7 days +const AMOUNT = parseUnits("10.00", 6); // 10 USDC — always 6 decimals + +const publicClient = createPublicClient({ chain: arcTestnet, transport: http() }); +const payerWallet = createWalletClient({ + account: privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`), + chain: arcTestnet, + transport: http(), +}); + +const refundProtocolAbi = [ + { name: "pay", type: "function", stateMutability: "nonpayable", + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "refundTo", type: "address" }, // where funds go if refunded + ], outputs: [] }, + { name: "withdraw", type: "function", stateMutability: "nonpayable", + inputs: [{ name: "paymentIDs", type: "uint256[]" }], outputs: [] }, + { name: "refundByRecipient", type: "function", stateMutability: "nonpayable", + inputs: [{ name: "paymentID", type: "uint256" }], outputs: [] }, + { name: "refundByArbiter", type: "function", stateMutability: "nonpayable", + inputs: [{ name: "paymentID", type: "uint256" }], outputs: [] }, + { name: "setLockupSeconds", type: "function", stateMutability: "nonpayable", + inputs: [{ name: "recipient", type: "address" }, { name: "recipientLockupSeconds", type: "uint256" }], outputs: [] }, + { name: "payments", type: "function", stateMutability: "view", + inputs: [{ name: "paymentID", type: "uint256" }], + outputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "releaseTimestamp", type: "uint256" }, + { name: "refundTo", type: "address" }, + { name: "withdrawnAmount", type: "uint256" }, + { name: "refunded", type: "bool" }, + ] }, + { name: "balances", type: "function", stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }] }, + { name: "nonce", type: "function", stateMutability: "view", + inputs: [], outputs: [{ name: "", type: "uint256" }] }, +] as const; + +const erc20Abi = [ + { name: "approve", type: "function", stateMutability: "nonpayable", + inputs: [{ name: "spender", type: "address" }, { name: "amount", type: "uint256" }], + outputs: [{ name: "", type: "bool" }] }, +] as const; + +// Step 1: Approve USDC spend +const approveHash = await payerWallet.writeContract({ + address: USDC, + abi: erc20Abi, + functionName: "approve", + args: [REFUND_PROTOCOL, AMOUNT], +}); +await publicClient.waitForTransactionReceipt({ hash: approveHash }); + +// Step 2: Get current nonce (this will be the payment ID) +const paymentId = await publicClient.readContract({ + address: REFUND_PROTOCOL, + abi: refundProtocolAbi, + functionName: "nonce", +}); + +// Step 3: Pay — funds go into escrow +const payHash = await payerWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: refundProtocolAbi, + functionName: "pay", + args: [ + recipientAddress, + AMOUNT, + payerAddress, // refundTo — payer gets money back if refunded + ], +}); +await publicClient.waitForTransactionReceipt({ hash: payHash }); + +console.log(`Payment ID: ${paymentId}`); +console.log(`Funds locked until: ${new Date((Date.now() / 1000 + LOCKUP_SECONDS) * 1000).toISOString()}`); +``` + +### 2. Arbiter sets lockup period + +The arbiter must set the lockup period for each recipient before payments +are made. Lockup of 0 = no lockup (immediate withdrawal allowed). + +```typescript +const arbiterWallet = createWalletClient({ + account: privateKeyToAccount(process.env.ARBITER_KEY as `0x${string}`), + chain: arcTestnet, + transport: http(), +}); + +// Set 7-day lockup for a merchant +await arbiterWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: refundProtocolAbi, + functionName: "setLockupSeconds", + args: [merchantAddress, BigInt(7 * 24 * 3600)], +}); +``` + +### 3. Recipient withdraws after lockup + +```typescript +const recipientWallet = createWalletClient({ + account: privateKeyToAccount(process.env.RECIPIENT_KEY as `0x${string}`), + chain: arcTestnet, + transport: http(), +}); + +// Withdraw multiple payments at once +const withdrawHash = await recipientWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: refundProtocolAbi, + functionName: "withdraw", + args: [[paymentId]], // array of payment IDs +}); +await publicClient.waitForTransactionReceipt({ hash: withdrawHash }); +``` + +### 4. Voluntary refund by recipient + +The merchant can issue a refund without arbiter involvement. + +```typescript +const refundHash = await recipientWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: refundProtocolAbi, + functionName: "refundByRecipient", + args: [paymentId], +}); +// Funds go back to the refundTo address specified in pay() +``` + +### 5. Forced refund by arbiter + +Used when fraud is detected or the recipient refuses to issue a refund. +The arbiter can use the recipient's balance or its own deposited funds. + +```typescript +// Arbiter forces refund — draws from recipient's balance first, +// then from arbiter's own deposited balance if insufficient +const forceRefundHash = await arbiterWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: refundProtocolAbi, + functionName: "refundByArbiter", + args: [paymentId], +}); +// Funds go to the refundTo address — recipient cannot block this +``` + +### 6. Early withdrawal authorized by arbiter + +The arbiter can release funds before the lockup expires, with an optional +fee. The recipient must sign the withdrawal terms (EIP-712). + +```typescript +import { signTypedData } from "viem/accounts"; + +// Recipient signs the early withdrawal terms +const domain = { + name: "MyApp Refund Protocol", + version: "1.0", + chainId: arcTestnet.id, + verifyingContract: REFUND_PROTOCOL, +}; + +const types = { + EarlyWithdrawalByArbiter: [ + { name: "paymentIDs", type: "uint256[]" }, + { name: "withdrawalAmounts", type: "uint256[]" }, + { name: "feeAmount", type: "uint256" }, + { name: "expiry", type: "uint256" }, + { name: "salt", type: "uint256" }, + ], +}; + +const expiry = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour +const salt = BigInt(Math.floor(Math.random() * 1e15)); + +const signature = await signTypedData({ + privateKey: process.env.RECIPIENT_KEY as `0x${string}`, + domain, + types, + primaryType: "EarlyWithdrawalByArbiter", + message: { + paymentIDs: [paymentId], + withdrawalAmounts: [AMOUNT], + feeAmount: parseUnits("0.50", 6), // 0.50 USDC arbiter fee + expiry, + salt, + }, +}); + +const { v, r, s } = { + v: parseInt(signature.slice(130, 132), 16), + r: signature.slice(0, 66) as `0x${string}`, + s: `0x${signature.slice(66, 130)}` as `0x${string}`, +}; + +// Arbiter executes early withdrawal with recipient's signature +const earlyWithdrawAbi = [{ + name: "earlyWithdrawByArbiter", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "paymentIDs", type: "uint256[]" }, + { name: "withdrawalAmounts", type: "uint256[]" }, + { name: "feeAmount", type: "uint256" }, + { name: "expiry", type: "uint256" }, + { name: "salt", type: "uint256" }, + { name: "recipient", type: "address" }, + { name: "v", type: "uint8" }, + { name: "r", type: "bytes32" }, + { name: "s", type: "bytes32" }, + ], + outputs: [], +}] as const; + +await arbiterWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: earlyWithdrawAbi, + functionName: "earlyWithdrawByArbiter", + args: [ + [paymentId], + [AMOUNT], + parseUnits("0.50", 6), + expiry, + salt, + recipientAddress, + v, r, s, + ], +}); +``` + +--- + +## Arbiter fund management + +The arbiter can pre-fund the contract to cover forced refunds when the +recipient has already withdrawn their balance. + +```typescript +const depositAbi = [ + { name: "depositArbiterFunds", type: "function", stateMutability: "nonpayable", + inputs: [{ name: "amount", type: "uint256" }], outputs: [] }, + { name: "withdrawArbiterFunds", type: "function", stateMutability: "nonpayable", + inputs: [{ name: "amount", type: "uint256" }], outputs: [] }, +] as const; + +// Deposit 100 USDC as arbiter reserve (approve first) +await arbiterWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: depositAbi, + functionName: "depositArbiterFunds", + args: [parseUnits("100.00", 6)], +}); +``` + +When the arbiter covers a refund from its own balance, the recipient incurs +a debt. The debt is automatically settled when the recipient next receives +a payment via `pay()` or when `settleDebt()` is called manually. + +--- + +## Reading payment state + +```typescript +const payment = await publicClient.readContract({ + address: REFUND_PROTOCOL, + abi: refundProtocolAbi, + functionName: "payments", + args: [paymentId], +}) as readonly [string, bigint, bigint, string, bigint, boolean]; + +const [to, amount, releaseTimestamp, refundTo, withdrawnAmount, refunded] = payment; +const isLocked = Date.now() / 1000 < Number(releaseTimestamp); +const canWithdraw = !isLocked && !refunded; + +console.log("Recipient :", to); +console.log("Amount :", formatUnits(amount, 6), "USDC"); +console.log("Locked :", isLocked); +console.log("Refunded :", refunded); +console.log("Can withdraw:", canWithdraw); +``` + +--- + +## Decision guide: which refund path to use + +| Situation | Who acts | Function | +|---|---|---| +| Merchant wants to refund voluntarily | Recipient | `refundByRecipient()` | +| Fraud detected, merchant unresponsive | Arbiter | `refundByArbiter()` | +| Recipient needs funds early, agrees to fee | Arbiter + Recipient sig | `earlyWithdrawByArbiter()` | +| Lockup expired, no dispute | Recipient | `withdraw()` | +| Recipient spent balance, arbiter covered refund | Auto on next payment | `settleDebt()` | + +--- + +## Common mistakes + +### 1. Setting refundTo as zero address +```typescript +// WRONG — will revert with RefundToIsZeroAddress +await pay(recipient, amount, "0x0000000000000000000000000000000000000000"); + +// CORRECT — always provide a valid refundTo address +await pay(recipient, amount, payerAddress); +``` + +### 2. Forgetting to approve USDC before pay() +```typescript +// WRONG — transferFrom will fail without prior approval +await writeContract({ functionName: "pay", args: [to, amount, refundTo] }); + +// CORRECT — approve first, then pay +await writeContract({ address: USDC, functionName: "approve", args: [REFUND_PROTOCOL, amount] }); +await writeContract({ functionName: "pay", args: [to, amount, refundTo] }); +``` + +### 3. Arbiter trying to refund with no funds available +```typescript +// If recipient already withdrew AND arbiter has no deposited balance, +// refundByArbiter() will revert with InsufficientFunds. +// Always keep arbiter reserve funded for dispute coverage. +``` + +### 4. Reusing early withdrawal signatures (replay attack) +```typescript +// WRONG — using the same salt twice +const salt = 0n; +// second call with same params will revert with WithdrawalHashAlreadyUsed + +// CORRECT — always use a unique salt per withdrawal +const salt = BigInt(Date.now()); // or crypto.getRandomValues +``` + +### 5. Not checking payment state before withdraw +```typescript +// CORRECT — always verify lockup and refund status before calling withdraw +const [, , releaseTimestamp, , , refunded] = await readContract({ + functionName: "payments", args: [paymentId] +}); +if (refunded) throw new Error("Payment already refunded"); +if (Date.now() / 1000 < Number(releaseTimestamp)) throw new Error("Still locked"); +``` + +### 6. Using 18 decimals for USDC +```typescript +// WRONG +const amount = parseUnits("10.00", 18); + +// CORRECT — USDC always uses 6 decimals on Arc +const amount = parseUnits("10.00", 6); +``` + +--- + +## Use cases + +### Merchant payments with chargeback protection + +Deploy one `RefundProtocol` per merchant tier. Set lockup based on risk: +high-risk merchants get 30-day lockup, trusted merchants get 3 days. +The arbiter is your compliance/fraud team wallet. + +### Gig economy payouts + +Freelancer completes work → client pays via `pay()` with 48h lockup → +client reviews → approves (lockup expires, freelancer withdraws) or +disputes (arbiter investigates, calls `refundByArbiter()` if justified). + +### AI-validated escrow (arc-escrow pattern) + +AI agent validates deliverable quality → calls `refundByArbiter()` to +reverse payment if quality threshold not met, or lets lockup expire for +automatic release. Combines with ERC-8183 for full `use-agent-economy` +integration. + +--- + +## Related skills + +- [`use-agent-economy`](../use-agent-economy/SKILL.md) — combine with ERC-8183 for AI-validated job escrow +- [`use-developer-controlled-wallets`](../use-developer-controlled-wallets/SKILL.md) — manage arbiter wallet +- [`use-arc`](../use-arc/SKILL.md) — chain config and contract deployment on Arc + +--- + +## Updating the refund address + +The payer (or whoever is the current `refundTo`) can update where funds +go if a refund is triggered. Useful when the payer's wallet changes. + +```typescript +const updateRefundToAbi = [{ + name: "updateRefundTo", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "paymentID", type: "uint256" }, + { name: "newRefundTo", type: "address" }, + ], + outputs: [], +}] as const; + +// Only the current refundTo address can call this +await refundToWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: updateRefundToAbi, + functionName: "updateRefundTo", + args: [paymentId, newRefundToAddress], +}); +``` From c0c590e624b71ea9f631dc8ee0db8ce80026d9ba Mon Sep 17 00:00:00 2001 From: OliverDevDS Date: Wed, 13 May 2026 22:50:54 -0300 Subject: [PATCH 2/2] fix: correct debt settlement, lockup order, earlyWithdraw signature and security notice --- .../skills/use-refund-protocol/SKILL.md | 195 +++++++++++++----- 1 file changed, 141 insertions(+), 54 deletions(-) diff --git a/plugins/circle/skills/use-refund-protocol/SKILL.md b/plugins/circle/skills/use-refund-protocol/SKILL.md index 4bd20418..8d0dcad6 100755 --- a/plugins/circle/skills/use-refund-protocol/SKILL.md +++ b/plugins/circle/skills/use-refund-protocol/SKILL.md @@ -55,6 +55,10 @@ Day 0 Lockup period (0–180 days) Release A lockup of 0 means funds are available immediately — useful for low-risk merchant flows. Up to 180 days maximum (`MAX_LOCKUP_SECONDS`). +> **Important:** `lockupSeconds[recipient]` defaults to `0` until the arbiter +> calls `setLockupSeconds()`. The lockup must be configured **before** the +> first payment to that recipient, or funds will be immediately withdrawable. + --- ## Repository @@ -112,6 +116,11 @@ forge create src/RefundProtocol.sol:RefundProtocol \ ### 1. Basic payment with lockup +> **Prerequisite:** The arbiter must call `setLockupSeconds()` for the +> recipient **before** any payment is made. `lockupSeconds[to]` defaults to +> `0`, so payments made before this step are immediately withdrawable. +> See [Section 2](#2-arbiter-sets-lockup-period) for the arbiter setup. + ```typescript import { createPublicClient, createWalletClient, http, parseUnits } from "viem"; import { arcTestnet } from "viem/chains"; @@ -119,7 +128,6 @@ import { privateKeyToAccount } from "viem/accounts"; const USDC = "0x3600000000000000000000000000000000000000" as const; const REFUND_PROTOCOL = "0xYOUR_DEPLOYED_CONTRACT" as const; -const LOCKUP_SECONDS = 7 * 24 * 3600; // 7 days const AMOUNT = parseUnits("10.00", 6); // 10 USDC — always 6 decimals const publicClient = createPublicClient({ chain: arcTestnet, transport: http() }); @@ -167,7 +175,23 @@ const erc20Abi = [ outputs: [{ name: "", type: "bool" }] }, ] as const; -// Step 1: Approve USDC spend +// Step 1: Arbiter configures lockup for this recipient (run once per recipient) +// Must be done BEFORE the first pay() — otherwise lockupSeconds[to] is 0 +// and funds are immediately withdrawable. +const arbiterWallet = createWalletClient({ + account: privateKeyToAccount(process.env.ARBITER_KEY as `0x${string}`), + chain: arcTestnet, + transport: http(), +}); +const lockupHash = await arbiterWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: refundProtocolAbi, + functionName: "setLockupSeconds", + args: [recipientAddress, BigInt(7 * 24 * 3600)], // 7-day lockup +}); +await publicClient.waitForTransactionReceipt({ hash: lockupHash }); + +// Step 2: Approve USDC spend const approveHash = await payerWallet.writeContract({ address: USDC, abi: erc20Abi, @@ -176,14 +200,14 @@ const approveHash = await payerWallet.writeContract({ }); await publicClient.waitForTransactionReceipt({ hash: approveHash }); -// Step 2: Get current nonce (this will be the payment ID) +// Step 3: Get current nonce (this will be the payment ID) const paymentId = await publicClient.readContract({ address: REFUND_PROTOCOL, abi: refundProtocolAbi, functionName: "nonce", }); -// Step 3: Pay — funds go into escrow +// Step 4: Pay — funds go into escrow const payHash = await payerWallet.writeContract({ address: REFUND_PROTOCOL, abi: refundProtocolAbi, @@ -196,8 +220,17 @@ const payHash = await payerWallet.writeContract({ }); await publicClient.waitForTransactionReceipt({ hash: payHash }); +// Step 5: Read the actual release timestamp from the contract +// (do not estimate — read the value recorded by pay() for correctness) +const [, , releaseTimestamp] = await publicClient.readContract({ + address: REFUND_PROTOCOL, + abi: refundProtocolAbi, + functionName: "payments", + args: [paymentId], +}); + console.log(`Payment ID: ${paymentId}`); -console.log(`Funds locked until: ${new Date((Date.now() / 1000 + LOCKUP_SECONDS) * 1000).toISOString()}`); +console.log(`Funds locked until: ${new Date(Number(releaseTimestamp) * 1000).toISOString()}`); ``` ### 2. Arbiter sets lockup period @@ -273,53 +306,55 @@ const forceRefundHash = await arbiterWallet.writeContract({ ### 6. Early withdrawal authorized by arbiter +> ⚠️ **Security notice — do not use in production without reviewing the +> upstream status.** The `refund-protocol` README currently has an active +> Security Notice stating that `earlyWithdrawByArbiter()` contains an issue +> that allows an arbiter to drain other users' payments. A fix is still in +> development. Do not expose this function in a production system until the +> upstream contract is patched and audited. +> +> The integration below is provided for reference only so you understand the +> signing pattern. Gate it behind a feature flag and audit the contract fix +> before enabling it. + The arbiter can release funds before the lockup expires, with an optional -fee. The recipient must sign the withdrawal terms (EIP-712). +fee. The recipient must sign the withdrawal parameters. -```typescript -import { signTypedData } from "viem/accounts"; - -// Recipient signs the early withdrawal terms -const domain = { - name: "MyApp Refund Protocol", - version: "1.0", - chainId: arcTestnet.id, - verifyingContract: REFUND_PROTOCOL, -}; - -const types = { - EarlyWithdrawalByArbiter: [ - { name: "paymentIDs", type: "uint256[]" }, - { name: "withdrawalAmounts", type: "uint256[]" }, - { name: "feeAmount", type: "uint256" }, - { name: "expiry", type: "uint256" }, - { name: "salt", type: "uint256" }, - ], -}; +**Signing note:** The contract's `_hashEarlyWithdrawalInfo()` hashes the +parameters with `abi.encode(...)` directly — it does **not** use EIP-712 +typed-data encoding. Using `signTypedData()` produces a different digest and +the signature will not verify onchain. Use `encodeAbiParameters` + `keccak256` ++ `sign` to match the contract exactly. -const expiry = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour -const salt = BigInt(Math.floor(Math.random() * 1e15)); +```typescript +import { encodeAbiParameters, keccak256, parseUnits } from "viem"; +import { sign } from "viem/accounts"; + +const feeAmount = parseUnits("0.50", 6); // 0.50 USDC arbiter fee +const expiry = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour +const salt = BigInt(Math.floor(Math.random() * 1e15)); // unique per call + +// Hash parameters exactly as _hashEarlyWithdrawalInfo() does in the contract: +// keccak256(abi.encode(paymentIDs, withdrawalAmounts, feeAmount, expiry, salt)) +const hash = keccak256( + encodeAbiParameters( + [ + { type: "uint256[]" }, + { type: "uint256[]" }, + { type: "uint256" }, + { type: "uint256" }, + { type: "uint256" }, + ], + [[paymentId], [AMOUNT], feeAmount, expiry, salt] + ) +); -const signature = await signTypedData({ +// Sign the raw keccak256 hash — no EIP-712 domain, no Ethereum prefix +const { v, r, s } = await sign({ + hash, privateKey: process.env.RECIPIENT_KEY as `0x${string}`, - domain, - types, - primaryType: "EarlyWithdrawalByArbiter", - message: { - paymentIDs: [paymentId], - withdrawalAmounts: [AMOUNT], - feeAmount: parseUnits("0.50", 6), // 0.50 USDC arbiter fee - expiry, - salt, - }, }); -const { v, r, s } = { - v: parseInt(signature.slice(130, 132), 16), - r: signature.slice(0, 66) as `0x${string}`, - s: `0x${signature.slice(66, 130)}` as `0x${string}`, -}; - // Arbiter executes early withdrawal with recipient's signature const earlyWithdrawAbi = [{ name: "earlyWithdrawByArbiter", @@ -346,7 +381,7 @@ await arbiterWallet.writeContract({ args: [ [paymentId], [AMOUNT], - parseUnits("0.50", 6), + feeAmount, expiry, salt, recipientAddress, @@ -380,8 +415,38 @@ await arbiterWallet.writeContract({ ``` When the arbiter covers a refund from its own balance, the recipient incurs -a debt. The debt is automatically settled when the recipient next receives -a payment via `pay()` or when `settleDebt()` is called manually. +a debt. New payments via `pay()` increase the recipient's balance but do +**not** settle the debt automatically — `pay()` does not call `_settleDebt`. +Debt is only cleared when `settleDebt(recipient)` is called explicitly, or +when the recipient calls `withdraw()`, which settles outstanding debt before +releasing funds. + +```typescript +const settleDebtAbi = [ + { name: "settleDebt", type: "function", stateMutability: "nonpayable", + inputs: [{ name: "recipient", type: "address" }], outputs: [] }, + { name: "debts", type: "function", stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }] }, +] as const; + +// Check outstanding debt before deciding whether to call settleDebt +const debt = await publicClient.readContract({ + address: REFUND_PROTOCOL, + abi: settleDebtAbi, + functionName: "debts", + args: [recipientAddress], +}); + +if (debt > 0n) { + await arbiterWallet.writeContract({ + address: REFUND_PROTOCOL, + abi: settleDebtAbi, + functionName: "settleDebt", + args: [recipientAddress], + }); +} +``` --- @@ -414,9 +479,9 @@ console.log("Can withdraw:", canWithdraw); |---|---|---| | Merchant wants to refund voluntarily | Recipient | `refundByRecipient()` | | Fraud detected, merchant unresponsive | Arbiter | `refundByArbiter()` | -| Recipient needs funds early, agrees to fee | Arbiter + Recipient sig | `earlyWithdrawByArbiter()` | +| Recipient needs funds early, agrees to fee | Arbiter + Recipient sig | `earlyWithdrawByArbiter()` ⚠️ | | Lockup expired, no dispute | Recipient | `withdraw()` | -| Recipient spent balance, arbiter covered refund | Auto on next payment | `settleDebt()` | +| Recipient spent balance, arbiter covered refund | On `withdraw()` or explicit call | `settleDebt()` | --- @@ -441,14 +506,24 @@ await writeContract({ address: USDC, functionName: "approve", args: [REFUND_PROT await writeContract({ functionName: "pay", args: [to, amount, refundTo] }); ``` -### 3. Arbiter trying to refund with no funds available +### 3. Paying before setLockupSeconds is configured +```typescript +// WRONG — lockupSeconds[recipient] defaults to 0; payment is immediately withdrawable +await pay(recipient, amount, refundTo); // no lockup in effect + +// CORRECT — arbiter sets lockup first, then payer sends funds +await arbiterWallet.writeContract({ functionName: "setLockupSeconds", args: [recipient, lockupDuration] }); +await payerWallet.writeContract({ functionName: "pay", args: [recipient, amount, refundTo] }); +``` + +### 4. Arbiter trying to refund with no funds available ```typescript // If recipient already withdrew AND arbiter has no deposited balance, // refundByArbiter() will revert with InsufficientFunds. // Always keep arbiter reserve funded for dispute coverage. ``` -### 4. Reusing early withdrawal signatures (replay attack) +### 5. Reusing early withdrawal signatures (replay attack) ```typescript // WRONG — using the same salt twice const salt = 0n; @@ -458,7 +533,7 @@ const salt = 0n; const salt = BigInt(Date.now()); // or crypto.getRandomValues ``` -### 5. Not checking payment state before withdraw +### 6. Not checking payment state before withdraw ```typescript // CORRECT — always verify lockup and refund status before calling withdraw const [, , releaseTimestamp, , , refunded] = await readContract({ @@ -468,7 +543,7 @@ if (refunded) throw new Error("Payment already refunded"); if (Date.now() / 1000 < Number(releaseTimestamp)) throw new Error("Still locked"); ``` -### 6. Using 18 decimals for USDC +### 7. Using 18 decimals for USDC ```typescript // WRONG const amount = parseUnits("10.00", 18); @@ -477,6 +552,18 @@ const amount = parseUnits("10.00", 18); const amount = parseUnits("10.00", 6); ``` +### 8. Using signTypedData for earlyWithdrawByArbiter signatures +```typescript +// WRONG — signTypedData produces EIP-712 canonical array encoding; +// the contract uses abi.encode() directly, so the digests differ and +// the signature will not verify onchain. +const signature = await signTypedData({ domain, types, primaryType, message }); + +// CORRECT — match _hashEarlyWithdrawalInfo() exactly with encodeAbiParameters +const hash = keccak256(encodeAbiParameters([...], [[paymentId], [amount], fee, expiry, salt])); +const { v, r, s } = await sign({ hash, privateKey }); +``` + --- ## Use cases