Documentation

Everything we built, and why it works that way.

Tagpay moves USDG between @handles using passkey-owned smart accounts, a relaying server, and a Telegram bot that talks to the same ledger as this site. These notes cover the account model, each payment path, the x402 challenge format, gas handling, and the limits that are still in place.

01 · Overview

One handle, one passkey, one asset

There is no sign-up form, no wallet connection and no deposit address to copy. A person claims a handle, creates a passkey, and from then on a chat line is a payment.

Network
Robinhood Chain · chain id 4663
Asset
USDG (Global Dollar) · 0x5fc5360D…F1d168
Account type
ERC-4337 smart account, owned by one passkey
EntryPoint
v0.6 · 0x5FF137D4…6d2789
Interfaces
Telegram bot + this web app, on one ledger
Custody
No app-held key to any account

What replaced the wallet

A smart account that the person owns through their passkey. Our servers prepare operations and pay the gas; they never hold a key that could sign one.

What one line does

/pay @nova 5 lunch resolves the handle, checks the live balance, and settles on-chain inside the sender's mandate — or hands the approval back to their device.

02 · Accounts

A @handle is the account. The passkey is the owner.

Signing up creates a handle and nothing else. The chain address only exists once the person makes a passkey, because the passkey is what owns it — derivable, but never held by us.

The handle

Every account gets one automatically, generated from the email prefix and kept unique. Handles clean to 3–20 letters, numbers and underscores.

When a chat links, the account adopts that Telegram username as its handle if it is still free, so the name people already tag is the name in the ledger.

The passkey

A WebAuthn P-256 key created on the user's device. We store its public key, the credential id, and the hostname it was made on — enough to prompt for it, never enough to sign.

There is no master seed. Nobody at Tagpay can move a user's USDG.

The address

The smart account address is derived from the passkey's public key by the account factory, so it is reproducible from the key alone and identical every time it is asked for.

Gas fields are fixed in the operation itself, which keeps a signed operation byte-for-byte reproducible later.

Restoring a wallet

An existing passkey wallet can be attached to a signed-in account by naming its address or its handle. It only restores visibility — every movement still needs the passkey signature.

Replacing a passkey is refused while the current wallet still holds USDG, so funds can never be stranded behind a key that is about to be replaced.

What the app stores, and what it doesn't

Wallet rows hold the handle, the owner, the passkey public key, the derived address, the mandate and the Telegram binding. They never hold a balance or a private key. Balances come from balanceOf on the USDG contract at read time.

03 · Payments

How a payment actually settles

A transfer is either a replay of an allowance the user granted, or a smart-account operation the passkey signed. Both end in the same place: USDG moved on Robinhood Chain, and a receipt with a hash.

Two paths, one intent

Inside the user's mandate, the relay calls USDG transferFrom from their address — an allowance they approved, not a key they gave away.

Above it, no signature exists, so the server builds a user operation and hands it to the browser for the passkey to approve.

Rebuilt, never trusted

A signed operation comes back with its nonce and signature. The server rebuilds it from the same inputs and relays that, so what lands on-chain is what the user meant to approve.

A stale or tampered approval simply fails to match, and nothing is sent.

Reading the real result

handleOps can succeed while the inner transfer reverts. The relay decodes the UserOperationEvent and only marks a transfer settled when that event reports success.

Checks before anything moves

The handle must exist, both accounts must have an address, the amount must be above zero and the live on-chain balance must cover it. Amounts are kept to two decimals end to end.

You can't pay your own handle.

Cash-outs

Withdrawals move USDG from the smart account to an external EVM address — the one saved in Settings, or one passed with the command. They follow the same mandate rules and the same passkey approval, with a minimum of 1.00 USDG.

04 · Gas

Gas, paid by relay, owned by the account

A brand-new smart account cannot pay for its own first operation, so the relay funds it just enough to move, and anything left over remains the account's own money.

Top-up rules

An account below 0.00025 ETH is funded to 0.0005 ETH — roughly three operations' worth. Anything more would simply park the user's ETH.

What one operation costs

Estimates are fixed: 150k call, 550k verification and 80k pre-verification gas, with a 0.2 gwei fee cap against a base fee around 0.02 gwei.

That prefunds about 0.000156 ETH up front. The EntryPoint refunds what the operation did not spend, straight into the account's own deposit.

Gas lives in two places

ETH at the account's address, plus the deposit the EntryPoint still holds for it. Both are the account's money, so the balance page adds them together instead of showing a balance that looks short.

Taking it back out

One passkey-approved operation moves both: the wallet's ETH, and the deposit via withdrawTo to the saved payout address. A small reserve stays behind so the operation itself can be paid for.

If a refund ever fails

The relay compares what the account actually lost with what the operation really cost. If the EntryPoint refund didn't arrive, the difference is credited back to the account, capped at 0.01 ETH per operation.

05 · Requests

x402: getting paid without asking twice

A request is a payment challenge with a price, a payer and an expiry. Agents read it over HTTP; the answering side stays the same passkey ledger as everything else.

Creating a challenge

/request @alex 5 design writes a PaymentRequest with a UUID challenge id, a 15-minute expiry, and the memo.

If the payer already has a mandate that covers the amount and a balance to match, it settles immediately and the requester is told so. Otherwise it stays pending.

GET /api/x402/:challengeId

