Shioaji Pro
為 agent 交易鋪路:交易終端全程由 coding agent 打造,介面 100% 開源、桌面版內建 AI Agent 與全套專業下單面板 — 這個終端就是能力證明。
Translation pending
A username layer for crypto. Send and receive across Ethereum, Solana, and Bitcoin with a single @paytag.
FinanceTypeScript · SolidityGitHub @ansah25OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Username-to-wallet identity layer for multi-chain crypto payments
Translation pending
Taken from the repository README
Loading the full README.
Read the full README ↗Username-to-wallet identity layer for multi-chain crypto payments.
Replace 0x4f3edf83…d8a3c2 with @derrick. Paytag maps one human-readable name to your addresses on Ethereum, Base, Solana and Bitcoin. Names live in an immutable, admin-free registry on Base; the API reads them straight from the contracts; an npm SDK resolves them in one call; a web app registers and manages them from your wallet.
PaytagRegistry and PaytagResolver, verified on Basescan/resolve/derrick/on-chain; live health at /status@paytagdev/sdk on npm/onchain/resolve/derrick. (The Ponder indexer that served indexer.paytag.dev was decommissioned on 2026-10-24, host included; see On-chain reads.)PaytagRegistry · PaytagResolvernpm install @paytagdev/sdk
import { resolve } from '@paytagdev/sdk';
const user = await resolve('derrick');
// { username: 'derrick', addresses: { ethereum: '0x4f3edf83…', … } }
Zero config — the SDK ships with https://api.paytag.dev as the default base URL. To read on-chain state instead, use resolveViaIndexer('derrick'); it has the same shape plus owner, raw records and source: 'onchain'. See packages/sdk/README.md for resolveAddress, available, custom clients, and error codes.
flowchart TD
U["User + browser wallet"] --> W["Web app<br/>paytag.dev"]
W -->|"signed tx"| C["PaytagRegistry + PaytagResolver<br/>Base Sepolia, immutable"]
O["API<br/>Express + Supabase"] -->|"eth_call (Multicall3)"| C
O -->|"events, lazily<br/>(lists only)"| C
O -->|"/onchain/*"| S["@paytagdev/sdk"]
O -->|"/onchain/*"| W
S --> W
S --> X["Any app"]
S -.->|"default source: off-chain /resolve"| O
resolve() reads the off-chain API (the original MVP), resolveViaIndexer() the on-chain registry (through the API's /onchain endpoints), with the same response shape./status probes the API (and its database), an on-chain lookup and the contracts live.The design decisions behind the records, normalization, reverse resolution and the v0.2 contract fixes are written up in Why Paytag stores a Solana address as 32 raw bytes on Base (published on Paragraph; source in writing/).
Every on-chain write in the app follows the same lifecycle (one hook, useOnchainTx, driven by a unit-tested state machine). Registration is shown: two transactions, a commit that hides the name and a reveal that claims it. Each write is simulated first, so failures surface before the wallet opens. Record edits work the same way. After the receipt, every on-chain read asks the API for at least that block (minBlock), so the page reflects the write without waiting for anything:
sequenceDiagram
autonumber
actor U as User
participant A as Web app
participant W as Wallet
participant B as Base Sepolia
participant P as API
U->>A: Register @alice
Note over A: Step 1: reserve (commit). The name stays hidden
A->>B: Simulate commit(hash(alice, wallet, secret))
A->>W: Request signature
W->>B: Broadcast commit
A->>B: Wait for receipt
Note over A: Countdown on chain time, at least 15 s
Note over A: Step 2: register (reveal)
A->>B: Simulate register("alice", secret) (eth_call)
alt Would revert
B-->>A: Custom error, e.g. NameTaken
A-->>U: Readable reason, and the wallet never opens
else Would succeed
A->>W: Request signature
alt Rejected
W-->>A: User rejected
A-->>U: "Rejected in your wallet"
else Signed
W->>B: Broadcast register
A->>B: Wait for receipt (a cancel or replacement counts as failure)
A-->>U: Done, with Basescan link
A->>P: GET /onchain/names?owner=…&minBlock=<receipt block>
P->>B: Multicall3: owner, epoch, records (at ≥ that block)
P-->>A: The new name, already yours
end
end
| Layer | Choice |
|---|---|
| Contracts | Solidity 0.8.26, Foundry (unit, fuzz, invariant, gas snapshots) |
| On-chain reads | viem + Multicall3 against public Base Sepolia RPC (keyed fallback); lists from a Supabase events table synced via Blockscout |
| SDK | TypeScript, tsup (ESM + CJS), zero runtime dependencies |
| Web app | Next.js 15, React 19, wagmi v2 + viem, Vercel |
| Off-chain API | Node.js, Express, TypeScript, Zod, Supabase (Postgres + RLS), Fly.io |
| Tests | Foundry, Jest, Vitest; all in GitHub Actions |
Runs the web app on your machine against the live API (Node 24, the version CI, Fly and Vercel run):
git clone --recurse-submodules https://github.com/ansah25/paytag && cd paytag
npm install
NEXT_PUBLIC_API_BASE_URL=https://api.paytag.dev npm run web:dev # http://localhost:3002
On Windows PowerShell, set the variable first: $env:NEXT_PUBLIC_API_BASE_URL="https://api.paytag.dev"; npm run web:dev.
Everything on-chain works from there (look up, register, manage, with a Base Sepolia wallet), as does resolving and sending to off-chain names. Signing in to claim an off-chain name needs your own API, because the hosted one only accepts sign-ins from paytag.dev. If a page can't reach the hosted services, check paytag.dev/status; the full-local setup below runs without them.
API (needs a Supabase project; the free tier is fine):
cp .env.example .env
# Fill in SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, JWT_SECRET (32+ chars)
Apply the schema once in your Supabase SQL editor by running each file in supabase/migrations/ in order (0001_init.sql through 0008_onchain_events.sql), then:
npm run dev # API on http://localhost:3000
npm run web:dev # web app on http://localhost:3002, using the local API by default
The local API reads Base Sepolia over the public RPC; its on-chain lists catch up on the first request (or run npx ts-node --transpile-only scripts/onchain-backfill.ts once).
Contracts: cd contracts && forge test (see Contract tests).
Live on Base Sepolia (contracts v0.2): register a name in two steps (commit, then reveal), point it at your addresses on each chain, make it your wallet's primary name, transfer it, and resolve it publicly at paytag.dev/on-chain/<name>. See Architecture for how the pieces connect.
Design: Base is the canonical registry. Solana and Bitcoin addresses live as records on the EVM name (keyed by ENSIP-9 / SLIP-44 coin type), not as parallel deployments. One source of truth for ownership; every other chain is resolved through records. Both contracts are immutable and admin-free.
| Contract | Role |
|---|---|
PaytagRegistry | Normalization, reserved-name blocklist, commit-reveal registration, ownership, transfer, per-name epochs |
PaytagResolver | Per-chain address records scoped to the current epoch, forward-verified reverse resolution |
What v0.2 enforces in the contracts:
(name, epoch), so the previous owner's addresses stop resolving at once, for every reader, including anyone calling addr() directly. Their primary name goes dark with them, because reverse() only accepts the current epoch's ETH record.commit(hash) first; register(name, secret) between 15 seconds and 24 hours later. The name isn't legible on-chain or in the mempool before the transaction that claims it, and the minimum age keeps commit and reveal in different blocks. Commitments are stored per committer, so nobody can disturb someone else's.Name rules match the API exactly: 3–20 characters, [a-z0-9_], ASCII A–Z lowercased, everything else rejected, and the API's reserved usernames (paytag, admin, support, …) blocked forever. A backend test (tests/reservedOnChain.test.ts) fails if the two reserved lists drift apart.
Base Sepolia (chain 84532), verified on Basescan and Sourcify. Deployed at block 48167395 from the Deploy script; the full record is in contracts/broadcast/. The ABI is frozen at tag v0.2.0-contracts.
| Contract | Address |
|---|---|
PaytagRegistry | 0x351fed5e079C421493A485C1b90Cc115394C5cb7 |
PaytagResolver | 0x0b8BC41A7CE8cD5ACD21e62A5bE40AC281627f66 |
Superseded deployments, left on-chain and unused:
0xC465…5012 / 0xdd08…9142, tag v0.1.0-contracts): records survived a transfer (a known trade-off when it shipped), and so did primary names (found while building the indexer the same day); only the indexer hid both, and only from its own readers. Registration had no commit-reveal. Writing the design write-up made the case for moving both fixes into the contracts; v0.2 does that. Before v0.2 was deployed, an independent review also caught a commitment-griefing bug in its first draft (a shared commitment map let anyone restart another user's clock); commitments are now stored per committer.0xf082…8721 / 0xa403…e828): a pre-merge review found that it emitted misleading events for zero-address ETH records and lacked the reserved-name blocklist. Nothing was built on it.The contracts are covered on every metric, not just lines: 100% of lines, statements, branches (23/23) and functions in both contracts (forge coverage). The strongest evidence is the 6 stateful invariants below, checked against a full ghost model over 256 random sequences per run (16,384 calls locally, 32,768 in CI's deeper profile). Alongside them, in contracts/test/:
testFuzz_normalizeIsIdempotent, testFuzz_normalizeMatchesReference (contract vs. an independent reference model over arbitrary bytes, including the exact revert reason), testFuzz_registerThenResolve, the reserved list, ownership and reverse authorization.reverse(a) is exactly the model's answer, and round-trips (addr(reverse(a), 60) == a) wherever it is set3e558ab): reverse always returning zero, owner ETH writes reverting, non-ETH records dropped, transfer always reverting, the reverse re-check removed, the reserved check removed, and the transfer auth check removed. For v0.2, five more (PR #14): no epoch bump on transfer, the minimum commitment age removed, addr ignoring the epoch, reverse ignoring the epoch, and a shared commitment map.| File | Lines | Statements | Branches | Functions |
|---|---|---|---|---|
PaytagRegistry.sol | 100% (59/59) | 100% (64/64) | 100% (13/13) | 100% (12/12) |
PaytagResolver.sol | 100% (28/28) | 100% (39/39) | 100% (10/10) | 100% (5/5) |
Every package's tests run in CI on each push: contracts (unit, fuzz, invariants, and a gas-snapshot drift check), backend, SDK, and web (including a drift check that the app's copies of the ABIs, codec and name rules match the API's).
Per-call gas from fixed scenarios in contracts/test/Gas.t.sol, written to contracts/snapshots/GasTest.json. Figures exclude the 21k base transaction cost. The full per-test snapshot is contracts/.gas-snapshot; CI fails if either drifts.
| Call | v0.1 | v0.2 | Δ |
|---|---|---|---|
commit | — | 44,049 | new |
register (20 chars) | 54,791 | 56,278 | +1,487 |
register (3 chars) | 50,643 | 52,130 | +1,487 |
transfer | 29,537 | 29,746 | +209 |
setRecord ETH (new) | 53,156 | 53,414 | +258 |
setRecord ETH (overwrite) | 36,056 | 36,314 | +258 |
setRecord SOL 32 B (new) | 75,725 | 75,980 | +255 |
setRecord BTC 22 B (new) | 53,332 | 53,587 | +255 |
setReverse | 47,772 | 53,231 | +5,459 |
addr ETH | 2,824 | 8,272 | +5,448 |
addr SOL | 5,452 | 10,897 | +5,445 |
reverse | 4,763 | 10,219 | +5,456 |
Registering now takes two transactions, so it costs a commit plus a register (about 96–100k gas before base costs, against 51–55k in v0.1). A transfer costs almost the same, because the epoch shares the owner's storage slot and is written in the same SSTORE. Reads that check the epoch (addr, reverse, setReverse) pay for one cold call to the registry, about 5.4k gas inside a transaction; reading a name off-chain through eth_call costs nothing either way. setRecord already called the registry for the owner, and now gets the epoch from the same call.
Before v0.1 shipped, an optimization pass made two changes that v0.2 keeps:
register used to allocate a second buffer and copy byte by byte. It now lowercases the calldata copy in place inside an unchecked loop, with single-comparison range checks (uint8(c - 0x61) < 26). The saving scales with name length.mapping(… => bytes) into a dedicated mapping(bytes32 => address). Reverse checks are now one SLOAD with no memory decode.The reserved-name check costs about 800 gas per register (28 hash comparisons; it also adds about 1.3 KB of bytecode). The zero-address check costs 73 gas per ETH write.
What I didn't change, and why: writes are dominated by the 22.1k cold SSTORE, which no layout trick avoids. A 32-byte Solana key costs two slots (length + data) because Solidity only inlines bytes of up to 31 bytes. Packing it into one bytes32 would need a coin-type-specific layout, adding complexity for about 20k gas once per record. I wasn't willing to make that trade.
Served by the API under api.paytag.dev/onchain, same response shapes as the Ponder indexer that used to serve them (so the SDK's resolveViaIndexer reads them unchanged). The indexer polled the chain every second, roughly 86,000 RPC calls a day, to watch two contracts that emit almost nothing. It was decommissioned on 2026-10-24 (service, database and the indexer.paytag.dev DNS record), and RPC cost now scales with reads instead of with time. SDK versions before 0.7.0 default resolveViaIndexer to that host; point their indexerUrl at https://api.paytag.dev/onchain, or upgrade.
| Endpoint | Returns |
|---|---|
GET /onchain/resolve/:name | { username, node, owner, registeredAt, epoch, recordsReset, addresses, records, source: "onchain" }. Same base shape as the API's /resolve. epoch counts transfers to a different owner; recordsReset is true when the name changed owner and the current owner hasn't set a record since |
GET /onchain/reverse/:address | { address, username }. username is null unless the claim is live (PaytagResolver.reverse()) |
GET /onchain/available/:name | { available, reason?, suggestions }. Format, the registry's reserved list, then taken |
GET /onchain/names?owner=0x… | Names an address owns now |
GET /onchain/pointing?account=0x… | Names whose ETH record is the address but which it doesn't own (candidates for its primary name) |
resolve, reverse, names and pointing take ?minBlock=<n>: the answer reflects at least block n (the app passes the block of the write it just made), or is 503 { code: "ONCHAIN_BEHIND" } if the RPC hasn't reached it. An RPC outage is 503 { code: "ONCHAIN_UNAVAILABLE" }.
How it works:
eth_call to Multicall3 per lookup: owner, epoch, the ETH / Base / SOL / BTC records and the block number, all at the same block. Current state, so there's no sync lag and nothing to go stale. Cached for 30s like the rest of the API (negative answers too); minBlock skips an entry read before that block. The contracts can't list a name's records, so records holds the coin types the app writes.onchain_events (migration 0008) keeps NameRegistered, Transfer and RecordSet. When a list is requested, the API catches the table up to head - 10 (a reorg margin), reads the few blocks past it fresh, and re-checks every candidate against live state. So a stale or reorged row can make a list miss a name for a moment, never show a wrong one. The catch-up uses Blockscout's logs API (one call per contract for any gap); if that fails (or hasn't finished indexing), eth_getLogs in 200-block chunks (the public RPC refuses ranges over 200), at most ONCHAIN_MAX_CHUNKS_PER_REQUEST per request, with progress persisted.UPDATE, expiring by itself) makes a second concurrent sync skip rather than duplicate work. Not an advisory lock: PostgREST runs each call on a pooled connection, so a session lock would stick to whichever connection took it.https://sepolia.base.org first (from web/lib/endpoints.json, the same entry the CSP allows), then BASE_SEPOLIA_RPC_FALLBACK_URL if set (a keyed provider; server-side only).NameRegistered row is only stored if the name hashes to its node, since the name (and registration time) can't be re-checked against contract state later.src/onchain/generated/reservedNodes.ts and drift-checked in CI).scriptPubKey, EVM chains are 20 address bytes. Test vectors are in tests/onchain/vectors.json.Rollout of a fresh deployment: run migration 0008, then npx ts-node --transpile-only scripts/onchain-backfill.ts once, so no request ever pays for the backfill. A daily keep-alive workflow reads Postgres (a free Supabase project pauses after a week without activity) and keeps the events table near the head.
recordsReset, rather than letting it quietly resolve to nothing./on-chain/<name>) and Paytag accounts (the off-chain API, at paytag.dev/<name>) keep separate records. A Paytag account is linked when the on-chain name of the same spelling is owned by the account's verified Ethereum address; the API reads the registry when it builds a resolution (cached in memory for 30s, like the rest of it) and stores nothing in the database. New Paytag signups can't take a name held on-chain by another wallet (409 USERNAME_TAKEN_ONCHAIN), but the contract is permissionless, so the reverse can't be enforced: the /on-chain app warns and asks for confirmation instead. Only public data is compared, so the link status never reveals which wallet registered an account. While the registry is on testnet, on-chain isn't canonical: if the chain can't be read, signups go ahead.addr and reverse read the current epoch from the registry, about 5.4k gas inside a transaction. Free off-chain.0/o, 1/l) remain, so names are displayed in a monospace font.git submodule update --init --recursive
cd contracts && forge test
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health | — | Health check |
| GET | /auth/nonce?wallet=0x… | — | Issue nonce for wallet signing |
| POST | /auth/verify | — | Verify the signed SIWE message ({ wallet, signature, nonce, domain }), return JWT |
| POST | /auth/logout-all | JWT | Revoke every session of the account |
| POST | /register | JWT | Register a username |
| GET | /me | JWT | The wallet's username and profile |
| PATCH | /me/profile | JWT | Update display name (≤ 40) and bio (≤ 120); null clears |
| POST | /add-address | JWT | Add or update an address for a chain (resets its verification) |
| DELETE | /address/:chain | JWT | Remove the address for a chain |
| POST | /verify-address/nonce | JWT | Get a single-use message to sign with a mapped address (Ethereum, Solana) |
| POST | /verify-address | JWT | Submit the signature; marks the address verified |
| GET | /resolve/:username | — | Resolve username → addresses, verification, profile and on-chain link status |
| GET | /available/:username?wallet=0x… | — | Check availability, with suggestions. TAKEN_ONCHAIN when the name is held on-chain by a wallet other than wallet |
Sign-in nonces are stateless: each carries its issue time and a MAC binding it to the wallet, and nothing is stored when one is issued, so requesting a nonce can never invalidate someone else's. They expire after NONCE_TTL_SECONDS and are single-use (spent nonces are recorded on successful sign-in and remembered well past their lifetime). /auth/nonce, /auth/verify and /register share a limit of 20 requests a minute per client IP. Every string input has a length cap, and published addresses are checksum-validated per chain (EIP-55, base58check, bech32/bech32m, 32-byte Solana keys).
Public read endpoints (/resolve, /available, /onchain/*, /health) are CORS-open. /resolve responses carry Cache-Control: public, max-age=30, stale-while-revalidate=60. /resolve, /available and /onchain/* share a limit of 120 requests a minute per client IP (per API machine; the web app's server-rendered pages present API_SERVER_KEY instead); over it, 429 { code: "RATE_LIMITED" } with Retry-After. In production the API sits behind Cloudflare: a Transform Rule adds a secret x-paytag-edge header, the API refuses requests without it (except /health, for Fly's checks), and the limit keys on Cloudflare's CF-Connecting-IP. IPv6 clients are limited per /64. Roll out in this order: add the Transform Rule first (harmless on its own), then set EDGE_SECRET on Fly; the other way round refuses every real request. /ping is /health behind the guard (it also reads Postgres), and www.paytag.dev/status probes it, so a mismatch shows there as the API being down. Only one secret is accepted, so rotating it means a short window of 403s between changing the rule and the Fly secret.
onchain.status is linked (the on-chain name's owner is the verified ethereum address), not_linked (held on-chain, by another address), none (not registered on-chain), or unavailable when the chain couldn't be read. (syncing is no longer sent: the API reads the registry directly.) /resolve and /available wait up to 800ms for the read and /register up to 1s, then carry on without the answer.
When Postgres can't be reached, any endpoint that reads it answers 503 { code: "DATABASE_UNAVAILABLE" }, which clients can retry. The API itself doesn't retry: each Supabase request gets SUPABASE_TIMEOUT_MS (default 5s), once, then fails. /register is safe to retry after a 503: if the earlier attempt had committed, the same wallet asking for the same name gets its account back instead of USERNAME_TAKEN. Resuming a paused project: a paused Supabase project doesn't wake on a request (it's restored from the dashboard), and its first queries after a restore can take longer than 5s. Those return 503 and /status shows the database down, even though the project is up and warming. If the 503s persist for more than a minute or two after a restore, raise the budget until it's warm (flyctl secrets set SUPABASE_TIMEOUT_MS=30000, then unset it).
Resolution response
{
"username": "derrick",
"addresses": {
"ethereum": "0x4f3edf83…d8a3c2",
"solana": "4Nd1mYz7K8jM…",
"bitcoin": "bc1qw508d6q…"
},
"verified": { "ethereum": true },
"displayName": "Derrick",
"bio": null,
"avatarUrl": null,
"onchain": { "status": "linked" }
}
Error response
{ "error": "Username not found", "code": "USER_NOT_FOUND" }
GET /auth/nonce?wallet=0x…&domain=<its host> → receives { nonce, message }. domain must be on the API's SIWE_DOMAINS allowlist and, from a browser, match the request's Origin.message exactly as given: a Sign-In with Ethereum (EIP-4361) message naming the domain, so wallets warn when another site asks for it.{ wallet, signature, nonce, domain } to /auth/verify → receives { token, wallet }. The API rebuilds the message itself; without nonce/domain it answers 400 CLIENT_OUTDATED.Authorization: Bearer <token> on protected routes. Tokens last 24 h, name their issuer and audience, and carry the account's session version: POST /auth/logout-all signs the account out everywhere.Sign-in is EOA-only (ecrecover); smart-contract wallets (EIP-1271) can't sign in yet.
Nonces are stateless (issued with a MAC binding them to the wallet; nothing stored at issue), TTL-bounded (default 300 s) and single-use: a successful sign-in records the nonce in auth_nonces_used, kept well past the TTL, so a replay is refused.
Lowercase, 3–20 characters, [a-z0-9_]. One username per wallet.
.
├── src/ # Backend (Express + TS)
│ ├── config/ # env, supabase client
│ ├── controllers/ # route handlers (thin)
│ ├── onchain/ # contract reads, ENSIP-9 codec, generated ABIs
│ ├── services/ # auth, user, wallet, on-chain (business logic)
│ ├── middleware/ # auth, validate, errorHandler
│ ├── routes/ # router wiring
│ ├── utils/
│ ├── app.ts
│ └── index.ts
├── contracts/ # Solidity registry + resolver (Foundry)
├── scripts/ # ABI/codec codegen (export-abis.mjs), on-chain backfill
├── packages/sdk/ # @paytagdev/sdk (npm package)
├── web/ # Next.js dApp (deployed at paytag.dev)
├── supabase/migrations/ # SQL schema
└── tests/ # Backend Jest tests
The repo is an npm workspace; npm install at the root installs everything.
| Script | Action |
|---|---|
npm run dev | API with hot-reload |
npm run build | Compile TypeScript |
npm start | Run compiled build |
npm test | Backend tests |
npm run sdk:build | Build the SDK |
npm run sdk:test | Run SDK tests |
npm run web:dev | Build the SDK, then run the web app on :3002 |
npm run web:build | Production build of the web app |
npm run abis / abis:check | Regenerate / drift-check the generated on-chain files (run forge build in contracts/ first) |
@paytagdev/sdk on npmresolveViaIndexer in the SDK (0.3.0)/status, per-deploy indexer schemasusers + wallet_mappings, no public access to auth_nonces / auth_nonces_used)MIT