DogecoinArcade
@ Create a new account

The inscription API

docs/inscription-api.md

Jump to: 1. What an inscription is · 2. How your page is served · 3. The endpoints · 4. Recursion: using other inscriptions · 4a. A worked example, on the chain · 5. Writing one · 6. Owning and moving one · 7. What does not exist, and why

A reference for anyone inscribing an HTML page, a script, or anything that asks this node questions. Every claim here about what an inscribed page can and cannot do was measured in a browser, by inscribing a page that tries it — see tests/test_inscription_sandbox.py.

1. What an inscription is

A file written onto the chain in full and uncompressed, owned by an address, with an immutable JSON field beside it. It is split across as many transactions as it needs (7,646 bytes each), reassembled by every node that indexes the chain, and verified against the SHA-256 in its own manifest.

The JSON field can be up to 65,535 bytes. It travels in the manifest ahead of the content, so a long one simply takes more transactions: a 12 KB registry beside a small file is two pieces, not an error.

It is addressed two ways, and both work everywhere an id is accepted:

`` /content/0 by number /content/2ea169a4c7b1... (64 hex characters) by transaction id ``

Length decides which: 64 characters is always a transaction id, because a hex string can be all digits and 0000…0001 is a perfectly ordinary txid.

2. How your page is served

`` GET /content/<id> Content-Type: <whatever you inscribed> Access-Control-Allow-Origin: * X-Content-Type-Options: nosniff Cache-Control: public, max-age=31536000, immutable Content-Security-Policy: sandbox allow-scripts allow-pointer-lock; default-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; style-src 'self' 'unsafe-inline' data:; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none' ``

The viewer loads it into

``html <iframe src="/content/<id>" sandbox="allow-scripts allow-pointer-lock" referrerpolicy="no-referrer"></iframe> ``

Note what is not in that sandbox list: no allow-same-origin. Your page runs in an opaque origin. That one omission is what makes it safe to run a stranger's code inside a wallet that can spend.

Where your page turns up

Two places, both with the same sandbox:

So write your page as though several copies of it might be on one screen at once, none of them focused, some of them below the fold and loaded lazily. Three things follow. Do not assume you are the only frame, do not assume anybody has clicked anything before your code runs, and do not use window.top or parent for anything -- the sandbox refuses it, and the page around you is somebody else's feed rather than the viewer you tested in.

Nothing else changes: no allow-same-origin there either, the same endpoints answer, and location.pathname is still /content/<id> so a page still knows which inscription it is.

Keep every address root-relative (/r/wallet, /content/<id>), as in every example here. When the wallet is reached remotely, your page is served from a hostname of its own that carries nothing but /content/ and /r/ — a page that hard-codes a host, or reaches for a wallet page, breaks there and nowhere else.

What that means for you, measured

You tryWhat happens
<script> inline in your fileruns
eval, new Functionruns
document.cookieSecurityError
parent.documentSecurityError
top.location = …SecurityError
localStorage, sessionStorageSecurityError — use arcade.storage, §3
fetch('/content/…'), fetch('/r/…')works
fetch('/tokens') or any other wallet pageblocked by CORS
fetch('https://anywhere-else/')blocked by the policy
<form> submissionblocked

The last two are worth dwelling on. Your page cannot phone home. It cannot tell anybody that it was looked at, cannot load a font or a script from a CDN, and cannot beacon out an address it read. Everything it needs must be on the chain already. This is not a courtesy: it is connect-src 'self' plus default-src 'self', enforced by the browser.

So: inscribe your dependencies, or do without them.

3. The endpoints

Every one is a read. There is no endpoint that spends, signs, sends or changes anything — the wallet's own controls are not reachable from inside the frame.

All return JSON with Access-Control-Allow-Origin: * unless noted.

Content

GET /content/<id>the bytes, with the content type its creator gave it
GET /content/<id>?download=1the same, as an attachment

A content type this node will not render in a page — anything outside image/ video/ audio/ text/plain text/html text/css application/json application/javascript application/pdf font/ model/ — is served as application/octet-stream with Content-Disposition: attachment. An inscription is arbitrary bytes and guessing at them is how a text file becomes a script.

