build strategy · arc testnet

Real onchain, five secrets, one build.

Every mega-prompt in this repo uses the same pattern, because it's the only pattern that lets a Lovable account ship a verifiable Arc Testnet demo in one shot.

Why Arc Testnet?

Arc is Circle's EVM-compatible L1 with sub-second finality where USDC is the native gas token (6 decimals, not 18). Contracts deploy via Circle's Smart Contract Platform — no MetaMask, no Hardhat, no funded EOA. Gas comes straight from the treasury wallet in USDC. Verified on arcscan (Blockscout) with a single standard-JSON POST (no API key). Payments settle in USDC, EURC or cirBTC via ordinary ERC-20 transfers. Judges try the demo without holding ETH.

The recipe

recipe
# 1. Add the secrets (Settings -> Secrets). NOTE: NO VITE_ prefixes — Lovable rejects them.
CIRCLE_API_KEY=...                 # console.circle.com (you paste)
PRIVY_APP_ID=...                   # dashboard.privy.io (you paste)
# The three below are generated for you by scripts/bootstrap-circle.mjs
CIRCLE_ENTITY_SECRET=...           # auto — printed by the bootstrap script (32-byte hex)
CIRCLE_TREASURY_WALLET_ID=...      # auto
CIRCLE_TREASURY_ADDRESS=0x...      # auto — the address to fund. Expose via route loader, not VITE_

CIRCLE_KIT_KEY=...                 # optional, App Kit widgets

# 2. Run the bootstrap once in the Lovable sandbox — it generates + registers
#    CIRCLE_ENTITY_SECRET (POST /entitySecret, not PUT) and mints an ARC-TESTNET
#    treasury wallet. Persists the hex to disk BEFORE the network call.
node scripts/bootstrap-circle.mjs
# Paste the three values it prints into Project Settings -> Secrets.

# 3. Fund the printed treasury ADDRESS with USDC (gas), EURC, cirBTC:
open https://faucet.circle.com/

# 4. Copy a mega-prompt from this repo into Lovable. One paste:
#    - scaffolds the React app on Arc (viem defineChain, decimals: 6)
#    - writes a Solidity contract (pragma 0.8.24 exact, credit in NatSpec)
#    - deploys via Circle SCP (USDC gas straight from the treasury)
#    - verifies on arcscan / Blockscout via standard-JSON (no plugin, no key)
#    - wires Privy Google sign-in via route loader (NO VITE_ secret names)
#    - ships a USDC / EURC / cirBTC token switcher

# 5. Open the arcscan link. Your demo is provably onchain on Circle Arc.

1. The contract — credit baked in

Every Solidity file deployed from a Creative Blockchain prompt MUST carry the hackathon credit in NatSpec, so provenance lives onchain alongside the bytecode.

contracts/Provenance.sol
// contracts/Provenance.sol — hackathon credit in NatSpec, pragma pinned exactly
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;   // exact — NO caret. Must match the pinned solc@0.8.24.

/// @title Provenance
/// @notice Built during the Creative AI & Quantum Hackathon
/// @notice organised by StreetKode Fam during Indian Krump Festival 14
contract Provenance {
    // token = USDC / EURC / cirBTC ERC-20 on Arc Testnet
    event Logged(address indexed author, address indexed token, uint256 amount, string cid, uint256 at);

    function log(address token, uint256 amount, string calldata cid) external {
        emit Logged(msg.sender, token, amount, cid, block.timestamp);
    }
}

2. Deploy via Circle SCP (USDC gas)

No MetaMask, no Hardhat. Compile with solc, submit to Circle's Smart Contract Platform, and pay gas in USDC straight from the treasury wallet. Entity secret ciphertext is single-use — regenerate every call.

scripts/deploy-arc.mjs
// scripts/deploy-arc.mjs — Circle SCP deploy. USDC gas from the treasury wallet.
// No MetaMask. No Hardhat. Compile with solc@0.8.24, submit to Circle, poll for the address.
import fs from "node:fs"; import crypto from "node:crypto"; import solc from "solc";
const API = "https://api.circle.com/v1/w3s";
const H = { Authorization: `Bearer ${process.env.CIRCLE_API_KEY}`, "Content-Type": "application/json" };

async function encryptEntitySecret() {
  const { data } = await fetch(`${API}/config/entity/publicKey`, { headers: H }).then(r => r.json());
  return crypto.publicEncrypt(
    { key: crypto.createPublicKey(data.publicKey), oaepHash: "sha256",
      padding: crypto.constants.RSA_PKCS1_OAEP_PADDING },
    Buffer.from(process.env.CIRCLE_ENTITY_SECRET, "hex")
  ).toString("base64"); // single-use — regenerate every call
}

