Dev.to AI ๐Ÿค– Ai ๐Ÿ‘ 0 ๐Ÿ“– 7 min read

An API that misses its deadline should pay you back: a bonded pay-per-call escrow on Arc

Your agent paid an API 0.01 USDC and the API never answered. Unless the provider decides to refund you, that money is gone, and your only recourse is a refund policy, a support ticket or a chargeback. The provider had no

An API that misses its deadline should pay you back: a bonded pay-per-call escrow on Arc

Your agent paid an API 0.01 USDC and the API never answered. Unless the provider decides to refund you, that money is gone, and your only recourse is a refund policy, a support ticket or a chargeback. The provider had nothing at stake.

I wanted the opposite default: if the API misses its deadline, it pays you back, plus a penalty, without you having to ask. ReplyBond is a small Solidity escrow on Arc mainnet that does exactly that, and this post walks through the five design choices that make it work, what the AI coding agent (Arc Studio) built, what I did by hand, and where it stops being honest to call it "guaranteed".

A real Arc mainnet job: the 45 s clock hits zero, timeout pays the buyer 0.015 USDC, and the explorer shows the same transaction.

Job 24 on mainnet: the provider stays silent, the 45 s clock (sped up x8) hits zero, timeout pays the buyer 0.010 back + 0.005 from the bond, and the explorer shows the same transaction moving 0.015 USDC.

The shape of a job

A provider posts an offer with a bond. A buyer accepts it by paying the price, and the clock starts on acceptance. Every job ends in exactly one of four terminal states:

Ending Who triggers it Who gets price + bond
Paid buyer calls acknowledge after an answer provider
Claimed anyone calls claim after the answer + a grace window provider
TimedOut anyone calls timeout after the deadline, no answer buyer
Withdrawn provider withdraws an offer nobody took provider (bond only)

There is no owner and no upgrade path. On the demo, the price is 0.010 USDC, the bond 0.005 USDC, the response window 45 s and the grace window 120 s.

1. The bond goes in before the buyer pays

The order matters. The provider locks the penalty first, as msg.value on postOffer; only then can a buyer accept by paying exactly price:

