Skip to content

docs: add Smart Economy Engine example with X402 oracle and dynamic vault strategy#190

Open
consumeobeydie wants to merge 3 commits into
circlefin:mainfrom
consumeobeydie:docs/smart-economy-engine-example
Open

docs: add Smart Economy Engine example with X402 oracle and dynamic vault strategy#190
consumeobeydie wants to merge 3 commits into
circlefin:mainfrom
consumeobeydie:docs/smart-economy-engine-example

Conversation

@consumeobeydie

Copy link
Copy Markdown

Summary

This PR adds an example showing how to build a self-sustaining autonomous agent economy on Arc Testnet that uses an X402-gated market oracle to make dynamic vault strategy decisions.

What's included

Live Verification

3 cycles run on Arc Testnet:

  • Cycle 1: BEARISH → 1.5 USDC mission
  • Cycle 2: BEARISH → 1.5 USDC mission
  • Cycle 3: BULLISH → 4.5 USDC mission + yield distributed
  • Final vault: 17.75 USDC (started at 13 USDC)

Related

Series

This is the 11th example in this series:
1-10: Foundry, X402, ERC-8004, ERC-8183, Unified Flow, Dashboard, MCP, Multi-Agent, Transaction Memo, ERC-4626 Vault
11: Smart Economy Engine (this PR)

consumeobeydie and others added 3 commits June 20, 2026 18:28
@osr21

osr21 commented Jun 24, 2026

Copy link
Copy Markdown

Great examples — the architecture and live cycle results in smart-economy-engine.md are really clear. A few additions from firsthand experience with both the Memo contract and the gas estimation bug, in case they're useful to fold in:


1. The viem workaround is more practical than cast send

Both files document cast send as the workaround for the gas estimation revert. That works for testing but isn't practical inside a running Node.js/TypeScript service. We've verified that viem's writeContract works perfectly — you just need to bypass estimation with an explicit gas field:

// Works — explicit gas bypasses eth_estimateGas entirely
const hash = await walletClient.writeContract({
  address: "0x5294E9927c3306DcBaDb03fe70b92e01cCede505",
  abi: MEMO_ABI,
  functionName: "callWithMemo",
  args: [target, calldata, correlationId, memo],
  gas: 300_000n,   // ← explicit override; estimation would revert
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
// ↑ see point 3 about receipt status

The actual gas consumed on our transactions is 123k–128k even with the 350k ceiling, so a 300k limit is conservative and safe. Circle Developer Controlled Wallets also accepts a gas override via the contractCallParams field, which avoids the estimation issue there too.


2. There are two Memo functions — callWithMemo and memo()

The PR documents callWithMemo, which wraps and executes the inner call (preserving msg.sender via the CallFrom precompile). There's a sibling function memo() that does something slightly different — it attaches a memo to a call that's already been executed by the contract itself:

// memo() — use when your contract already executes the inner call
// callWithMemo() — use when you need msg.sender preserved on the inner call
const MEMO_ABI = [
  {
    name: "memo",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "target",   type: "address" },
      { name: "data",     type: "bytes"   },  // inner calldata
      { name: "memoId",   type: "bytes32" },
      { name: "memoData", type: "bytes"   },  // encoded note bytes
    ],
    outputs: [],
  },
  {
    name: "callWithMemo",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "target",        type: "address" },
      { name: "data",          type: "bytes"   },
      { name: "correlationId", type: "bytes32" },
      { name: "memo",          type: "string"  },
    ],
    outputs: [
      { name: "success", type: "bool"  },
      { name: "result",  type: "bytes" },
    ],
  },
] as const;

Both share the gas estimation bug. We used memo() in our payment flow (backend wallet executes the ERC20 transfer, then immediately records the memo); callWithMemo is the right choice when you need the inner call's msg.sender to be the caller's address.

One note on types: the last parameter of memo() is bytes not string — encode the note as UTF-8 bytes:

const memoData = toHex(Buffer.from("payment note text", "utf8"));