const name = process.argv[2];
const src  = fs.readFileSync(`contracts/${name}.sol`, "utf8");
const out  = JSON.parse(solc.compile(JSON.stringify({
  language: "Solidity", sources: { [`${name}.sol`]: { content: src } },
  settings: { optimizer: { enabled: true, runs: 200 },
    outputSelection: { "*": { "*": ["abi", "evm.bytecode.object"] } } },
})));
const c = out.contracts[`${name}.sol`][name];

// Payload shape that actually works: idempotencyKey, abiJson as STRING,
// positional constructorParameters, flat feeLevel. NOT nested fee.type/config.
const { data: { contractId } } = await fetch(`${API}/contracts/deploy`, {
  method: "POST", headers: H, body: JSON.stringify({
    idempotencyKey: crypto.randomUUID(),
    name,
    walletId: process.env.CIRCLE_TREASURY_WALLET_ID,
    blockchain: "ARC-TESTNET",
    abiJson: JSON.stringify(c.abi),
    bytecode: "0x" + c.evm.bytecode.object,
    constructorParameters: [],
    feeLevel: "MEDIUM",
    entitySecretCiphertext: await encryptEntitySecret(),
  }),
}).then(r => r.json());

// Poll contract.status === "COMPLETE" (NOT .state).
let addr;
for (let i = 0; i < 60 && !addr; i++) {
  await new Promise(r => setTimeout(r, 2000));
  const { data: { contract } } = await fetch(`${API}/contracts/${contractId}`, { headers: H }).then(r => r.json());
  if (contract.status === "COMPLETE") addr = contract.contractAddress;
}
fs.writeFileSync("src/data/contract.json", JSON.stringify({ address: addr, abi: c.abi }, null, 2));
console.log(`Deployed ${name} -> ${addr}`);

3. Verify on arcscan / Blockscout (REST)

One POST to /api/v2/smart-contracts/<addr>/verification/via/flattened-code. No API key, no Etherscan, no Hardhat plugin.

scripts/verify-arc.mjs
// scripts/verify-arc.mjs — Blockscout standard-JSON. NO API key. NO plugin. NO flattening.
import fs from "node:fs";
import solc from "solc";
const addr = JSON.parse(fs.readFileSync("src/data/contract.json", "utf8")).address;
const [, , sourcePath, contractName] = process.argv;   // e.g. contracts/Provenance.sol Provenance
const source   = fs.readFileSync(sourcePath, "utf8");
const fileName = sourcePath.split("/").pop();
// Compiler version derived from LOCAL solc — pin solc@0.8.24 in devDeps so it matches deploy.
const compilerversion = "v" + solc.version().split(".Emscripten")[0];
const input = JSON.stringify({
  language: "Solidity", sources: { [fileName]: { content: source } },
  settings: { optimizer: { enabled: true, runs: 200 },
    outputSelection: { "*": { "*": ["abi", "evm.bytecode.object"] } } },
});
const params = new URLSearchParams({
  module: "contract", action: "verifysourcecode",
  contractaddress: addr, contractname: `${fileName}:${contractName}`,
  compilerversion, optimizationUsed: "1", runs: "200",
  sourceCode: input, codeformat: "solidity-standard-json-input",
  licenseType: "3", constructorArguments: "", autodetectConstructorArguments: "true",
});
const res = await fetch("https://testnet.arcscan.app/api?" + params, { method: "POST" });
console.log(res.status, await res.text());
// 200 "Smart-contract verification started" = QUEUED. Poll
// GET https://testnet.arcscan.app/api/v2/smart-contracts/${addr} until is_verified: true (~1-2 min).

4. Multi-stablecoin support (USDC · EURC · cirBTC)

src/lib/tokens.ts
// src/lib/tokens.ts — USDC · EURC · cirBTC on Arc Testnet
export const ARC_CHAIN_ID = 5042002;
export const ARC_RPC_URL  = "https://rpc.testnet.arc.network";  // pin .network — .io intermittently DNS-fails
export const ARC_EXPLORER = "https://testnet.arcscan.app";

export const TOKENS = {
  USDC:   { symbol: "USDC",   address: "0x3600000000000000000000000000000000000000", decimals: 6, label: "US Dollar (native gas)" },
  EURC:   { symbol: "EURC",   address: "0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a", decimals: 6, label: "Euro Coin" },
  cirBTC: { symbol: "cirBTC", address: "0xf0C4a4CE82A5746AbAAd9425360Ab04fbBA432BF", decimals: 8, label: "Circle Wrapped BTC" },
} as const;
export type TokenKey = keyof typeof TOKENS;

5. Sign in with Google via Privy

src/main.tsx
// src/lib/arc-chain.ts + src/routes/index.tsx — viem chain + Privy on Arc via route loader
import { defineChain } from "viem";
// CRITICAL: decimals: 6 because USDC is the gas token on Arc. Using 18 corrupts every fee by 10^12.
export const arcTestnet = defineChain({
  id: 5042002,
  name: "Arc Testnet",
  nativeCurrency: { name: "USD Coin", symbol: "USDC", decimals: 6 },
  rpcUrls: { default: { http: ["https://rpc.testnet.arc.network"] } },
  blockExplorers: { default: { name: "Arcscan", url: "https://testnet.arcscan.app" } },
  testnet: true,
});