function postOffer(
    bytes32 requestHash,
    address buyer,
    uint256 price,
    uint64 responseWindow,
    uint64 graceWindow
) external payable nonReentrant returns (uint256 id) {
    if (msg.value == 0) revert InvalidBond();
    if (price == 0) revert InvalidPrice();
    if (responseWindow == 0) revert InvalidResponseWindow();
    ...

Because USDC is Arc's native balance, the bond, the price, the penalty and the gas are all the same asset. There are no ERC-20 approvals in the flow at all: one payable call locks the bond, another locks the price.

2. "Answering" means writing the output on-chain

A deadline is meaningless if "I answered" is just the provider's word. So an answer only counts if the provider calls respond with the output itself, between 1 and 1,024 bytes, at or before the deadline:

function respond(uint256 id, bytes calldata output) external nonReentrant {
    Job storage job = _requireJob(id);
    if (msg.sender != job.provider) revert NotProvider(id);
    _requireStatus(id, job, Status.Accepted);
    if (block.timestamp > uint256(job.deadline)) revert ResponseDeadlinePassed(id);
    if (output.length == 0 || output.length > MAX_OUTPUT) revert InvalidOutputLength(output.length);

    job.responseHash = keccak256(output);
    job.respondedAt = uint64(block.timestamp);
    job.status = Status.Responded;
    emit ResponseCommitted(id, job.responseHash, output, job.respondedAt);
}

The contract stores only the keccak256 and emits the bytes in the ResponseCommitted event, so delivery is provable from the chain alone, and MAX_OUTPUT bounds the gas. The boundary is deliberate: respond is valid up to and including the deadline, timeout only strictly after it, so there is no second where both are possible.

3. Nobody has to ask for the refund

timeout is permissionless. Anyone can call it once the deadline passes, and the money always goes to the buyer, never to the caller:

function timeout(uint256 id) external nonReentrant {
    Job storage job = _requireJob(id);
    _requireStatus(id, job, Status.Accepted);
    if (block.timestamp <= uint256(job.deadline)) revert DeadlineNotPassed(id);

    uint256 amount = job.price + job.bond;
    job.status = Status.TimedOut;
    emit JobTimedOut(id, job.buyer, msg.sender, amount);
    _pay(id, job.buyer, amount);
}

The mirror case needed the same treatment. If the provider did answer and the buyer simply goes quiet, the provider must not be stuck: after respondedAt + graceWindow, anyone can call claim, and the provider gets price + bond. A silent buyer cannot take the answer and keep the money.

4. A payout must never block another job

Pushing native value to an arbitrary address is the classic way to brick an escrow: one payee that reverts (or burns gas, or returns a huge blob) and settlement is stuck. ReplyBond sends with a 100,000-gas cap and a zero-length return area, so it never copies return data:

function _pay(uint256 id, address payee, uint256 amount) internal {
    totalLocked -= amount;
    if (!_sendNative(payee, amount)) {
        owed[payee] += amount;
        totalOwed += amount;
        emit PayoutOwed(id, payee, amount);
    }
}

/// @dev Bounded gas and zero-length return area: never copies returndata.
function _sendNative(address payee, uint256 amount) private returns (bool success) {
    assembly ("memory-safe") {
        success := call(PAYOUT_GAS, payee, amount, 0, 0, 0, 0)
    }
}

If the send fails, the amount is credited to owed[payee] and the payee pulls it later with withdrawOwed(). State changes happen before any payout, and every entry point sits behind OpenZeppelin's ReentrancyGuard. The contract's doc comment writes down the invariants the Foundry suite checks, including conservation (balance >= totalLocked + totalOwed) and "each job reaches exactly one terminal state, once".

5. Read the payout from the chain's own Transfer log

This is the part only Arc gives you, and the one I liked most. On Arc, a native USDC movement out of a contract shows up in the receipt as an ERC-20-style Transfer log emitted by a system address (EIP-7708), right next to the contract's own events. From a real testnet timeout receipt:

log#25 emitter=0x1234fb0f...2458 [ReplyBond] JobTimedOut(id=1, amount=15000000000000000)
log#26 emitter=0xfffffffffffffffffffffffffffffffffffffffe [ARC SYSTEM EMITTER]
       Transfer(from=<contract>, to=<buyer>, value=15000000000000000)

So the app does not trust its own contract's event to say "paid". classifyReceipt only returns PAID when the receipt carries the settle event and a system-emitter Transfer from the contract to that payee for that exact amount:

export const SYSTEM_EMITTER = '0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE'
const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'

const transfer = receipt.logs.find(
  (l) =>
    l.address.toLowerCase() === SYSTEM_EMITTER.toLowerCase() &&
    l.topics.length === 3 &&
    l.topics[0].toLowerCase() === TRANSFER_TOPIC &&
    topicAddress(l.topics[1]) === contract.toLowerCase() &&
    topicAddress(l.topics[2]) === settle.payee.toLowerCase() &&
    BigInt(l.data as Hex) === settle.amount,
)
if (!transfer) return none('no USDC Transfer log to the payee', base)

The UI stamps "INDEMNITY PAID" only on a PAID result. One bug here is worth naming: an early version stored a rate-limited RPC read as a permanent failure. Unreadable receipts now retry with backoff instead of being recorded as unreadable for good.

What the AI agent built, and what I did by hand

The contract, its Foundry tests, the API routes and the React app came from Arc Studio turns, driven with the Studio CLI (arc-studio run / pull) and committed in order; each commit subject names its turn. Studio wrote the contract in turn 1, deployed it to Arc testnet and ran the Foundry suite there. Later turns built the app, the demo-provider and demo-buyer routes, the receipt classifier, and fixes such as Vercel's file tracer folding BigInt math on a null binding and breaking the build.

What Studio did not do: mainnet. Studio deploys to testnet only, so I deployed the unchanged Studio source with forge create from my own keystore. contracts/ReplyBond.sol has not changed since Studio turn 1, and Sourcify reports an exact match, which is the check that the bytecode on mainnet really is that source.

The numbers: forge test on the pinned forge 1.7.1 gives 66 passed, 0 failed (63 unit and fuzz tests plus 3 invariants at 256 runs); bun test tests/ gives 116 passed, 0 failed across 16 files. One run through the live app on mainnet covered all three settlement paths, each moving 0.015 USDC: acknowledge, timeout and claim.

Where it stops being a guarantee

  • Delivery, not correctness. The contract proves an output was delivered on-chain on time. It does not prove the output is right. Outputs are public and capped at 1,024 bytes.
  • The demo provider is an echo service. It answers with your input in capitals, so the demo proves timing and payment, not useful answers.
  • Permissionless is not automatic. Someone has to send timeout or claim. The server does it for demo jobs.
  • No always-on worker. The demo provider answers only while some request runs the sweep (a page load, an API call, or the daily cron). A buyer who accepts a job and never calls the API can time it out and collect the bond. That is the timeout path working as designed against a provider that is not always on, and the demo bounds it with 20 new offers per hour, 80 per day and a 0.1 USDC wallet floor.
  • Unaudited, and the demo fixes price, bond and windows rather than letting the buyer choose them per call.

Next on the list: an always-on provider worker, per-call deadlines and bonds, and a way to check answers so that correctness, not only delivery, can back a payout.

Try it

Press "Settle a missed deadline now" on https://replybond.edycu.dev: no wallet needed, it sends a real timeout on Arc mainnet and stamps only once the receipt shows the 0.015 USDC Transfer. Or open https://replybond.edycu.dev/app, connect a wallet on Arc mainnet and "Pay & call" for 0.010 USDC.

Code: https://github.com/edycutjong/replybond ยท BUIDL: https://dorahacks.io/build/50635

๐Ÿ“ฐ Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.