If the node did not keep that content (it keeps its own in full and describes everybody else's), you get 404 and enough to go and get it yourself:

``json { "error": "this node did not keep the content of that inscription", "sha256": "b1946ac92492d2347c6235b4d2611184…", "length": 135150 } ``

About an inscription

GET /r/inscription/<id>

``json { "id": "2ea169a4c7b1…", "number": 41, "creator": "nAlice1…", "owner": "nBob22…", "block": 1486617, "contenttype": "image/jpeg", "length": 135150, "sha256": "b1946ac9…", "transactions": 18, "held": true, "json": { "name": "Sunrise", "collection": "first" }, "rawjson": "{\"name\": \"Sunrise\", \"collection\": \"first\"}", "collection": "first", "edition": null } ``

The creator's JSON comes with it, parsed, and with every entry of every listing too — so a gallery of a hundred inscriptions is one call, not a hundred and one. json is null when there was none or when what was inscribed does not parse; rawjson is always exactly what is on the chain, because that is a fact and our ability to parse it is not.

GET /r/metadata/<id> — the JSON field, parsed for you:

``json { "json": { "name": "Sunrise", "collection": "first" }, "raw": "{\"name\": \"Sunrise\", \"collection\": \"first\"}" } ``

json is null when there was none, or when what was inscribed does not parse — raw is always exactly what is on the chain. Ordinals hands back hex-encoded CBOR and leaves you to decode it; this is JSON because that is what it was written as.

Lists

GET /r/inscriptionsa page of them, newest first
GET /r/inscriptions/<address>everything one address owns
GET /r/inscriptions/counthow many there are

Each entry is the same shape as /r/inscription/<id>, including its JSON.

Parameters on /r/inscriptions, all optional:

limithow many, up to 500. Default 100.
offsethow many to skip — walking back through pages
afteronly numbers above this one, for a page polling for new work
owner, creatorfilter to an address

/r/inscriptions/count takes owner and creator too, so a page can size its own paging before it asks for anything.

Collections

collection and edition on every inscription say which set the index filed it in. The rule reads only the inscribed JSON, so every node files the same sets: a string collection field names the set outright; otherwise a name ending in #<number> — Doge Punks #12, the shape every HashLips build writes — files it as edition 12 of Doge Punks, taking edition from the JSON if there is one. A set belongs to the address that created its items: two creators using one name are two sets, so nobody can put their own items into somebody else's.

GET /r/collectionsthe sets, newest first. limit (up to 500), offset, creator
GET /r/collections/counthow many. creator
GET /r/collection/<creator>/<name>one set and a page of its items, by edition

``json { "creator": "nAlice1…", "name": "Doge Punks", "count": 100, "firstnumber": 41, "lastnumber": 140, "firstedition": 1, "lastedition": 100, "cover": "2ea169a4c7b1…", "covertype": "image/png" } ``

cover is the lowest edition's id, for a thumbnail. The single-collection call adds items — the same shape as /r/inscription/<id> — with limit (up to 500) and offset, and with traits=1 a count of every trait value across the whole set, read from the items' HashLips attributes:

``json { "creator": "…", "name": "Doge Punks", "count": 100, "…": "…", "items": [ { "id": "…", "number": 41, "edition": 1, "json": { "…": "…" } } ], "traits": { "Background": { "Blue": 60, "Red": 40 }, "Hat": { "Cap": 12 } } } ``

So the rarity of any item is one call: traits[t][v] / count.

``js // everything in one collection, in one call const set = await (await fetch('/r/collection/' + creator + '/' + encodeURIComponent('Doge Punks') + '?limit=500')).json(); ``

The chain

GET /r/blockheighta number, or null if the node is not reachable
GET /r/blocktimeUNIX seconds of the newest block, or null

What is on its way

GET /r/pending/<address> -> {address, incoming: [...], outgoing: [...]}: the pieces and tokens a transaction in the mempool is moving to or from an address, before its block. Each is {txid, kind, from, to, why} (why is transfer, send, grant or swap) with inscription, number and name for a piece, or property_id, units, amount and name for a token. It is read fresh and kept nowhere: a dropped transaction simply disappears from it.

/r/inscriptions/<address>?pending=1 adds what is arriving (pending: "arriving") and marks what is leaving (pending: "leaving"); /r/balances/<address>?pending=1 adds arriving and leaving units to each token. owner and units are still what the blocks say -- so a game can show a piece the moment it is collected, and say it is on its way.

Balances and names

GET /r/balances/<address>

``json [ { "propertyid": 3, "name": "Arcade Test", "balance": "989,995", "units": 98999500000000, "divisible": true } ] ``

balance is the string a person reads; units is the integer the ledger actually holds, so a page doing arithmetic never has to parse the display form back. Only non-zero holdings appear.

GET /r/tag/<name> → { "tag": "alice", "address": "nAlice1…" } (address is null if nobody holds it)

GET /r/address/<address> → { "address": "nAlice1…", "tag": "alice" } (tag is null if they have not claimed one)

Tokens

GET /r/token/<id> → one token, as it was issued:

``json { "propertyid": 22, "name": "Iron Sword", "category": "Games", "subcategory": "Weapons", "url": "", "data": "{\"mygame\": {\"tier\": 3}}", "details": {"mygame": {"tier": 3}}, "described": "…", "issuer": "nIssuer1…", "divisible": false, "managed": true, "supply": 500 } ``

details is the token's data parsed, when it is a JSON object, every key as the issuer wrote it, so a game can keep its own item fields there. It is the issuer's text: read it as data, never as markup or code. GET /r/tokens?ids=20,22,26 answers up to 100 at once, which is enough to classify a whole wallet in one request; unknown ids are left out.

GET /r/holders?token=<id> → { "holders": ["nAddr1…", …] } — who holds that token now, largest holding first, addresses only. For the pieces of a collection, GET /r/holders?creator=<address>&collection=<name> answers who owns them. ?ids=<id> is taken as ?token=<id> (one id; several tokens at once is what /r/tokens is for).

An empty list means nobody holds it, and nothing else. A call that names nobody, and a node that cannot look right now, come back as an error ({ "error": … }, status 404 / 503) rather than an empty list — treat an empty list as a fact about the token, and an error as "ask again", because a holders' list you read as empty is a list that removes everyone from the asset's group chat.

Asking the wallet to send

A page cannot spend. It can ask: file a request naming what should move and to whom, and the person looking at the page is shown the transaction it would be — built, signed, decoded, fee and every output — and approves or refuses it in the wallet, outside your sandbox. You never see a key, never choose the coins, and never learn anything you could not read from the chain.

POST /r/send with a JSON body (send Content-Type: application/json; the preflight is answered):

field
kindcoins, token or inscription
toan address on this chain, or a @tag
amountfor coins and token: what to send, as a decimal string
propertyidfor token
inscriptionfor inscription: its number or id. It must be this wallet's
fromoptional: which of the wallet's addresses pays. Left out, the wallet chooses
labeloptional: what your page calls itself, shown in quotes to the person
noteoptional: why, in a sentence, shown in quotes to the person
refoptional: your page's own name for this send, a string of 1 to 64 characters (anything else is a 400). Asked again with the same ref, by the same wallet on the same chain, within 7 days, nothing new is filed or sent: see Asking again safely below
silentoptional: true and the person is never shown a card. What a test chain lets a game do unasked -- a token its creator issued, or a piece its creator inscribed, sent back to that creator, from the page open as itself -- goes through; anything else fails at once with this needs the player's say-so. The same flag works on claim and inscribe messages

``js const asked = await (await fetch('/r/send', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({kind: 'token', to: '@shop', propertyid: 3, amount: '1', label: 'Hat Shop', note: 'one red hat'})})).json(); // {id: '402d9a31…', status: 'pending', ...} ``