3. waitForTransactionReceipt does not throw on revert (Arc Testnet)

This one is worth adding to the "Common Pitfalls" section — it burned us early. On Arc Testnet, viem's waitForTransactionReceipt returns normally even when the transaction reverted. The status field is "reverted" but no error is thrown, so without an explicit check the call appears to succeed:

const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status === "reverted") {
  throw new Error(`Transaction reverted — gasUsed: ${receipt.gasUsed}, hash: ${hash}`);
}
// Only reach here if genuinely succeeded

We discovered this when createPair appeared to complete but the pair was never created — the receipt came back fine, status was "reverted", but viem didn't surface it as an error. Applies to every writeContract call on Arc Testnet, not just Memo.


4. Suggested addition to the "memoData" / encoding note in transaction-memo-integration.md

The file notes that bytes32 correlationId must be exactly 32 bytes — a similar note for the memo/memoData field would help: it needs to be ABI-encoded bytes (not a raw string) when using memo(), and a UTF-8 string when using callWithMemo. This distinction isn't obvious from the contract addresses alone since the ABI wasn't published at the time.


Happy to share working TypeScript snippets for any of the above if useful for the examples. The vault + oracle architecture is a nice demonstration of the full Arc primitive stack.

@osr21

osr21 commented Jun 24, 2026

Copy link
Copy Markdown

Really useful addition — the Smart Economy Engine example nicely ties together the three Arc primitives in a single autonomous loop.

A few notes that might be useful to fold into the x402 section, based on running a similar x402-gated API pattern on Arc Testnet:

1. The 60-second EIP-3009 backdate is correct (and possibly conservative)

Arc Testnet block timestamps can lag wall clock by more than you might expect — we see 30–60 s drift in practice. The 60 s backdate in hermes-arc-x402 is the right default. For a tighter validity window (e.g. 5 minutes instead of 10), I would pad the backdate to 90 s to avoid edge cases where a slightly slow block causes a valid-but-rejected auth.

2. Filter on from when verifying Transfer events at 0x3600...0000

USDS/USDC on Arc Testnet (0x3600000000000000000000000000000000000000) is also the native gas token in its wrapped ERC-20 form — so the contract emits Transfer events both for user payments and for native gas wrapping operations. If you query the contract for Transfer events without scoping by from == payer, you can pick up unrelated wraps and incorrectly count them as payment confirmation. Filter should be:

// Verify EIP-3009 payment on Arc Testnet — scope by (from, to, amount)
const logs = await publicClient.getLogs({
  address: '0x3600000000000000000000000000000000000000',
  event: parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)'),
  args: { from: payerAddress, to: recipientAddress },
  fromBlock: txReceipt.blockNumber,
  toBlock:   txReceipt.blockNumber,
});
const paid = logs.some(l => l.args.value !== undefined && l.args.value >= requiredAmount);

3. We're building the same x402 gate pattern on our stablecoin DApp

For context — we're implementing x402 on three endpoints of our Arc Testnet DApp (arc-stablecoin-dapp) using @x402/express + a self-hosted facilitator (Arc Testnet isn't in the official Coinbase facilitator list yet, so the facilitator lives inside the Express server itself and verifies via viem):

Endpoint Price Why
GET /api/escrows/:id/oracle-check 0.01 USDC Real CoinGecko call, external rate limit
GET /api/cctp/attestation/:txHash 0.05 USDC Polls Circle IRIS API per-request
GET /api/dashboard/stats 0.001 USDC DB aggregation, matches your oracle price point

The 0.001 USDC per-cycle price point for your oracle is sensible for agent loops — at that price 1 USDC of faucet funds runs 1,000 cycles, which is more than enough to demonstrate the economic loop convincingly.

4. One thing worth documenting in the hermes-arc-x402 README

The receipt.status === "reverted" silent failure I mentioned in the earlier comment applies to the EIP-3009 tx submission too. If the USDC authorization has expired or the nonce is stale, waitForTransactionReceipt still returns without throwing — the transfer just silently fails. Worth adding an explicit status check after submission before treating the payment as confirmed.

