# Decision log

Newest first. Each entry: what was decided, by whom, why, and what it invalidates.

---

## D-002 — Target chain changes from Litecoin to Pepecoin (2026-09-10)

**Decided by:** Martin.
> "Cancel the light coin install. Let's work on Pepe coin but not inscriptions like Doge's.
> Inscriptions like using Omni. I think there are better features with Omni like the check sum
> validator and built-in token exchange"

**Action taken:** Litecoin Core install cancelled and fully removed (binaries, datadir, staged
tarballs). Nothing was left on disk; 416 GiB free, same as before we started.

**What this invalidates:**
- Phase 2 as originally written (recovering Martin's 2021-2023 Omni inscriptions from Litecoin)
  is **suspended, not impossible**. See "Recovery is not lost" below.
- Phase 3's plan to validate our consensus engine against a live OmniLite reference node.

**What survives unchanged:** Phase 1 Omni research. The Omni protocol sources (OmniLite,
Omni Core, the spec) remain the ground truth regardless of which chain we deploy on.

---

## D-001 — Proceed on the 5400 RPM HDD; blockfilterindex disabled (2026-09-10)

Superseded in part by D-002. Machine survey remains valid.

---

# Feasibility: can Omni run on Pepecoin?

**Verdict: yes.** Omni is a meta-layer, not a chain feature — it is a payload encoding plus
off-chain consensus rules, interpreted by an indexer. It needs no forked node, only a node
whose transaction policy will relay the payload-carrying outputs.

All of the following are ***verified in source code***, from
`github.com/pepecoinppc/pepecoin` at tag **v1.1.0** (commit `4fb5a0cd`, 2024-12-16),
cloned to `/home/martin/reference/pepecoin`.

| Requirement | Pepecoin v1.1.0 | Source | Verdict |
|---|---|---|---|
| **Class C** — `OP_RETURN` data output | `MAX_OP_RETURN_RELAY = 83` bytes (80 usable payload) | `src/script/standard.h:30` | ✅ identical to BTC/LTC |
| Data carrier relayed by default | `DEFAULT_ACCEPT_DATACARRIER = true` | `src/script/standard.h:16` | ✅ |
| Data size tunable | `-datacarrier`, `-datacarriersize` | `src/init.cpp:483-484,1094-1095` | ✅ |
| **Class B** — bare multisig relayed | `DEFAULT_PERMIT_BAREMULTISIG = true` | `src/validation.h:143` | ✅ |
| Bare multisig standardness | `x-of-3` accepted as standard | `src/policy/policy.cpp:41-48` | ✅ exactly what Class B needs (1-of-3) |
| Dust limit (soft) | `DEFAULT_DUST_LIMIT = RECOMMENDED_MIN_TX_FEE` = **0.01 PEP** | `src/policy/policy.h:70`, `:23` | ✅ cheap |
| Dust limit (hard, standardness) | `DEFAULT_HARD_DUST_LIMIT = DUST/10` = **0.001 PEP** | `src/policy/policy.h:81` | ✅ tunable via `-harddustlimit` |
| Recommended min fee | `RECOMMENDED_MIN_TX_FEE = COIN/100` = **0.01 PEP** | `src/policy/policy.h:23` | ✅ |
| Coin precision | `COIN = 100000000` (8 decimals) | `src/amount.h:18` | ✅ same as BTC/LTC |

**Other chain parameters** (*verified in source code*, `src/chainparams.cpp`):

| Parameter | Value | Line |
|---|---|---|
| Block spacing | **60 seconds** (Litecoin is 150 s, Bitcoin 600 s) | `:89` |
| Subsidy halving interval | 100,000 blocks | `:78` |
| AuxPoW (merged mining) from height | 42,000 | `:136` |
| AuxPoW chain ID | `0x003f` (63) | `:119` |
| Mainnet P2P port | 33874 | `:153` |
| Testnet P2P port | 44874 | `:282` |
| Regtest P2P port | 18444 | `:392` |

**Consequence of 1-minute blocks:** inscription chunks confirm ~2.5x faster than on Litecoin
and ~10x faster than Bitcoin. Good for multi-chunk file inscription, which is inherently
serial across many transactions.

---

# Corrections to two assumptions in the pivot

Martin cited "the check sum validator and built-in token exchange" as reasons to prefer Omni.
Both features are real, but one does not do what the name suggests.

### 1. Omni's "consensus hash" validates ledger state, not file content

`omni_getcurrentconsensushash` hashes the **entire Omni ledger** (balances, properties,
offers) at a given block, so two independent implementations can prove they agree.
It is an implementation-agreement check. It is **not** a per-inscription content checksum,
and it will not tell you whether a recovered JPEG is intact.

A content hash is still the right idea — it was already in the Phase 4 v2 spec
("a SHA-256 hash of the content"). That is ours to design; Omni does not supply it.
*Label: assumption pending Phase 1 verification against `consensushash.cpp`.*

### 2. The "built-in exchange" trades token ↔ base currency, probably not token ↔ token

Omni's **DEx** (types 20 offer / 22 accept) sells a token for the chain's native coin —
so on Pepecoin, token ↔ PEP. That works and transfers cleanly.

Omni Core on Bitcoin additionally has **MetaDEx** (types 25-28) for token ↔ token trading.
The transaction-type list Martin compiled from OmniLite does **not** include 25-28, which
suggests OmniLite lacks MetaDEx. If token-to-token trading matters, that capability may have
to be ported from Omni Core rather than OmniLite.
*Label: assumption — must be confirmed in Phase 1 against `src/omnicore/omnicore.h`.*

---

# The significant new risk: no reference implementation

On Litecoin, OmniLite exists. Our engine could be proven correct by replaying real mainnet
history and comparing `omni_getcurrentconsensushash` against a reference node at every block.

**On Pepecoin there is no Omni, so there is no oracle.** We would be the first and only
implementation, defining truth with no independent way to catch our own consensus bugs.
Also: on day one the DEx has no counterparties, and we must define our own genesis
parameters (activation height, Exodus-equivalent address, ecosystem IDs).

**Mitigation — and it removes the need for any 217 GiB sync:** OmniLite ships unit tests
(`src/omnicore/test/`) and functional tests (`test/functional/omni_*.py`) that run on
**regtest**. Those are executable, authoritative test vectors for the Omni rules, and they
need no mainnet chain at all. Building our engine against that suite gives real correctness
validation on this modest hardware. This was already the Phase 3 plan; it now carries more
weight because it is our *only* oracle.

# Recovery is not lost

Martin's 2021-2023 files are still in Litecoin blocks and are not going anywhere. Omni's
encoding (Class B / Class C, the payload layouts) is **chain-independent**, so if the
extractor is written chain-agnostically from the start — chain params, genesis height and
Exodus address as configuration, not constants — recovering them later costs a Litecoin
sync and a config file, not a rewrite.

**Design requirement adopted:** every component we build takes chain parameters as config.
No hard-coded Pepecoin assumptions.

---

## D-003 — App scope decisions (2026-09-10)

Decided by Martin:

| Question | Decision |
|---|---|
| First feature after the M0-M2 foundation | **M3 MetaDEx exchange** |
| Protocol marker (Class C, replaces Omni's `omni`) | **`pepo`** |
| Crowdsales, types 51/53 | **In scope** |

Marker note: `pepo` is consensus-critical. It can change freely before launch; after the
first mainnet inscription it cannot change without splitting state.

---

# ⚠️ MetaDEx constraints that affect the order-book design

*Verified in source code*, `omnicore/src/omnicore/tx.cpp:1643-1666` (`logicMath_MetaDExTrade`):

| Rule | Code | Consequence |
|---|---|---|
| Both sides are **property IDs** (`u32`) | payload layout, `createpayload.cpp:486` | **The native coin is not a property. MetaDEx cannot quote PEP.** |
| Non-fungible properties rejected | `isPropertyNonFungible(...)` → `PKT_ERROR_TOKENS -27`, `:1653` | **NFTs cannot trade on MetaDEx at all** |
| `property != desired_property` | `:1658` → `PKT_ERROR_METADEX -29` | no self-pairs |
| Both sides same ecosystem | `isTestEcosystemProperty(...)`, `:1666` | main and test books never mix |
| Type must be permitted for that property at that block | `IsTransactionTypeAllowed(...)`, `:1643` | activation rules are per-property |

## What this means in practice

**A TOKEN/PEP market with real bids and asks is not possible on MetaDEx.** Token ↔ native coin
is DEx only (types 20/22), which is **one-sided sell offers** — an offers list, not a book.

This is not an oversight in Omni; it is how Omni is structured. Omni's answer was a **base
property**: property 1 (`OMNI`) is a token, so every pair `TOKEN/OMNI` is a genuine two-sided
MetaDEx book, while `OMNI/BTC` is handled by the one-sided DEx.

For us the same shape applies: we need a **base property** — call it property 1 — against which
all MetaDEx pairs quote. Which raises the question Omni answered in 2013 with the Exodus
crowdsale: **how does property 1 come into existence and get distributed?**

Options:
1. **Crowdsale (type 51).** Send PEP to the issuer during a window, receive base tokens. This is
   literally what Omni did, and crowdsales are now in scope (D-003).
2. **Burn-to-mint.** Send PEP to the Exodus-equivalent address, receive base tokens at a fixed
   ratio. Simple, permissionless, ongoing, no issuer trust.
3. **Fixed issuance (type 50)** to a treasury address, distributed manually. Simplest to build,
   most centralised, weakest story.
4. **No base property.** Accept that only DEx (one-sided, token↔PEP) exists. Much weaker than
   what Martin asked for.

**Second open problem: NFTs cannot trade on MetaDEx** (`:1653`). Selling an NFT therefore needs
either the DEx path or a mechanism we design. Flagged for M4.

---

## D-004 — Protocol shape (2026-09-10)

Decided by Martin:

| Question | Decision | Consequence |
|---|---|---|
| Base property (property 1) origin | **Burn-to-mint** | Send PEP to the Exodus-equivalent address, receive base tokens at a fixed ratio, permanently open. No issuer trust, no deadline, no premine. Gives the base token a hard PEP cost floor. |
| NFT sales | **Extend DEx (20/22) to accept non-fungible ranges** | Deliberate, documented divergence from Omni Core, which rejects NFTs on MetaDEx (`tx.cpp:1653`) and has no NFT DEx path. Must be spec'd and tested as our own rule. |
| Activation height | **Set at launch, ~tip + 1 day** | Left unset in config until M7. Indexer backlog stays at zero through development. |
| Ecosystems | **Keep both (main + test)** | Mirrors Omni ID ranges; gives an on-mainnet sandbox that never mixes with the main book. |

### Consequences that now need specifying

1. **Burn-to-mint is a new transaction path with no Omni equivalent.** Omni's Exodus output is
   a *marker* for Class B, not a value sink. We are giving it a second meaning: PEP sent to it
   mints base tokens. These two roles must not collide — a Class B marker output must not
   accidentally mint. Needs an explicit, tested rule (e.g. minting only on an output that is
   the sole payment to Exodus in a transaction carrying no Omni payload, or a dedicated
   mint transaction type).
2. **NFT-on-DEx is our own consensus rule.** No reference implementation exists for it, so it
   carries the same "no oracle" risk as everything else, and needs its own test vectors.

---

## D-005 — Name, economics, signing, scope (2026-09-10)

Decided by Martin:

| Question | Decision |
|---|---|
| **Project name** | **Ribbit** |
| **Class C marker** | **`rbit`** (0x72 0x62 0x69 0x74) — **supersedes `pepo` from D-003** |
| Base token | **1 PEP = 1 RBIT**, divisible (8 dp), **uncapped**, minted by burn-to-mint only. No premine. |
| Signing | **Node wallet RPC** — app builds payload, `pepecoind` funds and signs via `fundrawtransaction` / `signrawtransaction`. Keys never enter our process. |
| Litecoin recovery (old Phase 2) | **Dropped.** |

### Effect of dropping Litecoin recovery

This is a real simplification, not just a deletion:

- **No v1 format reader.** The 4×255-char base64-in-type-50-text-fields format, and all its
  quirks (stray `'`, extra/missing `=`), leave the spec entirely. Inscriptions are **v2 only**.
- **No chain-agnostic requirement.** The design constraint adopted in D-002 is withdrawn.
  Chain parameters still live in config rather than as literals — that is ordinary good
  practice — but we no longer carry portability as a goal or test it.
- Martin's 2021-2023 files stay where they are, in Litecoin blocks. They are not going
  anywhere, but reversing this decision later would mean building the v1 reader and a
  Litecoin sync from scratch, not flipping a flag.

### Burn-to-mint, settled parameters

- Ratio **1:1**, divisible, uncapped, permanently open — no deadline, no cap, no issuer.
- Every RBIT therefore has a hard floor of 1 burned PEP behind it.
- Still to specify (see D-004): the rule that separates a **minting** payment to the Exodus
  address from a Class B **marker** output, so a Class B transaction can never mint by accident.

---

## D-006 — Openness, access, feature scope (2026-09-10)

| Question | Decision |
|---|---|
| Spec and code | **Public from the start** |
| Web UI binding | **127.0.0.1 only** — reach remotely via SSH port-forward |
| Base token ticker | **RBIT** |
| Delegates (73/74) | **In scope** |
| Freezing (71/72/185/186) | **In scope** |
| Send-to-owners (3) | **In scope** |
| Restricted send (2) | **Out of scope** |

**Why public matters more than it looks:** a second independent implementation is the only
genuine oracle for consensus correctness, and that can only exist if the spec is public. This
partially answers the "no reference implementation" risk from D-002 — not immediately, but it
is the only path that ever resolves it.

**Why localhost-only matters:** the app can trigger signing through the node wallet (D-005),
so exposing the UI exposes spending authority. Localhost is the correct default and costs
nothing, since SSH port-forwarding covers remote use.

---

## D-007 — No base token; burn-to-mint removed (2026-09-10)

**Decided by Martin:** no base token at all. **Supersedes D-004 and D-005 on this point.**

### What prompted it

Martin asked: *"If the Pepe is burned when the rbit is created, how would somebody convert
their RBIT back to Pepe?"*

**They cannot.** Ribbit is a sidecar meta-layer with **no consensus power over PEP** — only a
private-key holder can move PEP. Burning means sending to an address with no key, so the
protocol can never release it. This is structural. **No design gives trustless redemption of
the base coin**; any redemption requires a custodian holding real PEP. (Omni itself was
custodial here: its Exodus address held real BTC controlled by the foundation, not burned.)

### Correction to an earlier claim of mine

D-005 recorded that burn-to-mint gives RBIT *"a hard floor of 1 burned PEP."* **That was
wrong, and backwards.** Under uncapped 1:1 minting:

- 1 PEP is a permanent **ceiling** — nobody pays more than 1 PEP for something they can mint
  at 1 PEP.
- With no redemption there is **no floor whatsoever**.
- Secondary demand is ~zero, since anyone needing the token mints rather than buys, so holders
  can only exit by undercutting.

A quote asset is precisely what traders hold *between* trades, so this would have taxed every
user of the order book. Dropping it is the right call.

### What this removes

| Removed | Effect |
|---|---|
| RBIT the token, property 1 | no base/quote asset ships with the protocol |
| Burn-to-mint | **the Exodus address reverts to being purely a Class B marker, as in Omni** |
| Exodus dual-meaning rule | the "marker vs value sink" collision problem (D-004) disappears entirely |
| One of two unreferenced divergences | only NFT-on-DEx remains as novel consensus code |

Net: meaningfully less consensus surface, and less of it unvalidated. "Ribbit" and the `rbit`
marker are unaffected — they name the protocol, not a token.

### What the exchange looks like now

| Pair | Mechanism | Book shape |
|---|---|---|
| token ↔ token | **MetaDEx (25-28)** | **genuine two-sided book**, bids and asks |
| token ↔ PEP | DEx (20/22), payment window | one-sided sell offers |
| NFT ↔ PEP | DEx, extended for ranges (D-004) | one-sided listings |

No canonical quote asset ships. If the market converges on one — a wrapped-PEP or a stablecoin
issued by a participant — it becomes the de-facto quote asset without us blessing it. That is a
defensible position: we provide the venue, not the money.

### Implementation notes

1. **Property IDs 1 and 2 are special-cased in Omni** as `OMNI_PROPERTY_MSC` / `OMNI_PROPERTY_TMSC`
   (*verified in source*, `omnicore.h:123-124`). With no base token these are free. Decision:
   leave 1 and 2 **permanently reserved and unassigned**, user properties start at 3. Costs
   nothing and keeps a slot if a base token is ever revisited.
2. **Ported Omni test vectors assume OMNI/TOMNI exist.** Any vector that funds an account with
   property 1 or 2 needs adaptation. Expect friction in M2-M3 test porting; budget for it.

---

## D-008 — Crowdsales dropped (2026-09-10)

**Decided by Martin:** remove types 51 (create property variable / crowdsale) and 53 (close
crowdsale) from scope. **Supersedes D-003.**

**Rationale:** a crowdsale's payload carries `propertyIdDesired` (*verified in source*,
`createpayload.cpp:228-239`) — it can only be funded with **another property**, never the
native coin. With no base token (D-007), there is nothing to fund the first crowdsale with.
Keeping them would have meant either shipping a feature that cannot be used, or adding a
second novel consensus rule (native-coin crowdsale funding) immediately after deleting one.

**Removed:** bonus math, deadlines, early-close rules, issuer-percentage handling, and the
crowdsale-participation code path in state processing. A meaningful slice of M6.

**Consensus-hash note:** `GetConsensusHash()` has a crowdsale section
(*verified in source*, `consensushash.cpp:232-234`). We keep the section in our implementation
but it will always be empty, so it contributes nothing. Documented so that re-adding crowdsales
later does not change the hash of existing state.

**Reversible?** Yes, cleanly. Crowdsales touch property creation and balances but nothing else
depends on them.

---

## D-009 — Encrypted messaging: integration and carrier (2026-09-11)

Decided by Martin.

| Question | Decision |
|---|---|
| Where it lives | **Inside Ribbit** — a Ribbit message type sharing the payload codec, Class B/C encoding, indexer, reorg handling and database |
| Primary carrier | **Class B** (7,646 bytes/tx), **Class C** for messages fitting in 72 bytes |
| Messages > 7,646 bytes | **Chained Class B with a countdown index**, using the Doginals UTXO-chaining mechanism |

### Correction: the 25-transaction limit is not a ceiling

I had cited Pepecoin's 25-ancestor mempool limit as an argument against OP_RETURN
chunking. **Martin pointed out this is wrong**, and he is right: the limits are on
**in-mempool** ancestors (`init.cpp:437`, `:439`), and `removeForBlock`
(`txmempool.h:554`) drops mined transactions from the mempool, so a confirmation
resets the count. Send 25, wait one block (~60 s), send 25 more. It is a
throughput constraint, not a ceiling.

The real arguments for Class B are **atomicity** and **cost**:

- A single-transaction message either confirms whole or not at all. A
  16-transaction message has 16 chances to half-land, and a partial message is
  on-chain **permanently** — unreadable but not removable.
- It removes an entire category of design: no chunk index, count, gap detection,
  reassembly or chaining for anything under ~7,598 characters.
- ~0.022 PEP in one transaction versus ~0.04 PEP across sixteen.

### A size limit that binds before the count does

*Verified in source*, `validation.h:76,80`:
`DEFAULT_ANCESTOR_SIZE_LIMIT = DEFAULT_DESCENDANT_SIZE_LIMIT = 101` kB.

An unconfirmed chain is capped at ~101 kB **regardless of transaction count**.
Irrelevant on the OP_RETURN path (25 × ~250 B ≈ 6 kB, so the count binds first),
but decisive for chained Class B: at ~14.7 kB per full transaction only **~6** can
chain unconfirmed, not 25. Martin's batch-and-wait strategy applies exactly here.

---

# What Doginals does, and what we take from it

Read from Martin's own `github.com/martinseeger2002/doginals`, `doginals.js:434-560`.

```js
MAX_CHUNK_LEN   = 240    // bytes per data push
MAX_PAYLOAD_LEN = 1500   // bytes per transaction
```

1. Data is split into 240-byte chunks. The inscription script is `'ord'`, chunk
   count, content type, then `(index, chunk)` pairs where the index **counts
   down**: `parts.length - n - 1`, so **the final chunk carries index 0**.
2. Chunks are packed into ~1500-byte partials, one per transaction.
3. Each transaction creates a **P2SH output** committing to its partial. The
   redeem script is only `<pubkey> OP_CHECKSIGVERIFY <OP_DROP × n> OP_TRUE`.
4. The **next** transaction spends it, and the data rides in that transaction's
   **scriptSig** as plain pushes, which the `OP_DROP`s discard.

### Why 1500 is not arbitrary

*Verified in source*, `pepecoin/src/policy/policy.cpp:78-93`:

```
scriptSig.size() > 1650   ->  "scriptsig-size", non-standard
!scriptSig.IsPushOnly()   ->  "scriptsig-not-pushonly"
```

plus `MAX_SCRIPT_ELEMENT_SIZE = 520` (`script.h:22`). So ~1500 bytes of data, a
~72-byte signature and a ~40-byte redeem script land just under 1650. The
constant is tuned precisely to that ceiling. **The technique is mainnet-standard
on Pepecoin.**

### The two properties worth stealing

- **Ordering is enforced by the UTXO chain, not by trusting an index.** A chunk
  cannot be reordered or skipped, because each transaction spends the previous
  one's output. Only whoever holds that output can extend the chain — which is
  the sender binding the brief wanted from change-chaining, except structural
  rather than conventional.
- **Completion is self-describing.** The countdown reaching 0 *is* the
  terminator. An abandoned chain never reaches 0, so an incomplete message is
  always distinguishable from a complete one — no separate seal transaction, no
  ambiguity.

### Carrier comparison

| Carrier | Bytes/tx | Atomic | Ordering | Mainnet-standard |
|---|---|---|---|---|
| Class C (OP_RETURN) | 72 | ✅ | needs index | ✅ |
| **Class B (multisig)** | **7,646** | ✅ ≤7,646 | n/a, single tx | ✅ |
| Doginals P2SH | ~1,500 | ❌ | **UTXO chain** | ✅ |

**Decision: borrow the mechanism, not the carrier.** Chained *Class B* with a
countdown index gives Doginals' structural ordering and self-describing
completion while carrying **5× more data per transaction**.

### Resulting shape

| Message size | Carrier | Chunking machinery |
|---|---|---|
| ≤ 72 bytes | Class C, one transaction | none |
| ≤ 7,646 bytes | **Class B, one transaction** | **none** |
| larger | chained Class B, countdown index | chunk header only on this path |

---

## D-010 — The Messenger is testnet-only, permanently (2026-09-11)

**Decided by Martin:** *"The Messenger part of the application should always use testnet."*

This is a **permanent product rule**, not a testing phase. Encrypted messaging
never operates on mainnet.

### What this changes

| Area | Effect |
|---|---|
| Network selection | The messaging code path **refuses to run against mainnet**, enforced in code rather than by configuration. A mainnet params object passed to a messaging function is an error, not an option. |
| Mainnet standardness | Still designed for. Testnet does not enforce standardness (`fRequireStandard = false`, `chainparams.cpp:309`), so a transaction that only works there would be silently non-standard. Keeping to mainnet rules means the format stays honest and portable. |
| Threat model | Testnet chains **can be reset or deep-reorged**. Messages are not durable. This must be stated plainly to users — it is a property of the medium, not a bug. |
| Coins | Testnet PEP has no value, so fee estimates are informational only. |
| Node | Requires a **second node** alongside the mainnet Ribbit node. |

### Why designing to mainnet limits still matters

Testnet accepts non-standard transactions. Without the discipline of mainnet
limits we would not discover that a format is unrelayable until someone tried it
somewhere real. The regtest standardness check (`-acceptnonstdtxn=0`) in Step 4
is what actually proves it.

---

## D-011 — Renamed to DogecoinArcade; multi-chain (2026-09-11)

**Decided by Martin:** *"Let's change the name of the application to DogecoinArcade.
I know it's for Pepe, but it should also work on Doge in the exact same way."*

| Item | Old | New |
|---|---|---|
| Project | Ribbit | **DogecoinArcade** |
| Class C marker | `rbit` | **`arcd`** (0x61 0x72 0x63 0x64) |
| Python package | `ribbit` | **`arcade`** |
| Distribution name | `ribbit` | **`dogecoin-arcade`** |
| Repo path | `~/ribbit` | `~/dogecoin-arcade` |

**Entries D-001 through D-010 above still say "Ribbit".** They are left as written:
a decision log that gets retroactively edited is not a record. Read "Ribbit" as
"DogecoinArcade" in anything dated before 2026-09-11.

The marker change was free because nothing has launched. **After the first
mainnet transaction it would be impossible** — a marker change splits state.

### Relationship to the existing GitHub repo

`github.com/martinseeger2002/DogecoinArcade` already exists and already contains
encrypted messaging (`SendSms.py`, `DecryptSmsData.py`, `encrypt_data.py`,
`callGetPubKey.py`). **Martin's decision: keep them entirely distinct.** This is a
new project that shares a name; nothing is merged and nothing is pushed.

---

# Dogecoin support: verified, and nearly free

Verified against `github.com/dogecoin/dogecoin` at 1.14.99, cloned to
`/home/martin/reference/dogecoin`.

Every constant this protocol depends on is **identical** between Dogecoin and
Pepecoin — same values, and the same source line numbers, because Pepecoin is a
Dogecoin fork:

| Constant | Value | Source (identical in both) |
|---|---|---|
| `MAX_OP_RETURN_RELAY` | 83 | `script/standard.h:30` |
| `DEFAULT_ACCEPT_DATACARRIER` | true | `script/standard.h:16` |
| `DEFAULT_PERMIT_BAREMULTISIG` | true | `validation.h:143` |
| x-of-3 bare multisig standard | yes | `policy/policy.cpp:41` |
| `COIN` | 100000000 | `amount.h:18` |
| `RECOMMENDED_MIN_TX_FEE` | COIN/100 | `policy/policy.h:23` |
| `DEFAULT_DUST_LIMIT` | = min fee | `policy/policy.h:70` |
| `DEFAULT_HARD_DUST_LIMIT` | DUST/10 | `policy/policy.h:81` |
| scriptSig standardness limit | 1650 | `policy/policy.cpp:86` |
| `DEFAULT_ANCESTOR_LIMIT` / size | 25 / 101 kB | `validation.h:74,76` |
| Block spacing | 60 s | `chainparams.cpp` |

**Only chain identity differs:**

| | Dogecoin | Pepecoin |
|---|---|---|
| mainnet P2PKH version | 30 (`D…`) | 56 (`P…`) |
| mainnet P2SH version | 22 | 22 |
| mainnet P2P / RPC | 22556 / 22555 | 33874 / 33873 |
| testnet P2P / RPC | 44556 / 44555 | 44874 / 44873 |

Because `Params` already holds exactly these fields, Dogecoin support cost
**three new `Params` instances and zero code changes**.

### ⚠️ Testnet addresses are ambiguous between the two chains

Dogecoin testnet and Pepecoin testnet both use `PUBKEY_ADDRESS = 113` and
`SCRIPT_ADDRESS = 196`. **A testnet address cannot tell you which chain it is
for.** Never infer the chain from an address; record it explicitly alongside any
key announcement or message. Noted in `arcade/config.py` beside the params.

### Messaging guard

`require_messaging_network()` enforces D-010 in code: messaging functions refuse
any mainnet params object. `MESSAGING_NETWORKS` is `{test, regtest, doge-test,
doge-regtest}`.

---

# Martin's existing encryption, compared to the new brief

Read from `github.com/martinseeger2002/DogecoinArcade`, `encrypt_data.py`.

**Existing scheme:** secp256k1 ECDH → HKDF-SHA256 → AES-256-GCM key-wrap →
AES-256-GCM data.

| | Existing | Brief requires |
|---|---|---|
| Curve | secp256k1, using **wallet keys** | X25519, **separate from wallet** |
| Construction | ECDH + HKDF + AES-GCM keywrap + AES-GCM | libsodium sealed box |
| Envelope overhead | **93 bytes** (33 pubkey + 12 IV + 32 wrapped key + 16 tag) | **48 bytes** (32 ephemeral pubkey + 16 MAC) |

The existing construction is cryptographically sound — ECDH+HKDF+AES-GCM is a
legitimate KEM/DEM. Two reasons the new design still differs:

1. **45 bytes of pure overhead.** A sealed box derives the data key directly from
   the ECDH output instead of wrapping a separate AES key. On an 80-byte
   OP_RETURN that difference decides whether a short message fits at all.
2. **Wallet keys are the wrong keys.** Encrypting to a spending key welds
   messaging identity to spending authority: the key is exposed by every
   signature, cannot be rotated without moving funds, and its compromise reads
   every past message. Separate X25519 identity keys fix all three.

---

## D-012 — The application is permanently dual-chain (2026-09-11)

**Decided by Martin:** *"the page should have both testnet and mainnet on it
forever because we will be using testnet for messages and main for tokens and
nfts and also pep transactions."*

| Chain | Role | Sections |
|---|---|---|
| **Testnet** | Messenger | Inbox, Compose, Keys |
| **Mainnet** | Ledger | Tokens, NFTs, Exchange, PEP transactions, Explorer |

This is a permanent split, not a migration path. It supersedes the implicit
assumption in the web UI that one network was selected at launch.

### Why the split makes sense

Messages are **not durable**: testnet chains get reset and deep-reorged (D-010),
and messages are conversational rather than assets. Tokens, NFTs and PEP balances
**are** assets and belong on a chain nobody resets. Each side gets the chain whose
properties match what it holds.

It also means a bug in the messaging code can never touch real value, which is a
stronger guarantee than any amount of care in the messaging code itself.

### The immediate constraint

The mainnet node runs with **`disablewallet=1`** (docs/00-node-setup.md), chosen
in Phase 0 because nothing then needed to sign. So mainnet is currently
**read-only**: it can serve blocks, indexed token state and NFT state, but cannot
send PEP or create a token.

Enabling a mainnet wallet is a deliberate step Martin has to take, because that
wallet holds real money. Until then the UI must show mainnet sections as
read-only rather than failing obscurely at signing time.

### Guards that must hold

1. `require_messaging_network()` continues to refuse mainnet for anything in
   `arcade.messaging`. The dual-chain UI makes it *easier* to point a messaging
   call at the wrong node, not harder, so this matters more now, not less.
2. `verify_connected_chain()` runs on every connection, so a misconfigured
   datadir cannot silently cross the streams.
3. Every section states which chain it is acting on, in the interface itself. A
   user who cannot tell whether they are about to spend testnet or real coins is
   one misclick from a bad day.

---

## D-013 — The wallet is the identity; there is no passphrase (2026-09-11)

**Decision.** The messaging identity is derived from a wallet address. There is
no passphrase, no key file, and nothing for a user to write down. `wallet.dat`
is the only thing they have to keep.

### What this replaces

D-009 gave the messaging key its own passphrase, encrypted at rest with Argon2id.
That was cryptographically sound and a poor product. The failure mode was
absolute: forget the passphrase and every message ever sent to that identity is
unreadable, by anyone, forever. Two attempts were made to soften it — generating
the passphrase rather than letting the user invent one, then saving it on the
machine so it could be looked up later — and the second undoes most of the point
of the first. Storing a secret next to the thing it protects is not protection;
it is a passphrase-shaped ritual.

Martin's instruction was direct: *"I don't want a pass phrase to be saved or
shown. The only thing that the user should need is their wallet.dat files."*

### How it works

    signature = wallet.signmessage(address, "DogecoinArcade messaging identity v1")
    seed      = HKDF-SHA256(signature)
    identity  = X25519 keypair from that seed

`signmessage` is deterministic — RFC 6979 — so the same address always produces
the same signature and therefore the same identity. Verified on Pepecoin:
three calls, byte-identical.

The address is filed in the wallet under the account `arcade-identity`. Accounts
live inside wallet.dat, which is what makes a restore self-sufficient: the
application finds the address again without needing its own database.

### The subtle part, which was a bug first

`getaccountaddress` returns a **fresh** address as soon as the current one has
been used. Calling it on each startup would therefore have changed the identity
silently, orphaning every message already received — the exact failure the
passphrase was removed to avoid. Caught in testing, before it shipped.

The rule is now: take the account's existing addresses, sort them, use the
first. `getaccountaddress` is reached only when the account is genuinely empty.
Three independent installations against one wallet derive the same identity, and
the CLI and the web interface agree with each other.

### What it costs, stated plainly

**Whoever can use the wallet can re-derive the identity.** The wallet's security
becomes the messaging security; if the wallet is unencrypted, so is the identity
in practice. Against a standalone key with its own passphrase this is a real
reduction, and it is the trade being made deliberately: the passphrase's failure
mode was certain and common, this one requires an attacker to already have the
wallet — at which point they have the coins too.

A spending key is still never used for encryption. Deriving a separate X25519
key keeps encryption away from spending, so a compromise of one is not
automatically a compromise of the other, and the messaging key can be rotated by
moving to a different address without moving funds.

### Consequences

- Fingerprints are gone from the interface. They named the identity in a form no
  user could act on. Names from the address book come first, then the address.
  The CLI keeps them: it is a diagnostic tool and that is where they belong.
- `vault.py` — the credential store that remembered passphrases — is deleted.
- Key files still work when named explicitly with `--key`, for identities made
  before this. They are never picked up implicitly, because the web interface
  derives from the wallet and the two halves of the application answering to
  different identities would be worse than either choice alone.
- Backup and restore had to become real features rather than documentation:
  `arcade/backup.py` and the Backup page. If the wallet is the only thing that
  matters, keeping it has to be something a person can actually do.

---

## D-014 — Public group posts may run on mainnet (2026-09-11)

**Decision.** A public, unencrypted group channel runs on **both** chains. D-010
is unchanged: nothing encrypted ever touches mainnet.

### Why this is not a reversal of D-010

D-010 says *the Messenger* is testnet-only, permanently, and the reasons were
specific: messaging code is young, a bug in it must never be able to touch real
value, and an encrypted message carries key material that would tie a mainnet
identity to a correspondence. None of that applies to a public post.

A group post is not sealed to anybody. It carries no key material. It reveals
nothing that publishing it does not already reveal, because publishing it *is*
the act. So the question "may this run on mainnet?" stops being a safety question
and becomes a cost question, which is the user's to answer. Martin answered it.

### How the boundary is kept

`MessageSender` still calls `require_messaging_network()` on construction. The
only way past it is `public_only=True`, which is:

- opt-in, never a default;
- named for what it permits rather than what it disables, so a call site asking
  for mainnet has to say out loud that what it is sending is public;
- passed by exactly one call path, the group-post route;
- covered by a test asserting that encrypted sending is still refused on `main`
  and `doge-main`.

### Carriage, and why a post is short

Class C: one OP_RETURN output. One output means **no dust**, so a post costs a
fee and nothing else. That caps a post at about 60 characters.

That is a real limit and the right trade. Class B would carry 7.5 KB, but it pays
roughly one unspendable output per 30 bytes — on testnet that is free coins, and
on mainnet it is somebody's money burnt to say something in public. A 60-character
public post costs about 0.0028 in fees; the same text carried Class B would cost
that plus several coins of dust forever.

### What the interface must say

The rest of the application trains the expectation that messages are private, so
the public half has to contradict that expectation loudly rather than quietly:

1. Every screen where a post can be written states that nothing there is
   encrypted, that it is permanent, and that it cannot be deleted.
2. The two chains are chosen explicitly, labelled *free coins, nothing at stake*
   and *real coins, permanent*. There is no single control that silently spends
   money.
3. A mainnet post shows the post's text back to the user inside the confirmation,
   with the fee, before anything is broadcast — the ground rule for mainnet
   transactions, applied here.

### Channels have no membership

A channel is a name. Anyone using the same name is in the same room; there is no
invitation, no member list, no way to remove anyone, and no moderation. Saying so
plainly is the whole of the access-control design, and pretending otherwise would
be worse than the limitation.

---

## D-015 — One starting block height per version (2026-09-11)

**Decision.** Every installation reads messages from the same block, declared in
code per network and changed only by a release.

### The problem it fixes

Each machine began scanning at whatever height its own identity happened to be
created. Two people running the same version therefore saw different histories,
and neither could tell why: one would see a public post or a key announcement the
other had simply never scanned. Nothing was broken, and nothing said anything —
the machines were reading different parts of the same chain.

That is a poor property for a shared medium. A public channel in particular is
supposed to be the same room for everyone in it.

### How it works

`Params.messaging_start_height` is a floor, applied first:

    floor = max(messaging_start_height, activation_height)
    start = max(recorded identity height, floor)

A local record can push the start **later**, never earlier. Later is legitimate —
nothing written before a key existed can be addressed to it, so reading it is
wasted work. Earlier is not: it would let one machine read history the protocol
says is not there.

Testnet is set to **1,483,184**, the tip on 2026-09-11. Everything before it is
development traffic from building the application, and is deliberately unread.

### What changing it means

Raising it declares a clean slate for everyone at once. Lowering it asks every
installation to rescan, which is cheap only if it is not lowered far. Either is a
release decision and belongs in a commit, not in a setting — the value has to be
the same everywhere or it is not doing its job.

Clearing history locally ("Start fresh") resets to this height rather than to the
machine's current block, so clearing brings an installation back into step with
everyone else instead of pinning it wherever it happened to be.

### What it does not do

It is not consensus. Nothing validates that a peer used the same value, and a
modified client can read from anywhere it likes — the chain is public. It removes
an accidental divergence between honest installations, which is what it was
costing.

---

## D-016 — Tokens go live: the start block, and tokens on testnet for everyone (2026-09-12)

**Decision.** The mainnet token index starts at block **1,208,000** (Pepecoin).
Until the first token exists on mainnet the number may be moved by a release;
after that it is fixed for ever. Tokens are **also indexed on testnet**, from
1,484,661, on every installation, and the Tokens page switches between the two
chains by clicking the chain tag beside its heading.

### What "activation height" now means

D-004 left it "set at launch, ~tip + 1 day", and M2 through M5 ran with mainnet
set to `None`, which disables the index outright. The tokens interface is the
launch: it creates, sends, grants and revokes on whichever chain is the ledger,
and an index that never starts cannot show any of it. The value was chosen on
2026-09-12 at tip 1,206,546 — about a day ahead, as D-004 asked.

It is still free to move. Nothing before the first mainnet token transaction
depends on where reading began, so a later release may raise or lower it without
changing what anyone holds. From the first token on, moving it would change
history: raising it would un-create that token, lowering it could create
another. So the rule is the one from D-015, sharpened: **changeable until it
matters, then never.**

Dogecoin mainnet stays at `None`. Nothing has been exercised there.

### Tokens on testnet, for everyone

D-012 put the ledger on mainnet permanently. That stands: mainnet is where a
token is worth anything. It does not mean testnet tokens are useless — they
are how anyone tries the thing for nothing, and how the two machines check
each other before real coin is involved. The alternative was trusting regtest
alone: one node, no reorgs anyone did not stage, no second machine.

So both chains are indexed, always, whether or not anyone is looking, and the
Tokens page shows one at a time. Martin asked for exactly this shape: **the
red "main" tag is the switch**. Clicking it goes to testnet, clicking the
green "test" tag comes back, and the choice is kept in a file
(`~/.dogecoinarcade/tokens-chain`) so an update does not put someone back on
mainnet unasked. A token's own page carries its chain's tag but does not
switch — the token belongs to that chain. The earlier `--ledger-network test`
option, which made testnet the ledger for the whole process, is gone; one
way to do a thing.

Two consequences are worth writing down:

- Testnet is read twice: once by the public-board scanner and once by its token
  index. Each keeps its own tip (`AppState.tips` and `AppState.ledger_tips`),
  because when they shared one the first reader hid every new block from the
  second.
- The Messenger's transactions are all Omni type 200 (AnyData). Omni Core treats
  200 as **valid and a no-op** (`tx.cpp:2735-2747`), and the engine now does the
  same. Before this, every message would have been indexed as an invalid token
  transaction with "not yet implemented", thousands of rows of noise about
  transactions that were never about tokens. This is a consensus-relevant change
  and it is free, because there is no mainnet ledger yet to disagree with.

### What is broadcast is what was shown

Every token action is two submissions: the first builds, funds and signs a
transaction and shows it decoded — sender, recipient, each output labelled,
fee, total, txid; the second broadcasts. The second sends **the same bytes**.
The prepared transaction is kept in the process under its txid and the confirm
form hands that txid back; if it is no longer there (the server restarted), the
action is rebuilt and shown again rather than sent. The wallet's plain coin
send rebuilds on confirmation, which is harmless for a coin send with the same
destination and amount but would let a token transaction go out with a txid and
fee the user never saw.

### Sender and recipient are checked by the engine's own rules

A token transaction the wallet funded is read back through `determine_sender`
and `determine_reference` — the functions the indexer will run on it — and
refused if they do not resolve to the intended addresses. Not a restatement of
the rules in the builder: the same code. A send from the wrong address is a
send of somebody else's tokens, invalid and paid for, and this is the check
that stops it before the fee is spent.

### Where it stops

An unknown or out-of-scope message type stops the index at that block (M2 hard
rule 2, unchanged) and the tokens page says so, with the block and the type.
Balances shown from then on are as of the block before. The index retries only
when a new block arrives, since the block it stopped on will not read
differently five seconds later.

## D-017 — A bot RPC in Omni Core's vocabulary (2026-09-12)

**Decision.** arcade-web serves JSON-RPC at `/rpc/test` and `/rpc/main` with
Omni Core's method names (`omni_getbalance`, `omni_getallbalancesforid`,
`omni_send`, …), authenticated by a cookie file exactly as bitcoind is. Writing
methods prepare and return the transaction; `omni_broadcast` sends it. Full
reference: `bot-rpc.md`.

