Skip to main content
This quickstart guides you through the ERC-8183 job lifecycle on Arc Testnet. You’ll create developer-controlled smart contract account wallets, create a job, fund escrow with USDC, submit a deliverable hash, and complete the job as the evaluator. Select the tab that matches your preferred setup.

ERC-8183 contract on Arc Testnet

ContractAddress
AgenticCommerce reference implementation0x0747EEf0706327138c69792bF28Cd525089e4583

Prerequisites

Before you begin, make sure you have:
  1. A Circle Developer Console account
  2. An API key created in the Console: Keys → Create a key → API key → Standard Key
  3. Your Entity Secret registered

Step 1. Set up your project

Create a project directory, install dependencies, and configure your environment.

1.1. Create the project and install dependencies

mkdir erc8183-quickstart
cd erc8183-quickstart
npm init -y
npm pkg set type=module
npm pkg set scripts.start="tsx --env-file=.env index.ts"

npm install @circle-fin/developer-controlled-wallets viem
npm install --save-dev tsx typescript @types/node

1.2. Configure TypeScript (optional)

This step is optional. It helps prevent missing types in your IDE or editor.
Create a tsconfig.json file:
npx tsc --init
Then, update the tsconfig.json file:
cat <<'EOF' > tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["node"]
  }
}
EOF

1.3. Set environment variables

Create a .env file in the project directory and add your Circle credentials:
CIRCLE_API_KEY=YOUR_API_KEY
CIRCLE_ENTITY_SECRET=YOUR_ENTITY_SECRET
  • CIRCLE_API_KEY is your Circle Developer API key.
  • CIRCLE_ENTITY_SECRET is your registered Entity Secret.
The npm run start command loads variables from .env using Node.js native env-file support. The python index.py command loads the same .env file via python-dotenv.
Prefer editing .env files in your IDE or editor so credentials are not leaked to your shell history.

Step 2. Create developer-controlled wallets

In this step, you create two Arc Testnet dev-controlled wallets for the ERC-8183 flow: a client wallet and a provider wallet. In this quickstart, the client also acts as the evaluator. If you already have two Arc Testnet funded dev-controlled wallets for this flow, skip to Step 4.The Step 2 through 9 sections explain the flow in smaller pieces. Not every step includes a code snippet, and the snippets are not cumulative. To run the full workflow end to end, use the complete script at the end of this tutorial.
const walletSet = await circleClient.createWalletSet({
  name: "ERC8183 Job Wallets",
});

const walletsResponse = await circleClient.createWallets({
  blockchains: ["ARC-TESTNET"],
  count: 2,
  walletSetId: walletSet.data?.walletSet?.id ?? "",
  accountType: "SCA",
});

const clientWallet = walletsResponse.data?.wallets?.[0]!;
const providerWallet = walletsResponse.data?.wallets?.[1]!;

console.log(`Client:   ${clientWallet.address} (${clientWallet.id})`);
console.log(`Provider: ${providerWallet.address} (${providerWallet.id})`);
console.log(`Evaluator: ${clientWallet.address} (${clientWallet.id})`);

Step 3. Fund the client wallet

The script will pause to allow you to fund the client wallet with Arc Testnet USDC from one of these faucets:You only fund the client wallet as the script transfers starter USDC to the provider wallet automatically before the ERC-8183 flow begins.
The public faucet is rate-limited, so this quickstart avoids requiring a second faucet request for the provider wallet.

Step 4. Create the job

Call createJob(provider, evaluator, expiredAt, description, hook) on the deployed ERC-8183 reference implementation. This creates the job in the Open state. This quickstart uses address(0) for hook so the flow stays on the default non-hooked path.
const createJobTx = await circleClient.createContractExecutionTransaction({
  walletAddress: clientWallet.address!,
  blockchain: "ARC-TESTNET",
  contractAddress: AGENTIC_COMMERCE_CONTRACT,
  abiFunctionSignature: "createJob(address,address,uint256,string,address)",
  abiParameters: [
    providerWallet.address!,
    clientWallet.address!,
    expiredAt.toString(),
    "ERC-8183 demo job on Arc Testnet",
    "0x0000000000000000000000000000000000000000",
  ],
  fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});

Step 5. Set the budget