202 with the request; 400 with an error for anything that can be refused at once — an address on the other chain, a token that does not exist, an inscription this wallet does not own, more than twenty requests already waiting. Then poll:

``js const answer = await (await fetch('/r/send/' + asked.id)).json(); // status: 'pending' | 'sent' (with txid) | 'denied' | 'failed' (with error) | 'expired' ``

Asking again safely (ref). A browser can lose the reply to a POST that reached the node ("Load failed" in Safari), and a page that simply asks again files a second request, which can mean paying twice. Give each send a ref of your own (an order number, deposit-<save id>-<n>) and send the same ref when you retry. A repeat is answered 200 with "repeat": true and the first request as it stands now: the same id (so your polling of /r/send/<id> carries on), and its current status, with the txid once it is sent. The body of a repeat is not read again: a ref names one send, so use a new ref for a new one. A ref belongs to the wallet that would send (the signed-in reader on a public arcade, the node's own wallet otherwise) and to the chain, so two players using the same ref get a request each. Only a request that was filed is remembered: a 400 is not, so a corrected retry with the same ref is judged afresh, while one that was filed and then failed (this needs the player's say-so, a broadcast error) answers that failure again. Two POSTs with the same ref at the same moment: the second waits for the first and gets its answer. Refs are kept 7 days and across restarts, and /r/send/<id> of a request filed with a ref still answers after the arcade restarts.

