Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions docs/runbooks/circle-paymaster-release-gate.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,40 @@ SMOKE_CHAIN=base SMOKE_MAINNET_OK=1 \
SMOKE_PRIVATE_KEY=0x<使い捨て鍵> PIMLICO_API_KEY=<your-key> \
SMOKE_ORIGIN=https://open-pay.jp \
node scripts/smoke-circle-crossswitch.mjs

# Optimism / Polygon / Avalanche mainnet (実 USDC・fee 実測ゲート)
# SMOKE_CHAIN を optimism / polygon / avalanche に変えるだけ。各 chain ごとに
# その chain の実 USDC ~2 を使い捨てウォレットに入れて 1 回ずつ実行する。
SMOKE_CHAIN=optimism SMOKE_MAINNET_OK=1 \
SMOKE_RPC_URL=<専用 RPC (NEXT_PUBLIC_OPTIMISM_RPC_URL)> \
SMOKE_PRIVATE_KEY=0x<使い捨て鍵> PIMLICO_API_KEY=<your-key> \
SMOKE_ORIGIN=https://open-pay.jp \
node scripts/smoke-circle-crossswitch.mjs
```

> **OP/Polygon/Avalanche は fee 実測ゲート**。gate 出力 JSON の見方:
> - `recommendedSurchargeBps` = 表示基準 (surcharge=0 の `displayBaseUsdc`) を Circle の実徴収まで
> 底上げする最小 surcharge + 300bps margin。**これが登録値**。`CIRCLE_GAS_SURCHARGE_BPS[<chainId>]`
> に入れてから flag 有効化。登録まで `resolveUsdcGaslessProvider` は pimlico に倒れる (安全側)。
> - `circleVsPimlicoRatio` = Circle gas ÷ Pimlico gas (同 EOA・同 gate)。**有効化是非の判断材料**。
> Pimlico は既に全 chain で安価に動くため、比が大きい chain は Circle にするとガス代が上がる
> (Circle 優先は信頼性/公式サポート理由でコスト最適ではない)。
> - `markupVsActualGasBps` は診断のみ (L2 は actualGasCost に L1 data fee が含まれず過大に出る)。登録に使わない。
>
> ### 2026-05-31 実測結果
> | chain | gate | Circle gas | Pimlico gas | Circle÷Pimlico | 判定 |
> |-------|------|-----------|-------------|----------------|------|
> | Optimism (10) | ✅ PASS | ~0.0012 USDC | 0.0005 USDC | **2.31×** | **有効化済** (surcharge 1000bps 登録)。再ゲート: displayBase 0.001336 ≥ 実徴収 0.001156 → 表示基準 surcharge 0%、10% は policy + L1 cushion |
> | Polygon (137) | ✅ PASS | ~0.038 USDC | 0.0065 USDC | **~5.8×** | コスト高 — 有効化見送り (Pimlico 維持) |
> | Avalanche (43114) | ❌ FAIL | — | — | — | **7702 非互換**で有効化不可 (下記) |
>
> **Avalanche は ACP-209「EIP-7702 *style*」AA** で nonce/balance 扱いが canonical EIP-7702 と
> 異なり、標準 viem/permissionless/Pimlico 7702 スタックでは委任が張れず 3 leg とも AA23
> (validateUserOp revert)。Circle だけでなく **Pimlico 7702 経路も失敗**するため、現スタックでは
> Circle・Pimlico 7702 とも有効化不可。canonical 7702 対応 or Avalanche 専用 AA 経路ができるまで保留。
> (副次的論点: Avalanche の既存 gasless が pristine EOA の 7702 委任に依存していれば同様に動かない
> 可能性 — MAv2 等の deployed smart account 経路は別途要確認。)

> ⚠️ Base mainnet は実 USDC (~2 で十分・ガスはセント単位)。`SMOKE_MAINNET_OK=1` 必須。
> 公開 RPC (`mainnet.base.org`) は rate limit で 7702 bootstrap が壊れるため専用 RPC 必須。

Expand Down Expand Up @@ -140,13 +172,20 @@ Pimlico paymaster ↔ Circle paymaster を往復しても壊れない」** を
登録で制御**する (ゲート通過まで登録しない)。
4. **Arbitrum mainnet** (fee=10%・Circle dev docs で Arb/Base のみ 10% と確認): `SMOKE_CHAIN=arbitrum`
で本 smoke ゲートを通過させ、`CIRCLE_GAS_SURCHARGE_BPS` に `[arbitrum.id]: 1000` を登録。
5. **10% 非適用 chain (Optimism/Polygon/Ethereum/Avalanche/Unichain)**: Circle dev docs では
10% surcharge 非適用だが「USDC→ETH slippage の未定量 spread」があり **0% 確定ではない**。
各 chain で `SMOKE_CHAIN=<chain>` ゲートを実行し、**実 receipt の徴収 USDC vs 生ガスで実効 fee を
実測** → その値 (round-up + margin) を `CIRCLE_GAS_SURCHARGE_BPS` に登録してから有効化 (C4)。
未登録の間は `resolveUsdcGaslessProvider` が pimlico に倒すため自動的に Pimlico erc20 fallback。
5. **10% 非適用 chain (Optimism/Polygon/Avalanche)**: smoke config 整備済
(`SMOKE_CHAIN=optimism|polygon|avalanche`)。gate が `displayBaseUsdc` / `recommendedSurchargeBps`
(表示基準を満たす最小 surcharge + 300bps margin) / `circleVsPimlicoRatio` (コスト competitiveness) を出す。
**PASS かつ** 推奨値を `CIRCLE_GAS_SURCHARGE_BPS` に登録してから有効化 (C4)。未登録の間は
`resolveUsdcGaslessProvider` が pimlico に倒すため自動的に Pimlico erc20 fallback。
2026-05-31 実測 (上記「2026-05-31 実測結果」表):
- **Optimism**: ✅ **有効化済** (`[optimism.id]: 1000`)。表示基準 surcharge 実測 0%、10% は Base/Arb
と揃える policy + OP-stack L1 data fee 変動への cushion。Circle ≈ Pimlico の 2.31×。
- **Polygon**: PASS だが Circle ≈ Pimlico の ~5.8× とコスト高 → 有効化見送り (Pimlico 維持)。
- **Avalanche**: ❌ ACP-209「7702 style」AA 非互換で 3 leg とも AA23・委任張れず。**有効化不可**
(Circle/Pimlico 7702 とも)。canonical 7702 or 専用 AA 経路ができるまで保留。
- **Ethereum L1 / Unichain は smoke 未整備**。L1 はガスが絶対額で高い (数$/tx) ため小額決済に
不向き、Unichain は buyer-only で優先度低。必要になった時点で同様に config を足す。
- 全 mainnet で USDC は **native** を使用 (Polygon も `0x3c49…`・USDC.e ではない)。
- Ethereum L1 はガスが絶対額で高い (数$/tx) ため小額決済には不向き — 有効化の価値を要検討。
- v0.8 paymaster は全 mainnet で `0x0578…700Ec` (deterministic)。gate が codehash を log するので
Base と一致を確認後 `CIRCLE_PAYMASTER_CODEHASH` に登録すると本番の codehash 検証が効く。

Expand Down
13 changes: 11 additions & 2 deletions lib/circlePaymaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,17 @@ export const CIRCLE_GAS_SURCHARGE_BPS: Readonly<Record<number, number>> = {
// dev docs: 10% surcharge は Arb/Base のみ)。SMOKE_CHAIN=arbitrum の実機ゲート通過を前提に有効化。
[base.id]: 1000,
[arbitrum.id]: 1000,
// ↑ 他 mainnet (OP/Polygon/Ethereum/Avalanche/Unichain) は 10% 非適用 + slippage spread が
// 未定量のため、各 chain の実機ゲートで実効 fee を実測してから登録する (C4)。
// Optimism: 2026-05-31 ゲート通過。実測の表示基準 surcharge は 0% (displayBaseUsdc 0.001336 ≥
// Circle 実徴収 0.001156)・Circle ≈ Pimlico の 2.31×。10% は Base/Arb と揃える policy 値で、
// OP-stack の L1 data fee 変動 (displayBase は L2 gas price ベースで L1 を追従しない・実徴収が
// displayBase の 87% を消費し raw headroom が ~15% しかない) への余裕も兼ねる。gate は 10% が
// 必要最小 0% を十分上回ることを確認済 (permit 側は 10× 係数で実徴収を常にカバー・revert なし)。
[optimism.id]: 1000,
// ↑ 他 mainnet (Polygon/Ethereum/Avalanche/Unichain) は未登録 = Circle 無効 (Pimlico erc20 に倒れる)。
// gate (SMOKE_CHAIN=...) が recommendedSurchargeBps + circleVsPimlicoRatio を出力する。
// 2026-05-31 実測 (docs/runbooks):
// - Polygon (137): PASS だが Circle ≈ Pimlico の ~5.8× とコスト高 → Pimlico 維持 (有効化見送り)。
// - Avalanche (43114): ❌ ACP-209「7702 style」AA 非互換で委任張れず (AA23)。有効化不可。
// testnet (QA 用・本番 mainnet NETWORK_ENV では非活性)
[arbitrumSepolia.id]: 1000,
[baseSepolia.id]: 1000,
Expand Down
165 changes: 163 additions & 2 deletions scripts/smoke-circle-crossswitch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ import {
HttpRequestError,
TimeoutError,
} from 'viem';
import { arbitrum, arbitrumSepolia, base } from 'viem/chains';
import {
arbitrum,
arbitrumSepolia,
avalanche,
base,
optimism,
polygon,
} from 'viem/chains';
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
import {
createBundlerClient,
Expand Down Expand Up @@ -107,6 +114,43 @@ const CHAIN_CONFIGS = {
isMainnet: true,
funding: '本物の Arbitrum USDC を ~2 (使い捨てウォレットのみ・faucet 無し)',
},
// ↓ OP / Polygon / Avalanche は Circle dev docs で 10% surcharge **非適用**だが
// USDC→native の slippage spread が未定量。本 gate は実 receipt から「Circle が
// 実際に引いた USDC ÷ 実ガスの market USDC 換算」= 実効 fee を計測し、その値を
// CIRCLE_GAS_SURCHARGE_BPS の登録根拠にする (計画 C4・docs/runbooks)。
optimism: {
chain: optimism,
usdc: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // USDC_OPTIMISM_MAINNET (native・lib/tokens.ts)
circlePaymaster: '0x0578cFB241215b77442a541325d6A4E6dFE700Ec', // v0.8 mainnet (deterministic・全 chain 同一)
rpcEnv: 'NEXT_PUBLIC_OPTIMISM_RPC_URL',
rpcUrl: 'https://mainnet.optimism.io',
isMainnet: true,
funding: '本物の Optimism USDC を ~2 (使い捨てウォレットのみ・faucet 無し)',
},
polygon: {
chain: polygon,
usdc: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // USDC_POLYGON_MAINNET (native・USDC.e ではない・lib/tokens.ts)
circlePaymaster: '0x0578cFB241215b77442a541325d6A4E6dFE700Ec', // v0.8 mainnet (deterministic)
rpcEnv: 'NEXT_PUBLIC_POLYGON_RPC_URL',
rpcUrl: 'https://polygon-rpc.com',
isMainnet: true,
funding: '本物の Polygon USDC を ~2 (native・gas は POL だが gasless なので不要・使い捨てのみ)',
},
avalanche: {
chain: avalanche,
usdc: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', // USDC_AVALANCHE_MAINNET (native・lib/tokens.ts)
circlePaymaster: '0x0578cFB241215b77442a541325d6A4E6dFE700Ec', // v0.8 mainnet (deterministic)
rpcEnv: 'NEXT_PUBLIC_AVALANCHE_RPC_URL',
rpcUrl: 'https://api.avax.network/ext/bc/C/rpc',
isMainnet: true,
funding: '本物の Avalanche USDC を ~2 (native・使い捨てウォレットのみ・faucet 無し)',
// ⚠️ 2026-05-31 gate: pristine EOA で 3 leg とも AA23 (validateUserOp revert)・委任が
// 張られず delegateAfter=null。Avalanche C-Chain は ACP-209「EIP-7702 *style*」AA で
// nonce/balance 扱いが canonical EIP-7702 と異なり、標準 viem/permissionless/Pimlico
// 7702 スタックでは委任が適用されない (OP/Polygon/Base/Arb では同一スクリプトが成功)。
// → Circle だけでなく **Pimlico 7702 leg も失敗**。現スタックでは有効化不可。
// (canonical 7702 対応 or Avalanche 専用 AA 経路ができるまで保留。)
},
};
const SMOKE_CHAIN = process.env.SMOKE_CHAIN || 'arbitrum-sepolia';
const CFG = CHAIN_CONFIGS[SMOKE_CHAIN];
Expand Down Expand Up @@ -237,6 +281,7 @@ async function pimlicoLeg({ owner, publicClient, bundlerTransport, label }) {
entryPoint: receipt.entryPoint,
paymaster: receipt.paymaster,
usdcOut: usdc,
actualGasCost: receipt.actualGasCost,
};
}

Expand Down Expand Up @@ -349,6 +394,8 @@ async function circleLeg({ owner, publicClient, bundlerTransport, permitAmount,
entryPoint: receipt.entryPoint,
paymaster: receipt.paymaster,
usdcOut: usdc,
// EntryPoint が paymaster に課金した実ガス (native wei)。実効 fee 計測の分母。
actualGasCost: receipt.actualGasCost,
};
}

Expand Down Expand Up @@ -457,6 +504,53 @@ async function main() {
log(`USDC balance: ${formatUnits(bal, 6)} USDC`);
if (bal === 0n) throw new Error(`USDC 残高 0 — ${CFG.funding} を入金して再実行`);

// 表示見積の基準値 (useGasQuoteCircle / useGasQuoteUsdc と同式) + 実効 fee の素材を取得。
// displayBaseUsdc = (500k + postOp) × standard maxFeePerGas × rate / 1e18
// = surcharge=0 で UI が「最大ガス代 (顧客負担)」として出す額。Circle の実徴収が
// これを超えると顧客は表示より多く課金される → surcharge でこの基準を底上げする。
let exchangeRate = null;
let displayBaseUsdc = null;
const OVERHEAD_UNITS = 500_000n; // DEFAULT_USEROP_GAS_UNITS (本番 NEXT_PUBLIC_GAS_QUOTE_OVERHEAD_GAS 未設定)
try {
const quoteClient = createPimlicoClient({
transport: bundlerTransport,
entryPoint: { address: entryPoint08Address, version: '0.8' },
});
const [quotes, gasPrice] = await Promise.all([
quoteClient.getTokenQuotes({ tokens: [USDC], chain: CHAIN }),
quoteClient.getUserOperationGasPrice(),
]);
if (quotes.length > 0) {
exchangeRate = quotes[0].exchangeRate;
const effPostOp =
quotes[0].postOpGas > CIRCLE_MIN_POSTOP_GAS ? quotes[0].postOpGas : CIRCLE_MIN_POSTOP_GAS;
displayBaseUsdc =
((OVERHEAD_UNITS + effPostOp) * BigInt(gasPrice.standard.maxFeePerGas) * exchangeRate) /
10n ** 18n;
}
} catch (e) {
log(`(token quote 取得失敗 — 実効 fee 計測はスキップ: ${e.shortMessage || e.message})`);
}
log(`USDC/native rate: ${exchangeRate ?? '(取得不可)'} (1e18 scale)`);
log(
`表示 gas 基準 (surcharge=0): ${displayBaseUsdc !== null ? formatUnits(displayBaseUsdc, 6) + ' USDC' : '(計測不可)'}`,
);

// **登録根拠** = 表示基準を Circle の実徴収まで底上げする最小 surcharge。
// surchargeVsDisplayBps = max(0, Circle 実徴収 ÷ displayBase − 1)。
// 参考診断 = 実ガス (native) 比の markup。L2 は actualGasCost に L1 data fee が含まれず
// 過大に出るので**登録には使わない** (診断ログのみ)。
const surchargeVsDisplayBps = (toCircle) => {
if (displayBaseUsdc === null || displayBaseUsdc <= 0n || !toCircle) return null;
return Math.max(0, Number((toCircle * 10_000n) / displayBaseUsdc) - 10_000);
};
const markupVsActualGasBps = (toCircle, actualGasCost) => {
if (!exchangeRate || !actualGasCost || actualGasCost === 0n) return null;
const marketUsdc = (actualGasCost * exchangeRate) / 10n ** 18n;
if (marketUsdc <= 0n) return null;
return Number((toCircle * 10_000n) / marketUsdc) - 10_000;
};

const delBefore = await delegateOf(publicClient, owner.address);
log(`委任 (before): ${delBefore ?? '0x (pristine — 初回 UserOp で自動委任)'}`);

Expand All @@ -473,10 +567,24 @@ async function main() {
for (const run of legs) {
try {
const r = await run();
// ガス徴収額 = owner 発 USDC 総額 − merchant transfer (TRANSFER_AMOUNT)。
r.gasChargeUsdc = r.usdcOut ? r.usdcOut.total - TRANSFER_AMOUNT : null;
if (r.provider === 'circle' && r.usdcOut) {
r.surchargeVsDisplayBps = surchargeVsDisplayBps(r.usdcOut.toCircle);
r.markupVsActualGasBps = markupVsActualGasBps(r.usdcOut.toCircle, r.actualGasCost);
}
log(
` → success=${r.ok} entryPoint=${r.entryPoint} paymaster=${r.paymaster} ` +
`USDC out=${formatUnits(r.usdcOut.total, 6)} (→Circle=${formatUnits(r.usdcOut.toCircle, 6)})`,
`USDC out=${formatUnits(r.usdcOut.total, 6)} (gas 分=${r.gasChargeUsdc !== null ? formatUnits(r.gasChargeUsdc, 6) : '?'})`,
);
if (typeof r.surchargeVsDisplayBps === 'number') {
log(
` Circle gas=${formatUnits(r.usdcOut.toCircle, 6)} USDC|表示基準を満たす最小 surcharge=${(r.surchargeVsDisplayBps / 100).toFixed(2)}%` +
(typeof r.markupVsActualGasBps === 'number'
? `|(診断: 実ガス比 +${(r.markupVsActualGasBps / 100).toFixed(0)}%・L1 data fee 含まず)`
: ''),
);
}
results.push(r);
} catch (e) {
log(` ✖ leg 失敗 [${isTransient(e) ? 'transient' : 'deterministic'}]: ${e.shortMessage || e.message}`);
Expand All @@ -501,25 +609,78 @@ async function main() {
);
const delegationStable = delAfter?.toLowerCase() === IMPL_7702.toLowerCase();

// 登録推奨 surcharge = max(表示基準を満たす最小 surcharge) + 300bps margin・100 切り上げ。
const displaySurcharges = results
.filter((r) => r.provider === 'circle' && typeof r.surchargeVsDisplayBps === 'number')
.map((r) => r.surchargeVsDisplayBps);
const maxDisplaySurchargeBps = displaySurcharges.length ? Math.max(...displaySurcharges) : null;
const recommendedSurchargeBps =
maxDisplaySurchargeBps === null
? null
: Math.max(0, Math.ceil((maxDisplaySurchargeBps + 300) / 100) * 100);

// コスト競争力: Circle gas ÷ Pimlico gas (同 EOA・同 gate)。Pimlico が既に各 chain で
// 安価に動いているため、この比が大きい chain は Circle 有効化でガス代が上がる
// (Circle 優先は信頼性/公式サポート理由でコスト最適ではない、を定量化する)。
const circleGasCharges = results
.filter((r) => r.provider === 'circle' && r.gasChargeUsdc !== null && r.gasChargeUsdc > 0n)
.map((r) => r.gasChargeUsdc);
const pimlicoLegR = results.find(
(r) => r.provider === 'pimlico' && r.gasChargeUsdc !== null && r.gasChargeUsdc > 0n,
);
const avgCircleGas = circleGasCharges.length
? circleGasCharges.reduce((s, v) => s + v, 0n) / BigInt(circleGasCharges.length)
: null;
const circleVsPimlicoRatio =
avgCircleGas !== null && pimlicoLegR
? Number((avgCircleGas * 100n) / pimlicoLegR.gasChargeUsdc) / 100
: null;

log(JSON.stringify(
{
chain: `${CHAIN.name} (${CHAIN.id})`,
allLegsSuccess: allOk,
sameSender: senders.size <= 1,
allEntryPointV08: epAllV08,
circleChargedUsdc: circleLegOk,
delegationStable_0xe6Cae83: delegationStable,
delegateBefore: delBefore,
delegateAfter: delAfter,
displayBaseUsdc: displayBaseUsdc !== null ? formatUnits(displayBaseUsdc, 6) : null,
maxSurchargeVsDisplayBps: maxDisplaySurchargeBps,
recommendedSurchargeBps,
circleVsPimlicoRatio,
legs: results.map((r) => ({
leg: r.leg, provider: r.provider, ok: r.ok,
entryPoint: r.entryPoint, paymaster: r.paymaster,
usdcOut: r.usdcOut ? formatUnits(r.usdcOut.total, 6) : null,
gasChargeUsdc: r.gasChargeUsdc !== null ? formatUnits(r.gasChargeUsdc, 6) : null,
usdcToCircle: r.usdcOut ? formatUnits(r.usdcOut.toCircle, 6) : null,
surchargeVsDisplayBps:
typeof r.surchargeVsDisplayBps === 'number' ? r.surchargeVsDisplayBps : null,
markupVsActualGasBps:
typeof r.markupVsActualGasBps === 'number' ? r.markupVsActualGasBps : null,
txHash: r.txHash, error: r.error,
})),
},
null, 2,
));

if (maxDisplaySurchargeBps !== null) {
log(
`\n計測: 表示基準を満たす最小 surcharge (最大) = ${(maxDisplaySurchargeBps / 100).toFixed(2)}% ` +
`→ 推奨 CIRCLE_GAS_SURCHARGE_BPS[${CHAIN.id}] = ${recommendedSurchargeBps} (+300bps margin・100 切り上げ)`,
);
if (circleVsPimlicoRatio !== null) {
log(
` コスト: Circle gas ≈ Pimlico の ${circleVsPimlicoRatio.toFixed(2)}倍 ` +
`(比が大きいほど Circle 有効化で顧客ガス代が上がる — 有効化是非の判断材料)`,
);
}
} else {
log('\n計測: 表示基準 surcharge は取得不可 (displayBase 欠落 or 全 circle leg 失敗)。');
}

const PASS = allOk && epAllV08 && circleLegOk && delegationStable;
log(`\n${PASS ? '✅ PASS' : '❌ FAIL'} — ${PASS
? '同一 EOA・同一 v0.8 EntryPoint で Pimlico↔Circle paymaster 往復成功。Circle 投入ゲート (送信面) クリア。'
Expand Down
Loading
Loading