### Why Omni's names

The engine is Omni's rules on Pepecoin; the people most likely to write a bot
against it are the people who have written one against Omni Core. Giving them
the names and field shapes they know (`propertyid`, `balance`/`reserved`/
`frozen`, amounts as strings) makes the port a URL change. Where the index
holds no such thing — `reserved`, `frozen` — the field is present and zero
rather than absent, so a client that reads it does not break. Where the
arcade has something Omni does not, it is added (`omni_getinfo` reports the
index's own height, what it is behind, and whether it stopped) rather than
squeezed into an Omni field with a different meaning.

### Why a cookie, and why not the CSRF token

The standing rule is cookie or `rpcauth` for the node, never a plaintext
password in a script; the same reasoning holds one layer up. The server
writes `~/.dogecoinarcade/rpc.cookie` (mode 0600, fresh each start) and a
script sends it as HTTP basic auth. There is nothing to configure and nothing
to paste. The forms' CSRF token is the wrong key for this: it is embedded in
every page, so any script that can read a page has it, and a browser can be
made to send it. The cookie lives only in a file the user owns, which a web
page cannot read. Loopback binding stays what it was.

### Why sending is two calls

Omni's `omni_send` broadcasts. Here it prepares, and `omni_broadcast` sends
the bytes that were prepared — the same rule as the confirm screen (D-016).
The alternative, one call that spends, was rejected because a bot that cannot
see the fee before paying it cannot budget an airdrop, and because the
transaction the server broadcasts should always be one somebody (or
something) was shown. The cost is one extra call per send, which the example
bot pays in one line.

### One URL per chain

Omni Core serves the chain its node is on. The arcade indexes both, and the
Tokens page has a switch. A bot must not follow that switch — a script that
starts sending on mainnet because someone clicked a tag in a browser is a
disaster — so the chain is in the URL and nowhere else. `/rpc` without a
chain is a 404 that says so. The shell tool `arcade-rpc` defaults to testnet
and needs `-main` said out loud.

### What it does not do

No send-to-owners (Omni type 3). The engine does not implement it, and a
transaction of that type stops the index (hard rule 2). Airdrops are one send
per holder, which the example bot does with a done-file so a stopped run
resumes without double paying. No rate limiting and no per-method
permissions: the cookie is the operator's own authority, the same as the
shell that can run `pepecoin-cli sendtoaddress`.

## D-018 — A door you can see, with a key and a deadline (2026-09-12)

Reaching the wallet from a phone meant an SSH port-forward, which is fine for
people who already know how and a wall for everyone else. The Remote page opens
a Cloudflare quick tunnel and shows a QR code.

The temptation is to call the random hostname the security. It is not: a URL is
read over a shoulder, lives in a phone's history, and passes through Cloudflare's
edge. So the tunnel is a route, and three separate things are the protection —
a key that only the QR code carries, a deadline the user cannot forget to
enforce, and a flat refusal to serve the bot RPC through it.

Told apart by headers, not by address: cloudflared runs on the machine and
connects to 127.0.0.1, so a phone in another country arrives from localhost.
Verified against a live tunnel rather than assumed, and the guard deliberately
does not treat an unfamiliar Host as remote, so someone reaching their own
wallet over their own LAN is not locked out by a feature they never enabled.

Registered as middleware, not per route. A door guarded route by route is a door
that is open the first time somebody adds a route and forgets.

cloudflared is pinned by version and by hash. Cloudflare publishes no checksum
file beside the binaries, so the hashes come from GitHub's asset digests, read
once and written into the installer: it checks against something it carried with
it, not against a number fetched from the same place as the file.

## D-019 — Collections are read from the chain, and a run is a file on disk (2026-09-14)

A HashLips build is a few thousand pictures with a JSON file each. Inscribing
one by hand is a few thousand trips through the single-file form, so there is a
wizard: point it at the `build` folder, see every item priced, confirm once.

Two decisions underneath it.

**The collection is what the JSON says, not what the wizard was told.** The
wizard could have written "these belong together" somewhere — a manifest
inscription, a field of its own. It does not. The index files an inscription
into a set from its inscribed JSON alone: `collection` if there is one,
otherwise a `name` ending in `#<number>`, which is the shape HashLips already
writes. Every node running the indexer files the same sets from the same bytes,
nothing has to be inscribed in addition to the items, and a set cannot be
renamed or hijacked afterwards because there is nothing to edit. A set is keyed
by its creator as well as its name, so two people using the same name are two
sets. The ledger schema is version-locked, so the filing lives in a new table
of its own, filled in for an existing index the first time it is opened.

**A run is a job on disk, written one transaction at a time.** Every txid the
node accepts is recorded before the next piece is built. Pause is a flag read
between pieces; resume starts at the next piece with no txid; a crash is a
`running` job found at startup, which restarts itself. The one gap — the node
took a transaction and the machine died before the record was written — is
closed by asking the chain index which pieces of that inscription id it has
already seen, so the worst case of a power cut is nothing, not a duplicate
paid for twice. The wallet is split in batches sized by the largest item
coming up, so a long run neither waits a block per piece nor makes ten thousand
outputs at once, and the send lock is held per item so messages still go out
during a run.

## D-020 — A caller may ask; only the person may send (2026-09-14)

An inscribed page and a bot on the RPC both have reasons to want this wallet
to move something — a marketplace, a game, a prize. Neither can be allowed to
do it. A page is code a stranger inscribed, running in a frame of a wallet
that can spend; a bot with the cookie can already spend, but a bot that a
stranger's code can drive should not be handed that.

So there is one more verb between "read" and "send": **ask**. `POST /r/send`
and `da_request*` file a request — kind, recipient, amount, and the caller's
own words about itself and why. The request is a row on disk, not a
transaction: nothing is built until the owner looks at it, and what is built
then is shown in full and broadcast only if the yes names the txid that was
shown, the same rule as every other send (D-016). It is one queue for coins,
tokens and inscriptions because the person deciding should see one screen
whatever is moving, and the caller should learn one shape.

Two limits keep asking from being a nuisance rather than a risk. A request
nobody answers within an hour expires, so a page somebody looked at last week
is not still asking. No more than twenty may wait, so a page that files in a
loop gets an error, not an audience. Filing needs no key at all — the frame
has no origin to present one from — which is fine precisely because filing is
a question. The `omni_send*` calls keep their two-step for a program that
holds the wallet; the difference between them and `da_request*` is whose
finger is on the button.

## D-021 — The wallet remembers for a page; the page never gets an origin (2026-09-14)

"Can inscriptions use localStorage? They should be able to." They should,
and they cannot: a sandboxed frame without `allow-same-origin` has an opaque
origin, and an opaque origin has no storage — the browser throws on the
property itself. The flag that would fix it is the flag that would hand a
stranger's code this wallet's origin, cookies, CSRF token and every page in
it (D-018). A second origin for content would work on this machine and not
over the tunnel, where there is one hostname, and it would still put every
inscription in one shared bucket.

So the wallet remembers instead. The page loads `/r/storage.js` and gets
`arcade.storage`, the shape of `localStorage` with a `ready` promise in front
of it; the shim talks to the viewer over `postMessage`; the viewer — the
wallet's own page, outside the sandbox — keeps the data in
`~/.dogecoinarcade/pagedata.sqlite` under the id of the inscription in the
frame the message came from. The page never says who it is, so it cannot lie
about it. Limits (256-character keys, 64 KiB values, 1,000 keys and 1 MiB per
page) are there so a page in a loop fills its own room and nothing else.

Two things fall out that `localStorage` would not have given. The data is
the same on the phone and the desk, because it is the wallet's and not the
browser's. And the same channel is why the approval pop-up is safe: it is a
frame of the wallet's own page held by the viewer, `frame-ancestors 'self'`
on every page the wallet draws, and the inscription can neither see it nor
frame one of its own.

## D-022 — Over the tunnel, inscribed pages get a hostname of their own (2026-09-14)

Testing everything on testnet over the real tunnel found the inscribed shop
page saying "…" on the phone. Every `fetch('/r/…')` from inside the frame
came back 403: the sandbox gives the page an opaque origin, and a document
with an opaque origin sends no cookie — not the remote key, nothing. Its own
`<script src>` did not load either. On this machine nobody had noticed,
because on this machine there is no door.

The ways out that were rejected: `allow-same-origin` (D-021 already says
why); asking page authors to write `credentials: 'include'` and answering
CORS with `Access-Control-Allow-Origin: null` (every page would have to
know, and `null` is every sandbox on the web); serving the page API without
any key over the tunnel (the random hostname sits in a phone's history and
in Cloudflare's logs, and `/r/wallet` says what the wallet holds).

What is done instead: the Remote page opens **two** quick tunnels. The first
is the wallet, as before — key in the QR, cookie, everything. The second is
the pages' door: its hostname is handed out only inside the viewer's own
page, cookie-guarded, as the `src` of the sandbox frame, and it serves
nothing but `/content/*` and `/r/*` — exactly what a page may reach anyway,
none of it able to spend or draw a wallet page — everything else answers
404 whatever cookie it carries. That hostname is as secret as the key: never
shown, never in a URL a person types or a history keeps (frame addresses
are not history), and gone with the tunnel. A tunnel is "open" only while
both cloudflared processes live; one dying shuts the whole thing rather
than leaving pages that hang. On this machine nothing changes: the frame is
`/content/<id>` on the same host, and `is_remote` is what it was.
Stood in for in the browser test by `wallet.localhost` and
`pages.localhost`, two names for loopback that the guard tells apart by
Host exactly as it does the real ones.

## D-023 — An inscribed page talks to other nodes through the wallet, and nobody is asked (2026-09-14)

Asked for: inscriptions that send node-to-node messages to another
DogecoinArcade node and get replies, without approval, on testnet.

The channel already existed for bots (`da_send`, `da_inbox`); what a page
lacked was any way to reach it. It cannot fetch off the machine (the sandbox
CSP, `arcade/web/content.py`), it has no cookie for the bot RPC and must never have one, and
it has no key. So it goes the way storage goes (D-021): the page posts to
the viewer around it, the viewer — the wallet's own page, with the CSRF
token — calls `/node/<id>`, and the wallet seals with its own identity and
sends from its funded address. The page's identity is the frame it was put
in, never a claim in the message, so a page can read only the replies to
what it sent itself.

Why no approval, when coins and tokens and inscriptions all queue: a
message moves no value, and node-to-node messages live on the messaging
chain, which is testnet only by D-010 — there is no mainnet path to gate.
The thing a page could do without asking is spend testnet coins in a loop;
the answer to that is a cap (thirty an hour per page, `nodetalk.py`), not
a person pressing Approve thirty times. The route refuses outright if the
messaging chain were ever mainnet, in case D-010 is one day reversed.

What a page sees of the inbox: messages from nodes it has written to, with
an id above the inbox's newest at the moment it first wrote. Not the rest
— every other page's replies and every bot's traffic are in the same
table. The other node answers the wallet, not the page, because the page
has no key; two pages writing to one node both see its answers, and the
documentation says to put something of your own in the message. A
per-page key would fix that and was not done: it would make every page a
second identity to fund, back up and announce, for a problem a nonce in
the body solves.


## D-024 — A shop is an inscription, and a swap is one transaction both sides signed (2026-09-14)

Asked for: an inscription that sells — a buy button that sends a token, an
NFT or coins in exchange for any of the same, without the seller pressing
anything, including a random pick from a collection so a minting event is
just a shop; and the two transfers landing in the same block.

The same block was the whole problem. Two sends, however close together,
are two transactions, and there is always a block in which one has landed
and the other has not — and a mempool in which the second can be dropped.
So there is no second transaction. A swap is ONE Class C payload naming
both legs (`inscriptions.Swap`, two `Leg`s: an inscription, a token amount,
or coins paid inside the transaction itself), in a transaction whose inputs
are signed by both the seller and the buyer. The engine (`state.Engine._swap`)
moves both legs when the block lands or refuses the whole thing, on every
node. The buyer is whoever signed after the seller; the seller is paid net
of what the seller put in, so a buyer who routes the seller's own coins
back has paid nothing. Every node reaches the same verdict from the bytes,
which is why neither party has to trust the other to go second.

Where the terms live: in the shop inscription's own JSON, which travels
with the content and is covered by its hash. Not in a message, not in the
page. A buyer's wallet reads the listings from its own ledger before it
asks the seller for anything, and the seller's wallet reads the same bytes
before it offers. No page is trusted about a price. Who may sell from a
shop is the wallet that created the inscription AND still holds it —
creating it makes the listings the owner's own words, holding it means the
shop closes by sending the inscription away and cannot be copied by
re-inscribing the JSON.

Why the seller is not asked, when D-020 says only the person may send: the
person already said yes, in writing, when they inscribed the shop. The
shopkeeper (`shopkeeper.py`, on the block watcher's thread) adds nothing to
that: it offers exactly what the listing says, locks one of its outputs for
the offer so nothing else in the wallet spends it, and signs only a
transaction that matches the offer it made — its outpoint first, exactly
the legs, paid what it asked (`swap.countersign`). The buyer IS asked,
once, in the approvals pop-up, with the transaction as built by their own
wallet; Approve signs the buyer's half only. Between the two, every step
is a node-to-node message (D-023) — offer asked, offer made, half-signed
transaction handed over, signature sent — so it is testnet only by D-010
and a minute or several per step, and the page shim (`/r/swap.js`) says so
rather than spinning.

Extended by the same reasoning: a page this wallet created and still holds
may send from it without asking (`/r/owner.js`, `/owner/<txid>`), because
those are the person's own words too; the send is written in the approvals
list as one from a page of your own, so it is visible after the fact.
Refused outright in any other wallet and on mainnet.

Swaps are read from a starting height per chain (`config.py`, `swaps_from`;
testnet 1,488,450) so that a Class C payload of this shape in an older
block — there are none, but the check costs nothing — is recorded as
unread rather than as an old transaction meaning something new.

## D-025 — Mining is a button, a thread, and a notice; never one long RPC (2026-09-14)

Asked for: mine a testnet block from the wallet page any time, not only
when the balance is zero; and a notice that a block is being mined, so the
button is not pressed again and again.

A block on this testnet takes minutes (difficulty 0.0005–0.0006, so about
2.2 million hashes at the node's 1,622 per second on the mini PC) and
`generatetoaddress` hashes inside the RPC thread; one call for the whole
search sat past the RPC client's 120-second timeout and the page said the
node was not responding when it was busy. So `miner.mine_one` asks for a
first batch of 20,000 tries, measures the rate, and then asks for about
thirty seconds' worth at a time until a block comes, the person presses
Stop, or the ceiling is hit. Each answer is a chance to report progress
and to stop; the RPC timeout is never near. The search runs in a thread
the web app owns, with one `state.mining` record — started, tries, rate,
expected hashes at today's difficulty — that the wallet page polls every
three seconds and shows as a sentence: so many hashes so far, about so
long on average, sooner or later by luck. Stop stops after the batch in
hand, and says so. Mainnet is refused because mainnet has miners.

## D-026 — Fees are what the block assembler counts, and a refusal pauses the run (2026-09-14)

Found: on testnet, a collection run put one inscription chunk per block,
with a hundred waiting, on a network mining blocks a minute apart.

The node's block assembler prices a transaction by fee per *virtual* byte
(`miner.cpp:494`, `txmempool.cpp:71-73`, `policy/policy.cpp:215-218`):
`max(size, sigops × 20)`, and a bare CHECKMULTISIG output counts twenty
legacy sigops (`script.cpp:156-173`). A Class B chunk of 98 data outputs
is 39,240 virtual bytes against about 3,900 real ones; at the assembler's
minimum of 0.01 PEP per kB it needs 0.39 PEP where we paid 0.11. The
fee-rate pass took nothing of ours; the priority pass took one transaction
and stopped (`miner.cpp:613`). One chunk per block, exactly.

The fix is in one place: every `fundrawtransaction` in the application
goes through `fees.fund`, which decodes what the node built, counts its
sigops the way the node does, and re-funds at a rate that clears the
minimum on virtual size — a plain send is unchanged, a chunk costs about
3.5 times what it did (0.004 PEP per data output), and a block now takes
up to nine chunks, which is the block's own sigop limit. Estimates shown
before sending (`inscribe.plan`, `sender.plan`) count the same way, so the
number the person confirms is the number they pay.

Two things the runner (`collections.py`) did wrong in that afternoon are
changed with it. A node that refuses a chunk — chain too long, wallet
short, node down — is not the item's fault and is the next item's problem
too, so the job pauses with the reason instead of failing that item and
the thirty-nine after it; Resume puts failed items back to pending and
carries on. The chain-too-long refusal in particular means "not until a
block", so that one is not even a pause: the run waits for the chain to
move — up to the same half hour — and tries the same item again, and
nobody has to press Resume. And a job whose last split has not confirmed waits a
generous half hour for a block (one confirmation is what the node's
ancestor limit is about, `validation.h:74-80`), then pauses saying so,
rather than chaining a third unconfirmed transaction the node would
refuse anyway. The first chunk of a send prefers confirmed outputs for
the same reason.

## D-027 — A shopkeeper answers questions, never answers (2026-09-14)

Two shops on testnet, each on its own node, each with a shopkeeper reading
the node-to-node inbox. One node ordered from the other, the other answered,
and the two of them then talked to each other for eleven minutes — one
message a block out of each wallet, thirty-two messages and about 0.74 tPEP
between them, until a person stopped a node.

The reason was one line. `shopkeeper._swap_message` took any message whose
`swap` field said `offer` or `sign` to be an order, and an answer carries the
same `swap` value as the question it answers. So a refusal — "no such
inscription on this node" — was read by the other shopkeeper as a fresh
order, refused in turn, and that refusal read as an order again. Nothing was
wrong with either shop, both sales completed correctly, and neither node did
anything it was not asked to: they were asking each other.

An answer is now recognised by what only an answer carries — `re`, the txid
it answers, and `ok` — and a message carrying either is not an order. The
shape of the protocol made this possible and a named kind (`offer-reply`)
would make it impossible; that is a bigger change to a format two nodes on
the network already speak, and it can wait for a version of the protocol
that changes other things too. What cannot wait is that an automatic answer
must never be able to provoke another one.

The general rule this is a case of: anything that replies without being
asked needs a reason to stop. A shopkeeper that refuses an order is being
useful; a shopkeeper that refuses a refusal is a loop with a fee attached.
Every automatic answer added from here — approvals, swaps, whatever comes
after — gets a test that feeds it its own output and expects silence.

The same sale showed a smaller thing: `swap.countersign` unlocked the
seller's outpoint when an offer expired but not when it sold, so a sold
output stayed in the node's `lockunspent` list until the node restarted.
Harmless while the output is spent, wrong as bookkeeping, and it would
refuse a later legitimate offer that wanted a recycled outpoint. It unlocks
on both paths now.

## D-028 — What a wallet holds is asked of the index, not of the page on show (2026-09-14)

"Yours" on the inscriptions page said "Nothing yet on Testnet" to a wallet
that held seven inscriptions, one of them bought ten minutes earlier. The
list was built by filtering the page of inscriptions already on screen —
`[row for row in data["inscriptions"] if row["owner"] in owned]` — and that
page is the newest twenty-four. It was right for as long as everything ever
inscribed fitted on one page. The afternoon a hundred-piece collection
landed, every piece this wallet owned was older than page one, and the
wallet's own section went empty.

The index is asked directly now: `inscriptions(owners=[...])` and
`inscription_count(owners=[...])`, one query over the addresses the wallet
holds, newest first, capped at a page with "newest 24 of 107" when there are
more. A wallet is many addresses, so the parameter is a list and an empty
list means no addresses, which is not the same as "no filter" — that
distinction is the whole reason `owners=[]` returns nothing rather than
everything.

The general shape of the mistake is worth naming, because it is easy to
repeat: a derived view computed from a paginated list is only correct while
the list is short enough not to be paginated. Anything that answers "what is
mine" asks the store, not the screen.

## D-029 — A shop's own sales are written in the approvals book (2026-09-14)

Approvals is where a person looks to see what this wallet did for something
that is not a person: a page asked to send coins, a program on the bot RPC
asked for tokens, the buyer's half of a swap. A shop was the one thing
missing. The shopkeeper sells what the inscription lists and signs it
without asking (D-024), so the sale left no mark on the page a person reads
— the wallet handed over a hundred tokens and the only record was in
swaps.sqlite.

A sale is now written to the same queue the moment it is broadcast, with
origin `shop` and status `sent`. It is never pending, because there is
nothing to decide: it has happened. That is why `file()` takes a status —
something already done does not count against the cap on how many requests
may wait, and it is decided as it is written. The line reads from the shop's
side, "sold 100 Arcade Test for 1.00000000 coins", rather than the buyer's.

Two orderings matter. The record is written after the broadcast, not before:
a sale that happened and was not written down is a bookkeeping problem, and
a sale that was written down and did not happen is a lie. And a book that
cannot be written must not undo a sale — if the queue throws, the shopkeeper
logs it and the sale stands, because the chain is the ledger and this is a
convenience.

## D-030 — The wallet answers "what do I have", the sections answer "what exists" (2026-09-14)

Two different questions had been sharing a page. /tokens listed every token on
the chain AND the balances on your own addresses with a send form beside each;
/inscriptions listed everything ever inscribed AND your own pieces with a Send
button. Both pages were a public directory with a private wallet folded into
the middle of it, and on a phone the private half was below a screenful of
other people's things.

They are split along the question they answer. The wallet has three tabs —
Wallet, Tokens, NFTs — and each shows what this wallet holds and what can be
done with it: coins, token balances with the send form, inscriptions with
Send. /tokens keeps what exists on the chain and how to make one; /nfts keeps
everything inscribed and how to inscribe. Each page points at the other, so
neither is a dead end.

Two renames go with it. Inscriptions is called NFTs in the interface, because
that is the word people arrive with; it is still an inscription in the code,
on the wire and in the API, and the page says so. /inscriptions redirects to
/nfts rather than 404ing: links to it are on the chain, in other people's
notes and in a shop page's own address bar, and a rename that breaks them is
not a rename.

And the Keys page is gone. It listed announced public keys and offered to
publish yours — a page of plumbing, and the fingerprints on it were the thing
the owner asked never to show. The one control worth keeping, "publish your
key on the chain", now sits on the Address book beside the name it publishes,
which is the page a person is on when they think about how others find them.

## D-031 — A wallet holds one balance of a token, not one per address (2026-09-14)

The wallet's Tokens tab listed a row per (address, token), so a wallet with
Arcade Test on two addresses showed "Arcade Test" twice with two different
numbers and two send forms, and no total anywhere. The owner asked the
obvious question: why is it listed twice? Because that is how the node keeps
it — which is plumbing, not an answer.

One row per token now, with the total. The addresses are still there, behind
"where it sits", because they are real and occasionally matter; they are not
the first thing shown. The API already took this view: `/r/wallet` has summed
per token from the start, with a comment saying a wallet with coins on
fifteen addresses holds one balance, not fifteen. The page had simply never
been brought into line with it.

The send follows. A token send comes out of exactly one address, so the
wallet picks: among the addresses holding enough, one that also has coins for
the fee. When no single address holds enough, the refusal names the largest
piece rather than the total — "this wallet holds 1,000, but a send comes out
of one address and the largest holds 600" — because the total is not what a
send can move, and a message that quotes it would be describing money the
transaction cannot reach.

## D-032 — An announcement carries both addresses and a name that can be checked (2026-09-14)

A key announcement said: here is my key, here is the address it belongs to,
and here is a name I am telling you. The name was the weak part. Anyone can
publish any name, so the interface had to say so everywhere it was shown,
and a reader's address book filled itself in with a stranger's assertion.

It carries an @tag instead. A tag is on the chain: the tag index says which
address holds which name, first claim wins, and a reader can check the
announcement against it rather than believing it. Where they disagree, the
announcement is wrong and the address book says so instead of repeating the
claim. The name a person types stays where it always worked — travelling
with a first message, and as the label you give somebody in your own book —
and is no longer published.

It also carries the same wallet's address on the other chain. One
announcement then answers both questions a reader has: where do I message
this person, and where do I pay them. The 20 bytes say nothing about which
chain they are for, so the reader supplies that from its own parameters
(`other_pubkeyhash_version`) rather than guessing — Dogecoin and Pepecoin
testnets share a version byte, and an address inferred from bytes alone
could be either.

The format grew by sections rather than by a new field order: `0x02` for the
other chain's hash160, `0x03` for the tag, after the existing `0x01` identity
tail. A reader stops at the first section byte it does not know, so an older
reader gets exactly what it got before, and a newer sender loses nothing by
saying more. NUL padding — what Class B adds — ends the walk like any
unknown section, which is why the section bytes start at 1.

And claiming a tag is now something a person can do. The chain rules had
been implemented and tested since @tags went in; there was no button
anywhere, so no tag had ever been claimed. It is on the address book, two
steps like a send, on the chain the messages are on — tags are per-chain,
and the same name on two chains is two names.

## D-033 — A sale reserves what it sold until its block (2026-09-14)

The shopkeeper excluded from a random pick everything held in an *open*
offer. A sale closes its offer as "sent" at broadcast, and the ledger goes
on naming the seller as the owner until the block is indexed. For that
window — about a block — a sold Goofball was in neither set: not reserved,
and still apparently the seller's. A second buyer's order could be offered
the same piece.

Nothing could be stolen: a swap is one transaction and the engine refuses it
when the seller no longer holds what it promises, so the second sale simply
fails. But the second buyer pays a message fee and waits a block to be told
no, and a mint page watching for their piece waits for something that will
never arrive. A rare race on a quiet chain, and the ordinary case for a
popular mint, which is exactly what this was built for.

A sold offer now reserves its item and its outpoint until `index.transaction`
can see its transaction. An index that cannot answer counts as not having
it: holding a piece back for one more block costs a buyer nothing, and
offering it twice costs them a fee.

Found by the GX10 reading the code rather than by a failure, which is the
cheaper way to find it.

## D-034 — One name for yourself, and it is the @tag (2026-09-14)

The address book had a box called "How you introduce yourself": a name you
typed, sent with a first message, posted beside anything you put on the
public board, and published with your key. The @tag does the same job and
can be checked. Two names for one person is one too many, and the typed one
was the weaker of the two — nobody could verify it, and publishing it put an
unverifiable claim on the chain permanently.

So it is gone: the box, the route that saved it, and the careful machinery
around publishing it — a byte counter, a warning about multisig outputs, a
refusal to trim. `profile_name` now reads the tag the chain gives this
wallet's identity address, so everywhere that introduced you by name
introduces you by `@tag`, and a wallet with no tag introduces itself by its
addresses alone, as it always could.

What is lost is the ability to be called "Big Chief Energy". What is gained
is that nobody can be called "Big Chief Energy" without the chain agreeing —
including someone claiming to be you.

## D-035 — A post may name an inscription; the board shows a card, never the page (2026-09-14)

Somebody posts a link to an inscription on the public board. The useful
thing is to see what it is without leaving the conversation. The dangerous
thing is that an inscription can be a page of scripts, and a post is written
by a stranger.

So the board renders a card from what this node's own index says — number,
name, type, size, collection — and a button that opens
`/inscriptions/<txid>/view`. It never renders the inscription's content, not
even escaped. The viewer is the one place with a sandbox (allow-scripts and
no allow-same-origin, its own content CSP) and the one place with a wallet
on the other side of the frame to answer a shop page's questions. A board
that framed pages would be a message list where every post can run code, and
a mint page polling its collection and flipping two hundred tiles a second
would run in every scrollback.

The card is drawn only for an inscription this node has indexed: a txid it
does not know is left as text, because a card claiming to describe something
unknown is worse than no card. At most four to a post.

Recognised by the 64 hex characters, with or without a `/content/`,
`/inscriptions/` or `/nfts/` prefix, so a link and a bare txid both work and
an ordinary sentence cannot become a card by accident.

## D-036 — A collection can sell itself, from the run that inscribes it (2026-09-14)

Inscribing a hundred pieces and then selling them were two unrelated jobs.
The first was a wizard; the second meant writing a page, writing the shop
JSON beside it, and inscribing that by hand — which is what was done for the
Goofballs, carefully, twice.

The wizard asks instead. A checkbox, on by default, a price, and whether the
price is in coins or in a token. The runner inscribes the pad when the last
item is on its way, and only then: a pad offering a random item of a
collection half of which was never inscribed would be selling things that do
not exist. It is inscribed last for the same reason, and a run that had a
failed item does not get one at all.

If the pad cannot be inscribed the run stays done. Every item is still up,
which is what the run was for; the refusal is written on the job and the pad
can be made by hand. A collection reported as failed because its shop window
did not go up would be the tail wagging the dog.

The page is the one the Goofball mintpad used, with the names taken out and
two sentences removed — the ones explaining that both wallets sign one
transaction. They were true and they were the page lecturing about its own
plumbing; what a buyer needs to know is the price and that nothing moves
unless everything does, which the wallet's own approval screen says.

The price is settled before anything is paid for rather than after, which is
the point of asking at step 2 rather than offering a button at the end.

## D-037 — The exchange reads the chain, not a list (2026-09-14)

Everything for sale is already on the chain: a shop is an inscription whose
JSON names its listings, and it closes by being sent away. So the Exchange
does not keep a directory. It asks the index for every inscription whose
JSON names a shop and whose creator still holds it, reads the listings the
way a storefront page does, and shows what is left.

That is what makes "it should be removed from the listing automatically"
true rather than a job somebody has to do. A mintpad that has minted out
has no items left to pick and drops off the Mintpads tab; an NFT that has
moved makes its listing unavailable and drops off the Marketplace. Nothing
is told; there is nothing to tell.

Four tabs because there are four questions: what has somebody offered me,
which mintpads still have pieces, what tokens are being sold and for what,
and which single NFTs are for sale. The Tokens tab is shops, not a book of
bids and asks -- a real order book is consensus-level matching (M3), and
calling a list of shop prices an order book would be a lie about what it
does.

## D-038 — An offer is made on an NFT, not on a listing (2026-09-14)

A shop offers what its owner decided to sell. An offer is the other
direction: anyone may offer for any NFT, whether or not it is for sale, and
the wallet that holds it decides.

It is not a PSBT, whatever it is called. The buyer cannot build the
transaction: the engine requires the seller's own output to be the first
input, that is what makes them the seller, and the buyer has no way to know
which output that will be or to spend it. So an offer is a message -- this
item, this price -- and accepting builds the seller's half exactly as a shop
would, with the same checks: still theirs, buyer holds what they promised, an
output locked to carry it.

What makes it one press rather than two is where the buyer's consent lives.
The person named the price when they made the offer, so when the holder's
answer comes back their wallet signs its half without asking again -- and
only after checking that the answer names the same item and the same price
as the offer it is answering. A yes cannot be turned into a different trade
by the party answering it. That check is the whole reason this is safe to do
unattended; without it, accepting could hand back any terms at all.

An offer on something of yours is never answered automatically. Nobody but
the holder can say yes to parting with their own NFT, so the shopkeeper
writes it down and leaves it on the Exchange for a person.

## D-039 — The chart is what traded, and the gaps are part of it (2026-09-14)

There is no order book to draw (D-037), so the chart is drawn from the only
prices that exist: the swaps the chain has read. A swap is two legs; a price
is one divided by the other; a candle is the open, high, low and close of
the prices inside a day.

The gaps are drawn as gaps. A day with no trade is a dot on the axis, never
a line ruled to the next price, because a line between two trades a week
apart is a picture of prices nobody paid. Two of thirty days traded is what
a quiet chain looks like, and the chart says so under it.

One currency to a chart. Coins and tokens on one axis is adding pounds to
metres, so an NFT chart is "NFTs paid for in Arcade Test" or "in coins", and
the tab draws one for each currency anybody has actually paid in -- which is
also why the first NFT chart this ever drew was in tokens, not coins:
every Goofball so far sold for ten Arcade Test.

Read straight out of `arcade_tx` rather than kept in a table of its own. A
swap's payload begins with a fixed ten bytes -- the AnyData type, INSC,
version 1, kind 5 -- so one indexed LIKE finds every trade and nothing else,
and a chart of a chain's whole history costs one query and a few hundred
parses. A table would have had to be filled from somewhere, and the somewhere
is this query.

Drawn as SVG in the page. A wallet should not fetch a charting library from
anywhere to say what two trades went for, and the page's own CSP would stop
it if it tried.

## D-040 — A collection is a market, and you can only offer what you have (2026-09-14)

Two things the marketplace was getting wrong.

The chart was per currency: "NFTs paid for in Arcade Test", every collection
on one axis. What a Goofball goes for says nothing about what a Doge Punk
goes for, and a chart of both is a chart of neither. It is one chart per
(collection, currency) now, and pieces that belong to no collection are a
market of their own -- they are single things, and that is what they have in
common.

And the offer form listed every token on the chain. Offering a token you do
not hold is a fee spent to be told no, and told no by the other side, a block
later, for something your own wallet could see immediately. The list is what
this wallet holds, with the balance beside each; the token picker is not
drawn at all when it holds none; and the door checks again when the form
comes back, because a form is a convenience and not a guarantee. The coins
option shows the balance for the same reason.

The check the door already had -- `_buyer_for`, shared with the shop
machinery -- is the one that matters, and it now runs before anything is
sent rather than after the message is paid for.

## D-041 — A collection shows a different one of itself each time (2026-09-14)

A shop card for a collection showed no picture, or would have shown the
cover — edition #1, every time. A collection is a set of different things,
and one picture that happens to be first is a poor advertisement for that.
The card draws a random piece, picked per request, from the pieces this node
actually holds the bytes for and that a browser will draw. A thumbnail that
404s is worse than no thumbnail.

## D-042 — An offer is said on the chain, because a holder never asked to be reachable (2026-09-14)

The first version of Make offer sent the offer as a node-to-node message to
the wallet holding the NFT. That worked only when the holder had published a
key AND was holding the piece on the very address they published — which is
to say, almost never. The owner put it plainly: it only works if that is
their main wallet and it has been published.

The asymmetry is the answer. Somebody holding an NFT never asked to be
contacted; somebody making an offer is asking for a reply. So the offer goes
on the chain as a payload every node reads (`KIND_OFFER`), where the holder's
own wallet finds it by watching its own things — any address, no key, no
announcement. The answer travels back as a message to the buyer, and the
wallet refuses to MAKE an offer until this wallet's key is published, which
is the one requirement the party who wants an answer can satisfy and be told
about.

It fits one OP_RETURN — the inscription's 32 bytes and a price leg, 55 bytes
wrapped — so an offer is a flat fee and no dust. Nothing is locked by one: it
is an offer, and the engine refuses only what nobody could act on (an
inscription this chain has never seen, your own piece, a price of nothing).
Whether the buyer can pay is answered when the holder accepts and the swap is
built, because a balance at this block says nothing about a balance three
blocks later.

Offers now reach a wallet the way everything else here does: by being on the
chain and indexed, rather than by being delivered.

## D-043 — A token send comes out of several piles when it has to (2026-09-14)

A token send comes out of exactly one address, so a wallet holding a token
in several piles could not send more than its largest pile held. It was
refused with the largest pile's number, which was true and unhelpful: the
wallet had the tokens.

It sends in several transactions now, shown together and broadcast on one
yes. Which piles, in order:

* the SMALLEST pile that can cover the whole amount, when one can. It spends
  a small pile up rather than breaking a large one, which is what leaves a
  wallet with fewer, bigger pieces over time;
* otherwise the largest piles first, and the remainder from the smallest
  pile that covers it -- as few transactions as possible, and the last one
  does not shatter another big pile.

Piles that have coins of their own go first, because every transaction pays
its fee from the address it comes out of and a pile with no coins cannot
send at all. When one is needed anyway, the wallet's existing message names
the address and the amount it needs, which is better than a general refusal.

The address being sent TO is never used as a pile: a send from an address to
itself moves nothing.

Every transaction is built and shown before any is broadcast, and what goes
out is what was shown, which is the rule for a single send (D-016) applied to
a set. They do not depend on each other, so one failing does not invalidate
the rest -- and what did go is named, because a partly-sent amount the person
is not told about is the worst outcome there is.

## D-044 — A half-built swap holds the coins it spends (2026-09-14)

A buyer reported "input 1 unknown transaction failed" buying from the Arcade
Test counter. The seller's node was refusing the buyer's half because one of
its inputs was not in the seller's UTXO set.

The buyer's wallet had spent it. A swap takes several blocks and several
messages -- the order, the offer, the signed half -- and every message is a
transaction paying a fee out of the same wallet. Nothing stopped the wallet
from funding one of those messages with the very output the half-signed swap
was spending. By the time the seller looked, a block later, it was gone.

`swap.build` locks the inputs it chooses, as `make_offer` already locked the
seller's. And the refusal says what happened rather than "spent or unknown":
either that wallet spent it since, or this node has not seen its block, and
either way another offer will be built from what is there now.

## D-045 — A refusal names the thing that is actually wrong (2026-09-14)

Two refusals a real buyer hit in one evening, both true and neither usable.

"input 1 is spent or unknown" was the seller's node failing to find an input
of the buyer's half. What had happened was that the buyer's own wallet spent
it (D-044); what the buyer read was a sentence about a transaction they had
never seen. It now says which of the two things happened and what to do.

"nZxSHUHvvh… has no output worth 0.00000000 to swap from" was an address
holding five thousand tokens and no coins. The floor came out negative and
printed as zero, so the message asked for nothing and refused anyway. There
are two different failures there and they now read differently: an address
with no spendable coins at all is told that every side of a swap puts one of
its own outputs in, and that a fraction of a coin is enough; an address whose
outputs are all too small is told the size it needs and the size of its
largest.

The rule this is a case of: a refusal is a sentence a person acts on. If it
quotes a number, the number has to be one they can do something about.

## D-046 — One address a chain, and only the wallet this application made (2026-09-14)

A Core wallet spreads itself out: a fresh address for every payment received,
another for every lot of change. Every awkward thing this evening came from
it. A token stranded on a receiving address with no coins to move it. An NFT
on one address and the published key on another, so nobody could work out who
to write to. A balance in three piles needing three transactions to spend
(D-043). A swap and the message carrying it picking the same single output
(D-044).

So: one address a chain, holding the coins, the tokens and the NFTs. On the
messaging chain it is the address the identity is derived from; on the other
it is a named account address the node keeps for the life of the wallet.
"Show a receiving address" shows that one rather than minting another, and
change already came back to the sender everywhere the arcade builds a
transaction. Address reuse links a wallet's activity together for anyone
reading the chain; that is the price, and this ledger is address-keyed and
public anyway.

The gather runs under the application's one-send-at-a-time lock, like
everything else that spends, and does nothing at all when something else
holds it. Housekeeping must never build a transaction from the outputs a
collection run is part way through spending: a run's chunks are funded ahead
and spent one after another, not locked, so nothing else would have stopped
the two colliding. An open offer's output is already safe by a different
route -- it is locked at the node, and `listunspent` leaves locked outputs
out, so the gather cannot see it.

Anything that lands elsewhere walks home by itself, a few things every couple
of blocks, coins first -- a token or a piece cannot move off an address that
cannot pay its own fee, so the wallet sends it the fee and fetches it on the
next pass. Automatically on testnet, where a transaction costs nothing real.
Never on mainnet: sweeping somebody's coins without asking is spending their
money for them.

Which addresses count depends on the chain, and the address says which chain
it is for. On a test chain the node exists to run this: every address in its
wallet was made by the arcade, so all of them are its and everything is
gathered. On a real chain the node is usually somebody's own wallet as well,
and those coins are not ours to show, to spend or to gather -- there, only
what the arcade filed under its own accounts counts. Reading it off the
version byte means no caller has to remember to pass a flag, which is the
kind of thing that gets forgotten exactly once, on mainnet.

## D-047 — One guide, shipped with the application (2026-09-14)

There were two guides: `docs/features.md`, which the site publishes, and a
shorter one written by hand in `guide.py` for the application. A test kept
their section titles in step, which caught a heading that moved and never
caught a sentence that went stale. Two descriptions of one program is one too
many, and the short one was always a little behind.

The application ships the document itself now --
`arcade/web/templates/guide.md`, the same bytes the site publishes -- and
renders it. The test compares the two files rather than their headings: same
file or the suite fails.

Markdown is rendered in about forty lines here rather than by a library. The
subset the document uses is small, and a wallet should not grow a dependency
to show its own help. Escaping comes first and markup second, because a
document is text before it is markup.

And a bug now has somewhere to go from inside the application: the guide
links to the site's bug page. Somebody who has just found a fault is standing
in the wallet, not on the website.

## D-048 — An order book on the chain, settled by swap (2026-09-15)

An Omni-style book for tokens against the chain's own coin. The payloads were
already there and unimplemented — `MetaDExTrade` (25) and the cancels (26, 27)
have been parsed since the payload module was written, and the balance table
has carried `metadex_reserve` since the schema was.

Property 0 means the coin. Omni has no such id, because Omni's MetaDEx pairs
two tokens and leaves the native coin to its older DEx; here the coin is one
side of every pair, so it needs a name.

**Asks hold their tokens; bids hold nothing.** An ask moves what it offers
into `metadex_reserve`, so the book cannot show tokens the seller has since
spent. Nothing can hold coins the same way — there is no covenant on this
chain that would reserve them and still let the wallet live — so a bid is an
intent, and whether the coins are there is settled when somebody fills it and
the transaction either stands up or does not. The page says so rather than
drawing two sides that look equally solid.

**Nothing is matched by the engine.** Omni's MetaDEx can match without either
party present because both sides are escrowed tokens; with a coin on one side,
somebody has to sign. So a fill is a swap — one transaction carrying both
legs, which has worked since D-024 and was proved between two machines
tonight. The book is what is on offer; the swap is how it settles.

Martin asked whether the resting orders could be partially-signed transactions
that a taker finishes. They cannot, and the reason is worth writing down.
`SIGHASH_SINGLE|ANYONECANPAY` lets a maker sign one input and commit to one
output — but a maker needs to commit to *two* things: the payload naming the
legs, and their own payment output. Commit to the payload and a taker can
redirect the payment; commit to the payment and a taker can rewrite the legs
and take more than was offered. There is no sighash flag that covers exactly
those two outputs and leaves the rest open. A maker's node signing at fill
time is the honest version of the same idea, and it is what a shop already
does.

Price is two integers and stays that way: `amount_desired / amount_for_sale`,
compared and sorted as a Fraction. A cancel at a price matches on the ratio,
so 100-for-1 and 200-for-2 are one price and both go. A book sorted on floats
is a book whose order nobody can reproduce.

## D-049 — The chain is what an answer is checked against (2026-09-15)

An offer for an NFT is said on the chain (D-042) and answered by a message.
The answering wallet checks that the answer names the same item and the same
price it offered — and it was checking against a note it kept locally.

It never wrote the note. The route that makes an offer broadcast the payload
and recorded nothing, so every answer to every offer was found to be about an
offer this wallet had never made, ignored in silence, and every offer timed
out. The unit test passed because it inserted the row by hand; the path that
was supposed to write it had never run. That is the second time in two days
that a green test covered a fixture rather than the code.

So the check reads the chain. `index.offer(txid)` returns what was offered,
by whom, for what, and the answer is measured against that. A wallet that was
reinstalled since it made the offer, or whose local note was lost, still
knows exactly what it asked for, because it is written where everyone can see
it. The local note is still written — it is what shows an offer before its
block lands — but nothing depends on it any more.

Two smaller things from the same report, both about telling a person what is
happening:

An offer already accepted is waiting for the buyer's wallet to sign, not
waiting for another Accept. The page drew the button again, somebody pressed
it, and the refusal told them their own offer belonged to somebody else. The
Exchange now says "accepted — waiting for @them to sign, held until 15:49",
or "sold" with the transaction, and the refusal distinguishes the two cases
rather than assuming the worst one.

Reported by a person on Windows who read the code, found the missing call,
and said which line it belonged on. Twice now.

## D-050 — The mempool is read, so a message arrives in seconds (2026-09-15)

Every message here is a transaction, and the scanner read blocks. So a
message cost a block to arrive, and a swap cost several: the order, the
offer, the signed half, each waiting for a miner. On a chain with minute
blocks that is a trade measured in minutes, most of it spent waiting for
carriage rather than for anything to be decided.

The scanner reads the mempool too, every pass. Rows land at height 0, which
already meant "not in a block yet" for a post this machine had just made, and
now means the same for anything seen on its way. The block promotes the row
in place rather than writing a second one, and it keeps its id -- so a
program that has already acted on a message does not see it twice.

What this does NOT touch is the ledger. Balances, the order book,
inscriptions and the consensus hash are read from blocks and only from
blocks, because they are what two nodes have to agree about and the mempool
is not a thing anyone agrees about. This is carriage: messages, a shop's
answer, half a swap. The transaction that settles a trade is the swap itself,
which still has to be mined, and nothing was made to depend on an unconfirmed
transaction being real.

A message that never confirms leaves a row saying it never confirmed, and the
messenger says "in the pool, not in a block yet" rather than dating it. A
shopkeeper acting on an unconfirmed order can at worst lock one of its own
outputs for fifteen minutes and offer something nobody asked for -- the same
exposure as an order that arrives and is then abandoned, which was always
possible.

The pool is looked at every few seconds and mostly does not change, so the
transactions already read are remembered and not fetched again.

## D-051 — Told before it is paid for (2026-09-15)

A buyer's side of a swap is two transactions: the half they sign, and the
message that carries it. Each needs an output of its own, and the swap
refuses at the moment of signing when there is only one -- which is three
transactions and two waits after the person pressed Buy, having already paid
for an order and held a seller's goods for nothing.

The wallet now looks before the order goes out. If the address that would pay
has fewer than two confirmed outputs, the offer is refused there and then,
with what to do about it: split the address, one transaction and one block,
and every purchase afterwards runs at the speed of the pool rather than a
block a step.

A page can ask too -- the shop answer carries `can_buy` -- but the page that
matters most cannot be changed: a mintpad is an inscription, and the one
selling Goofballs was written before any of this. So the wallet says it
itself, in its own chrome around the frame, where it can be said about any
inscribed page whatever that page knows.

The number is two, not one: the message and the half are separate
transactions and neither can spend what the other is spending (D-044).

## D-052 — No confirmation for a message on a chain where coins are free (2026-09-15)

Sending a message and posting to the board each showed the transaction
first: what it costs, how many transactions, how long it will take. That
step exists so nobody spends real coins by accident, and on mainnet it
stays.

On testnet it is a step between a person and the thing they have just
typed. The coins are free, the board is a test board, and the thing being
confirmed is a sentence. A single testnet message already skipped it; files
and chunked sends did not, on the grounds that dust and minutes are worth
knowing in advance -- but they are reported as they happen, by the progress
bubble that was built for exactly that, so the warning was arriving twice
and blocking once.

Both go straight out on testnet now. Mainnet is unchanged, and a mainnet
board post still shows its bill and says "Post to mainnet" on a red button.

## D-053 — A board open on another screen refreshes itself (2026-09-15)

The live-refresh script keys off an element with `id="live"`, and the public
board did not have one. So a post made anywhere -- another tab, the phone
over the tunnel, this machine while somebody watched from the sofa --
appeared only when the page was reloaded by hand. The messenger has had the
element since it was written; the board simply never got one.

With it, the board also needed something to notice: only the block watcher
bumped the counter, so a post made in this process showed up a poll later at
best. Recording a post bumps it now, which is the moment there is something
new to see.

## D-054 — The commands repair the environment they live in (2026-09-15)

A Windows install ran `dogecoinarcade-update` and got:

    Error while finding module specification for 'arcade.update'
    (ModuleNotFoundError: No module named 'arcade')

The virtual environment was there. The application was not. `pip install
--upgrade` removes the old version before unpacking the new one, so a build
that fails in between -- or an install that stops halfway, or a file pip
cannot replace because the interface is running -- leaves an environment
with nothing in it. From there every command the installer wrote is a
traceback, and the updater is no help at all: it lives inside the package it
would have reinstalled.

So the way back out is now outside the environment. The installer writes
`~/.dogecoinarcade/repair.py` as soon as the venv exists, before anything
that can fail, and it imports nothing from the application: it reinstalls
from `~/.dogecoinarcade/src` with the venv's own Python and says what to do
if that is not there either. Each shim runs it first -- the updater when
`import arcade.update` fails, the other two when their executable is missing
-- and then does its own job. The installer and the updater both check the
import after pip reports success, because pip has reported success and left
nothing importable.

Shims also carry a version now. Presence was the only test the updater
made, so a corrected shim could never reach a machine that already had a
file by that name -- which is every machine that has ever updated. The
installer answers `shims_current`; a mismatch rewrites the set.

The repair script goes beside the environment, except when the environment
lives inside a checkout: the first live run of this left an untracked
`repair.py` in the repository, on its way to being committed and published.
A checkout's environment gets its script in `~/.dogecoinarcade/` and
reinstalls from the checkout itself, which is the source it was installed
from.

An installation already in this state cannot repair itself -- there is
nothing on it that knows how. Running the installer again fixes it, and
keeps the wallet, messages and chain data:

    curl.exe -O https://dogecoinarcade.com/install.py
    py install.py --skip-core

## D-055 — The Windows installer puts its commands on the PATH (2026-09-15)

On Linux and macOS the commands go in `~/.local/bin`, which every shell
already looks in. Windows has no equivalent, so they went into
`%LOCALAPPDATA%\DogecoinArcade\bin` -- a directory nothing looks in -- and
the installer's answer was a note at the end saying so. Every command it had
just written was "not recognized" until the user fixed their own PATH.

It now adds the directory to the user's PATH itself, through the registry
rather than `setx`: `setx` truncates a PATH longer than 1024 characters, and
has been quietly eating people's environments for years. The user Path is
read, the directory appended if it is not already there, and the value
written back. The terminal that ran the installer inherited its PATH when it
opened and cannot see the change, so the closing note says to open a new one.

## D-056 — One way to launch a browser in the tests (2026-09-15)

Three files opened Firefox and only one of them honoured the overrides.
`tests/test_browser.py` reads ARCADE_GECKODRIVER and ARCADE_FIREFOX_BINARY,
because a snap-packaged Firefox is a symlink to `/usr/bin/snap` and
geckodriver rejects it with "binary is not a Firefox executable" before it
launches anything. `tests/test_showcase.py` and
`tests/test_inscription_sandbox.py` each built their own with
`webdriver.Firefox(options=options)` -- no service, no binary location -- so
on that machine they skipped whatever the environment said.

The GX10 found it while running the suite as a second opinion: with the
variables set it ran the approvals tests and skipped 20 others with the same
message, which is the signature of two code paths rather than one machine.
Twenty tests quietly not running on the machine whose whole job was to run
them.

`tests/browsers.py` is the only place that launches one now, and the skip it
raises names the variable that gets past the usual cause.

## D-057 — A wait that throws is not a wait (2026-09-15)

`tests/test_browser_approvals.py` failed three times across two machines,
two installs and two builds, always in a full run and never on its own. The
assertion it failed on was the wrong place to look:

    WebDriverWait(browser, 10).until(lambda b: b.execute_script(
        "return document.title").startswith(("visits", "error")))

`document.title` is `None` while a document is mid-navigation. `.startswith`
on None raises AttributeError *inside* the lambda, and an exception that is
not in `ignored_exceptions` ends the wait immediately rather than retrying:
the wait returned after one poll, having waited for nothing, and the
assertion after it read a stale title. Which is why a busy machine failed
and an idle one passed -- the second load only has to be slower than the
first poll.

Every title wait in the file goes through one helper now. A value that is
not a string is "not ready yet", and `document.readyState` is asked first so
the usual case is a wait rather than a lucky read. The dialog waits had the
same shape with a different throw -- `document.getElementById('ask').open`
on a null element -- and use `?.` now.

The GX10 found it by reproducing the failure and then reading what the
lambda actually did, rather than re-running until it passed. Two clean runs
had already been offered as evidence that it was a race worth ignoring.

## D-058 — An offer is readable from the mempool (2026-09-15)

Danny offered for Goofball #100 and both ends showed nothing: his Exchange
said he had made no offers, the holder's said nothing had been offered. It
was in the pool the whole time and confirmed at block 1,490,734, four
minutes later, at which point both showed it. Everyone involved concluded it
had never been posted.

Offers are read from the pool now, the same way messages already are (D-050)
and for the same reason: an offer is a message to whoever holds a piece, and
a block is a minute or ten. `pending_offers()` reads the pool fresh on every
call and writes nothing down -- the ledger is built from blocks and only from
blocks, so an offer that never confirms leaves nothing behind and two nodes
still agree about the chain when their pools differ. A pool offer is checked
exactly as the indexer checks an indexed one: the inscription must exist, it
must not already be the offerer's, and an offer of nothing is not an offer.

`offer()` falls back to the pool as well, because it is what an answer is
checked against (D-049): finding no offer for one a holder can plainly see
would be worse than the wait. And the watcher compares the pool's offer ids
each pass, so the page holding it refreshes itself -- the scanner bumps the
generation for a message, and an offer is not a message.

## D-059 — A red circle for the things that are waiting (2026-09-15)

Unopened messages, posts on the board since this wallet last looked, and
offers on its pieces that it has not answered. Three different questions, one
shape beside the nav item that answers it.

They are counted differently because they are different. A private message
is addressed to you and carries its own read mark. A public post is addressed
to nobody, so "unread" can only mean "arrived since you last looked" -- one
mark per chain, set when the board is opened, no per-post state. An offer is
neither: it is waiting for a decision, and it stops waiting when one is made,
so accepting clears it without anything being marked.

The offer count needs the node -- the pool, and which addresses are ours --
and every page would ask. It is worked out at most once every ten seconds and
kept; the Exchange page itself does the work properly. Nothing is drawn at
zero: a badge that is always there is not a badge.

## D-060 — The decision is where the question is (2026-09-16)

The approval pop-up frames the wallet's own approval page and puts a Close
button under it. For a swap the page is longer than the frame: two legs, five
rows, every output. Approve and Refuse were below the fold, so the person
being asked had to scroll inside a modal to find them -- with Close, the one
button that does nothing, in plain view the whole time.

They are pinned to the bottom edge of the frame now, level with Close.
`fixed`, not `sticky`: sticky was tried first and is wrong here, because a
sticky element may only move within its containing block, so on a page whose
panel is short the bar stays exactly where it was and the fix works only for
the long pages somebody happened to test. In a framed document `fixed` pins
to the frame's own viewport, which is the box this is about. It leaves the
flow, so a spacer holds the room it would have taken and the last output is
not underneath it.

The test squeezes the window until the page scrolls and asks where the button
was painted, rather than what its computed `position` says -- a computed
`sticky` was true of the version that did not work.

## D-061 — The book reads the pool (2026-09-16)

The same wait offers had (D-058), and worse, because an order is a public
statement of a price. Until its block landed nobody could see it: not the
person who placed it, whose own list said they had no orders, and not the
person who would have taken it. A book a block behind the prices people are
actually offering is a book nobody can trade on.

`pending_orders()` reads type 25 out of the pool and sorts it into the book
by price, marked `pending`, alongside what is mined. Cancels are read too --
types 26, 27 and 28 -- and applied to both books, because a price somebody
has withdrawn is not a price whether or not a miner has noticed. A pair whose
only order is unmined appears in the pair list, or the order is invisible for
want of something to click on.

Nothing is written down. The tokens an ask sells are reserved when its block
lands and not before, so a pool order is a claim rather than a settled one --
the same line the messenger draws: the pool is read for carriage, the ledger
is built from blocks. Two nodes with different pools still agree about the
chain.

Validated exactly as the indexer validates it, minus what only a block can
answer: two different sides, one of them the coin, amounts in range, the
property exists. Whether the seller still holds what it is selling is settled
by the block, and by the swap that fills it.

## D-062 — A swap may fill a standing order (2026-09-16)

A resting ask holds its tokens in `metadex_reserve`, where a swap cannot
reach them: `_check_leg` reads the free balance. So the only way to fill an
order was to cancel it, wait a block, and trade with somebody who could still
see a price that had just been withdrawn. The book was a noticeboard.

From `fills_from` a swap may take from that reserve and reduce the order by
what it took. Martin chose partial fills: what is left stays on the book at
its price, which is what an order book normally means.

**Which orders.** The swap does not name them. Every node works it out from
indexed state -- the seller's orders on that pair, cheapest first, then
oldest, then by txid -- so two nodes reading the same block pick the same
ones. Naming them would have cost a payload change and an inscription kind,
and bought nothing: what a swap names is what moves.

**What protects the maker.** The price. An order is a public promise to sell
at a price, and its reserve may be spent at that price or better, never
worse: `take.amount * sale_amount >= want_amount * give.amount`, integers, no
division. What is left of a partly filled order rounds the maker's way, so a
remainder is never cheaper than what was promised. The maker also signs the
transaction -- an input is a signature -- so consent is explicit; the price
guard is the rule that still holds when a wallet signs something it did not
read closely.

**Why a height.** It makes valid what used to be invalid, so a node on the
old code calls the same block's transaction insufficient-balance while a new
one applies it, and the two ledgers part company with nothing to say they
have. `fills_from` is set hours ahead of the tip it was written at, both
known nodes update before it, and nothing fills an order below it.

Only a token sold for coins is a fill. An NFT swap, a token-for-token swap
and everything else are swaps and touch no book.

## D-063 — What a taker actually needs from a maker (2026-09-16)

Martin asked whether an order should publish the UTXO information needed to
build the transaction, or whether a node should call the other node for it
when a pair is opened. Reading `swap.build` (arcade/swap.py:893) answers it,
and the answer is neither.

To fill a resting **ask** the taker needs exactly one outpoint of the
maker's, of any size: its value comes straight back to the maker plus the
coins owed, and the token leg moves by the meta-layer rule -- the seller is
the first input -- not by coin value. The order transaction itself creates
one: it is sent from the maker's address and pays its change back there, an
output every node that read the order has already seen. So an ask is
buildable from public chain data with no round trip and no protocol change.
Publishing an outpoint in the order would cost bytes, pin the maker to an
output that may be spent by the time anyone takes it, and buy nothing.

To fill a resting **bid** the maker must fund the coin leg, which one change
output rarely covers. Martin chose: ask the maker's node, which picks its own
inputs and signs its half, exactly as a shop answers today. It costs no
wall-clock over publishing outpoints, because either way the maker must be
online to sign -- and if it must be online anyway, asking it is strictly
better than trusting a list of outpoints from an hour ago.

Neither publishing nor serving outpoints is a security risk: an outpoint is
public, and a transaction spending it is worthless without the signature. The
invariant that makes any of this safe is the one that was already there
(D-038): a node signs only what it has re-derived from its own order and
checked in full. Never because it was asked.

## D-064 — The book is in the consensus hash (2026-09-16)

`metadex_records` read a table called `metadex_trade` that was never created:
the book lives in `book_order`. So the section was always empty and the order
book was not covered by the hash at all. Three orders stood on the live
testnet book with 1,500 tokens reserved behind them, and that section was the
digest of the empty string.

The GX10 found it by reading the sections rather than the total, which is
what a second node is for: from one machine the hash matched itself
perfectly.

It matters more from D-062 on. A fill mutates those rows -- what is left of
an order, what it still wants, what it still holds -- so two nodes could have
disagreed about every price on the book and reported the same consensus hash
at the same height. The reserve *was* covered, through the balance rows; what
it was reserved for was not.

The record is this node's row rather than Omni's MetaDEx layout, because the
book is not Omni's: the coin is one side of every pair here and has a
property id. Adding a section that was empty changes the hash for everyone,
which is not a fork -- the hash is a check value, nothing consumes it
automatically, and no ledger reads differently because of it. What must not
change is anything else: on this node, across the change, balances stayed
171137da… and properties 65149d52…, both matching the GX10's reading at
1,492,351, while the total moved 0ec434ec… -> 9f10d752… and metadex_trades
went from the empty digest to 1964f338….

## D-065 — Releases are signed, and install themselves (2026-09-16)

Martin asked for automatic updates and, in the next breath, that they be
secure. The second half is the whole of the work.

Until now the installer checked the archive against a SHA-256 published
beside it, and its own comment said what that was worth: "it is not a
signature, and it cannot be -- whoever could replace the archive could
replace the sum next to it". It catches a truncated download. Both halves
come from the same website, so it catches nothing else.

That was defensible while a person typed `dogecoinarcade-update` and could
look at what they were installing. It stops being defensible the moment the
machine does it on its own: an automatic updater that trusts a website is a
website with a shell on every machine that ever installed this, with nobody
watching. Automatic updates could not be added to what was there; they
needed something else underneath.

So a release carries an Ed25519 signature over its manifest -- revision,
archive hash, publication time -- made with a key that lives on the
publishing machine and is on no server. Every installation carries the public
half **in its own code**, which is what makes it a pin: an attacker who can
write to the website cannot hand out a new public key, because every
installed copy already has this one. Changing the key takes a release signed
with the old one. PyNaCl, which was already a dependency, and Ed25519, which
is the construction for signing a small message; nothing is invented here.

The refusals are total and deliberate. No manifest, a manifest signed by
another key, a body that does not match, or a release older than the one
installed -- and nothing is installed. The last is not paranoia: a signed
release stays signed for ever, so somebody who can serve files but not sign
them can still serve last month's, the one whose bug they know. `published`
is inside the signature, so it cannot be edited to look new.

What this does NOT protect is a first install. `install.py` runs before the
application exists, on whatever Python is lying around, with no PyNaCl and
nothing pinned; it has TLS and the published sum, and that is the best it can
do. The chicken-and-egg is real and worth stating plainly rather than
papering over: the first copy is trusted, every copy after it is checked.

The automatic part is then small: the watcher looks every six hours, never
while a send is in flight, and runs the updater in a subprocess so the new
code finishes the job. A checkbox on the Overview turns it off, because this
is a machine fetching code from a website and running it, and some people
want to look first. On by default, because a node that is behind does not
merely lack features: a consensus rule starts at a height (D-062), and old
code reads the same block differently from everybody else. The people most
likely to be behind are the ones least likely to be watching for a release.

## D-066 — Every fetch says who it is (2026-09-16)

The signed manifest was published and the updater could not read it: HTTP 403,
Cloudflare error 1010, "banned based on your browser's signature". `curl` got
the same file with no trouble. The difference is the User-Agent -- Cloudflare's
browser integrity check refuses `Python-urllib/3.x`, which is what
`urllib.request.urlopen` sends when nobody sets one.

Every fetch this project makes now names itself. That is correct regardless of
Cloudflare: a program that downloads software should say what it is.

What it means for machines already installed is worse than a bug in a new
feature. Their updater sends the old header, so it cannot read the revision
file and cannot download the archive -- an automatic update would have failed
silently on every one of them, and a manual one fails with a 403 nobody could
act on. Their way back is the installer: `curl` fetches `install.py`
(unaffected, curl is not blocked), and a freshly downloaded `install.py`
carries this fix.

Also worth Martin turning the zone setting off, so already-installed copies can
update themselves rather than being reinstalled by hand.

And the likely answer to a question from this morning. Daniel's Windows install
"did not work", and left a virtual environment with no application in it
(D-054). `install.py` downloads the archive with `urllib`. If the zone was
refusing that header then too, his install downloaded nothing, which is exactly
the shape of what he was left with.

## D-067 — Taking a price off the book (2026-09-16)

The book could be read and written and not traded on. D-062 made the engine
accept a fill; this is the two wallets agreeing on one.

The taker presses Take and its node asks the maker's node for the one thing
it cannot work out alone: which of the maker's outputs will carry the swap
(D-063). The maker's node answers with an offer priced from its own order --
never from the question -- and locks that output. The taker's node checks the
answer against two things that cannot both be forged: the note it wrote
before it asked, and the order as its own index reads it off the chain. Then
it signs its half and hands it back, and the maker's node countersigns and
broadcasts, through the same `countersign` that has been carrying shop sales
since D-038.

Three things are new and each is a refusal:

  * **A note is written before the question goes out.** The answer to a fill
    makes this wallet sign a transaction that pays coins. Without a record of
    having asked, any node could send an unsolicited answer and be paid for
    it. An answer whose `re` matches nothing is dropped.
  * **The price is checked against our own arithmetic**, not believed. The
    taker works out what it will pay from the order on the chain, rounded up
    the way the engine's guard requires, and refuses any answer that asks for
    more.
  * **The builder is told it is filling an order.** `holds` reads the free
    balance, and a resting ask's tokens are in its reserve, so every fill
    would otherwise be refused before it was built. With the order in hand it
    checks the reserve instead -- and the engine checks it again when the
    swap lands, which is the check that counts.

A bid is not filled this way. The coin side has to be funded by the wallet
that holds the coins, so filling a bid is the token holder asking the bidder,
which is the same conversation in the other direction and is not built yet.

## D-068 — A release announces itself on the board (2026-09-16)

Automatic updates poll, and a poll is a compromise: every six hours is often
enough to matter and rare enough not to hammer the site. A consensus rule
that starts at a height does not care about that compromise. The machines
that have not looked yet are exactly the ones that will read the block wrong,
and today's near-miss was that shape -- the GX10 sat two releases behind the
one that moved `fills_from`, believing activation was 1,496,000 while this
node had moved it to 1,492,600.

So a release is announced on the public board, in a channel of its own, as
one line of plain text: `arcade-release <revision>`. A node that sees one
checks immediately instead of at its next poll.

**The notice carries no authority.** It names a revision and nothing else.
What gets installed is decided by the signature on the manifest (D-065), so
the worst a forged notice can do is make a node fetch a manifest it would
have fetched anyway. That is the property that makes this safe, and it is
worth stating because the obvious design -- "a message that tells nodes to
update" -- would not be.

It is still checked: the sender must be the address that published
@bigchiefenergy, read from the chain rather than pinned, so it moves when its
holder moves it. That stops nuisance nudges, not attacks.

Announcing is self-selecting rather than configured. The node that holds the
tag a notice must come from is the node that publishes releases, and it
announces only a revision it is itself running -- telling other people to run
something nobody has run is how a bad release reaches everybody at once.

## D-069 — A guard that is right because of its caller is not a guard (2026-09-16)

Three findings from the GX10, reviewing the fill path before it was traded on.

**`build(..., from_order=...)` checked one of four things.** A fill skips the
seller's free-balance check, because a resting ask's tokens are in its
reserve. The skip tested `leg.amount > from_order["reserved"]` and nothing
else -- not that the order sells that property, not that it belongs to the
seller, not that it is the order this offer is for. It read as "some order
holds enough units of something". Nothing could reach it: `_fill` is the only
caller and fetches the order from its own note. But the discipline lived in
the caller, and a guard that is correct only because of who calls it breaks
the day somebody else calls it. All four are checked now, and the order a
fill is for travels inside the offer so the check can be made against the
offer rather than against the call site.

**Eight watcher tests could not run.** `FakeState` had no `setting`, which
the watcher now asks before updating itself. The watcher was fine; its
stand-in was not -- but the release went out with no passing watcher tests
behind it, and the watcher is what notices the block a fill lands in.

**A sandbox test failed in a way it could not distinguish from a breach.**
`test_it_cannot_read_any_other_page_of_the_wallet` asserts that a fetch of
`/guide` from inside the frame is blocked. It got `None`: the collector
waited for "seven of the possible eight" probes, `wallet` was the missing
one, and the assertion read an absent key. So a probe that had not answered
yet was indistinguishable from a sandbox that let the read through -- and the
failure mode of an isolation test must never be ambiguous. Every asynchronous
probe now says `pending` before it starts, and the collector waits for all
eight with none pending.

The pattern in all three: each was found by someone reading the code and the
test rather than the result. The suite was green on this machine for the
first two, and the third had been green for weeks.

## D-070 — A tag is a name for an address, everywhere (2026-09-16)

Two places took an @tag: sending an inscription, and addressing a message.
Everything else wanted 34 characters of base58 -- coins, tokens, grants,
revokes, and the address book itself, which is the one place whose whole job
is remembering addresses so people do not have to.

One resolver now, used by every send. It turns `@name` into the address the
chain says holds it, leaves anything else alone, and refuses by name --
"nobody holds @stranger on testnet" -- rather than by a base58 complaint
about a string that was never meant to be one.

Resolved at the moment of sending and never remembered. A tag can move, and a
wallet that pays yesterday's answer pays the wrong person.

The address book is the exception that proves the rule: it takes a tag in
either address field and **stores the address the tag named**, not the tag.
A book that kept the name would quietly follow it if it moved, and paying
whoever holds a name today is not what somebody meant when they wrote it down
last year. Every claimed name is offered as a completion while typing, which
is what makes the field usable without making the book depend on the chain.

The token list shows an issuer's tag where there is one, with the address
underneath in small type: an address is how the chain names an issuer, a tag
is how a person does, and the page can show both.

## D-071 — Where a piece has been (2026-09-16)

Martin asked whether an inscribed page can find out when an NFT first left
the wallet that made it -- the mint date in the sense that matters for
provenance. It could not, and the GX10 traced why rather than assuming: the
`inscription` row holds the CURRENT owner and is overwritten on every
transfer, so indexed state kept no history to serve. `/r/tx/{txid}` answers
how deep a transaction is and nothing about who sent it, and none of the
other twenty page endpoints exposes a transfer.

Every hand a piece changes in is written down now, as the move is applied:
which piece, from whom, to whom, at what height, and whether it moved as a
transfer or inside a swap. A swap counts -- provenance that recorded only
transfers would miss every piece that was ever bought, which is most of the
interesting ones. Keyed by the transaction, so replaying a block cannot
double a row, and journalled, so a reorg unwinds it with everything else.

`/r/inscription/{key}/history` serves it, with `left_creator` answered
directly rather than left for the page to work out: the first move whose
sender is the creator is the question people are actually asking, and an API
that makes every caller re-derive the same answer invites them to derive it
differently.

**Not in the consensus hash**, and the GX10 called this right: these rows are
derived from transactions the hash already covers, and adding a section
changes every node's total for no gain in what is actually checked.

**From here on, not backwards.** The 106 inscriptions already on this chain
have no history and will not grow one without a rescan. What Martin asked
for is a fact about pieces as they trade, and a rescan to backfill six weeks
of development traffic would cost more than it tells anybody.

## D-072 — An address book that follows its people (2026-09-16)

A contact's @tag is read from the chain every time the page is drawn, never
stored beside the name. So somebody who claims a new tag appears under it in
everyone's address book, without telling anybody and without a single row in
anybody's book changing.

That is the same rule as D-070 seen from the other end, and the pair is the
point: **the name follows, the address does not.** What is stored is the
address, so what a contact gets paid cannot change under them; what is shown
is whatever the chain says today, so nobody is left calling somebody by a
name they gave up. A book that stored the tag would have got this exactly
backwards -- displaying a stale name and paying whoever holds it now.

Both of a contact's addresses are looked up now. It was the testnet one only,
so anybody saved by their mainnet address alone went nameless.

## D-073 — The history was recoverable after all (2026-09-16)

D-071 said "from here on, not backwards", on the grounds that a rescan to
backfill six weeks of development traffic would cost more than it tells
anybody. Two things made that wrong.

The GX10 corrected itself first: it had said the nodes run without `-txindex`,
reading a comment in `_tx_status` rather than the machine. They do run with
it -- the installer writes `txindex=1` on both chains -- so reading an old
transaction is a lookup, not a rescan. (The comment is corrected too. A
comment that describes a configuration we do not write is a comment that will
mislead somebody.)

The second is simpler and neither of us said it at the time: **the rescan was
never necessary.** `arcade_tx` already keeps every protocol transaction this
node has read and judged -- payload, sender, reference, height, position. The
history can be replayed from rows that are already here. Ownership at each
step falls out of the replay: a piece starts with its creator and each
accepted move hands it on, which is the one fact no single row records.

Only a swap needs the node, for the buyer -- the first input that is not the
seller's -- and that is where `txindex` earns its place.

It runs once, when the table is empty, from the watcher rather than the
schema install, because it needs a node and a node that is not up yet must
not delay the interface.

**The first live run wrote four moves and reported success. There were
fourteen.** The rpc factory in the script that ran it raised AttributeError,
a bare `except Exception` caught it, and every one of the ten swaps was
skipped in silence -- so the run said "wrote 4" and nothing said "and could
not read ten". The code was right; the invocation was broken and the silence
was the bug. The GX10 caught it by replaying the same transactions its own
way and getting thirteen, then saying so rather than assuming it had made a
mistake.

For provenance a silent gap is not a missing answer, it is a wrong one: a
piece that was bought reads as a piece that never moved. Every skip is
logged now, with what raised and which transaction.

Run properly it rebuilt fourteen moves from 460 stored transactions -- four
transfers and ten swaps -- and the replay reproduces the ledger's current
owner for **109 of 109** pieces. That is the check worth having: the history
is not merely plausible, it is the only trail that ends where the chain says
everything is.

## D-074 — Both names, and pages get them too (2026-09-16)

A piece's page showed the owner's @tag and the creator's bare address. The
lists had been tag-aware for a while -- inscriptions, collections, the
exchange -- so the detail page was the one place that had fallen out of the
pattern, which is the usual way: the page written first is the page nobody
revisits.

Both are looked up in one call, which also covers the case that is most of a
collection most of the time: a piece still held by the wallet that made it,
where the two addresses are the same one.

The same gap existed a layer down and matters more there. `/r/inscription`
handed pages bare addresses, and a page cannot do anything about that -- its
entire chain access is the block height, the block time and how deep a
transaction is. An API that returns an address and no name has decided the
page cannot show one. `creatortag` and `ownertag` come with every describe()
now, null when nobody holds one, including in listings where one lookup
serves the whole page.

With a caution in the field itself, because the GX10 spotted that the send
page already carries it: a tag is read at **this node's tip**, and tags move.
It is what an address is called today, not a name that belongs to a piece.
Show it; do not store it against the piece. A page that caches one will be
wrong eventually, and will be wrong in the direction of naming the wrong
person.

## D-075 — No fee warnings for coins that are free (2026-09-16)

The address book warned twice that something "costs a small fee": claiming a
@tag, and publishing a key. The Overview said publishing "costs a few test
coins". Both transactions are messaging transactions, and messaging is
testnet only and always will be (D-010) -- a tag is claimed on the chain the
messages are on (D-032), so it cannot be anywhere else either.

Warning somebody about the cost of something that costs test coins, which
arrive free from a faucet, is noise dressed as caution. Noise is how a real
warning stops being read: this application has warnings that matter -- a
mainnet send, a permanent claim, an inscription that cannot be taken back --
and they are diminished by sitting beside three that do not.

The fee rows are gone from both previews as well. A number about nothing is
still a number somebody has to read.

What is worth saying is still said, because it is about consequence rather
than price: a key on the chain is public and permanent, and the first claim
on a tag wins.

## D-076 — One card, one button, one statement (2026-09-16)

Your @tag and your key on the chain were two cards with two buttons, and the
split was the bug. A name is only half claimed until the key announcement
says so too: the tag table binds the name to an address, the announcement
tells every wallet reading announcements what that address calls itself.
Somebody who changed their tag and stopped there stayed findable under a name
they no longer held, and nothing told them.

One card, one button. Type a name, press it, and both go out: the claim, and
the key saying it is yours. Unchanged name, and it is just the key -- which
is what somebody publishing for the first time wants anyway.

Two things follow that were not obvious until they were built:

The announcement carries the name **just claimed**, not the one read back off
the chain. The claim is not in a block yet when the announcement is built, so
reading the chain would state the old name, and the two would disagree until
somebody published a third time. The announcement is this wallet's statement;
the claim is what makes it true.

And re-announcing is allowed when the name changed. Publishing the same key
twice was refused, for good reason -- it is a permanent record and a second
one says nothing new. A second one carrying a different name says the only
thing that matters.

No preview step. Both transactions are testnet always, so there is no bill to
show, and what is worth knowing about a claim -- permanent, first claim wins,
and what letting go of it costs -- the card says before the button rather
than after.

## D-077 — An address book you can fill (2026-09-16)

Adding somebody meant pasting base58, which is an address book most people
never fill. Every name claimed on the chain is searchable now: type part of
one, press Add, and the entry is made.

Prefixes rank above the middle of a word and an exact match wins outright,
because somebody typing "mar" means @martin far more often than @postmaster.

What is saved is the ADDRESS the name points at, as everywhere else (D-070,
D-072). The card then shows whatever name that address holds today, so
somebody who claims a different one appears under it without anybody's book
changing and without what they get paid changing either.

## D-078 — Automatic, but not slow (2026-09-16)

The update check ran every six hours, which was chosen against a picture of
releases arriving weekly. On the day it shipped, nine went out in two hours,
and every machine sat on whichever one it happened to have when it last
looked -- including a node holding a consensus rule that had since moved,
whose owner was twice told to update by hand. He asked the obvious question:
I thought the updates were supposed to be automatic.

They were working exactly as built. The cadence was wrong, which is a
different and more embarrassing failure than a bug.

Fifteen minutes now. It costs one small signed file per machine per quarter
hour, and the board notice (D-068) remains the fast path: seconds, when there
is something to hear. The poll is the floor, not the mechanism.

Two things worth keeping in view. A source checkout never auto-updates --
`check()` says it is not fetchable, which is right: it IS the source. And the
notice path is currently inert here for a reason that is not the code's
fault: it only acts on posts from whoever holds @bigchiefenergy, and the
publishing node holds @notbigchiefenergy.

## D-079 — The book holds people the chain can name (2026-09-16)

Martin's call: the address book takes only addresses that hold a published
@tag, and anybody who wants to be in somebody's book claims one first.

The reasoning is the same one the book exists for. A name you typed beside a
row of base58 is checkable by nobody, including you -- it is a note about who
you *think* an address belongs to. A tag is a claim on the chain: it can be
looked up, it can be disputed, and the wallet shows it beside the address
every time. Requiring one turns the book from a private memo into a set of
statements anybody can verify.

The refusal says what to do about it, because the person being added can fix
it in one press on their own machine (D-076). Editing an entry saved before
the rule still works: the check runs only when an address is being set, and
locking somebody out of their own notes to enforce a rule about names would
be a bad trade.

A mainnet address never holds a tag directly -- tags are claimed on the
messaging chain -- so it is named through the announcement that binds both of
a wallet's addresses to one key.

## D-080 — A move is keyed by the pair, not the transaction (2026-09-16)

`inscription_move` was keyed on txid alone. One swap can hand over two
pieces -- a Goofball for a Goofball is the trade the whole swap feature was
built to make possible -- and `_move_leg` is then called twice for one
transaction. The second insert collided.

Worse than a collision: it raised `StateError`, not `InvalidTransaction`, so
it threw out of the handler instead of marking the transaction invalid. A
single two-inscription swap on the chain would have stopped indexing at that
height on every node that met one.

Keyed on (txid, inscription) now, and the journal registration widened to
match -- the GX10 caught that second half, and without it a reorg would have
unwound one leg of a two-piece swap and left the other. An installation with
the old key drops the table rather than migrating it: every row is derived
from transactions already read, so the backfill rebuilds the lot, and a
rebuild from the source beats a rewrite of a table that was wrong.

## D-081 — A schema that catches up (2026-09-16)

`CREATE TABLE IF NOT EXISTS` does nothing to a table it finds. So a column
added to a SCHEMA reaches new installations and silently misses every
existing one, and no test suite can see it: tests build their database from
nothing every time. The newest install works and the oldest breaks, which is
the worst way round.

It cost the first live fill. `offer` gained an `order` column; both machines
had been running since before it; the taker paid a message fee to ask, and
the maker's node answered "table offer has no column named order". SQLite
did not even fail loudly on the way in -- a double-quoted name with no
matching column is read as a STRING LITERAL, so four live rows sat there
with the word "order" in them and nothing complained.

A sweep of every live database against every declared schema then found the
mintpad's three columns missing from `job` in the same way.

So: `add_missing_columns(conn, SCHEMA)`, which reads the declarations and
adds what a present table lacks, run beside every `executescript(SCHEMA)` in
the project. By reading the schema rather than by remembering to write a
migration -- the remembering is the part that failed. Columns carrying
PRIMARY KEY, UNIQUE or REFERENCES are skipped: SQLite cannot ALTER those in,
and a table needing one needs rebuilding (D-080).

A test asserts every store that installs a schema runs the catch-up beside
it, so the next column added is not the next outage.

## D-082 — A swap says which order it fills (2026-09-16)

D-062 said a swap need not name the order it fills, because which orders are
consumed is a pure function of indexed state. The first live fill between two
machines proved that wrong, and it is worth writing down exactly how.

The trade worked. 333.33333333 Arcade Test for 4.00000000 coins, negotiated
over messages, both wallets signing, one transaction at block 1,492,625,
engine verdict valid, tokens where they should be. And the order it was taken
from still advertised 500 at 0.012, reserve untouched.

Because `_fills_for` drew on the book only for what the free balance could
not cover, and the maker held 980,801 loose. The engine could not tell a fill
from an ordinary swap, so it took the easy tokens and left the advertisement
standing. The book is decoration whenever a maker has stock, which is most of
the time, and the same 500 could be taken again and again.

**It is not derivable, and that is the finding.** "Sold 333 at 0.012 while
advertising 500 at 0.012" is produced byte-identically by a fill of the
advertisement and by a private sale beside it. What separates them is what
the taker asked for, and that never reaches the chain unless it is put there.
Any derivation rule is a fixed guess at intent, and whichever way it guesses,
the other intent is silently mis-executed.

The GX10 killed the obvious alternative. Drawing from the reserve first would
have fixed this trade -- but the price guard admits every swap priced at or
above the ask, so a maker selling 100 tokens at 0.05 in a private deal would
have that swap eat their 0.012 public ask. Premium sale destroys cheap
advertised liquidity the maker never meant to touch. That is the mirror of
the bug, and worse, because it is unrecoverable rather than merely
misleading. Narrowing to exact price equality does not work either: integer
partials rarely land exactly, and a maker who wants the ask to survive shifts
one satoshi.

So the swap carries the order's txid: 32 bytes after its two legs, and only a
token-for-coins swap can carry them -- two inscription legs are already 66 of
the 76 an OP_RETURN holds.

**A preference, never a condition**, which was the GX10's correction and is
the most important line here. A swap naming an order that has gone, moved
past its price, or emptied since the taker asked is still a valid swap. The
coin leg settles on the Dogecoin layer whether this layer likes it or not, so
making a stale name invalid would mean a taker paying real coins and
receiving nothing. What it names is what it prefers; the trade stands either
way.

The seller checks the name before signing, like every other term: a buyer
naming a different order, or none, is asking for a private sale beside the
advertisement rather than a fill of it. Same tokens, different thing.

Its own height, because it makes a payload legal that was invalid before.
A swap naming nothing is encoded and read exactly as it was, so no history is
reinterpreted -- including the swap that found this.

## D-083 — Price, then time (2026-09-16)

Martin: several people selling at the same price should settle in the order
they queued. That is what every order book means by fair, and it is the rule
a maker relies on when they place an order behind somebody else's.

Two places had it wrong.

**The book sorted the pool first.** An unmined order has `block_height` 0, so
a sort on (price, height, position) put the newest thing on the book ahead of
orders that had been standing for hours -- time priority exactly backwards.
Pending rows sort last within a price now, which is what they are: the newest
of all, by definition, because they have not been mined.

**A fill went to whichever row was pressed.** Somebody pressing Take on the
third row at a price is asking to buy at that price, not choosing which of
three identical offers gets the trade. The fill now goes to the earliest
order at that price or better with enough left.

One exception, and it is deliberate: an order whose maker this node cannot
reach is skipped rather than queued behind. A fill is a negotiation -- the
maker's node has to answer -- so an unreachable wallet cannot be traded with,
and stopping the queue on it would let one silent maker block a price for
everybody (D-042). Their order stands; it is simply not the one taken.

## D-084 — A thing that runs on its own has to say when it ran (2026-09-16)

Martin asked twice why updates were not automatic. Both times they were
running exactly as written, and both times neither of us could answer it from
outside the process, because the only evidence was a debug log nobody reads.
That is the actual defect: not the cadence, not the code, but that a
background job had no way to account for itself.

Two changes.

**It runs first, in its own hands.** `_auto_update` was ninth in the tick,
after the repairs, the scans, the ledger sync, the pool reads, the backfill
and the release notices. Any of those raising took the update with it, and
the outer catch logs at debug -- so a machine could go a week without
checking and say nothing at all. An updater downstream of every other
subsystem's health is not an updater. It now runs before any of them, wrapped
on its own, and a failure there is a warning rather than a shrug.

**It writes down what it decided**, every pass, and the Overview shows it:
when it last looked, what it concluded, what is installed and what is
published, and the error if it could not reach the site. So "why is this not
updating" has an answer on the screen instead of an argument.

Two truths that page now tells rather than hides: a source checkout never
updates itself, because the checkout IS the source; and a machine that has
just started has not checked yet, which looks identical to one that is broken
until something says which it is.

## D-085 — The notice path was inert for two reasons, both silent (2026-09-16)

A release announces itself on the board so other nodes check at once instead
of at their next poll (D-068). It had never once fired, and neither reason
announced itself.

**`my_tag()` asked the wrong chain.** It looked the address up on the ledger
chain; tags are claimed on the chain the messages are on (D-032). So a wallet
holding @notbigchiefenergy was told it held nothing -- and the gate on
announcing is "do I hold the release tag", so the publishing node never
announced.

**The pinned tag named somebody who does not exist.** `RELEASE_TAG` read
"bigchiefenergy" because that is what Martin called it when he asked for the
feature. The publishing node holds "notbigchiefenergy". A pin at a name
nobody has is not a pin, it is an off switch nobody can see. It now names the
tag that is actually claimed.

Both are the same failure in different clothes, and it is the one this whole
evening has been about: a check that never passes looks exactly like a
condition that never occurs. Neither logged anything, because neither was
wrong in a way the code could notice -- "I hold no tag" and "that post is not
from the tag holder" are both ordinary answers.

The Overview also still said updates were checked "every few hours" after the
cadence became fifteen minutes (D-078). Copy that describes an old behaviour
is a lie with a good conscience, and it is what Martin read when he asked why
this was not faster.

## D-086 — One authority for who holds a tag (2026-09-16)

The release notice still did nothing, and the reason is the sharpest of the
evening: the two sides of the feature asked different tables who holds a tag.

The publisher asks the **tag table** -- the claim on the chain -- and
announces. Every receiver asked its **key_announcement** rows, which carry
the tag an announcement *stated* when it was made. Those disagree the moment
somebody claims a different name: an announcement is a statement by the key
holder, and a tag claim does not write that table. On the GX10 the
announcement said `bigchiefenergy` and the tag table said
`notbigchiefenergy`, both for the same address, and the receive side returned
"nobody holds that" before it looked at a single post.

**It could not be seen from the publishing machine.** The half that works is
the half the publisher runs. From here the notice goes out, the board shows
it, and nothing ever says that no one acted. The GX10 found it by reporting
that its node did nothing -- which is the whole argument for a second machine
that talks back.

Both sides read the tag table now. An announcement's tag stays what it is --
a claim the wallet makes about itself, shown beside a name with a dispute
marker when the chain disagrees (D-032) -- and it is not consulted for
authority again.

One test-design note from the GX10 worth keeping: the notice named the
revision the receiver already ran, so "acted" and "did nothing" both ended at
*up to date*, and only the timestamp could tell them apart. A test where both
outcomes look the same is not a test. The next one announces a release the
receiver does not have.

## D-087 — An acceleration mechanism cannot deliver its own repair (2026-09-16)

The GX10's finding, and it generalises past tonight.

c6f0f3e teaches the RECEIVER to resolve the release tag from the tag table.
A node running the release before it cannot act on the notice that carries
that fix, because acting on notices is the thing it cannot do. It waits for
the ordinary poll like any other machine.

So: **a repair to the receive side of an acceleration mechanism can never be
accelerated by that mechanism. Its first delivery is always the slow path.**

The corollary bites harder than the rule. Notice-based acceleration only ever
reaches nodes already at or above the revision that made notices work, and
the machines furthest behind are the least likely to carry a working receive
path. The population that most needs accelerating is the population that can
least hear you -- which inverts the reason the mechanism exists (D-062: the
machines that have not looked yet are exactly the ones that will read the
block wrong). The poll is not a fallback for the notice. It is the floor
everything else stands on, and it is what must stay short.

A second corollary for test design, following the GX10's earlier one: "did
the notice work" is answerable one release AFTER the fix, never on the
release that contains it.

**Pool rows count**, which the GX10 asked about rather than assumed. A notice
is acted on at height 0 -- broadcast, not mined -- which is a block sooner
and is the point when a consensus height is close. Safe because the notice
carries no authority: the sender is checked, and what installs is decided by
the signature on the manifest, so a notice that never confirms costs one
fetch of a file the node would have fetched anyway.

## D-088 — The success case destroyed its own evidence (2026-09-16)

The GX10's node updated itself at 23:21:41 -- poll, not notice, nobody
touching it, arcade-web restarting on its own. The first automatic update
this project has ever done. And the journal said:

    automatic update failed: Checking the commands are on your PATH
      already there
    Checking cloudflared
          cloudflared 2026.9.1 is already installed

That "error" is the updater's progress output. The mechanism: `_auto_update`
runs `python -m arcade.update` as a subprocess; that subprocess restarts
arcade-web; systemd stops the parent and everything in its cgroup; the
subprocess is killed mid-flight; the return code is non-zero; the parent logs
a failure, writes "the update failed" -- and dies. The restart then wipes the
in-memory status, and the new process's own first check writes "up to date"
over it.

So the update worked, the log said it failed, and the page showed neither.
D-084 exists to make a background job able to account for itself, and the one
outcome a person most wants to see is the one that could not survive to be
shown: **the success case ends the process that would have recorded it.**

Two changes, and both are the same idea.

**The outcome is read from the installation, not the exit status.** What is
on disk after the subprocess is what happened. A process killed by the
restart it caused is not a failure; a process that exits cleanly having
installed nothing is.

**The status is written to disk before the subprocess runs and read back on
start.** A record held only in memory cannot survive the event it exists to
describe.

The general form, which is the fourth version of tonight's lesson: any job
whose success restarts its own process must record intent before acting and
judge by the world afterwards. Return codes describe the child; the
installation describes the outcome.

## D-089 — Every watcher phase in its own hands (2026-09-16)

Release announcements stopped. Three went out, then nothing for bb1fe5b --
and the announce code was unchanged, the tag was right, `installed ==
published` was true when checked by hand.

`_tick` ran its thirteen phases in a bare sequence. The first to raise took
every phase after it, and the outer catch logs at debug -- so a subsystem
switched itself off and nothing said so, on a machine that two agents were
watching closely.

D-084 moved the update to the front of that sequence and wrapped it, which
fixed the update and left the same trap for everything downstream of
whatever broke. The right answer is that no phase can take the others down.
Each runs in its own try now, and a failure is a warning naming the phase
rather than a debug line naming nothing.

The general form, and it is the fifth version of tonight's lesson: **a
sequence of independent jobs sharing one exception boundary is a sequence
where the first failure is the last thing that runs.** The cost of finding
that out is proportional to how quietly it happens.

## D-090 — The line that had never run (2026-09-17)

`_check_release_notices` threw `AttributeError: 'sqlite3.Row' object has no
attribute 'get'` on its first line, every tick, 36 times in three minutes.
`group_posts` returns `sqlite3.Row`, which indexes but has no `.get`.

**The tag fix did not break it. It unmasked it.** For the whole life of that
function the tag lookup answered "nobody holds that" and it returned before
the loop, so the line had never once executed. D-086 made the lookup resolve;
the loop ran for the first time and threw immediately. Three notices went out
under a runtime where `who` was still empty and the loop was unreachable --
that is why announcements looked like they worked and then stopped.

D-089 contained the blast radius and called the cause "something upstream
began raising". This is it, and the containment is what made it findable
rather than fatal: the phase now fails on its own instead of taking the
shopkeeper and the gather with it.

**1,247 tests said nothing.** Every test of this feature covered the lookup --
`revision_in`, `address_for_tag`, `my_tag`, "both sides ask the same table" --
with fakes. Nothing called the function with posts from a real store, so the
code the fix unlocked was executed by no test at all. The GX10 named the
missing one exactly: write a post through the real store, run the function,
assert the poll was reset. It is written, and it fails with the old line.

The lesson, which is the sixth of these and the sharpest: **a fix that makes
an unreachable path reachable is a new code path shipping without tests.**
The lookup got a test because it was what changed. The loop did not, because
it had not changed in years -- and it had never run.

## D-091 — Routes an inscription declares (2026-09-17)

Martin wants two NFTs to interact: two creatures held by different people,
battling in a third inscription that is an arena. It needs their power, their
life, their stats, and whether each player is ready.

**Declared, never executed.** An inscription's JSON may carry an `api` block
naming routes and where each answer lives, and the node is a fixed
interpreter over that. The alternative -- an inscription shipping code that a
stranger's node runs beside their wallet -- is remote code execution on
somebody else's machine, and no sandbox written in an evening makes it safe.
Every verb here is a lookup: a path into the piece's own JSON, a key the page
saved through `arcade.storage`, its owner, its number, its collection, a
constant.

**The declaration is inscribed**, which settles three things at once. What a
piece will answer is public, so a caller can read the spec before spending a
message on it. It is permanent, so a holder cannot quietly widen it. And it
was written by whoever made the piece, not whoever holds it today -- which is
what makes an answer worth anything to the other side of a battle.

**A node answers on the inscription's behalf**, with nobody at the screen.
That is the whole point: two pieces can interact while one of their owners is
asleep. Held here, the answer is immediate and free; held elsewhere, the
question goes as a sealed node-to-node message and the answer comes back
through the transport shops already use.

**No writes.** There is no verb that moves anything, and there will not be:
a page that wants to send a token or mint a piece files an approval and a
person says yes. Answers carry information, the chain carries consequences --
the same line D-082 drew for fills.

Which is also the design advice for the battle, and it is not a limitation of
this feature but of trust: base stats belong in the inscription, where both
sides can read them off the chain without asking anybody. `arcade.storage` is
private to one machine, so a power level kept there can be set to 9999 by its
owner. Storage is for what is only yours -- a toggle, a preference, whose
turn it is. Earned stats that both sides must believe belong on the chain.

## D-092 — A page may write an inscription, not only move one (2026-09-17)

The page API had four kinds and `inscription` among them meant *transfer*: it
validated that a piece already existed and that this wallet held it. A page
could hand a Goofball over and could not make anything. For the arena Martin
described that is the reward half -- a trophy to the winner, a creature from
breeding -- so it is now a fifth kind, `mint`.

It is unlike the other four in three ways, and each shows in the design.

**The content is the thing being approved.** Everything else names something
already on the chain; a mint carries bytes. So the request row holds them
from the moment it is filed, and what the approver sees is size, content
type, transaction count and cost -- "inscribe this" tells a person nothing
they can weigh, and the bill depends entirely on the content.

**It can half-happen.** A send lands or does not. A chunked inscription that
stops partway has spent outputs that cannot be recovered and leaves bytes on
the chain nobody can read. So approving one does not broadcast a transaction:
it starts the wallet's own inscriber, with the progress and resume the Wallet
page already has.

**A page can file it, so it needs a ceiling.** 100 KB, which is not a
protocol limit -- the wallet's inscriber handles far more -- but a request
body arriving over HTTP from a sandboxed frame, and a page that can file a
five-megabyte question can fill a queue with them. Bigger belongs in the
Wallet, where a person chose the file.

## D-093 — One revision remembered against many posts (2026-09-17)

`_release_seen` held a single revision while a board holds several, so the
newest-first walk alternated: take the newest, stop; next pass skip it, take
the second, stop; next pass the first looks unseen again. Every tick found
something "new", every tick reset the poll, and `_auto_update` is the first
phase of the next tick -- about twelve fetches of `source.rev` a minute from
one node, and it would have been every node with more than one notice.

"Often enough to matter, rare enough not to hammer the site" (D-078) was
neither.

A high-water mark on the post id replaces it: only posts above the mark are
considered, and the mark only moves forward. It cannot alternate, cannot
grow, and cannot be confused by a re-read.

**It also invalidated the test we had been waiting for.** The discriminator
was "the check timestamp moves within seconds of the board post rather than
at the quarter hour" -- and with this loop it moved within seconds of
everything, so a notice-triggered check and the bug were indistinguishable. A
positive result would have proved nothing. That is the third time tonight
that a measurement was worth less than it looked, and the GX10 caught all
three.

Fourth time, too, that removing a suppressor exposed code that had never run:
the AttributeError of D-090 aborted the loop before a second iteration could
ever matter.

And the notice line is a warning now rather than info. It fires once per
release, and info from this service does not reach the journal at all on the
GX10 -- so the one event worth diagnosing from outside was the one leaving no
trace.

## D-094 — Silence becomes a refusal (2026-09-17)

The GX10 pointed at the state this node was actually in: five orders resting
on the book, 1,500 Arcade Test reserved behind them, and a wallet that had
not emitted anything on chain for an hour because its service was running
dead code. A taker asking for one of those orders would have got **silence**
-- not a refusal, not an error, nothing. The note would sit at "waiting for
their node" for ever, and the book would go on advertising what could not be
traded.

An order stands whether or not the wallet behind it is running. That is
correct -- it is a chain fact, and a node being down does not withdraw a
price -- but it means the taker's side has to be the one that notices.

A fill nobody answers is closed as `unanswered` after the offer's own
lifetime, with what to do about it: the order may still be there, so try
again or take another at the same price. Silence is the one answer a person
cannot act on.

**Paged before it filtered**, in the first version. `fills()` is newest-first
with a limit of fifty, for showing somebody what they asked for, and expiring
through it meant an old note fell off the end of the page and was never
examined again -- permanently stuck in the state this exists to abolish. It
survives only in a wallet with more than fifty notes: the long-lived,
heavily-traded one, which is the wallet least able to afford it and the one
no test with a handful of rows can reach. The offers path never had this
because `open_offers` filters by status before paging. The fill path now asks
for candidates rather than for recent history.

This compounds with D-082 in a way worth naming: an order that does not
shrink when filled, resting on a node that may not be able to countersign,
and neither visible to anybody reading the book. Nothing was lost, because
nobody tried. It is the shape not to meet during real trading.

## D-095 — The publishing node runs the oldest code (2026-09-17)

Four releases went out tonight while the machine that published them ran code
from before the first of them. Its service had not restarted since 23:22, so
every fix shipped that night was running on the GX10 and not here -- and the
chain shows it exactly: the last transaction of any kind from this wallet is
the 23:20:36 announcement, then sixty-four minutes of silence.

The reason is structural rather than an oversight. A source checkout never
auto-updates, and that is right: the checkout IS the source, so there is
nothing to fetch. It collects its own fixes only when a human restarts it,
while every other node has them within fifteen minutes.

Which inverts the usual assumption. **"It works on the machine I published
from" is the weakest evidence available, not the strongest.** The publisher
is the node most likely to be running something nobody else is.

The operational form, which the GX10 wrote into its own notes: when a feature
works on the receiving node and not the publishing one, check the publisher's
`ActiveEnterTimestamp` before debugging the feature at all.

And it is the fourth time tonight the same law bit: the notice path could not
deliver its own fix (D-087), the updater could not report its own honesty
(D-088), removing a crash exposed a loop that had never iterated (D-090,
D-093), and the publisher cannot run what it publishes. Every one of them
looked like success from at least one machine.
