From cc7f50c4d03ae82c51b9d05d75487c78414f6d74 Mon Sep 17 00:00:00 2001 From: plagtech Date: Sun, 31 May 2026 21:16:05 -0700 Subject: [PATCH 1/3] feat: add batch payment integration for settling multiple USDC invoices via Spraay --- docs/integrations/spraay-batch-payments.md | 97 +++++++ .../src/payment/spraay-batch-payer.ts | 252 ++++++++++++++++++ .../src/payment/spraay-utils.ts | 58 ++++ .../test/spraay-batch-payer.test.ts | 108 ++++++++ 4 files changed, 515 insertions(+) create mode 100644 docs/integrations/spraay-batch-payments.md create mode 100644 packages/payment-processor/src/payment/spraay-batch-payer.ts create mode 100644 packages/payment-processor/src/payment/spraay-utils.ts create mode 100644 packages/payment-processor/test/spraay-batch-payer.test.ts diff --git a/docs/integrations/spraay-batch-payments.md b/docs/integrations/spraay-batch-payments.md new file mode 100644 index 0000000000..e9b81ba51d --- /dev/null +++ b/docs/integrations/spraay-batch-payments.md @@ -0,0 +1,97 @@ +# Batch USDC Payments with Spraay Protocol + +> Settle multiple Request Network invoices in a single on-chain transaction. + +## Overview + +[Spraay Protocol](https://spraay.app) provides multi-recipient batch transfer contracts deployed across Base, Ethereum, Arbitrum, Polygon, BNB Chain, and Avalanche. This integration lets you batch-pay Request Network invoices — paying 10, 30, or even 200 recipients in one transaction instead of N separate ones. + +## Installation + +```bash +npm install @requestnetwork/payment-processor ethers +``` + +## Usage + +### Pay specific invoices + +```typescript +import { ethers } from "ethers"; +import { SpraayBatchPayer } from "@requestnetwork/payment-processor"; + +const provider = new ethers.JsonRpcProvider("https://mainnet.base.org"); +const signer = new ethers.Wallet(PRIVATE_KEY, provider); + +const spraay = new SpraayBatchPayer(signer, 8453); // Base + +const result = await spraay.payInvoices({ + invoices: [ + { requestId: "01abc...", recipient: "0xAlice...", amount: "500.00" }, + { requestId: "02def...", recipient: "0xBob...", amount: "250.00" }, + { requestId: "03ghi...", recipient: "0xCarol...", amount: "1200.00" }, + ], +}); + +console.log(`Paid ${result.recipientCount} invoices in 1 tx: ${result.explorerUrl}`); +``` + +### Auto-discover and pay pending invoices + +```typescript +import { RequestNetwork } from "@requestnetwork/request-client.js"; + +const requestClient = new RequestNetwork({ + nodeConnectionConfig: { + baseURL: "https://gnosis.gateway.request.network/", + }, +}); + +const result = await spraay.payPendingInvoices( + requestClient, + payerAddress, + { + maxInvoices: 50, + maxAmount: "10000", // skip large invoices for manual review + } +); +``` + +## How It Works + +1. **Fetch** — Retrieves pending USDC invoices where you are the payer +2. **Validate** — Checks all recipient addresses and amounts +3. **Balance check** — Verifies you have sufficient USDC +4. **Approve** — Grants the Spraay batch contract permission to transfer USDC (one-time per amount) +5. **Batch transfer** — Calls `batchTransfer(token, recipients[], amounts[])` — **one transaction** +6. **Confirm** — Returns tx hash, block number, explorer link, and per-invoice status + +All payments are **atomic**: either every recipient gets paid, or the entire transaction reverts. + +## Supported Chains + +| Chain | Chain ID | Status | +|-----------|----------|--------| +| Base | 8453 | ✅ Live | +| Ethereum | 1 | ✅ Live | +| Arbitrum | 42161 | ✅ Live | +| Polygon | 137 | ✅ Live | +| BNB Chain | 56 | ✅ Live | +| Avalanche | 43114 | ✅ Live | + +## Gas Savings + +| Invoices | Individual | Spraay Batch | Savings | +|----------|-----------|--------------|---------| +| 5 | ~$0.05 | ~$0.02 | 60% | +| 10 | ~$0.10 | ~$0.02 | 80% | +| 30 | ~$0.30 | ~$0.03 | 90% | + +*Estimates on Base L2. Ethereum L1 savings are proportionally larger.* + +## Links + +- [Spraay Protocol](https://spraay.app) +- [Spraay Gateway API](https://gateway.spraay.app) — 115+ paid endpoints, 13+ chains +- [Standalone integration package](https://github.com/plagtech/spraay-request-network) +- [Spraay MCP Server](https://smithery.ai/server/@plag/spraay-payments-mcp) — 120 AI agent tools diff --git a/packages/payment-processor/src/payment/spraay-batch-payer.ts b/packages/payment-processor/src/payment/spraay-batch-payer.ts new file mode 100644 index 0000000000..6cbc10705f --- /dev/null +++ b/packages/payment-processor/src/payment/spraay-batch-payer.ts @@ -0,0 +1,252 @@ +/** + * Spraay Batch Payment Integration for Request Network + * + * Settles multiple Request Network invoices in a single on-chain transaction + * using Spraay Protocol's multi-recipient batch transfer contracts. + * + * @module @requestnetwork/payment-processor/spraay-batch-payer + * @see https://spraay.app + * @see https://github.com/plagtech/spraay-request-network + */ + +import { ethers, Signer, Contract, ContractTransactionReceipt } from "ethers"; +import { + SPRAAY_BATCH_CONTRACTS, + USDC_ADDRESSES, + CHAIN_NAMES, + EXPLORER_URLS, + ERC20_ABI, + SPRAAY_BATCH_ABI, +} from "./spraay-utils"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Single invoice payment descriptor */ +export interface InvoicePayment { + requestId: string; + recipient: string; + amount: string; + memo?: string; +} + +/** Input for a batch payment call */ +export interface BatchPaymentRequest { + invoices: InvoicePayment[]; + tokenAddress?: string; + chainId?: number; +} + +/** Per-invoice settlement status */ +export interface PaymentSettlement { + requestId: string; + recipient: string; + amount: string; + status: "settled" | "failed"; +} + +/** Result of a batch payment execution */ +export interface BatchPaymentResult { + transactionHash: string; + chainId: number; + recipientCount: number; + totalAmount: string; + payments: PaymentSettlement[]; + blockNumber: number; + explorerUrl: string; +} + +/** Options for auto-discovery of pending invoices */ +export interface PendingInvoiceOptions { + maxInvoices?: number; + minAmount?: string; + maxAmount?: string; +} + +// --------------------------------------------------------------------------- +// SpraayBatchPayer +// --------------------------------------------------------------------------- + +/** + * Batch-pay multiple Request Network invoices in a single on-chain transaction. + * + * @example + * ```typescript + * const spraay = new SpraayBatchPayer(signer, 8453); + * + * const result = await spraay.payInvoices({ + * invoices: [ + * { requestId: "01...", recipient: "0xAlice", amount: "500.00" }, + * { requestId: "02...", recipient: "0xBob", amount: "250.00" }, + * ], + * }); + * + * console.log(result.explorerUrl); // single tx covering both payments + * ``` + */ +export class SpraayBatchPayer { + private signer: Signer; + private chainId: number; + + constructor(signer: Signer, chainId: number = 8453) { + this.signer = signer; + this.chainId = chainId; + } + + /** + * Pay a specific list of invoices in one batch transaction. + * + * Steps: + * 1. Validate all recipient addresses + * 2. Parse amounts to token decimals + * 3. Verify sender balance covers total + * 4. Approve Spraay contract for USDC spend (if needed) + * 5. Execute `batchTransfer(token, recipients[], amounts[])` + * 6. Return tx receipt with per-invoice status + */ + async payInvoices(request: BatchPaymentRequest): Promise { + const chainId = request.chainId ?? this.chainId; + const batchContractAddr = SPRAAY_BATCH_CONTRACTS[chainId]; + const tokenAddr = request.tokenAddress ?? USDC_ADDRESSES[chainId]; + + // Validate chain support + if (!batchContractAddr) { + const supported = Object.entries(CHAIN_NAMES) + .map(([id, name]) => `${name} (${id})`) + .join(", "); + throw new Error( + `Spraay batch contract not available on chain ${chainId}. Supported: ${supported}` + ); + } + if (!tokenAddr) { + throw new Error(`USDC not configured for chain ${chainId}`); + } + if (request.invoices.length === 0) { + throw new Error("No invoices provided"); + } + if (request.invoices.length > 200) { + throw new Error("Max 200 recipients per batch. Split into multiple calls."); + } + + const token = new Contract(tokenAddr, ERC20_ABI, this.signer); + const decimals: number = await token.decimals(); + const senderAddr = await this.signer.getAddress(); + + // Build arrays + const recipients: string[] = []; + const amounts: bigint[] = []; + + for (const inv of request.invoices) { + if (!ethers.isAddress(inv.recipient)) { + throw new Error(`Invalid address for ${inv.requestId}: ${inv.recipient}`); + } + recipients.push(inv.recipient); + amounts.push(ethers.parseUnits(inv.amount, decimals)); + } + + const total = amounts.reduce((s, a) => s + a, 0n); + + // Balance check + const balance: bigint = await token.balanceOf(senderAddr); + if (balance < total) { + throw new Error( + `Insufficient balance. Need ${ethers.formatUnits(total, decimals)} USDC, ` + + `have ${ethers.formatUnits(balance, decimals)}.` + ); + } + + // Approve if needed + const allowance: bigint = await token.allowance(senderAddr, batchContractAddr); + if (allowance < total) { + const approveTx = await token.approve(batchContractAddr, total); + await approveTx.wait(); + } + + // Execute batch + const spraay = new Contract(batchContractAddr, SPRAAY_BATCH_ABI, this.signer); + const tx = await spraay.batchTransfer(tokenAddr, recipients, amounts); + const receipt: ContractTransactionReceipt = await tx.wait(); + + const settled = receipt.status === 1; + const explorerBase = EXPLORER_URLS[chainId] ?? "https://blockscan.com/tx/"; + + return { + transactionHash: receipt.hash, + chainId, + recipientCount: recipients.length, + totalAmount: ethers.formatUnits(total, decimals), + payments: request.invoices.map((inv) => ({ + requestId: inv.requestId, + recipient: inv.recipient, + amount: inv.amount, + status: settled ? "settled" : "failed", + })), + blockNumber: receipt.blockNumber, + explorerUrl: `${explorerBase}${receipt.hash}`, + }; + } + + /** + * Auto-discover pending USDC invoices from a Request Network client + * and batch-pay them all. + * + * @param requestClient - Initialized RequestNetwork client instance + * @param payerAddress - The payer's Ethereum address + * @param options - Filter options (max count, amount range) + */ + async payPendingInvoices( + requestClient: any, + payerAddress: string, + options?: PendingInvoiceOptions + ): Promise { + const requests = await requestClient.fromIdentity({ + type: "ETHEREUM_ADDRESS", + value: payerAddress, + }); + + const invoices: InvoicePayment[] = []; + + for (const req of requests) { + const data = req.getData(); + + if (data.state !== "created" && data.state !== "accepted") continue; + if (data.payer?.value?.toLowerCase() !== payerAddress.toLowerCase()) continue; + + const currency = data.currency; + if (currency?.type !== "ERC20") continue; + if (!this.isUSDC(currency.value)) continue; + + const paid = BigInt(data.balance?.balance ?? "0"); + const expected = BigInt(data.expectedAmount); + const remaining = expected - paid; + if (remaining <= 0n) continue; + + const amt = ethers.formatUnits(remaining, 6); + + if (options?.minAmount && parseFloat(amt) < parseFloat(options.minAmount)) continue; + if (options?.maxAmount && parseFloat(amt) > parseFloat(options.maxAmount)) continue; + + invoices.push({ + requestId: data.requestId, + recipient: data.payee?.value ?? "", + amount: amt, + memo: data.contentData?.reason ?? data.requestId.slice(0, 8), + }); + + if (options?.maxInvoices && invoices.length >= options.maxInvoices) break; + } + + if (invoices.length === 0) { + throw new Error("No pending USDC invoices found"); + } + + return this.payInvoices({ invoices }); + } + + private isUSDC(addr: string): boolean { + return Object.values(USDC_ADDRESSES).some( + (a) => a.toLowerCase() === addr.toLowerCase() + ); + } +} diff --git a/packages/payment-processor/src/payment/spraay-utils.ts b/packages/payment-processor/src/payment/spraay-utils.ts new file mode 100644 index 0000000000..d7df6ce192 --- /dev/null +++ b/packages/payment-processor/src/payment/spraay-utils.ts @@ -0,0 +1,58 @@ +/** + * Spraay Protocol contract addresses, chain constants, and ABIs + * for the batch payment integration. + */ + +/** Spraay SprayContract addresses (batch transfer) by chain ID */ +export const SPRAAY_BATCH_CONTRACTS: Record = { + 8453: "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC", // Base + 1: "0x15E7aEDa45094DD2E9E746FcA1C726cAd7aE58b3", // Ethereum + 42161: "0x5be43aA67804aD84fcb890d0AE5F257fb1674302", // Arbitrum + 137: "0x6d2453ab7416c99aeDCA47CF552695be5789D7ff", // Polygon + 56: "0x3093a2951FB77b3beDfB8BA20De645F7413432C1", // BNB Chain + 43114: "0x6A41Fb5F5CfE632f9446b548980dA6cE2d75afcC", // Avalanche +}; + +/** USDC contract addresses by chain ID */ +export const USDC_ADDRESSES: Record = { + 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Base + 1: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // Ethereum + 42161: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // Arbitrum + 137: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", // Polygon + 56: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", // BNB Chain + 43114: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", // Avalanche +}; + +/** Block explorer URLs by chain ID */ +export const EXPLORER_URLS: Record = { + 8453: "https://basescan.org/tx/", + 1: "https://etherscan.io/tx/", + 42161: "https://arbiscan.io/tx/", + 137: "https://polygonscan.com/tx/", + 56: "https://bscscan.com/tx/", + 43114: "https://snowtrace.io/tx/", +}; + +/** Human-readable chain names */ +export const CHAIN_NAMES: Record = { + 8453: "Base", + 1: "Ethereum", + 42161: "Arbitrum One", + 137: "Polygon", + 56: "BNB Chain", + 43114: "Avalanche", +}; + +/** Minimal ERC-20 ABI for approve, allowance, balanceOf, decimals */ +export const ERC20_ABI = [ + "function approve(address spender, uint256 amount) external returns (bool)", + "function allowance(address owner, address spender) external view returns (uint256)", + "function balanceOf(address account) external view returns (uint256)", + "function decimals() external view returns (uint8)", +]; + +/** Spraay SprayContract ABI — batch transfer functions */ +export const SPRAAY_BATCH_ABI = [ + "function batchTransfer(address token, address[] calldata recipients, uint256[] calldata amounts) external", + "function batchTransferWithReferences(address token, address[] calldata recipients, uint256[] calldata amounts, bytes32[] calldata references) external", +]; diff --git a/packages/payment-processor/test/spraay-batch-payer.test.ts b/packages/payment-processor/test/spraay-batch-payer.test.ts new file mode 100644 index 0000000000..dd8793f663 --- /dev/null +++ b/packages/payment-processor/test/spraay-batch-payer.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for SpraayBatchPayer + * + * Run: yarn test --grep "SpraayBatchPayer" + */ + +import { expect } from "chai"; +import { ethers } from "ethers"; +import { + SPRAAY_BATCH_CONTRACTS, + USDC_ADDRESSES, + CHAIN_NAMES, +} from "../src/payment/spraay-utils"; +import { SpraayBatchPayer } from "../src/payment/spraay-batch-payer"; + +describe("SpraayBatchPayer", () => { + describe("Contract addresses", () => { + it("should have batch contracts for all supported chains", () => { + const expectedChains = [8453, 1, 42161, 137, 56, 43114]; + for (const chainId of expectedChains) { + expect(SPRAAY_BATCH_CONTRACTS[chainId]).to.be.a("string"); + expect(ethers.isAddress(SPRAAY_BATCH_CONTRACTS[chainId])).to.be.true; + } + }); + + it("should have USDC addresses for all supported chains", () => { + const expectedChains = [8453, 1, 42161, 137, 56, 43114]; + for (const chainId of expectedChains) { + expect(USDC_ADDRESSES[chainId]).to.be.a("string"); + expect(ethers.isAddress(USDC_ADDRESSES[chainId])).to.be.true; + } + }); + + it("should have chain names for all supported chains", () => { + expect(CHAIN_NAMES[8453]).to.equal("Base"); + expect(CHAIN_NAMES[1]).to.equal("Ethereum"); + expect(CHAIN_NAMES[42161]).to.equal("Arbitrum One"); + expect(CHAIN_NAMES[137]).to.equal("Polygon"); + }); + }); + + describe("Validation", () => { + let spraay: SpraayBatchPayer; + + beforeEach(() => { + const wallet = ethers.Wallet.createRandom(); + spraay = new SpraayBatchPayer(wallet, 8453); + }); + + it("should reject empty invoice list", async () => { + try { + await spraay.payInvoices({ invoices: [] }); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("No invoices provided"); + } + }); + + it("should reject more than 200 invoices", async () => { + const invoices = Array.from({ length: 201 }, (_, i) => ({ + requestId: `req-${i}`, + recipient: ethers.Wallet.createRandom().address, + amount: "10.00", + })); + try { + await spraay.payInvoices({ invoices }); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("200"); + } + }); + + it("should reject unsupported chain", async () => { + try { + await spraay.payInvoices({ + invoices: [ + { + requestId: "test", + recipient: ethers.Wallet.createRandom().address, + amount: "10.00", + }, + ], + chainId: 999999, + }); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("not available"); + } + }); + + it("should reject invalid recipient address", async () => { + try { + await spraay.payInvoices({ + invoices: [ + { + requestId: "test", + recipient: "not-an-address", + amount: "10.00", + }, + ], + }); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Invalid address"); + } + }); + }); +}); From 08f6a131dbf101ee9e8629a4c4a62708c5c72acd Mon Sep 17 00:00:00 2001 From: plagtech Date: Mon, 1 Jun 2026 18:27:33 -0700 Subject: [PATCH 2/3] fix: address review feedback - skip invalid payees, check tx receipts, scope USDC to active chain, type request client --- .../src/payment/spraay-batch-payer.ts | 84 +++++++++++++++++-- .../test/spraay-batch-payer.test.ts | 65 ++++++++++++++ 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/packages/payment-processor/src/payment/spraay-batch-payer.ts b/packages/payment-processor/src/payment/spraay-batch-payer.ts index 6cbc10705f..6dd7ce2844 100644 --- a/packages/payment-processor/src/payment/spraay-batch-payer.ts +++ b/packages/payment-processor/src/payment/spraay-batch-payer.ts @@ -64,6 +64,41 @@ export interface PendingInvoiceOptions { maxAmount?: string; } +/** + * Minimal structural types for the Request Network client surface used here. + * Mirrors the relevant parts of `@requestnetwork/request-client.js` and + * `@requestnetwork/types` so callers get compile-time checking without us + * depending on the full client type. The real `RequestNetwork` client and + * `Request` objects satisfy these shapes. + */ +export interface RequestCurrencyInfo { + type: string; + value: string; + network?: string; +} + +export interface RequestData { + requestId: string; + state: string; + expectedAmount: string; + currency?: RequestCurrencyInfo; + payer?: { value?: string }; + payee?: { value?: string }; + balance?: { balance?: string } | null; + contentData?: { reason?: string }; +} + +export interface RequestLike { + getData(): RequestData; +} + +export interface RequestNetworkClientLike { + fromIdentity(identity: { + type: string; + value: string; + }): Promise; +} + // --------------------------------------------------------------------------- // SpraayBatchPayer // --------------------------------------------------------------------------- @@ -160,13 +195,29 @@ export class SpraayBatchPayer { const allowance: bigint = await token.allowance(senderAddr, batchContractAddr); if (allowance < total) { const approveTx = await token.approve(batchContractAddr, total); - await approveTx.wait(); + const approveReceipt = await approveTx.wait(); + // ethers v6: wait() resolves to null if the tx was replaced/dropped. + // Surface a clear error instead of falling through to a confusing + // allowance failure in batchTransfer. + if (!approveReceipt || approveReceipt.status !== 1) { + throw new Error( + "USDC approval transaction failed or was replaced before confirmation. " + + "Please retry the batch payment." + ); + } } // Execute batch const spraay = new Contract(batchContractAddr, SPRAAY_BATCH_ABI, this.signer); const tx = await spraay.batchTransfer(tokenAddr, recipients, amounts); - const receipt: ContractTransactionReceipt = await tx.wait(); + const receipt: ContractTransactionReceipt | null = await tx.wait(); + + if (!receipt) { + throw new Error( + "Batch transfer transaction was replaced or dropped before confirmation. " + + "Check the transaction status before retrying to avoid double payment." + ); + } const settled = receipt.status === 1; const explorerBase = EXPLORER_URLS[chainId] ?? "https://blockscan.com/tx/"; @@ -196,7 +247,7 @@ export class SpraayBatchPayer { * @param options - Filter options (max count, amount range) */ async payPendingInvoices( - requestClient: any, + requestClient: RequestNetworkClientLike, payerAddress: string, options?: PendingInvoiceOptions ): Promise { @@ -205,6 +256,12 @@ export class SpraayBatchPayer { value: payerAddress, }); + // USDC uses 6 decimals on every chain Spraay supports. This is only used + // to render a human-readable amount string; payInvoices() re-parses that + // string against the live token.decimals() value, which is the + // authoritative source for the actual on-chain amount. + const USDC_DECIMALS = 6; + const invoices: InvoicePayment[] = []; for (const req of requests) { @@ -215,21 +272,28 @@ export class SpraayBatchPayer { const currency = data.currency; if (currency?.type !== "ERC20") continue; + // Only match USDC on the chain this payer is configured for — a USDC + // address from another network must not be swept into this batch. if (!this.isUSDC(currency.value)) continue; + // Skip invoices with a missing or invalid payee rather than pushing an + // empty address, which would later abort the entire batch. + const payeeAddress = data.payee?.value; + if (!payeeAddress || !ethers.isAddress(payeeAddress)) continue; + const paid = BigInt(data.balance?.balance ?? "0"); const expected = BigInt(data.expectedAmount); const remaining = expected - paid; if (remaining <= 0n) continue; - const amt = ethers.formatUnits(remaining, 6); + const amt = ethers.formatUnits(remaining, USDC_DECIMALS); if (options?.minAmount && parseFloat(amt) < parseFloat(options.minAmount)) continue; if (options?.maxAmount && parseFloat(amt) > parseFloat(options.maxAmount)) continue; invoices.push({ requestId: data.requestId, - recipient: data.payee?.value ?? "", + recipient: payeeAddress, amount: amt, memo: data.contentData?.reason ?? data.requestId.slice(0, 8), }); @@ -244,9 +308,15 @@ export class SpraayBatchPayer { return this.payInvoices({ invoices }); } + /** + * Returns true only if `addr` is the USDC contract on this payer's + * configured chain. Checking against a single chain (not every chain's + * USDC) prevents cross-chain address collisions from being misclassified. + */ private isUSDC(addr: string): boolean { - return Object.values(USDC_ADDRESSES).some( - (a) => a.toLowerCase() === addr.toLowerCase() + const usdcForChain = USDC_ADDRESSES[this.chainId]; + return ( + !!usdcForChain && usdcForChain.toLowerCase() === addr.toLowerCase() ); } } diff --git a/packages/payment-processor/test/spraay-batch-payer.test.ts b/packages/payment-processor/test/spraay-batch-payer.test.ts index dd8793f663..e8b7ca7f88 100644 --- a/packages/payment-processor/test/spraay-batch-payer.test.ts +++ b/packages/payment-processor/test/spraay-batch-payer.test.ts @@ -105,4 +105,69 @@ describe("SpraayBatchPayer", () => { } }); }); + + describe("Pending invoice discovery", () => { + // A fake Request Network client returning canned requests, so we can + // assert the filtering logic without a live node. + const makeClient = (requests: any[]) => ({ + fromIdentity: async () => requests.map((r) => ({ getData: () => r })), + }); + + const payer = "0x1111111111111111111111111111111111111111"; + const goodPayee = "0x2222222222222222222222222222222222222222"; + + const baseRequest = (overrides: any = {}) => ({ + requestId: "01" + "a".repeat(62), + state: "created", + expectedAmount: "1000000", // 1 USDC (6 decimals) + currency: { + type: "ERC20", + value: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Base USDC + network: "base", + }, + payer: { value: payer }, + payee: { value: goodPayee }, + balance: { balance: "0" }, + contentData: { reason: "Invoice #1" }, + ...overrides, + }); + + it("skips invoices with a missing payee instead of aborting the batch", async () => { + // Mix one valid invoice with one that has a null payee. The bad one + // must be skipped, and the batch must still attempt with the good one. + const client = makeClient([ + baseRequest(), + baseRequest({ requestId: "02" + "b".repeat(62), payee: { value: undefined } }), + ]); + const wallet = ethers.Wallet.createRandom(); + const payer2 = new SpraayBatchPayer(wallet as any, 8453); + + // payInvoices will be reached and fail later (no provider/balance), + // but it must NOT fail with an "Invalid address" abort from the empty + // payee — that's the regression we're guarding against. + try { + await payer2.payPendingInvoices(client as any, payer); + } catch (err: any) { + expect(err.message).to.not.include("Invalid address"); + } + }); + + it("does not match USDC from a different chain", async () => { + // Configure for Base but feed an Ethereum-USDC invoice; it must be + // ignored, yielding "no pending invoices" rather than a wrong-chain pay. + const ethUsdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; + const client = makeClient([ + baseRequest({ currency: { type: "ERC20", value: ethUsdc, network: "mainnet" } }), + ]); + const wallet = ethers.Wallet.createRandom(); + const payer2 = new SpraayBatchPayer(wallet as any, 8453); + + try { + await payer2.payPendingInvoices(client as any, payer); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("No pending USDC invoices"); + } + }); + }); }); From 8b040a4c3ac80f359854cd437cdf11bcb4889beb Mon Sep 17 00:00:00 2001 From: plagtech Date: Mon, 1 Jun 2026 18:40:22 -0700 Subject: [PATCH 3/3] fix: throw on reverted batch transfer instead of returning a failed result --- .../src/payment/spraay-batch-payer.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/payment-processor/src/payment/spraay-batch-payer.ts b/packages/payment-processor/src/payment/spraay-batch-payer.ts index 6dd7ce2844..7c23aff3cb 100644 --- a/packages/payment-processor/src/payment/spraay-batch-payer.ts +++ b/packages/payment-processor/src/payment/spraay-batch-payer.ts @@ -219,7 +219,17 @@ export class SpraayBatchPayer { ); } - const settled = receipt.status === 1; + // A mined-but-reverted transaction (status 0) means no funds moved. Throw + // rather than returning a result with a real transactionHash, so callers + // can't mistake a reverted batch for an attempted/successful one. + if (receipt.status !== 1) { + const explorerBase = EXPLORER_URLS[chainId] ?? "https://blockscan.com/tx/"; + throw new Error( + `Batch transfer reverted on-chain (status ${receipt.status}). ` + + `No funds were transferred. Tx: ${explorerBase}${receipt.hash}` + ); + } + const explorerBase = EXPLORER_URLS[chainId] ?? "https://blockscan.com/tx/"; return { @@ -231,7 +241,7 @@ export class SpraayBatchPayer { requestId: inv.requestId, recipient: inv.recipient, amount: inv.amount, - status: settled ? "settled" : "failed", + status: "settled" as const, })), blockNumber: receipt.blockNumber, explorerUrl: `${explorerBase}${receipt.hash}`,