In this deployed contract, the provider sets the job price by calling setBudget(jobId, amount, optParams).
const setBudgetTx = await circleClient.createContractExecutionTransaction({
  walletAddress: providerWallet.address!,
  blockchain: "ARC-TESTNET",
  contractAddress: AGENTIC_COMMERCE_CONTRACT,
  abiFunctionSignature: "setBudget(uint256,uint256,bytes)",
  abiParameters: [jobId.toString(), JOB_BUDGET.toString(), "0x"],
  fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});

Step 6. Approve USDC and fund escrow

Before the client can fund the job, the USDC contract must approve the ERC-8183 contract to transfer the escrow amount. Then the client calls fund(jobId, optParams) to move the job into the Funded state.
const approveTx = await circleClient.createContractExecutionTransaction({
  walletAddress: clientWallet.address!,
  blockchain: "ARC-TESTNET",
  contractAddress: "0x3600000000000000000000000000000000000000",
  abiFunctionSignature: "approve(address,uint256)",
  abiParameters: [AGENTIC_COMMERCE_CONTRACT, JOB_BUDGET.toString()],
  fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});

const fundTx = await circleClient.createContractExecutionTransaction({
  walletAddress: clientWallet.address!,
  blockchain: "ARC-TESTNET",
  contractAddress: AGENTIC_COMMERCE_CONTRACT,
  abiFunctionSignature: "fund(uint256,bytes)",
  abiParameters: [jobId.toString(), "0x"],
  fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});

Step 7. Submit the deliverable

The provider submits a bytes32 deliverable hash, moving the job into the Submitted state.
const deliverableHash = keccak256(toHex("arc-erc8183-demo-deliverable"));

const submitTx = await circleClient.createContractExecutionTransaction({
  walletAddress: providerWallet.address!,
  blockchain: "ARC-TESTNET",
  contractAddress: AGENTIC_COMMERCE_CONTRACT,
  abiFunctionSignature: "submit(uint256,bytes32,bytes)",
  abiParameters: [jobId.toString(), deliverableHash, "0x"],
  fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});

Step 8. Complete the job

The evaluator completes the job by calling complete(jobId, reason, optParams). In this quickstart, the client is also the evaluator.
const reasonHash = keccak256(toHex("deliverable-approved"));

const completeTx = await circleClient.createContractExecutionTransaction({
  walletAddress: clientWallet.address!,
  blockchain: "ARC-TESTNET",
  contractAddress: AGENTIC_COMMERCE_CONTRACT,
  abiFunctionSignature: "complete(uint256,bytes32,bytes)",
  abiParameters: [jobId.toString(), reasonHash, "0x"],
  fee: { type: "level", config: { feeLevel: "MEDIUM" } },
});

Step 9. Check the final job state

Read the job back from the contract to confirm it reached Completed. This reference implementation does not return the deliverable in getJob(), so the script prints the submitted deliverable hash from local flow state instead.
const job = await publicClient.readContract({
  address: AGENTIC_COMMERCE_CONTRACT,
  abi: agenticCommerceAbi,
  functionName: "getJob",
  args: [jobId],
});

console.log(`Job ID: ${jobId}`);
console.log(`Status: ${STATUS_NAMES[Number(job.status)]}`);
console.log(`Budget: ${formatUnits(job.budget, 6)} USDC`);
console.log(`Hook: ${job.hook}`);
console.log(`Deliverable hash submitted: ${deliverableHash}`);

Full job lifecycle script

These complete scripts below combines all the preceding steps into a single runnable file.
import { createInterface } from "node:readline/promises";
import { setTimeout as delay } from "node:timers/promises";
import { stdin as input, stdout as output } from "node:process";
import { initiateDeveloperControlledWalletsClient } from "@circle-fin/developer-controlled-wallets";
import {
  createPublicClient,
  decodeEventLog,
  formatUnits,
  http,
  keccak256,
  parseUnits,
  toHex,
  type Address,
  type Hex,
} from "viem";
import { arcTestnet } from "viem/chains";

// To bootstrap provider wallet during setup (see Step 3)
const PROVIDER_STARTER_BALANCE = "1";