@osr21

osr21 commented Jun 24, 2026

Copy link
Copy Markdown

Follow-up: invalid_exact_evm_signature from MetaMask eth_signTypedData_v4 on EIP-3009 flows

Building on the earlier x402/EIP-3009 notes — one error we spent significant time debugging that does not appear to be documented anywhere: a MetaMask signing failure that produces code: -32000, message: "invalid_exact_evm_signature" on eth_signTypedData_v4 calls for TransferWithAuthorization.


The bug

When eth_signTypedData_v4 is called without EIP712Domain in the types object, MetaMask internally calls sanitizeData() from @metamask/eth-sig-util, which silently inserts EIP712Domain: [] (empty field list):

// What you send to MetaMask
{
  domain:      { name: "USDC", version: "2", chainId: 5042002, verifyingContract: "0x3600..." },
  types:       { TransferWithAuthorization: [...] },  // no EIP712Domain key
  primaryType: "TransferWithAuthorization",
  message:     { from, to, value, validAfter, validBefore, nonce }
}

// What MetaMask actually signs (after sanitizeData() injects it)
{
  types: {
    EIP712Domain: [],                        // ← injected as empty — wrong
    TransferWithAuthorization: [...]
  }
}

Because EIP712Domain: [] is empty, MetaMask computes the domain type hash from "EIP712Domain()" (no fields) rather than the correct "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)". The USDC contract rejects the resulting signature with invalid_exact_evm_signature.

The deceptive part: MetaMask shows a success prompt and the user approves the signature — the error only surfaces when the server submits transferWithAuthorization() on-chain. This makes it very hard to trace back to the signing step.


The fix

Explicitly include EIP712Domain in the types object with the correct field descriptors matching your domain keys:

const typedData = {
  domain: {
    name:              "USDC",
    version:           "2",
    chainId:           5042002,
    verifyingContract: "0x3600000000000000000000000000000000000000",
  },
  types: {
    // Explicitly list every field present in domain — order must match
    EIP712Domain: [
      { name: "name",              type: "string"  },
      { name: "version",           type: "string"  },
      { name: "chainId",           type: "uint256" },
      { name: "verifyingContract", type: "address" },
    ],
    TransferWithAuthorization: [
      { name: "from",        type: "address" },
      { name: "to",          type: "address" },
      { name: "value",       type: "uint256" },
      { name: "validAfter",  type: "uint256" },
      { name: "validBefore", type: "uint256" },
      { name: "nonce",       type: "bytes32" },
    ],
  },
  primaryType: "TransferWithAuthorization",
  message: { from, to, value, validAfter, validBefore, nonce },
};

const signature = await ethereum.request({
  method: "eth_signTypedData_v4",
  params: [walletAddress, JSON.stringify(typedData)],
});

With the explicit EIP712Domain field list, MetaMask computes the correct type hash and the USDC contract accepts the signature.


Why this matters for the x402 oracle / EIP-3009 pattern

Any x402 client that constructs TransferWithAuthorization typed data without the explicit EIP712Domain entry will silently produce signatures that pass MetaMask's UI approval but fail on-chain. Most x402 library examples and EIP-712 guides omit EIP712Domain from the types map on the assumption the wallet auto-infers it correctly. MetaMask does not — it inserts an empty definition.

The validAfter backdate note from the earlier comment (60-90 s conservatism) is also important to get right before debugging signatures — a simultaneous validAfter > block.timestamp rejection can look similar.


Confirmed working deployment

We built an X402 Pay page in osr21/arc-stablecoin-dapp using this pattern:

  • MetaMask signs TransferWithAuthorization off-chain (zero gas for user)
  • Express relay submits transferWithAuthorization() to USDC on Arc Testnet — relay pays gas
  • Multiple successful transfers confirmed on ArcScan

EIP712Domain fix: artifacts/arc-dapp/src/lib/x402-client.ts
Server relay: artifacts/api-server/src/routes/x402pay.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants