Documentation · times are UTC

How to ask for a sweep plan

The agent's cash sits in a small contract, IdlewharfTill (verified on Blockscout). The server holds an operator key that can only move USDG between that contract and the Steakhouse USDG vault on Morpho (Vault V2). Only the owner key, which is not on the server, can take money out. When to sweep is decided by the server; every move is a public transaction. IDLEWHARF is not affiliated with Robinhood, Morpho or Steakhouse.

1Try it, no wallet

free · 30 per hour per IP

One request returns a plan for a balance and a daily spend you choose. No key, no signup. Free plans never carry calldata.

curl "https://HOST/api/try/plan?balance=500&spend=12"

Or give it an address and it reads the chain (60 second cache per address): ?address=0x…&spend=12. We cannot read what an address spends, so without spend the plan assumes 1 USDG per day and says so in source.

Over the limit you get 429 with retryAfterSec. A balance of zero is not an error: the verdict is nothing and the note says to send USDG first.

Live response, fetched just now from this server

loading…

2Pay for a plan

0.01 USDG · HTTP 402

x402-style: HTTP 402 + tx hash. Not the Coinbase x402 facilitator / EIP-3009 scheme. The 402 body tells you what to send; you send an ordinary token transfer; you repeat the request with the transaction hash.

  1. Ask without payment and read the 402 body: accepts[0] names the asset (USDG), the amount (10000 raw = 0.01 USDG) and payTo, the till contract.
  2. Send the transfer from any wallet.
  3. Repeat the request with X-Payment: <txHash>. The server checks the receipt on chain.
curl -i "https://HOST/api/plan?address=0xYOU&spend=12"
# HTTP/1.1 402 Payment Required  (body: accepts[], free)

cast send USDG 'transfer(address,uint256)' PAYTO 10000 --rpc-url https://robinhood-rpc.publicnode.com --private-key $KEY

curl -H "X-Payment: 0xTXHASH" "https://HOST/api/plan?address=0xYOU&spend=12"

The same in Node with viem (15 lines):

import { createWalletClient, createPublicClient, http, parseAbi } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
const base = 'https://HOST', q = '/api/plan?address=0xYOU&spend=12';
const rpc = 'https://robinhood-rpc.publicnode.com';
const chain = { id: 4663, name: 'Robinhood Chain', nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: { default: { http: [rpc] } } };
const account = privateKeyToAccount(process.env.KEY);
const wallet = createWalletClient({ account, chain, transport: http(rpc) });
const pub = createPublicClient({ chain, transport: http(rpc) });
const r0 = await fetch(base + q); const req = r0.status === 402 && (await r0.json()).accepts[0];
const hash = await wallet.writeContract({ address: req.asset, abi: parseAbi(['function transfer(address,uint256) returns (bool)']), functionName: 'transfer', args: [req.payTo, BigInt(req.amount)] });
await pub.waitForTransactionReceipt({ hash });
const plan = await (await fetch(base + q, { headers: { 'X-Payment': hash } })).json();
console.log(plan.verdict, plan.txs);   // sign plan.txs yourself, with your own key

A payment is a bearer ticket: it is valid for 60 minutes and works once, for whoever presents it first. There are no refunds, and overpaying does not buy more. A request with a bad query or an RPC problem does not consume the hash while it is still inside its 60 minutes.

When a proof is refused

The 402 comes back again with a reason:

reasonmeaning
underpaidless than 0.01 USDG reached the till
wrong_asseta token other than USDG was sent
wrong_recipientUSDG went to some other address
expiredthe transaction is older than 60 minutes
already_usedthis hash already bought a plan
tx_failedthe transaction reverted
tx_not_foundnot mined yet or unknown; retry in a few seconds
rate_limited429, not 402: more than 30 verifications per minute from your IP; retryAfterSec says when
bad_hashX-Payment is not a 32-byte hex hash
no_payment_foundthe transaction has no USDG transfer to the till at all

If the chain cannot be read the answer is 503 with "note":"payment not consumed": retry.

3The plan object

what a paid call returns

The same object comes from the free and the paid endpoint. Paid plans for an address add txs: ready-to-sign transactions that go to the vault directly, with your own address as receiver. Nothing is executed by us. You sign with your own key and the funds stay yours.

fieldmeaning
walletUSDG in the wallet, vault shares, shares valued in USDG; source is chain or query
spendPerDay, sourceyour daily spend and where the number came from
bufferspend per day × 2 (48 h), never under 2 USDG
excesswallet minus buffer, never under 0
lowAt, refillTotake money back below 1 day of spend; refill to 3 days
apynet APY and its source with a UTC time; null when unknown (then no sweep)
gasgas price, ETH price (RC pool WETH/USDG, unofficial), cost of the round trip in USD
interestPerDay, breakevenDayswhat the excess earns per day and the days until that pays the gas
verdictsweep | hold | withdraw | nothing
txspaid, with ?address= only: approve exactly the amount, then deposit; or withdraw / redeem for a take-back
castthe same transactions as cast send strings

Using it with your own key: send each entry of txs in order as a plain transaction (to, data, value 0, with at least the given gasLimit). The approval is for the exact amount, so nothing stays pre-approved. Simulate first if you like: the vault shows previewDeposit, previewWithdraw and previewRedeem.

# what the paid call returns for a deposit, as cast commands
cast send 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 'approve(address,uint256)' 0xBeEff033F34C046626B8D0A041844C5d1A5409dd <raw> --rpc-url https://robinhood-rpc.publicnode.com --private-key $KEY
cast send 0xBeEff033F34C046626B8D0A041844C5d1A5409dd 'deposit(uint256,address)' <raw> 0xYOU --gas-limit 500000 --rpc-url https://robinhood-rpc.publicnode.com --private-key $KEY

