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
346 changes: 346 additions & 0 deletions examples/circle-e2e.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/core/src/engine/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ const VALID_STRATEGIES = [
'self_pay_gas', 'cancel_pending_txs', 'speed_up_transaction',
'split_transaction', 'swap_currency', 'topup_from_reserve',
'renew_session', 'remove_and_resubmit', 'refund_waterfall', 'switch_service',
// Circle-specific (validated in Nanopayments Exp A2/C2, Apr 2026)
'serialize_and_backoff', 'burst_then_pace', 'rotate_authorization', 'wait_attestation',
];

// System prompt split into composable sections so the trace-aware variant
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/engine/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,82 @@ export class HelixProvider {
return { success: true, overrides: { txHashes: hashes, chunks: numChunks }, description: `Split swap: ${numChunks} chunks` };
}

// ═══════ Circle-specific (Group 1 nanopayments, validated Apr 2026) ═══════

case 'serialize_and_backoff': {
// Wallets API concurrency lock — pause, signal caller to serialize
const delay = Number(context?.retryAfterMs ?? context?.defaultDelayMs) || 2000;
await sleep(Math.min(delay, 5000));
return {
success: true,
overrides: { _helix_serialize: true, _helix_concurrency: 1 },
description: `Serialized retry after ${delay}ms (Wallets API concurrency lock)`,
};
}

case 'burst_then_pace': {
// Gateway sliding-window — pause between bursts of N
const pauseMs = Number(context?.pauseMs) || 20000;
const burstSize = Number(context?.burstSize) || 10;
await sleep(pauseMs);
return {
success: true,
overrides: { _helix_burst_size: burstSize, _helix_pace_pause_ms: pauseMs },
description: `Paced retry after ${pauseMs}ms pause (Gateway sliding window)`,
};
}

case 'rotate_authorization': {
// EIP-3009 — generate fresh 32-byte nonce; caller must re-sign
const { randomBytes } = await import('node:crypto');
const newNonce = '0x' + randomBytes(32).toString('hex');
return {
success: true,
overrides: { authorization_nonce: newNonce, _helix_resign_authorization: true },
description: 'Generated fresh EIP-3009 nonce; caller must re-sign',
};
}

case 'wait_attestation': {
// CCTP — poll Circle attestation API until message is signed
// Requires Node 18+ for global fetch (verified — repo runs on node v24).
const messageHash = context?.messageHash ?? context?._messageHash;
if (!messageHash) {
return {
success: false,
overrides: {},
description: 'wait_attestation requires context.messageHash',
};
}
const pollIntervalMs = Number(context?.pollIntervalMs) || 5000;
const maxWaitMs = Number(context?.maxWaitMs) || 90000;
const env = context?.cctpEnv === 'mainnet' ? 'iris-api' : 'iris-api-sandbox';
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const resp = await fetch(`https://${env}.circle.com/v1/attestations/${messageHash}`);
if (resp.ok) {
const data = (await resp.json()) as { status: string; attestation?: string };
if (data.status === 'complete' && data.attestation) {
return {
success: true,
overrides: { attestation: data.attestation },
description: `Attestation ready after ${Date.now() - start}ms (${env})`,
};
}
}
} catch {
/* transient network — retry on next poll */
}
await sleep(pollIntervalMs);
}
return {
success: false,
overrides: {},
description: `Attestation not ready within ${maxWaitMs}ms (${env})`,
};
}

// ═══════ Default ═══════