const AGENTIC_COMMERCE_CONTRACT =
  "0x0747EEf0706327138c69792bF28Cd525089e4583" as Address;
const JOB_BUDGET = parseUnits("5", 6); // 5 USDC (ERC-20, 6 decimals)

const circleClient = initiateDeveloperControlledWalletsClient({
  apiKey: process.env.CIRCLE_API_KEY!,
  entitySecret: process.env.CIRCLE_ENTITY_SECRET!,
});

const publicClient = createPublicClient({
  chain: arcTestnet,
  transport: http(),
});

const agenticCommerceAbi = [
  {
    type: "function",
    name: "createJob",
    stateMutability: "nonpayable",
    inputs: [
      { name: "provider", type: "address" },
      { name: "evaluator", type: "address" },
      { name: "expiredAt", type: "uint256" },
      { name: "description", type: "string" },
      { name: "hook", type: "address" },
    ],
    outputs: [{ name: "jobId", type: "uint256" }],
  },
  {
    type: "function",
    name: "setBudget",
    stateMutability: "nonpayable",
    inputs: [
      { name: "jobId", type: "uint256" },
      { name: "amount", type: "uint256" },
      { name: "optParams", type: "bytes" },
    ],
    outputs: [],
  },
  {
    type: "function",
    name: "fund",
    stateMutability: "nonpayable",
    inputs: [
      { name: "jobId", type: "uint256" },
      { name: "optParams", type: "bytes" },
    ],
    outputs: [],
  },
  {
    type: "function",
    name: "submit",
    stateMutability: "nonpayable",
    inputs: [
      { name: "jobId", type: "uint256" },
      { name: "deliverable", type: "bytes32" },
      { name: "optParams", type: "bytes" },
    ],
    outputs: [],
  },
  {
    type: "function",
    name: "complete",
    stateMutability: "nonpayable",
    inputs: [
      { name: "jobId", type: "uint256" },
      { name: "reason", type: "bytes32" },
      { name: "optParams", type: "bytes" },
    ],
    outputs: [],
  },
  {
    type: "function",
    name: "getJob",
    stateMutability: "view",
    inputs: [{ name: "jobId", type: "uint256" }],
    outputs: [
      {
        type: "tuple",
        components: [
          { name: "id", type: "uint256" },
          { name: "client", type: "address" },
          { name: "provider", type: "address" },
          { name: "evaluator", type: "address" },
          { name: "description", type: "string" },
          { name: "budget", type: "uint256" },
          { name: "expiredAt", type: "uint256" },
          { name: "status", type: "uint8" },
          { name: "hook", type: "address" },
        ],
      },
    ],
  },
  {
    type: "event",
    name: "JobCreated",
    inputs: [
      { indexed: true, name: "jobId", type: "uint256" },
      { indexed: true, name: "client", type: "address" },
      { indexed: true, name: "provider", type: "address" },
      { indexed: false, name: "evaluator", type: "address" },
      { indexed: false, name: "expiredAt", type: "uint256" },
      { indexed: false, name: "hook", type: "address" },
    ],
    anonymous: false,
  },
] as const;

const STATUS_NAMES = [
  "Open",
  "Funded",
  "Submitted",
  "Completed",
  "Rejected",
  "Expired",
];

function extractJobId(txHash: Hex) {
  return publicClient
    .getTransactionReceipt({ hash: txHash })
    .then((receipt) => {
      for (const log of receipt.logs) {
        try {
          const decoded = decodeEventLog({
            abi: agenticCommerceAbi,
            data: log.data,
            topics: log.topics,
          });
          if (decoded.eventName === "JobCreated") {
            return decoded.args.jobId;
          }
        } catch {
          continue;
        }
      }
      throw new Error("Could not parse JobCreated event");
    });
}

async function waitForTransaction(txId: string, label: string) {
  process.stdout.write(`  Waiting for ${label}`);
  for (let i = 0; i < 60; i++) {
    await delay(2000);
    const tx = await circleClient.getTransaction({ id: txId });
    const data = tx.data?.transaction;

    if (data?.state === "COMPLETE" && data.txHash) {
      const txHash = data.txHash;
      console.log(
        ` ✓\n  Tx: ${arcTestnet.blockExplorers.default.url}/tx/${txHash}`,
      );
      return txHash as Hex;
    }
    if (data?.state === "FAILED") {
      throw new Error(`${label} failed onchain`);
    }
    process.stdout.write(".");
  }
  throw new Error(`${label} timed out`);
}