Calldata selectors, checked in the server tests: approve 0x095ea7b3, deposit 0x6e553f65, withdraw 0xb460af94, redeem 0xba087652.

A receiver that is a contract still gets a plan, with a note to make sure it can hold vault shares.

4The sweep rules

checked every 10 minutes

Keep enough USDG in the till for the next 48 hours of spending. Put the rest in the vault only if the interest it earns before it is needed back beats the gas of putting it in and taking it out. Take back only what the buffer is short, plus 72 hours.

excess          = wallet − buffer
roundTripGasUsd = (gasLimitSweep + gasLimitUnsweep) × gasPrice × ethUsd   (our till)
                  (gasLimitDeposit + gasLimitWithdraw + gasLimitApprove) × gasPrice × ethUsd   (plans for other wallets)
interestPerDay  = excess × apyNet / 365
breakevenDays   = roundTripGasUsd / interestPerDay
sweep           = excess ≥ minSweepUsdg and breakevenDays ≤ maxBreakevenDays
                  and sweeps today < maxTxPerDay and gas is not spiking

The numbers below are read from rules.js, the file the server itself loads. They change only with a new deployment, and the change is noted on this page.

namevaluemeaning
loading…

If the Morpho API is down, the APY comes from our own share-price samples over the last 24 hours. With less than 24 hours of samples there is no APY, and with no APY there is no sweep.

A bad week of gas does not drain the till: when the gas price is more than 10 times its 24-hour median, the cycle is skipped. Three failed sweeps in a row pause the engine for 6 hours. Withdraw tries unsweep, falls back to redeeming shares, and if both fail the status shows withdrawBlocked and the buffer is served from the till.

The engine runs even when sending is switched off: it then records its decision and sends nothing. /api/healthz shows the last decision.

5The vault and the till

ERC-4626 · Vault V2

Deposits sit in the Steakhouse USDG vault on Morpho (Vault V2), an ERC-4626 vault: USDG goes in, shares (18 decimals) come out, and convertToAssets says what they are worth.

vault
…
asset
…

Why previews and not maxDeposit: Vault V2 returns 0 for maxDeposit and maxWithdraw. We never use them. Before every send the server reads previewDeposit, previewWithdraw, previewRedeem and convertToAssets, simulates the exact call with eth_call, and does not send if the simulation fails.

The till

IdlewharfTill holds the agent's USDG and its vault shares. Its sweep(assets) approves exactly that amount and deposits into the vault, always leaving at least 2 USDG in the contract. unsweep(assets) withdraws and unsweepShares(shares) redeems. These three are the only things the server's operator key can call. Money leaves the contract only through the owner key (withdrawTo, exit, rescue), which is kept offline and is not on the server. The server cannot send USDG or shares to any address.

Owner runbook: if exit fails because the vault won't redeem: withdrawTo for the liquid USDG, then rescue the vault shares.

till
not deployed yet
owner
…
operator
…

Live vault reading

loading…

6Risk

Read before using any of this

  • The vault can lose money. Staking Rewards rates it CCC/CCC+. A loss in the vault is a loss for whoever holds shares, including the till.
  • Withdrawals depend on the vault's liquidity. They can be slow or fail; the status then shows withdrawBlocked.
  • The till contract was written for this project and has had no outside review. It holds small amounts on purpose.
  • The server decides when to sweep. If the server or the operator key is compromised, an attacker can move USDG between the till and the vault, but cannot send it elsewhere; only the owner key can take money out.
  • USDG is issued by Paxos; the issuer can pause transfers or freeze an address, which would stop the till's moves.
  • The vault's curator can add gates on deposits and withdrawals (all four gates are empty today; changes are timelocked). The owner can exit or move the vault shares out. The server reads the four gates every cycle and does not sweep in while any of them is set.
  • The ETH price used for gas is an unofficial pool price. The APY is the vault's live net APY as reported by the Morpho API; it moves and carries no promise.
  • A plan you buy is a calculation. It is not financial advice.

8API

JSON · no-store · CORS *
endpointreturns
GET /api/statusparked, in the till, earned, gas spent, APY, last sweep, next check
GET /api/try/planfree plan, 30 per hour per IP
GET /api/plan402, then 200 with the plan and calldata
GET /api/ledger?type=&limit=&before=every row has a transaction hash, except an unattributed inflow
GET /api/vaultaddress, share price, APY, liquidity, rating, Morpho warnings
GET /api/samples?hours=24one sample every 10 minutes
GET /api/healthzRPC and Morpho state, last sweep run, last decision, blocked flags

Chain reads are cached 25 seconds. When the RPC fails the last good snapshot is served with stale: true; a number the server cannot read is null, never 0. Types for every response: api.d.ts.

EARNED is parked value plus everything withdrawn minus everything deposited, from ledger rows that each have a hash. GAS SPENT is gasUsed × effectiveGasPrice summed over the receipts of transactions the operator sent.

An inflow the server cannot match to a transaction hash is recorded as a refill row tagged unattributed, the only row without a hash. The public RPC refuses log queries, so the till's balance is polled and any rise that no ledger row explains is booked that way.

9FAQ

Is this a contract?

Yes, one small one that holds the agent's cash; the sweep rules run on our server.

Can I deposit here?

No. Use the plan with your own key: the calldata deposits into the vault from your own address.

Is the APY promised?

No. It is the vault's live net APY as the Morpho API reports it, and it moves.

Who runs the wallet?

Our server runs the operator key; the owner key is offline.

Is Robinhood involved?

No. IDLEWHARF is not affiliated with Robinhood, Morpho or Steakhouse.

Why did it not sweep?

Usually because the amount above the buffer is too small to pay for the gas within 7 days. The plan says so in breakevenDays and note.