402 Payment Required
{
  "x402Version": 1,
  "error": "Payment Required",
  "accepts": [{
    "scheme": "exact",
    "network": "robinhood-usdg",
    "maxAmountRequired": "5.00",
    "payTo": "@nova",
    "from": "@alex",
    "resource": "/api/x402/:challengeId",
    "nonce": ":challengeId",
    "expiresAt": "2026-01-01T00:15:00.000Z"
  }]
}

Quoted publicly

The challenge endpoint is public and read-only: it quotes terms or reports that the challenge is already settled. It never moves money.

Paid three ways

On the payer's standing mandate, by sending /settle with the short code shown in the chat, or by approving it in the app when it is above the mandate.

Closes for good

Statuses are pending, paid, declined and expired. A closed or expired challenge answers 410, and an unknown id answers 404.

06 · Automation

Agent pay is an allowance, not a key

Switching agent pay on doesn't hand the app custody of anything. It has the passkey approve a USDG allowance for the relay, so the chain itself enforces the ceiling.

Approved by the owner

Turning it on is an approve call signed by the user's passkey. Turning it off approves zero, which revokes it on-chain.

A limit of zero is rejected, so enabling it always names a real ceiling.

Two limits, both checked

The stored per-transfer limit and the live on-chain allowance must both cover the amount. Either one alone is not enough to spend.

That is why tampering with a stored limit still cannot pull funds — the allowance is enforced by the USDG contract.

What it covers

Transfers, cash-outs and x402 requests under the limit run without a prompt. Above it, the operation is prepared for the passkey instead and the person finishes it in the app.

Opt-in by default

New accounts start with agent pay off and a zero limit. Nothing signs for a handle until its owner has approved the allowance themselves.

07 · Interface

The bot is a front end, not a second ledger

Every command funnels into the same gateway the web app uses. The bot keeps no balance of its own, so a payment made in a chat and a payment approved on the site are the same operation.

/pay @nova 5 lunch
Send USDG to a handle, with an optional memo.
/request @nova 5 design
Open an x402 challenge against a handle.
/balance
Your on-chain balance and address. Add a handle to read anyone's.
/settle 3F9A1C22
Pay a request addressed to you, by its code.
/cashout 5 0xAddress
Send USDG out to an external wallet.
/link TAGPAY7KQ9XZ
Bind this chat to the handle shown in the app.
/unlink
Disconnect this chat from its handle.

Linking one chat to one handle

The app shows a one-time code. Sending /link TAGPAY… binds that chat to the handle the code belongs to, and the code is cleared so it cannot be reused.

A fresh code on another chat moves the link rather than making people hunt for /unlink first.

Webhook, verified

Telegram delivers updates to a single server route. When a webhook secret is configured, the header Telegram echoes back must match it, otherwise the request is rejected before anything is parsed.

Works in groups

In a group the bot is addressed by mention — @tagpaybot pay @alex 5 lunch — so the command still routes with the right context.

Receipts and notifications

Each successful payment replies with the amount and the transaction hash. When the other side has a linked chat, they are notified that the USDG arrived or that a request is waiting.

When something needs a signature

Commands above the account's mandate answer honestly instead of failing silently: the chat says the payment needs the passkey and points at Overview, where the same operation is waiting to be approved.

08 · Storage

Three records, no balances

The database holds identity and history. Money is never a column — it lives on-chain, and the app reads it fresh every time a page or a command needs it.

Wallet

One per account: handle, owner, passkey public key + credential id + the hostname it was created on, the derived chain address, the agent mandate, payout address, and the Telegram binding.

Transfer

A movement that happened: both handles and owners, amount, memo, type (direct, x402, withdraw), the on-chain transaction hash as reference, and its status.

PaymentRequest

An x402 challenge: a UUID challenge id, requester and payer handles, amount, memo, a 15-minute expiry, status, and the reference of the transfer that paid it.

Who can read what

Writes happen server-side, where ownership is checked before anything changes. Transfers and requests are readable only by the two accounts involved, or by an admin. Wallet rows stay under admin control because every field in them is identity, not money.

09 · Guarantees

Safety properties and known limits

The design decisions that matter are the ones that hold when something goes wrong: no custody, no blind relaying, and no silent failures.

No key to the money

The only private key the app holds is the relay wallet's, which pays gas and fees. It can sponsor an operation; it cannot sign for an account.

Server-side authority

Prepared operations, key checks, allowance checks and record writes all run on the server behind a verified caller. The browser never sees a secret and never decides an outcome.

Failures are explained

EntryPoint errors are translated — missing relay ETH, expired approval, mismatched signature, an account that couldn't be deployed — instead of surfacing a raw revert.

Nothing settles by accident

A payment is recorded as settled only after the receipt confirms and the operation event reports success. Otherwise it stays pending and can be reconciled.

Known limits

  • Balances are never stored in the app. Every balance shown is read live from Robinhood Chain.
  • A passkey is bound to the hostname it was created on, so approvals elsewhere explain where to go.
  • Cash-outs need at least 1.00 USDG, and a payout address saved in Settings.
  • The relay wallet must hold ETH, or an operation fails with a top-up message instead of a silent error.
  • Requests expire after 15 minutes. Expired and declined challenges can no longer be paid.

Tagpay · USDG on Robinhood Chain