// Lovable rejects VITE_* secret names. Expose Privy + treasury via a route loader.
// src/routes/index.tsx
export const Route = createFileRoute("/")({
  loader: () => ({
    privyAppId:      process.env.PRIVY_APP_ID      ?? "",
    treasuryAddress: process.env.CIRCLE_TREASURY_ADDRESS ?? "",
  }),
  component: Home,
});

// Wrap Privy in <ClientOnly> + React.lazy — Workerd crashes on static import.
import { PrivyProvider } from "@privy-io/react-auth";
<PrivyProvider
  appId={privyAppId}
  config={{
    loginMethods: ["google", "email"],
    embeddedWallets: { createOnLogin: "users-without-wallets" },
    defaultChain:    arcTestnet as never,  // 'as never' required — Privy's chain type is narrow
    supportedChains: [arcTestnet as never], // BOTH required — omitting drops the chain silently
  }}
>
  <App />
</PrivyProvider>

Hackathon rules of thumb

  • · One mega-prompt = one build message. Don't iterate the architecture, iterate the UI.
  • · Always show the live arcscan link in the UI — that's your proof.
  • · Support all three tokens (USDC · EURC · cirBTC). Gate zero-address tokens in the switcher — never silently skip approve().
  • · Render your own pre-confirmation summary above Privy's approve modal. Privy always shows the gas-token (USDC) balance and leaves EURC/cirBTC amounts blank — that's not a bug, but users think it is.
  • · Never save a Lovable secret as VITE_* — the store rejects it. Expose via a TanStack route loader that reads process.env.
  • · Pin solc@0.8.24 in devDependencies and use exact pragma solidity 0.8.24; — newer solc yields bytecode Arcscan can't reproduce.
  • · Add a "Built during the Creative AI & Quantum Hackathon — StreetKode Fam · Indian Krump Festival 14" line to your footer.

Troubleshooting

Every mega-prompt ships this table inline, so a build recovers itself. Here it is browsable.

Saving a secret named VITE_PRIVY_APP_ID or VITE_CIRCLE_TREASURY_ADDRESS is rejected
Cause · Lovable reserves the VITE_ prefix for the build system.
Fix · Save as PRIVY_APP_ID / CIRCLE_TREASURY_ADDRESS and expose them via a TanStack route loader that reads process.env on the server.
RPC calls fail or DNS-error
Cause · You used rpc.testnet.arc.io — Circle's docs interleave both hostnames.
Fix · Pin https://rpc.testnet.arc.network.
Circle returns 409 already registered on a brand-new API key
Cause · Circle onboarding pre-primes the entity secret slot, so the first API registration always conflicts. New accounts 409 too.
Fix · Keep your locally minted 32-byte hex, RSA-OAEP encrypt it against /config/entity/publicKey, and paste the base64 ciphertext into Circle Console → Configurator → Entity Secret Ciphertext. Then re-run bootstrap with CIRCLE_ENTITY_SECRET=<hex>.
invalid entity secret after pasting the value
Cause · You pasted the recovery envelope Circle returned, not the secret.
Fix · Only the 64-character hex goes into CIRCLE_ENTITY_SECRET.
Circle SCP deploy returns 400
Cause · Missing idempotencyKey, abiJson sent as an array, or feeLevel nested as an object.
Fix · Send crypto.randomUUID(), JSON.stringify(abi), and a flat feeLevel: "MEDIUM".
Deploy polls forever and never returns an address
Cause · Reading data.contract.state instead of status.
Fix · Wait for contract.status === "COMPLETE", then read contract.contractAddress.
Arcscan reports Fail — Unable to verify
Cause · The local solc is newer than the deployed pragma, so bytecode can't be reproduced.
Fix · Pin solc@0.8.24 (no caret), use exact pragma solidity 0.8.24;, and set optimizer runs: 200 on both deploy and verify.
Native balance is off by a factor of 10^12
Cause · The viem chain defaulted to 18 decimals.
Fix · Set nativeCurrency.decimals: 6 — USDC is the gas token on Arc.
Privy modal asks for ETH
Cause · Arc has no ETH; gas is paid in USDC.
Fix · Fund the wallet at faucet.circle.com. No paymaster or sponsorship policy is needed.
Privy shows a USDC balance while approving EURC or cirBTC, with a blank amount
Cause · Privy always renders the gas-token balance and has no metadata for EURC/cirBTC on Arc.
Fix · Not a bug. Render your own pre-confirmation summary naming the amount, symbol, spender and token address.
A token's approve() step appears to be skipped
Cause · That token's configured address is 0x0000…0000.
Fix · Gate the chip (disabled with a hint) instead of skipping approve — a silent skip looks like a bug.