← docs

Writing an inscription that runs

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.

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

<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.

What that means for you, measured

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.


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=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 }

About an inscription

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.

Lists

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.

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/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();

The chain

GET /r/blockheight a number, or null if the node is not reachable
GET /r/blocktime UNIX seconds of the newest block, or null

Balances and names

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)

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
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.

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:

<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.

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.

<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.

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, 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.

{"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>

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 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.

<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.

Who is looking

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.

Errors

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.


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.

<!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

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

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

Finding the rest of a collection

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.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.


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

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.

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