default:
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/engine/seed-genes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,13 @@ export const SEED_GENES: Omit<GeneCapsule, 'id'>[] = [
{ failureCode: 'tip-403', category: 'compliance', strategy: 'switch_stablecoin', params: {}, successCount: 4, avgRepairMs: 800, platforms: ['tempo'], qValue: 0.72, consecutiveFailures: 0 },
{ failureCode: 'swap-reverted', category: 'dex', strategy: 'split_swap', params: { defaultChunks: 3 }, successCount: 3, avgRepairMs: 3500, platforms: ['tempo'], qValue: 0.68, consecutiveFailures: 0 },
{ failureCode: 'tx-reverted', category: 'batch', strategy: 'remove_and_resubmit', params: {}, successCount: 4, avgRepairMs: 450, platforms: ['tempo', 'coinbase'], qValue: 0.74, consecutiveFailures: 0 },
// Circle (validated in Nanopayments Exp A2/C2, Apr 2026 — see project_circle_bench_data).
// apiLayer is REQUIRED for Gene Map UNIQUE(failure_code, category, COALESCE(api_layer, '')) — null != 'wallets-api'.
{ failureCode: 'wallets-api-rate-limit', category: 'auth', apiLayer: 'wallets-api', strategy: 'serialize_and_backoff', params: { defaultDelayMs: 2000 }, successCount: 9, avgRepairMs: 2200, platforms: ['circle'], qValue: 0.95, consecutiveFailures: 0 },
{ failureCode: 'gateway-rate-limit', category: 'auth', apiLayer: 'gateway', strategy: 'burst_then_pace', params: { burstSize: 10, pauseMs: 20000 }, successCount: 7, avgRepairMs: 20500, platforms: ['circle'], qValue: 0.70, consecutiveFailures: 0 },
{ failureCode: 'gateway-nonce-used', category: 'signature', apiLayer: 'gateway', strategy: 'rotate_authorization', params: {}, successCount: 8, avgRepairMs: 250, platforms: ['circle'], qValue: 0.92, consecutiveFailures: 0 },
{ failureCode: 'cctp-attestation-pending', category: 'service', apiLayer: 'cctp', strategy: 'wait_attestation', params: { pollIntervalMs: 5000, maxWaitMs: 90000 }, successCount: 6, avgRepairMs: 32000, platforms: ['circle'], qValue: 0.98, consecutiveFailures: 0 },
// Helix's deterministic answer for user-side param mistakes: don't retry, hand to operator.
// qValue high (0.85) because hold_and_notify is the correct response to ANY param error.
{ failureCode: 'circle-param-invalid', category: 'auth', apiLayer: 'wallets-api', strategy: 'hold_and_notify', params: {}, successCount: 3, avgRepairMs: 5, platforms: ['circle'], qValue: 0.85, consecutiveFailures: 0 },
];
14 changes: 14 additions & 0 deletions packages/core/src/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ export type ErrorCode =
| 'wallets-api-rate-limit'
| 'gateway-rate-limit'
| 'gateway-nonce-used'
// Circle Web SDK numeric error codes (developers.circle.com/wallets/.../web-sdk)
| 'circle-param-missing' // code === 1
| 'circle-param-invalid' // code === 2
| 'circle-forbidden' // code === 3
| 'circle-unauthorized' // code === 4
| 'circle-retry' // code === 9
| 'circle-customer-suspended' // code === 10
| 'circle-pending' // code === 11
| 'circle-token-expired' // code === 155104
| 'circle-insufficient-funds' // code === 155201
| 'circle-exceed-withdraw-limit' // code === 155203
| 'circle-pending-tx-queue-full' // code === 155264
// CCTP (perceive does not emit yet; callers may emit from CCTP client code)
| 'cctp-attestation-pending'
| 'unknown';

// ── Failure Categories ──────────────────────────────────────────
Expand Down
157 changes: 134 additions & 23 deletions packages/core/src/platforms/circle/perceive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,139 @@ import type { FailureClassification, Platform } from '../../engine/types.js';

type ApiLayer = 'wallets-api' | 'gateway' | 'cctp' | null;