A request nobody answers within an hour expires. What is approved is priced against the wallet as it is when the person looks, not when you asked; a balance that was there and is not by then fails then, not silently.

What went through without a card is not announced to the player either: the page is told the outcome and the wallet shows it, and only what the person said yes to on a card gets a message. A game that settles as it is played -- a thing sent back to its creator, a claim from a refereed pool, a save of its own data -- therefore stays quiet while it is played. A failure is still said out loud unless the page asked with silent, in which case the page hears it and deals with it.

On a public arcade, the request goes to whoever is signed in and looking at your page, never to the node's own wallet. They see it on their own confirmation card (your label and note, the amount, who it pays, the fee), and their browser signs it; your polling of /r/send/<id> sees sent, denied or failed the same way. Nobody signed in means nobody to ask, and the request is refused with a sentence saying so. Coins and tokens can be asked for this way.

What this means for the person. A request is a question, never an action: the page in the frame can file one, and so can any other site that knows the wallet's address — which is why filing needs no key, and why the answer always goes through a page of the wallet that shows exactly what would go out. The bot RPC has the same queue (da_requestsend, da_requesttoken, da_requestinscription, da_request in bot-rpc.md), so a program can be given the ability to ask without the ability to spend.

Being told when it is final. sent means the wallet broadcast it; it does not mean the chain has it. Watch the txid:

GET /r/tx/<txid>

``json { "txid": "e88aa7…", "confirmed": true, "confirmations": 3, "conflicted": false, "block": 7211043, "blockhash": "…", "time": 1789431022 } ``

Any transaction the node knows, not only ones you asked for. confirmations is 0 while it waits in the mempool; conflicted is true when another transaction spending the same coins was confirmed instead, and this one never will be. 404 when the node has never heard of it. A shop hands over the hat at whatever depth it trusts — one confirmation, or six — and that is the shop's decision, not the wallet's. GET /r/send/<id> carries the same confirmations and confirmed once a request is sent, so polling one endpoint is enough.

How the person sees it. When your page runs in the viewer, the request opens in front of it at once: the transaction as built, fee and every output, and Approve and send / Refuse. That pop-up belongs to the wallet, outside your sandbox — your page cannot see it, cannot press anything in it, and cannot frame the approval page itself (frame-ancestors 'self'). On every other page of the wallet, including when the wallet is reached remotely, the same request waits under Approvals. Only a request filed while your page is open (or in the seconds it took to load) pops up over it; one left waiting from earlier is listed under the page with a link, and never opened over a page that did not ask.

Asking the wallet to inscribe

A page can ask the person looking to inscribe something of its own making — a score, a save, an achievement, a replay — as theirs. Any data, any game:

