Skip to content

Commit fd2c0bc

Browse files
authored
feat(x402): USDC UserOp sponsorship quote and precharge paymaster (#14)
Add POST /api/sponsor-userop at $0.10 USDC. Worker never signs or broadcasts; EntryPoint deposit cap defaults to 0.05 ETH. WQFLOPPaymaster precharges in validation to close the approve-then-revoke drain.
1 parent 30e727d commit fd2c0bc

7 files changed

Lines changed: 465 additions & 7 deletions

File tree

gc-workers/x402-paid-service/bazaar-listing.json

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "Yennefer x402 Paid Service — Tiered (Genesis Conductor)",
3-
"description": "Tiered x402 paid API on Base + Polygon mainnet. Six tiers from $0.01 discovery calls to $9,999 agent-to-agent source-exclusive checkout. USDC settlement via the Coinbase CDP facilitator (production-grade, EIP-3009). Agent-native: pay-per-call, no accounts or API keys. Covers generic execution, premium API access, AI inference, specialized data, licensing, and plugin fulfillment.",
3+
"description": "Tiered x402 paid API on Base + Polygon mainnet. Seven tiers from $0.01 discovery calls to $9,999 agent-to-agent source-exclusive checkout, plus $0.10 ERC-4337 UserOp sponsorship quotes. USDC settlement via the Coinbase CDP facilitator (production-grade, EIP-3009). Agent-native: pay-per-call, no accounts or API keys. Covers generic execution, premium API access, AI inference, specialized data, licensing, plugin fulfillment, and gas sponsorship quotes.",
44
"version": "2.1",
55
"url": "https://x402-paid-service.iholt.workers.dev",
66
"provider": "Genesis Conductor / Yennefer",
@@ -80,6 +80,35 @@
8080
"tier": "discovery"
8181
}
8282
},
83+
{
84+
"tier": "sponsor-userop",
85+
"path": "/api/sponsor-userop",
86+
"method": "POST",
87+
"networks": [
88+
"eip155:8453"
89+
],
90+
"price": {
91+
"amount": "100000",
92+
"currency": "USDC",
93+
"decimals": 6,
94+
"displayPrice": "$0.10"
95+
},
96+
"network": "eip155:8453",
97+
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
98+
"payTo": "0x60C4499870f115664d7FfD8411b023DBEf3377d9",
99+
"description": "ERC-4337 v0.6 UserOp sponsorship quote. Worker never signs or broadcasts; EntryPoint deposit cap default 0.05 ETH.",
100+
"keywords": [
101+
"paymaster",
102+
"erc-4337",
103+
"userop",
104+
"gas sponsorship",
105+
"account abstraction"
106+
],
107+
"useCases": [
108+
"quote UserOp sponsorship after USDC settlement",
109+
"agent gas market on Base"
110+
]
111+
},
83112
{
84113
"tier": "pro",
85114
"path": "/api/pro",
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.32;
3+
4+
/// ERC-4337 v0.6 UserOperation. Not PackedUserOperation (v0.7).
5+
struct UserOperation {
6+
address sender;
7+
uint256 nonce;
8+
bytes initCode;
9+
bytes callData;
10+
uint256 callGasLimit;
11+
uint256 verificationGasLimit;
12+
uint256 preVerificationGas;
13+
uint256 maxFeePerGas;
14+
uint256 maxPriorityFeePerGas;
15+
bytes paymasterAndData;
16+
bytes signature;
17+
}
18+
19+
interface IEntryPoint {
20+
function depositTo(address) external payable;
21+
function balanceOf(address) external view returns (uint256);
22+
function withdrawTo(address payable, uint256) external;
23+
}
24+
25+
interface IWQFLOP {
26+
function transferFrom(address, address, uint256) external returns (bool);
27+
function transfer(address, uint256) external returns (bool);
28+
function balanceOf(address) external view returns (uint256);
29+
function allowance(address, address) external view returns (uint256);
30+
}
31+
32+
/// wQFLOP ERC-4337 v0.6 paymaster.
33+
/// Tokens are pulled in validation (precharge) and excess is refunded in postOp.
34+
/// This closes the approve-then-revoke drain: callData cannot un-pull funds
35+
/// after validatePaymasterUserOp has already transferFrom'd the max.
36+
///
37+
/// postOpMode: 0 opSucceeded, 1 opReverted, 2 postOpReverted.
38+
/// Mode 2 must not revert (EntryPoint would otherwise retry forever).
39+
contract WQFLOPPaymaster {
40+
IEntryPoint public immutable entryPoint;
41+
IWQFLOP public immutable wqflop;
42+
address public immutable owner;
43+
44+
uint256 public rate;
45+
uint256 public constant SLIPPAGE_BPS = 300;
46+
uint256 public constant MIN_RATE = 1e12;
47+
48+
event Deposit(address indexed from, uint256 amount);
49+
event RateUpdated(uint256 oldRate, uint256 newRate);
50+
event WQFLOPCollected(address indexed user, uint256 amount, uint256 gasCost);
51+
event WQFLOPRefunded(address indexed user, uint256 amount);
52+
event WQFLOPWithdrawn(address indexed to, uint256 amount);
53+
54+
modifier onlyOwner() {
55+
require(msg.sender == owner, "WQFP: not owner");
56+
_;
57+
}
58+
59+
constructor(IEntryPoint _entryPoint, IWQFLOP _wqflop, uint256 _initialRate) {
60+
require(_initialRate >= MIN_RATE, "WQFP: rate too low");
61+
entryPoint = _entryPoint;
62+
wqflop = _wqflop;
63+
owner = msg.sender;
64+
rate = _initialRate;
65+
emit RateUpdated(0, _initialRate);
66+
}
67+
68+
receive() external payable {
69+
entryPoint.depositTo{value: msg.value}(address(this));
70+
emit Deposit(msg.sender, msg.value);
71+
}
72+
73+
function deposit() external payable {
74+
entryPoint.depositTo{value: msg.value}(address(this));
75+
emit Deposit(msg.sender, msg.value);
76+
}
77+
78+
function setRate(uint256 _rate) external onlyOwner {
79+
require(_rate >= MIN_RATE, "WQFP: rate too low");
80+
emit RateUpdated(rate, _rate);
81+
rate = _rate;
82+
}
83+
84+
function withdrawETH(address payable to, uint256 amount) external onlyOwner {
85+
entryPoint.withdrawTo(to, amount);
86+
}
87+
88+
function withdrawWQFLOP(address to, uint256 amount) external onlyOwner {
89+
uint256 bal = wqflop.balanceOf(address(this));
90+
uint256 wAmount = amount == 0 ? bal : amount;
91+
require(wqflop.transfer(to, wAmount), "WQFP: transfer failed");
92+
emit WQFLOPWithdrawn(to, wAmount);
93+
}
94+
95+
function entryPointBalance() external view returns (uint256) {
96+
return entryPoint.balanceOf(address(this));
97+
}
98+
99+
function maxCharge(uint256 maxCost) public view returns (uint256) {
100+
return (maxCost * rate * (10000 + SLIPPAGE_BPS)) / 1e18 / 10000 + 1;
101+
}
102+
103+
function validatePaymasterUserOp(
104+
UserOperation calldata userOp,
105+
bytes32,
106+
uint256 maxCost
107+
) external returns (bytes memory context, uint256 validationData) {
108+
require(msg.sender == address(entryPoint), "WQFP: only entrypoint");
109+
require(rate >= MIN_RATE, "WQFP: rate not set");
110+
111+
uint256 maxWQFLOP = maxCharge(maxCost);
112+
require(wqflop.balanceOf(userOp.sender) >= maxWQFLOP, "WQFP: low balance");
113+
require(
114+
wqflop.transferFrom(userOp.sender, address(this), maxWQFLOP),
115+
"WQFP: pull failed"
116+
);
117+
118+
context = abi.encode(userOp.sender, maxWQFLOP, rate);
119+
return (context, 0);
120+
}
121+
122+
function postOp(uint8 mode, bytes calldata context, uint256 actualGasCost) external {
123+
require(msg.sender == address(entryPoint), "WQFP: only entrypoint");
124+
(address sender, uint256 maxWQFLOP, uint256 rate_) = abi.decode(
125+
context, (address, uint256, uint256)
126+
);
127+
128+
uint256 actualWQFLOP = (actualGasCost * rate_) / 1e18;
129+
if (actualWQFLOP > maxWQFLOP) actualWQFLOP = maxWQFLOP;
130+
uint256 refund = maxWQFLOP - actualWQFLOP;
131+
132+
if (refund > 0) {
133+
bool ok = wqflop.transfer(sender, refund);
134+
if (!ok) {
135+
if (mode == 2) {
136+
emit WQFLOPRefunded(sender, 0);
137+
} else {
138+
revert("WQFP: refund failed");
139+
}
140+
} else {
141+
emit WQFLOPRefunded(sender, refund);
142+
}
143+
}
144+
145+
emit WQFLOPCollected(sender, actualWQFLOP, actualGasCost);
146+
}
147+
}

gc-workers/x402-paid-service/src/index.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe('x402 Worker discovery routes', () => {
153153
expect(health.response.status).toBe(200);
154154
await expect(responseJson<Record<string, unknown>>(health.response)).resolves.toMatchObject({
155155
status: 'ok',
156-
tiers: 6,
156+
tiers: 7,
157157
vault: VAULT,
158158
eth_pricing_enabled: true,
159159
});
@@ -177,8 +177,9 @@ describe('x402 Worker discovery routes', () => {
177177
const x402Body = await responseJson<{ endpoints: Array<{ path: string; shopify_url?: string }> }>(
178178
(await dispatch('/.well-known/x402')).response,
179179
);
180-
expect(x402Body.endpoints).toHaveLength(6);
180+
expect(x402Body.endpoints).toHaveLength(7);
181181
expect(x402Body.endpoints.map((endpoint) => endpoint.path)).toContain('/api/founders');
182+
expect(x402Body.endpoints.map((endpoint) => endpoint.path)).toContain('/api/sponsor-userop');
182183
expect(x402Body.endpoints.find((endpoint) => endpoint.path === '/api/founders')).toMatchObject({
183184
shopify_url: 'https://shop.example/founders',
184185
});

gc-workers/x402-paid-service/src/index.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { probeAlchemySurfaces } from './lib/alchemy/client';
1616
import { buildHotFundHtml } from './lib/hot-fund';
1717
import { isAllowedAlchemyRpcUrl, isAllowedCdpBaseRpcUrl } from './lib/rpc-allow';
1818
import { deliverTier } from './lib/tier-delivery';
19+
import { quoteSponsorship } from './lib/sponsor-userop';
1920
import type { SecretBindings } from './lib/secrets';
2021
import type {
2122
ExecutionContext,
@@ -104,13 +105,19 @@ interface Env {
104105
TIER_SPECIALIZED_USD6: string;
105106
TIER_FOUNDERS_USD6: string;
106107
TIER_SOURCE_EXCLUSIVE_USD6: string;
108+
TIER_SPONSOR_USD6: string;
107109
// ETH pricing tiers (wei)
108110
TIER_DISCOVERY_ETH_WEI: string;
109111
TIER_PRO_ETH_WEI: string;
110112
TIER_INFERENCE_ETH_WEI: string;
111113
TIER_SPECIALIZED_ETH_WEI: string;
112114
TIER_FOUNDERS_ETH_WEI: string;
113115
TIER_SOURCE_EXCLUSIVE_ETH_WEI: string;
116+
TIER_SPONSOR_ETH_WEI: string;
117+
/** Optional deployed WQFLOPPaymaster on Base. Quote-only if unset. */
118+
PAYMASTER_ADDRESS?: string;
119+
/** Wei cap for EntryPoint deposit used in sponsorship quotes. Default 0.05 ETH. */
120+
PAYMASTER_DEPOSIT_CAP_WEI?: string;
114121
// Solana config
115122
SOLANA_USDC_MINT: string;
116123
SOLANA_RPC_URL: string;
@@ -237,6 +244,16 @@ const TIERS: TierConfig[] = [
237244
shopifyUrl: (e) => e.SHOPIFY_SOURCE_EXCLUSIVE_URL,
238245
shopifyVariantId: (e) => e.SHOPIFY_SOURCE_EXCLUSIVE_VARIANT_ID,
239246
},
247+
{
248+
path: '/api/sponsor-userop',
249+
getAmount: (e) => e.TIER_SPONSOR_USD6 || '100000',
250+
getEthAmount: (e) => e.TIER_SPONSOR_ETH_WEI || '50000000000000',
251+
description:
252+
'ERC-4337 v0.6 UserOp sponsorship quote billed in USDC on Base. Worker never signs and never broadcasts; EntryPoint deposit is capped (default 0.05 ETH). Keywords: paymaster, erc-4337, userop, gas sponsorship, account abstraction, usdc gas.',
253+
displayPrice: '$0.10',
254+
asset: USDC_BASE,
255+
ethAsset: 'native',
256+
},
240257
];
241258

242259
// ── Solana USDC helpers ─────────────────────────────────────────────────────
@@ -852,7 +869,9 @@ async function handleTier(
852869
// above, so they skip delivery entirely; every other tier gets a real
853870
// product rather than its own description echoed back. deliverTier never
854871
// throws — settlement is already confirmed at this point.
855-
const delivery = tier.shopifyUrl
872+
const sponsor = tier.path === '/api/sponsor-userop' ? quoteSponsorship(body, env) : undefined;
873+
874+
const delivery = tier.shopifyUrl || sponsor
856875
? null
857876
: await deliverTier({
858877
tierPath: tier.path,
@@ -887,13 +906,15 @@ async function handleTier(
887906
charged_eth_wei: requestedEth ? amount : '0',
888907
payment_asset: requestedEth ? 'ETH' : 'USDC',
889908
...(delivery?.rtptpa ? { rtptpa: delivery.rtptpa } : {}),
909+
...(sponsor ? { sponsor } : {}),
890910
};
891911

892912
return Response.json({
893913
...result,
894914
payer: payerAddress,
895915
settlement_tx: settlementTx,
896916
...(fulfillment ?? {}),
917+
...(sponsor ? { sponsor } : {}),
897918
});
898919
}
899920

@@ -1006,7 +1027,7 @@ function buildLlmsTxt(hostname: string) {
10061027

10071028
return `# Genesis Conductor x402 Tiered Service
10081029
1009-
> Paid API using the x402 protocol on Base + Polygon. Six tiers from $0.01 to $9,999.
1030+
> Paid API using the x402 protocol on Base + Polygon. ${TIERS.length} tiers from $0.01 to $9,999, including UserOp sponsorship ($0.10).
10101031
10111032
## Overview
10121033
- **Networks**: Base mainnet (eip155:8453), Polygon mainnet (eip155:137)
@@ -1149,7 +1170,7 @@ export default {
11491170
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebAPI","name":"Genesis Conductor x402 Tiered Service","url":"https://${hostname}"}</script>
11501171
</head><body>
11511172
<h1>Genesis Conductor x402 Tiered Service</h1>
1152-
<p>Six tiers of paid API access — pay <strong>USDC</strong> or <strong>ETH</strong> on Base, or <strong>USDC</strong> on Solana.</p>
1173+
<p>${TIERS.length} tiers of paid API access — pay <strong>USDC</strong> or <strong>ETH</strong> on Base, or <strong>USDC</strong> on Solana.</p>
11531174
<table border="1" cellpadding="4"><thead><tr><th>Endpoint</th><th>Price</th><th>Description</th></tr></thead>
11541175
<tbody>${tierRows}</tbody></table>
11551176
<p>See <a href="/llms.txt">/llms.txt</a>, <a href="/.well-known/x402">/.well-known/x402</a>, or the <a href="/cashflow">internal cashflow dashboard</a>.</p>
@@ -1378,7 +1399,7 @@ export default {
13781399
name_for_human: 'Genesis Conductor x402',
13791400
name_for_model: 'genesis_conductor_x402',
13801401
description_for_human: 'Six-tier paid API using USDC, ETH, and Solana USDC micropayments on Base',
1381-
description_for_model: 'Six tiers of paid API access ($0.01–$9,999). Supports USDC, ETH, and Solana USDC. Call /.well-known/x402 first, then retry with PAYMENT-SIGNATURE or X-SOLANA-SIGNATURE header.',
1402+
description_for_model: `${TIERS.length} tiers of paid API access ($0.01–$9,999, plus $0.10 UserOp sponsorship). Supports USDC, ETH, and Solana USDC. Call /.well-known/x402 first, then retry with PAYMENT-SIGNATURE or X-SOLANA-SIGNATURE header.`,
13821403
auth: { type: 'none' },
13831404
api: { type: 'openapi', url: `https://${hostname}/openapi.json` },
13841405
contact_email: 'api@genesisconductor.io',
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
DEFAULT_DEPOSIT_CAP_WEI,
4+
ENTRY_POINT_V06,
5+
estimateMaxCostWei,
6+
parseUserOp,
7+
quoteSponsorship,
8+
} from './sponsor-userop';
9+
10+
const validOp = {
11+
sender: '0x1111111111111111111111111111111111111111',
12+
nonce: '0x0',
13+
initCode: '0x',
14+
callData: '0x',
15+
callGasLimit: '100000',
16+
verificationGasLimit: '100000',
17+
preVerificationGas: '50000',
18+
maxFeePerGas: '1000',
19+
maxPriorityFeePerGas: '100',
20+
paymasterAndData: '0x',
21+
signature: '0x',
22+
};
23+
24+
describe('parseUserOp', () => {
25+
it('accepts a v0.6 userOp', () => {
26+
const r = parseUserOp({ userOp: validOp });
27+
expect(r.ok).toBe(true);
28+
if (r.ok) expect(r.userOp.sender).toBe(validOp.sender);
29+
});
30+
31+
it('rejects a missing sender', () => {
32+
const r = parseUserOp({ ...validOp, sender: 'nope' });
33+
expect(r.ok).toBe(false);
34+
});
35+
});
36+
37+
describe('estimateMaxCostWei', () => {
38+
it('is (call+ver+pre)*maxFeePerGas', () => {
39+
const r = parseUserOp(validOp);
40+
expect(r.ok).toBe(true);
41+
if (r.ok) expect(estimateMaxCostWei(r.userOp)).toBe(250000n * 1000n);
42+
});
43+
});
44+
45+
describe('quoteSponsorship', () => {
46+
it('quotes within the default 0.05 ETH cap and never signs', () => {
47+
const q = quoteSponsorship({ userOp: validOp });
48+
expect(q.sponsorship_status).toBe('quoted_not_broadcast');
49+
expect(q.broadcast).toBe(false);
50+
expect(q.worker_signs).toBe(false);
51+
expect(q.network).toBe('eip155:8453');
52+
expect(q.entry_point).toBe(ENTRY_POINT_V06);
53+
expect(q.deposit_cap_wei).toBe(DEFAULT_DEPOSIT_CAP_WEI.toString());
54+
expect(q.within_cap).toBe(true);
55+
});
56+
57+
it('flags maxCost above the cap without broadcasting', () => {
58+
const q = quoteSponsorship({
59+
userOp: { ...validOp, callGasLimit: '5000000', maxFeePerGas: '100000000000' },
60+
});
61+
expect(q.sponsorship_status).toBe('quoted_not_broadcast');
62+
expect(q.within_cap).toBe(false);
63+
expect(q.broadcast).toBe(false);
64+
});
65+
66+
it('returns invalid_userop after a paid call with a bad body (no 5xx)', () => {
67+
const q = quoteSponsorship({ hello: 'world' });
68+
expect(q.sponsorship_status).toBe('invalid_userop');
69+
expect(q.reason).toMatch(/sender/);
70+
});
71+
72+
it('surfaces PAYMASTER_ADDRESS when it is a real address', () => {
73+
const q = quoteSponsorship(validOp, {
74+
PAYMASTER_ADDRESS: '0x2222222222222222222222222222222222222222',
75+
});
76+
expect(q.paymaster).toBe('0x2222222222222222222222222222222222222222');
77+
});
78+
});

0 commit comments

Comments
 (0)