function detectApiLayer(error: Error, context?: Record<string, unknown>): ApiLayer {
const url = String(context?.url ?? context?.requestUrl ?? '') + ' ' + error.message;
function detectApiLayer(error: any, context?: Record<string, unknown>): ApiLayer {
// The URL lives in different fields depending on the Circle SDK version:
// v10+: top-level error.url (SDK wraps AxiosError into its own class)
// v7 : error.config.url / error.response.config.url (raw AxiosError)
// Read both shapes; fall back to context, then message.
const url = String(
error?.url
?? error?.config?.url
?? error?.response?.config?.url
?? context?.url
?? context?.requestUrl
?? error?.message
?? ''
);
if (/api\.circle\.com\/v1\/w3s/i.test(url)) return 'wallets-api';
if (/gateway-api(-testnet)?\.circle\.com/i.test(url)) return 'gateway';
if (/x402-batching/i.test(url)) return 'gateway';
if (/iris-api(-sandbox)?\.circle\.com/i.test(url)) return 'cctp';
if (/TokenMessenger|MessageTransmitter|\bcctp\b/i.test(url)) return 'cctp';
return null;
}

export function circlePerceive(error: Error, context?: Record<string, unknown>): FailureClassification | null {
const msg = error.message;
const platform: Platform = 'circle';
const apiLayer = detectApiLayer(error, context);

const base = { severity: 'medium' as const, platform, details: msg, timestamp: Date.now() };
// The axios wrapper message (e.g. "Request failed with status code 429") —
// kept as `msg` for the existing apiLayer-coupled regex blocks below that
// assume this shape. New routing uses `circleMsg` instead.
const msg = error.message;

// ── Wallets API (api.circle.com/v1/w3s) ──
// Exp B: undocumented ~5 concurrent tx cap, returns {"code":5,"message":"API rate limit error"}
if (apiLayer === 'wallets-api') {
const errCode = (error as any)?.response?.data?.code ?? (context as any)?.errorCode;
if (errCode === 5 || /code:\s*5|API rate limit/i.test(msg)) {
// Circle returns its numeric code in different places depending on SDK version:
// v10+: top-level error.code (number) — SDK extracts it from response.data
// v7 : error.response.data.code — raw axios shape
// Only accept numeric values — that filters out axios's ERR_BAD_REQUEST (string)
// which can appear at error.code in the v7 shape.
const responseDataCode = (error as any)?.response?.data?.code;
const topLevelCode = (error as any)?.code;
const circleCode: number | undefined =
typeof responseDataCode === 'number' ? responseDataCode :
typeof topLevelCode === 'number' ? topLevelCode :
undefined;

// The actual Circle error message lives inside response.data.message.
// Fall back to the axios wrapper message only if the Circle payload is missing.
const circleMsg: string = String(
(error as any)?.response?.data?.message ?? error?.message ?? ''
);

const base = { severity: 'medium' as const, platform, details: circleMsg, timestamp: Date.now() };

// ════════════════════════════════════════════════════════════════════
// Web SDK numeric error codes (developers.circle.com/wallets/.../web-sdk).
// Source of truth: error.response.data.code (verified via probe).
// Routed BEFORE the api_layer-specific blocks so any layer that reports a
// canonical numeric code lands on the right capsule directly.
// ════════════════════════════════════════════════════════════════════
if (circleCode !== undefined) {
if (circleCode === 1) {
return { ...base, code: 'circle-param-missing', category: 'auth', severity: 'medium', apiLayer };
}
if (circleCode === 2) {
return { ...base, code: 'circle-param-invalid', category: 'auth', severity: 'medium', apiLayer };
}
if (circleCode === 3) {
return { ...base, code: 'circle-forbidden', category: 'auth', severity: 'high', apiLayer };
}
if (circleCode === 4) {
return { ...base, code: 'circle-unauthorized', category: 'auth', severity: 'high', apiLayer };
}
if (circleCode === 5) {
return { ...base, code: 'wallets-api-rate-limit', category: 'auth', severity: 'medium', apiLayer };
}
if (/insufficient|exceeds balance/i.test(msg)) {
return { ...base, code: 'payment-insufficient', category: 'balance', severity: 'high', apiLayer };
if (circleCode === 9) {
return { ...base, code: 'circle-retry', category: 'service', severity: 'low', apiLayer };
}
if (circleCode === 10) {
return { ...base, code: 'circle-customer-suspended', category: 'compliance', severity: 'critical', apiLayer };
}
if (circleCode === 11) {
return { ...base, code: 'circle-pending', category: 'service', severity: 'low', apiLayer };
}
if (circleCode === 155104) {
return { ...base, code: 'circle-token-expired', category: 'session', severity: 'medium', apiLayer };
}
// Circle returns 155258 in practice (probe-verified). The Web SDK enum
// documents 155201 — accept both as aliases for forward-compat.
if (circleCode === 155258 || circleCode === 155201) {
return { ...base, code: 'circle-insufficient-funds', category: 'balance', severity: 'high', apiLayer };
}
if (circleCode === 155203) {
return { ...base, code: 'circle-exceed-withdraw-limit', category: 'policy', severity: 'medium', apiLayer };
}
if (circleCode === 155264) {
return { ...base, code: 'circle-pending-tx-queue-full', category: 'service', severity: 'medium', apiLayer };
}
}

// Message-based fallbacks for cases where a numeric code isn't surfaced.
// GATED on apiLayer !== null — i.e. only fire after detectApiLayer has
// confirmed this is actually a Circle endpoint. Otherwise generic phrases
// like "insufficient balance" would steal plain test errors from other
// adapters in the chain.
if (apiLayer !== null) {
if (/api parameter missing/i.test(circleMsg)) {
return { ...base, code: 'circle-param-missing', category: 'auth', severity: 'medium', apiLayer };
}
if (/api parameter invalid/i.test(circleMsg)) {
return { ...base, code: 'circle-param-invalid', category: 'auth', severity: 'medium', apiLayer };
}
if (/insufficient.*(?:balance|funds|amount)|asset.*insufficient/i.test(circleMsg)) {
return { ...base, code: 'circle-insufficient-funds', category: 'balance', severity: 'high', apiLayer };
}
if (/exceed.*withdraw/i.test(circleMsg)) {
return { ...base, code: 'circle-exceed-withdraw-limit', category: 'policy', severity: 'medium', apiLayer };
}
if (/pending.*queue/i.test(circleMsg)) {
return { ...base, code: 'circle-pending-tx-queue-full', category: 'service', severity: 'medium', apiLayer };
}
if (/token expired/i.test(circleMsg)) {
return { ...base, code: 'circle-token-expired', category: 'session', severity: 'medium', apiLayer };
}
}

// ── Wallets API (api.circle.com/v1/w3s) ──
// Code-5 (wallets-api-rate-limit) is now handled in the numeric block above
// regardless of apiLayer. The /insufficient|exceeds balance/i message check
// is superseded by the broader insufficient-funds fallback above.
// Block kept for documentation of historical Exp B findings.
if (apiLayer === 'wallets-api') {
/* Numeric/message routing above now handles wallets-api errors. */
}

// ── Gateway (gateway-api*.circle.com via @circle-fin/x402-batching) ──
// Exp C: replays rejected with reason "nonce_already_used"
// Exp A/B: rate-limit and settlement errors are all flattened to "Payment processing error"
Expand Down Expand Up @@ -76,18 +181,24 @@ export function circlePerceive(error: Error, context?: Record<string, unknown>):
}
}

// Generic Circle SDK error shapes that can appear on any api_layer
if (/unauthorized|invalid api key/i.test(msg)) {
return { ...base, code: 'rate-limited', category: 'auth', severity: 'medium', apiLayer };
}
if (/(rate.?limit|429)/i.test(msg)) {
return { ...base, code: 'rate-limited', category: 'auth', severity: 'medium', apiLayer };
}
if (/timeout|ETIMEDOUT|ETIMEOUT/i.test(msg)) {
return { ...base, code: 'timeout', category: 'service', severity: 'medium', apiLayer };
}
if (/(500|502|503|internal_server_error|service_unavailable|bad_gateway)/i.test(msg)) {
return { ...base, code: 'server-error', category: 'service', severity: 'high', apiLayer };
// Generic Circle SDK error shapes — GATED on apiLayer !== null for the
// same reason as the message fallback above: with circleAdapter now at
// the front of the chain (so it can claim wallets-api 429s before privy's
// generic /429/ catches them), we must NOT claim arbitrary errors that
// happen to mention "503" / "rate limit" / "timeout" but aren't Circle.
if (apiLayer !== null) {
if (/unauthorized|invalid api key/i.test(msg)) {
return { ...base, code: 'rate-limited', category: 'auth', severity: 'medium', apiLayer };
}
if (/(rate.?limit|429)/i.test(msg)) {
return { ...base, code: 'rate-limited', category: 'auth', severity: 'medium', apiLayer };
}
if (/timeout|ETIMEDOUT|ETIMEOUT/i.test(msg)) {
return { ...base, code: 'timeout', category: 'service', severity: 'medium', apiLayer };
}
if (/(500|502|503|internal_server_error|service_unavailable|bad_gateway)/i.test(msg)) {
return { ...base, code: 'server-error', category: 'service', severity: 'high', apiLayer };
}
}

return null;
Expand Down
Loading
Loading