``js parent.postMessage({arcade: "inscribe", seq: 1, data: JSON.stringify({track: "Harbour", time: 120.4}), contenttype: "application/json", json: {game: "racecondition"}, // optional metadata label: "RACE CONDITION", note: "your lap times"}, "*"); // answers {arcade: "inscribe", seq, heard}, then {ok, txid} or {error} ``

field
datathe content: a string, or JSON, or base64 with encoding: "base64"
contenttypewhat it is; text/plain by default
jsonoptional: the inscription's metadata, an object
label, noteshown on the card, in quotes
verifyoptional: have the result checked by the game's judge first — see The referee, "Verified results"

The arcade shows its own card — what, how big, the fee — and the person's browser signs. It goes on the chain for good, as the person's own. Up to 7,000 bytes: one transaction. It counts against the account's inscription allowance like any other. "Cancelled." means they said no.

Games: rooms, trades, escrow and state

Four scripts a game page loads, each with its own guide:

ScriptWhat it isGuide
/r/realtime.jsarcade.realtime: rooms where players on any node meetMultiplayer and trades
/r/swap.jsarcade.swap.trade: a trade between two players, signed by bothMultiplayer and trades
/r/escrow.jsarcade.escrow: items held for a game, released by its judgeEscrow
/r/state.jsarcade.state: a game's own record on each NFTGame state

GET /r/state/<piece> and GET /r/escrow/<address> read the same things without a page. All of them are game-agnostic: the arcade moves and stores, and your judge decides.

Remembering things

Your page has no localStorage: an opaque origin has no storage, and the flag that would give it some would give it the wallet's origin too. What it has instead is the wallet's memory of it, the same shape and the same on every device the wallet is used from:

``html <script src="/r/storage.js"></script> <script> arcade.storage.ready.then(function (s) { var best = Number(s.getItem('best') || 0); // synchronous once ready if (score > best) s.setItem('best', String(score)); // a promise }); </script> ``

getItem, setItem, removeItem, clear, key(i), length, and all() for a copy of everything. Reads are synchronous once ready resolves; writes return a promise that rejects when the wallet refuses — a key over 256 characters, a value over 64 KiB, more than 1,000 keys or 1 MiB in all — and the mirror is rolled back. Strings only, as in localStorage; JSON.stringify what is not one.

It is kept under your inscription's id, by the viewer, which knows which frame it is talking to: another page cannot read or write yours, and yours cannot reach theirs, whatever id it claims. It is this wallet's memory — a high score, a settings panel, a half-finished game — not the chain: nothing here is inscribed, shared or provable. ready rejects after five seconds when no wallet is listening, which is what happens when somebody opens /content/<id> in a tab of its own; a page should still work then, with nothing remembered.

When no wallet is behind the page. On a public instance there is no wallet at all, and the door still answers: the memory is this browser's own localStorage, under your inscription's id and the account looking at it — an unsigned visitor shares one anonymous shelf. Same shape, same limits, the same refusals word for word. It does not sync to the account's other devices and a cleared browser forgets it; what a page must prove belongs on the chain, which is where storage was never going to help it.

The reader's contacts

The address book of the account signed in and looking at your page. It lives in that person's browser and nowhere else, so your page asks the viewer around it, and every answer is theirs to give:

```html <script src="/r/contacts.js"></script> <script> arcade.contacts.list().then(function (people) { // [{address, tag}] people.forEach(function (p) { console.log('@' + p.tag, p.address); }); });

arcade.contacts.add({address: '@alice', name: 'Alice'}) .then(function (r) { / {ok: true, status: "added" | "already"} / }, function (e) { / e.message: "declined", "sign in first ...", ... / });

arcade.contacts.remove('@alice') .then(function (r) { / {ok: true, status: "removed" | "absent"} / }); </script> ```

Talking to another node

Your page cannot fetch anything off the machine it runs on (§2). What it can do is send a node-to-node message — sealed to another DogecoinArcade node's key, sent as the wallet you are running in, carried on the chain — and read what that node sends back. The shop's own node, the game's table, a bot that fills orders: give the page that node's public key or contact code (da_identity on the other node prints both) and it can talk to it.

``html <script src="/r/node.js"></script> <script> var SHOP = 'ef12…'; // the shop node's public key, 64 hex, or its contact code arcade.node.send(SHOP, {order: 'hat', size: 'L'}) .then(function (r) { console.log('sent as', r.txid, 'cost', r.total); }); var stop = arcade.node.listen(function (reply) { console.log('the shop says', reply.json || reply.body); }); </script> ``

Nobody is asked. A message moves no value, and the messaging chain is testnet, only and always: what a message costs is one testnet transaction from a wallet that keeps itself funded. So it goes out at once — unlike a payment, which is a request until the person says yes. What stops a page in a loop from spending the wallet's testnet coins is a cap: thirty messages an hour per page, after which send rejects until the hour turns.

When an account is looking at the page on a public instance the door answers too: the message goes out under the account's own messaging key — the key that signs its feed posts — sealed in the page's browser before the node ever sees it, paid by the account, and metered by the account's message dial (thirty an hour) rather than the per-page cap. The node reads nothing of it; it is handed ciphertext and a destination and offers the carrier for the account's signature. A mainnet account is refused there: sealed messages are testnet's, only and always.

You read only your own conversations. The wallet's inbox also carries what other pages and the bots on the bot RPC are told; replies shows you what came from nodes you wrote to, from the moment you first wrote to them, and nothing else. The other node answers the wallet, not your page — your page has no key — so two pages that write to the same node both see its answers; put something of your own in the message if you need to tell them apart. Like storage, all of this is keyed by the frame the viewer put you in: a page opened in a tab of its own has no wallet listening and send rejects after a minute.

For an account's page the same rule holds with a shorter memory: the node keeps no conversation for anybody, so answers are read in the tab, off the chain, from the moment the page first wrote — and only from the nodes that page wrote to. Reload and the cursor starts again; the replies are still where the chain left them.

A shop, and buying from one

A shop is an inscription whose JSON says what it sells. The page inscribed with that JSON is the storefront; arcade.swap gives it the whole back end. Nobody types a price into a form: the terms are in the inscription, covered by its hash, and both wallets read them from their own ledgers before anything is asked of anybody.

``json {"shop": {"node": "arcade:test:…", "listings": [ {"give": {"token": 3, "amount": "100"}, "take": {"coins": "2"}}, {"give": {"collection": "Pixel Pals", "pick": "random"}, "take": {"token": 3, "amount": "10"}}, {"give": {"inscription": 57}, "take": {"inscription": 58}}]}} ``

node is where the seller's wallet listens — its contact code or public key (da_identity). Each listing's give is what the seller hands over and take what the seller gets; either side is coins, a token with an amount, an inscription by number or id, or (give only) a collection of the shop's creator's with "pick": "random" — a random item of it the shop still holds, which is what a minting event is.

``html <script src="/r/swap.js"></script> <script> arcade.swap.shop().then(function (s) { s.listings.forEach(function (l) { var b = document.createElement('button'); b.textContent = 'Buy ' + arcade.swap.describe(l.give) + ' for ' + arcade.swap.describe(l.take); b.disabled = !!l.available || !s.open || !s.ready; b.onclick = function () { arcade.swap.buy(l.n, {step: function (t) { status.textContent = t; }}) .then(function (r) { status.textContent = 'swapped in ' + r.txid; }) .catch(function (e) { status.textContent = e.message; }); }; document.body.appendChild(b); }); }); </script> ``

What the buyer sees, and why it is safe. The offer comes back from the shop's node as a node-to-node message with the two legs made concrete and one of the seller's outputs locked for it. The buyer's wallet builds the transaction itself — the seller's outpoint first, its own coins after, the swap in OP_RETURN — and shows it in the approvals pop-up like any other send; Approve signs the buyer's half only, and the half-signed transaction goes back to the shop's node, which checks it against exactly what it offered before signing and broadcasting. One transaction, both legs, or nothing: the engine on every node refuses a swap that shorts either side (arcade/state.py, _swap). The seller's side is answered by the shopkeeper in that wallet without anybody pressing anything, because the owner already said yes in writing when they inscribed the shop. The page never holds a key and cannot change a price.

Whose key signs it. Every offer says, in signed_by. One made by a shop's own wallet says "node": that wallet's key sits on the machine that made the offer, and its answer comes without anybody pressing anything, because the owner wrote the terms down when inscribing the shop. An offer or a leg that is waiting for your key says "account" — the node that handed it to you holds neither that key nor those coins, which is exactly why your browser is the one being asked to sign. Read the field rather than inferring it from the address that signs: signed_from is an index into inputs, not a person, and nothing hashes signed_by — it is told to you, not committed to.

Timing. Every step but the buyer's own yes is a message on the chain, and a message is in a block or it is nowhere: on testnet a minute or several, twice, then the swap. Say so in the page rather than spin.

Who may sell. The wallet that created the shop inscription and still holds it. Send the inscription away and the shop is closed; inscribe the same JSON yourself and you have your own shop, selling from your wallet. Testnet only, because node-to-node messages are.

Claims: a prize a page hands over

A claim is a sale hidden behind a phrase. It is on no public page and never announced; it completes only for someone who presents the phrase. That is how a game, a puzzle or any other page pays out: when the player wins, the page asks the wallet to claim, and the player sees an ordinary confirmation card.

Two kinds, made from the owner's own wallet:

When a player wins, the page sends:

``js parent.postMessage({arcade: "claim", seq: 1, listing: "<listing id>", secret: "<phrase>"}, "*"); ``

and listens for the answers, matched by seq:

The claimer pays the price you set plus the network fee, and the item and the coins move in one transaction that their browser checks before signing. For tokens, the wallet also refuses any transaction that does not end on an output of the claimer's own, because that is where a token send delivers.

The phrase is not a secret from everyone. A page's code is on the chain, so anyone who reads it can find the phrase and claim without playing. Derive it inside the page rather than showing it, and price and size a pool as a prize, not a vault. Ten wrong phrases an hour from one account are allowed, then it has to wait.

Keeping a pool honest. A lot is only claimable while its seller still holds the tokens for it; a pool cannot promise more than the seller has. Lots are withdrawn the way any listing is: by spending the coins they stand on.

Prize pools have their own guide: how a page finds its pool (GET /r/claimpool/<inscription>), claims from it with just the phrase, and how pools are made and deleted by inscriptions.

A page that is its owner's own words

A page asks with arcade.send and the person is asked. A page this wallet created and still holds is that person's own words, so it is not: arcade.owner.send builds, signs and broadcasts at once, and the send is written in the approvals list as one that came from a page of your own — a raffle page paying its winner, a shop restocking, a game paying out.

``html <script src="/r/owner.js"></script> <script> arcade.owner.identity().then(function (me) { if (!me.owner) return; // somebody else's wallet: ask instead (arcade.send) return arcade.owner.send({kind: 'token', propertyid: 3, amount: '5', to: winner}); }).then(function (r) { if (r) console.log('sent', r.txid); }); </script> ``

Testnet only: nothing leaves mainnet without a person looking at it.

On a public instance, where accounts sign for themselves, this door opens for nobody: send rejects, saying that a page may not spend an account's coins unattended — the operator's door opens because the wallet holding the key is the machine that made the page, and a node holding nobody's keys has no such standing. A page that sells from this browser is a shop, and answers through arcade.swap instead. identity() stays honest either way: owner is true in an account's tab exactly when the signed-in account created the page and still holds it.

Somebody by name

GET /r/profile/<tag>

``json { "tag": "alice", "address": "nAlice1…", "pending": false, "mainnet": "PAlice1…", "bio": "makes pixel art", "url": "https://dogecoinarcade.com", "picture": "a3f1…", "content": "/content/a3f1…" } ``

Everything a person published under that name, in one lookup: both addresses to pay them at, a line about themselves, a link, and the inscription they use as a face. It repeats what they put on the chain themselves and says nothing about the wallet drawing your page — so unlike /r/wallet there is nothing here to switch off.

Useful for greeting a visitor by name (/r/wallet gives you their tag), showing whose shop a page belongs to, or crediting whoever made a piece.

What a name wrote, and what was written to it

GET /r/written/<tag> — everything that name wrote itself, its posts and its comments together, newest first:

``json { "tag": "alice", "since": 1789400000, "written": [ { "id": "94570f87…", "kind": "post", "author": "nAlice1…", "author_tag": "alice", "parent_id": "", "root_id": "94570f87…", "root_kind": "post", "text": "made a thing", "time": 1789431022, "url": "/feed?post=94570f87…" }, { "id": "1a61ddea…", "kind": "reply", "author": "nAlice1…", "author_tag": "alice", "parent_id": "d41ab2…", "root_id": "2ea169a4…", "root_kind": "mintpad", "text": "two of these left", "time": 0, "url": "/launches/2ea169a4…" } ] } ``

A post and a comment are different rows in different tables, and this reads both keyed on the author — which is why it is its own route rather than a flag on the profile page. kind says which each row was; root_kind says what a comment hangs on (post, launch, mintpad, inscription) and url is the page that draws it, a comment's pointing at the thread rather than at itself. Something still in the mempool has time 0 and is always included, so a page can show what it just did before the chain has it. since is unix seconds or ISO, and two days back when absent — deliberately a short window, because this is a "lately" question and a name's whole history is its profile page; a caller that wants more narrows since. limit up to 500. Likes and shares are not writing and are not here. 404 with no such name when nobody holds the name.

The other direction is GET /r/replies/<tag>: every comment on what that name wrote, and every mention of it, same window, same paging, rows carrying aimed ("reply" or "mention") instead of kind. So "what has @X written lately" and "what have people said about @X" are two calls, and neither one is the other by accident.

Who is looking

GET /r/wallet

``json { "network": "test", "mainnet": false, "addresses": ["nAlice1…", "nAlice2…"], "tag": "alice", "coin": { "spendable": 13847.945, "ticker": "" }, "tokens": [ { "propertyid": 3, "name": "Arcade Test", "balance": "989,995", "units": 98999500000000, "divisible": true } ], "inscriptions": 2 } ``

One chain, said out loud. An inscription lives on exactly one, and a page shown a balance from the other would be reading a number about a wallet that does not exist where it is looking. Token balances are summed across every address the wallet has: fifteen addresses holding one token is one balance, not fifteen.

This is the only endpoint about the viewer rather than about the chain. The balances themselves are public — anyone with an index can look up any address — but which address belongs to the person reading your page is the one thing the chain does not say. It can be switched off, and when it is:

``json { "error": "this wallet does not tell inscriptions who is looking" } ` with status 403`. Handle that: a page that breaks when somebody declines is a page that punishes them for declining.

Errors

Always JSON, always with the CORS header so you can read them:

``json { "error": "no such inscription" } ``

404 for something that is not there, 403 for something you may not have.

4. Recursion: using other inscriptions

The point of /content/<id> being a plain URL is that inscribed pages can build on each other. A library goes on the chain once and everything after it refers to that one copy.

```html <!doctype html><meta charset="utf-8"> <!-- a library somebody inscribed as number 12 --> <script src="/content/12"></script> <canvas id="c" width="600" height="600"></canvas> <script> // my own metadata decides what to draw const id = location.pathname.split('/').pop(); fetch('/r/metadata/' + id) .then(r => r.json()) .then(meta => draw(meta.json ?? {}));

function draw(traits) { const ctx = document.getElementById('c').getContext('2d'); ctx.fillStyle = traits.background ?? '#111'; ctx.fillRect(0, 0, 600, 600); // ... the library inscribed above is available here } </script> ```

Three things make that work:

1. location.pathname inside the frame is /content/<id>, so a page can always find out which inscription it is without being told. 2. /content/12 is same-origin from the frame's point of view for fetching purposes, and carries CORS, so fetch and <script src> both reach it. 3. Nothing external is needed, so nothing external can break it or watch it.

Composing images

``html <img src="/content/40"> <img src="/content/41"> ``

Both are ordinary URLs and img-src 'self' permits them.

Finding the rest of a collection

``js const me = await (await fetch('/r/inscription/' + id)).json(); if (me.collection) { const set = await (await fetch('/r/collection/' + me.creator + '/' + encodeURIComponent(me.collection) + '?limit=500')).json(); const siblings = set.items.map(row => row.id); } ``

There is no /r/children here, because there is no parent-child relationship on this chain — the JSON field is where a collection is declared, and the index files it from that alone.

4a. A worked example, on the chain

examples/showcase/ in the repository is exactly the two inscriptions this section describes, ready to inscribe:

arcade-lib.jsa small library: every endpoint above wrapped, plus a canvas helper. 2,180 bytes, one transaction, about 0.43 to inscribe.
showcase.htmla page that loads that library off the chain and demonstrates the whole API against whatever node is reading it.

It shows, live: the library arriving from /content/<id>; the page working out which inscription it is from location.pathname; its own metadata; block height and time; who is looking and what they hold; a picture composed from another inscription by URL; something drawn by the inscribed library; and a table of everything the sandbox refuses, each one actually attempted.

They must go on in order, because the page refers to the library by id:

`` python3 examples/showcase/inscribe-showcase.py # what both will cost python3 examples/showcase/inscribe-showcase.py --library 7 # writes showcase.built.html ``

tests/test_showcase.py inscribes both into a test chain, opens the page in the real viewer and checks every section filled in — including that the canvas has pixels on it, which only the inscribed library can put there.

5. Writing one

From the Inscriptions page: choose a file, add JSON if you want it, press Price it, look at what it costs, press Inscribe it. Nothing is spent until the second press.

The JSON is validated before it is priced. It cannot be edited afterwards — ever, by anyone, including you — so a typo found at read time is found far too late.

What it will cost

FileTransactionsChainFeeDustTotalNet after sweeping
10 KB229 KB0.32.62.90.3
100 KB14205 KB2.118.120.12.1
1 MB1382.0 MB20.2178.0198.320.2
5 MB68810.1 MB100.9887.5988.4100.9

Most of it is dust in the outputs that carry the data, and that dust is spendable again by whoever made the inscription — every data output is a 1-of-3 multisig including your own key.

There is no ceiling. The only limit is arithmetic: the countdown that marks the last piece is two bytes, so 65,536 pieces, about 498 MB.

Being kind to the people who index it

6. Owning and moving one

An inscription belongs to an address, not to a satoshi. Spending your coins never moves it; sending it is always something you did on purpose.

7. What does not exist, and why

← all docs