async function printBalances(
  title: string,
  wallets: Array<{ label: string; id?: string; address?: string | null }>,
) {
  console.log(`\n${title}:`);

  for (const wallet of wallets) {
    const balances = await circleClient.getWalletTokenBalance({
      id: wallet.id!,
    });
    const usdc = balances.data?.tokenBalances?.find(
      (b) => b.token?.symbol === "USDC",
    );
    console.log(`  ${wallet.label}: ${wallet.address}`);
    console.log(`    USDC: ${usdc?.amount ?? "0"}`);
  }
}

async function main() {
  console.log("── Step 1: Create wallets ──");

  const walletSet = await circleClient.createWalletSet({
    name: "ERC8183 Job Wallets",
  });

  const walletsResponse = await circleClient.createWallets({
    blockchains: ["ARC-TESTNET"],
    count: 2,
    walletSetId: walletSet.data?.walletSet?.id ?? "",
    accountType: "SCA",
  });

  const clientWallet = walletsResponse.data?.wallets?.[0]!;
  const providerWallet = walletsResponse.data?.wallets?.[1]!;

  console.log("\n── Step 2: Fund the client wallet ──");
  console.log("  Fund this wallet with Arc Testnet USDC:");
  console.log(`  Client: ${clientWallet.address}`);
  console.log(`  Wallet ID: ${clientWallet.id}`);
  console.log("  Public faucet:  https://faucet.circle.com");
  console.log("  Console faucet: https://console.circle.com/faucet");
  console.log("\n  This script will fund the provider wallet automatically.");

  const rl = createInterface({ input, output });
  await rl.question("\nPress Enter after the client wallet is funded... ");
  rl.close();

  console.log("\n── Step 3: Transfer starter USDC to provider ──");
  const transferTx = await circleClient.createTransaction({
    walletAddress: clientWallet.address!,
    blockchain: "ARC-TESTNET",
    tokenAddress: "0x3600000000000000000000000000000000000000",
    destinationAddress: providerWallet.address!,
    amount: [PROVIDER_STARTER_BALANCE],
    fee: { type: "level", config: { feeLevel: "MEDIUM" } },
  });
  await waitForTransaction(
    transferTx.data?.id!,
    "transfer starter USDC to provider",
  );

  console.log("\n── Step 4: Check balances ──");
  await printBalances("Balances", [
    { label: "Client", ...clientWallet },
    { label: "Provider", ...providerWallet },
  ]);

  const now = await publicClient.getBlock();
  const expiredAt = now.timestamp + 3600n;

  console.log("\n── Step 5: Create job - createJob() ──");
  const createJobTx = await circleClient.createContractExecutionTransaction({
    walletAddress: clientWallet.address!,
    blockchain: "ARC-TESTNET",
    contractAddress: AGENTIC_COMMERCE_CONTRACT,
    abiFunctionSignature: "createJob(address,address,uint256,string,address)",
    abiParameters: [
      providerWallet.address!,
      clientWallet.address!,
      expiredAt.toString(),
      "ERC-8183 demo job on Arc Testnet",
      "0x0000000000000000000000000000000000000000",
    ],
    fee: { type: "level", config: { feeLevel: "MEDIUM" } },
  });
  const createJobTxHash = await waitForTransaction(
    createJobTx.data?.id!,
    "create job",
  );
  const jobId = await extractJobId(createJobTxHash);
  console.log(`  Job ID: ${jobId}`);

  console.log("\n── Step 6: Set budget - setBudget() ──");
  const setBudgetTx = await circleClient.createContractExecutionTransaction({
    walletAddress: providerWallet.address!,
    blockchain: "ARC-TESTNET",
    contractAddress: AGENTIC_COMMERCE_CONTRACT,
    abiFunctionSignature: "setBudget(uint256,uint256,bytes)",
    abiParameters: [jobId.toString(), JOB_BUDGET.toString(), "0x"],
    fee: { type: "level", config: { feeLevel: "MEDIUM" } },
  });
  await waitForTransaction(setBudgetTx.data?.id!, "set budget");

  console.log("\n── Step 7: Approve USDC - approve() ──");
  const approveTx = await circleClient.createContractExecutionTransaction({
    walletAddress: clientWallet.address!,
    blockchain: "ARC-TESTNET",
    contractAddress: "0x3600000000000000000000000000000000000000",
    abiFunctionSignature: "approve(address,uint256)",
    abiParameters: [AGENTIC_COMMERCE_CONTRACT, JOB_BUDGET.toString()],
    fee: { type: "level", config: { feeLevel: "MEDIUM" } },
  });
  await waitForTransaction(approveTx.data?.id!, "approve USDC");

  console.log("\n── Step 8: Fund escrow - fund() ──");
  const fundTx = await circleClient.createContractExecutionTransaction({
    walletAddress: clientWallet.address!,
    blockchain: "ARC-TESTNET",
    contractAddress: AGENTIC_COMMERCE_CONTRACT,
    abiFunctionSignature: "fund(uint256,bytes)",
    abiParameters: [jobId.toString(), "0x"],
    fee: { type: "level", config: { feeLevel: "MEDIUM" } },
  });
  await waitForTransaction(fundTx.data?.id!, "fund escrow");

  console.log("\n── Step 9: Submit deliverable - submit() ──");
  const deliverableHash = keccak256(toHex("arc-erc8183-demo-deliverable"));
  const submitTx = await circleClient.createContractExecutionTransaction({
    walletAddress: providerWallet.address!,
    blockchain: "ARC-TESTNET",
    contractAddress: AGENTIC_COMMERCE_CONTRACT,
    abiFunctionSignature: "submit(uint256,bytes32,bytes)",
    abiParameters: [jobId.toString(), deliverableHash, "0x"],
    fee: { type: "level", config: { feeLevel: "MEDIUM" } },
  });
  await waitForTransaction(submitTx.data?.id!, "submit deliverable");

  console.log("\n── Step 10: Complete job - complete() ──");
  const reasonHash = keccak256(toHex("deliverable-approved"));
  const completeTx = await circleClient.createContractExecutionTransaction({
    walletAddress: clientWallet.address!,
    blockchain: "ARC-TESTNET",
    contractAddress: AGENTIC_COMMERCE_CONTRACT,
    abiFunctionSignature: "complete(uint256,bytes32,bytes)",
    abiParameters: [jobId.toString(), reasonHash, "0x"],
    fee: { type: "level", config: { feeLevel: "MEDIUM" } },
  });
  await waitForTransaction(completeTx.data?.id!, "complete job");

  console.log("\n── Step 11: Check final job state ──");
  const job = await publicClient.readContract({
    address: AGENTIC_COMMERCE_CONTRACT,
    abi: agenticCommerceAbi,
    functionName: "getJob",
    args: [jobId],
  });
  console.log(`  Job ID: ${jobId}`);
  console.log(`  Status: ${STATUS_NAMES[Number(job.status)]}`);
  console.log(`  Budget: ${formatUnits(job.budget, 6)} USDC`);
  console.log(`  Hook: ${job.hook}`);
  console.log(`  Deliverable hash submitted: ${deliverableHash}`);

  console.log("\n── Step 12: Check final balances ──");
  await printBalances("Balances", [
    { label: "Client", ...clientWallet },
    { label: "Provider", ...providerWallet },
  ]);
}

main().catch((error) => {
  console.error("\nError:", error.message || error);
  process.exit(1);
});
Run the script:
npm run start

Verify the result

If the flow succeeds, the output should show:
  • a created job ID
  • a completed final status
  • the client balance reduced by the funded escrow amount
  • the provider balance increased after completion
If platform or evaluator fees are configured on the deployed contract, the provider receives the net amount after fees rather than the full job budget.
You can also inspect the transaction links in the terminal output on Arcscan Testnet.

Summary

After completing this quickstart, you’ve successfully:
  • Set up a project for running an ERC-8183 job flow on Arc Testnet
  • Prepared client and provider wallets for the client, provider, and evaluator roles
  • Walked through an example ERC-8183 job lifecycle
  • Confirmed balances and job state in the script output and reviewed transactions on Arcscan Testnet