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.
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.
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.
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
<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.
Keep every address root-relative (/r/wallet, /content/<id>), as in
every example here. On the owner's phone, over the remote tunnel, 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.
| You try | What happens |
|---|---|
<script> inline in your file |
runs |
eval, new Function |
runs |
document.cookie |
SecurityError |
parent.document |
SecurityError |
top.location = … |
SecurityError |
localStorage, sessionStorage |
SecurityError — use arcade.storage, §3 |
fetch('/content/…'), fetch('/r/…') |
works |
fetch('/tokens') or any other wallet page |
blocked by CORS |
fetch('https://anywhere-else/') |
blocked by the policy |
<form> submission |
blocked |
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.
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.
GET /content/<id> |
the bytes, with the content type its creator gave it |
GET /content/<id>?download=1 |
the 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:
{ "error": "this node did not keep the content of that inscription",
"sha256": "b1946ac92492d2347c6235b4d2611184…", "length": 135150 }
GET /r/inscription/<id>
{ "id": "2ea169a4c7b1…", "number": 41, "creator": "nYW2BP…", "owner": "ncpXrS…",
"block": 1486617, "contenttype": "image/jpeg", "length": 135150,
"sha256": "b1946ac9…", "transactions": 18, "held": true,
"json": { "name": "Bonga", "collection": "first" },
"rawjson": "{\"name\": \"Bonga\", \"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": { "name": "Bonga", "collection": "first" },
"raw": "{\"name\": \"Bonga\", \"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.
GET /r/inscriptions |
a page of them, newest first |
GET /r/inscriptions/<address> |
everything one address owns |
GET /r/inscriptions/count |
how many there are |
Each entry is the same shape as /r/inscription/<id>, including its JSON.
Parameters on /r/inscriptions, all optional:
limit |
how many, up to 500. Default 100. |
offset |
how many to skip — walking back through pages |
after |
only numbers above this one, for a page polling for new work |
owner, creator |
filter to an address |
/r/inscriptions/count takes owner and creator too, so a page can size its
own paging before it asks for anything.
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/collections |
the sets, newest first. limit (up to 500), offset, creator |
GET /r/collections/count |
how many. creator |
GET /r/collection/<creator>/<name> |
one set and a page of its items, by edition |
{ "creator": "nYW2BP…", "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:
{ "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.
// everything in one collection, in one call
const set = await (await fetch('/r/collection/' + creator + '/' +
encodeURIComponent('Doge Punks') + '?limit=500')).json();
GET /r/blockheight |
a number, or null if the node is not reachable |
GET /r/blocktime |
UNIX seconds of the newest block, or null |
GET /r/balances/<address>
[ { "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": "martin", "address": "nYW2BP…" }
(address is null if nobody holds it)
GET /r/address/<address> → { "address": "nYW2BP…", "tag": "martin" }
(tag is null if they have not claimed one)
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 | |
|---|---|
kind |
coins, token or inscription |
to |
an address on this chain, or a @tag |
amount |
for coins and token: what to send, as a decimal string |
propertyid |
for token |
inscription |
for inscription: its number or id. It must be this wallet's |
from |
optional: which of the wallet's addresses pays. Left out, the wallet chooses |
label |
optional: what your page calls itself, shown in quotes to the person |
note |
optional: why, in a sentence, shown in quotes to the person |
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:
const answer = await (await fetch('/r/send/' + asked.id)).json();
// status: 'pending' | 'sent' (with txid) | 'denied' | 'failed' (with error) | 'expired'
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 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>
{ "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, and on the phone over the remote tunnel, 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.
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:
<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.
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.
<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>
send(to, body) — body is a string or anything JSON-shaped, up to one
transaction (about 7 KB; identity() tells you the exact maxbytes).
Resolves to {txid, fee, total, size, fromaddress}; rejects with a reason
when the wallet refuses.replies({after, limit}) — what the nodes this page wrote to have said,
oldest first, each {id, txid, block, frompubkey, body, json, protocol,
apihash, compatible}. after is your cursor: keep the last id you
handled (in arcade.storage, say) and pass it back.listen(handler, {after, every}) — polls replies and calls handler
once per new reply; returns a function that stops it. Without after it
starts from now.identity() — this node's own pubkey and contactcode, in case the page
wants to show them; the other node learns them from the envelope anyway.sent() — what this page has sent, newest first.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.
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.
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.
{"shop": {"node": "arcade:test:…",
"listings": [
{"give": {"token": 3, "amount": "100"}, "take": {"coins": "2"}},
{"give": {"collection": "Goofball", "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.
<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>
shop() — {shop, node, seller, listings, mine, open, ready, height, from}.
Each listing is {n, give, take, text, available}; text is the listing
in words (100 Arcade Test for 2 coins), and available is null or the
reason the shop cannot give it right now (sold out, the seller no longer
holds it). open says the creator still holds the shop; mine
that the wallet looking is the one that keeps it; ready that this chain
reads swaps at its height (from is the block they start).buy(n, {step, timeout}) — the whole exchange: ask the shop's node for an
offer, wait for it, put it in front of the buyer, wait for the yes, wait
for the seller's signature. step(text) is told each stage. Resolves to
{txid, offer, give, take}; rejects with the shop's reason, the buyer's
refusal, or gave up waiting after timeout ms (30 minutes by default).offer(n),
awaitOffer(txid), accept(offer), status(request),
awaitDecision(request), awaitSwap(offer).describe(leg) — a leg in words: 100 Arcade Test, a random Goofball,
inscription #57 (Goofball #12), 2 coins.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.
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.
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.
<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>
identity() — {owner, creator, holder, network}; owner is true only
in the wallet that created the page and holds it.send(request) — the same body as POST /r/send. Resolves to {txid,
request, what, fee}; rejects, with a message the page can show, in any
other wallet, on mainnet, or when the send cannot be built.Testnet only: nothing leaves mainnet without a person looking at it.
GET /r/wallet
{ "network": "test", "mainnet": false,
"addresses": ["nYW2BP…", "nqLQ3u…"], "tag": "martin",
"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:
{ "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.
Always JSON, always with the CORS header so you can read them:
{ "error": "no such inscription" }
404 for something that is not there, 403 for something you may not have.
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.
<!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:
location.pathname inside the frame is /content/<id>, so a page can
always find out which inscription it is without being told./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.<img src="/content/40">
<img src="/content/41">
Both are ordinary URLs and img-src 'self' permits them.
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.
examples/showcase/ in the repository is exactly the two inscriptions this
section describes, ready to inscribe:
arcade-lib.js |
a small library: every endpoint above wrapped, plus a canvas helper. 2,180 bytes, one transaction, about 0.43 to inscribe. |
showcase.html |
a 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
# inscribe arcade-lib.js from the Inscriptions page, note its number
python3 examples/showcase/inscribe-showcase.py --library 7 # writes showcase.built.html
# inscribe 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.
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.
| File | Transactions | Chain | Fee | Dust | Total | Net after sweeping |
|---|---|---|---|---|---|---|
| 10 KB | 2 | 29 KB | 0.3 | 2.6 | 2.9 | 0.3 |
| 100 KB | 14 | 205 KB | 2.1 | 18.1 | 20.1 | 2.1 |
| 1 MB | 138 | 2.0 MB | 20.2 | 178.0 | 198.3 | 20.2 |
| 5 MB | 688 | 10.1 MB | 100.9 | 887.5 | 988.4 | 100.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.
text/html, image/png, application/json.
It is the only thing that decides how your inscription is shown./r/wallet refused is a page that works for
everybody.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.
POST /r/send,
§3) and be told yes or no; the yes is a button the person presses, in a
pop-up the page cannot reach. The two exceptions are the person's own
words: a page this wallet created and holds (arcade.owner, §3), and a
shop's listings, which its owner wrote down when inscribing them
(arcade.swap, §3). Both are testnet only.localStorage. An opaque origin has none. arcade.storage (§3) is
the wallet remembering for the page instead, per inscription, and it works
on every device the wallet is opened from.arcade.node, §3),
on testnet, sealed, and answered the same way./r/children, /r/parents, /r/sat. The first two describe a
relationship this chain does not record, and the third is about satoshi
ordinals, which this is not.held tells you, and the hash
and length let you fetch and verify from anywhere.