chore: checkpoint in-flight work across shell, contracts, and docs

Snapshot of the shared working tree's outstanding changes (multiple
concurrent workstreams), excluding generated runtime state files
(.football-tote-state.json, .tote-platform-state.json, .usdm-historical-chart.html).

Spans: wallet shell (connect/, keystore MultiKeyStore+vault, screens,
currency picker, sign-message), verify artifacts, new Potidaea/DRA/tote
contracts + regtest suites, cross-chain/T6 + WalletConnect Parameters,
a telegram-orderbook subproject, design mockups, and docs. See
shells/app/WALLET-COORDINATION.md for the per-file ownership split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
walerikus 2026-07-07 02:02:05 +02:00
parent fc3f27d116
commit 35076606c7
169 changed files with 26378 additions and 205 deletions

11
.gitignore vendored
View file

@ -79,6 +79,17 @@ Parameters/FIATm/transcripts/
# T6 ephemeral demo state (channel handle, routing run-state). Regenerated; not secrets.
.t6-*.json
# Local key store — chipnet (and any future test/mainnet) WIFs, address notes,
# device assignments. NEVER commit. Files here mirror the per-wallet
# `.chipnet-keys-*.json` at repo root but in a single human-readable place.
Parameters/Keys/
# Testnet faucet top-up dashboard — embeds chipnet dev-wallet addresses (A–G),
# so it stays local like the keystore. Public testnet receive addresses only,
# but kept out of git to match the keystore convention. Lives in the dApp folder
# alongside Explorer/Agora/Gallery.
Parameters/dApp/Faucet/
# Local scratch / temp files.
.tmp-*

View file

@ -0,0 +1,497 @@
# Orbital Swap / Potidaea — Business Model
*The monetization, liquidity, and go-to-market model for the native Bitcoin Cash ASM
(Orbital Swap), as built under the **Potidaea** brand. Brand & naming:
[potidaea_orbital-swap-asm.md](../OrbitalSwapASM_Potidaea/potidaea_orbital-swap-asm.md).
Mechanism (authoritative): [ASM · Orbital Swap — native BCH](ASM-Orbital-Swap-native.md).
Taxonomy: [README](README.md). **Project-wide companion (do not duplicate — extend):**
[`wiki/revenue-model.md`](../../wiki/revenue-model.md) + ADRs 0017/0019/0020/0021.*
**Date:** 2026-06-28 · rev. 2 (folds in the user's monetization plan) · **Status:** ratified
as **ADR-0048** (`wiki/decisions.md`) · **Scope:** native BCH ASM. Cross-chain ASM (Build B)
is a sibling rail, touched only where it feeds Potidaea (§5.E).
---
## 0. Why "Potidaea" — and why it matters to the model
**Potidaea = Ποτίδαια, "the city of Poseidon."** The brand is not decoration: it encodes
the single most important economic property of the product — **liquidity as deep as the
ocean.** Potidaea's depth is not a pool someone funded; it is the entire BCH base itself
(§6). Every claim in the marketing copy ("deep as an ocean") is a literal statement about
the mechanism, not a metaphor. Hold this while reading §6.
---
## 1. The structural problem (read this first)
Every classic exchange revenue line is **structurally zero** in Potidaea — by construction:
| Classic venue revenue | In Potidaea | Why |
|---|---|---|
| **Spread** (bid/ask) | **0** | one consensus rate, no two sides to clear between |
| **Slippage / price impact** | **0** | no curve, no pool to move — 1 unit and 1,000,000 convert at the same rate |
| **LP / swap fee** (Cauldron-style 0.3% to LP) | **0** | there is no LP; backing lives in each FT UTXO |
| **Pay-to-list as a *gate*** | **0** | "verified/canonical" is a computed bytecode predicate (ADR-0008) — swap access is never for sale |
| **Redeem fee** (MBA→BCH) | **0** | redemption is a P2PKH spend of a self-backed UTXO — **no contract path to attach a fee to** (V2.7 §5.2/§5.4, ADR-0011 §3) |
This is the pitch *and* the problem. The rule the whole model obeys:
> **You cannot tax the trade. You monetize the *service* of building it, the *discovery*
> of the assets that flow through it, and the *tooling* that issues them — never the rate,
> the backing, or the redemption.**
Every fee comes from the user's **free BCH**, is shown as a separate line item, and is
**soft / avoidable** (wallet-honored, forkable, never consensus-baked). Avoidability is
the shield, not a weakness (§8): an unavoidable rake is the rent-authority ADR-0008 forbids
and would fork the canonical bytecode that *is* the verification wedge.
---
## 2. Fee taxonomy (the canonical four — ADR-0017)
| Fee | Who pays → earns | In Potidaea | Ours? |
|---|---|---|---|
| **Counterparty cost** (spread/slippage/LP) | trader → other side | **eliminated** | No — the structural win |
| **Service fee** | user → facilitator | soft, wallet-appended, **0.5%** default, from free BCH | **Yes — primary direct** (§5.A) |
| **Issuer fee** (`feeBps`) | claimer → founder | contract-enforced on **claim** (BCH→MBA), 0–5% cap | Indirectly — we monetize *enabling* + *promoting* issuers (§5.B/§5.C) |
| **Miner fee** | broadcaster → miners | normal | No |
- **Claim (BCH→MBA) is the only fee-bearing primitive** (runs through a covenant).
**Redeem (MBA→BCH) is free.** MBA↔MBA = `redeem(src)+claim(tgt)` → one fee surface.
- **The issuer fee is the founder's money, not ours.** Our leverage: we run the factory
that mints founders (§5.B) and the marketplace that surfaces them (§5.C), and we take a
*contracted, on-chain split* of the founder fee only via the voluntary tier ladder
(ADR-0021) — never a captive skim.
---
## 3. Who earns what, per primitive
| Primitive | User pays | Founder | Platform | DEA holders | Miner |
|---|---|---|---|---|---|
| **Claim / swap** BCH→MBA | backing + 0.3–0.5% protocol fee + (founder `feeBps`) + miner | `feeBps` | minority slice (~0.1%) | **majority slice (~0.4%)** | yes |
| **Redeem** MBA→BCH | miner only | 0 | 0 | 0 | yes |
| **MBA↔MBA** | backing conserved + 0.3–0.5% + miner | claim-leg `feeBps` | minority slice | **majority slice** | yes |
| **Genesis** (create MBA) | creation fee (DEA) + miner | becomes founder | **creation fee (DEA)** | — | yes |
| **Olympia listing** | listing fee (DEA) | — | **listing fee (DEA)** | — | — |
| **Olympic promotion** | shared `feeBps` (voluntary) | keeps the rest | — | the **shared %** → holders | — |
User-facing truth that sells: **redeem costs only the network; a swap costs a small,
visible, avoidable 0.5% plus (if set) the asset founder's disclosed fee — and nothing to a
counterparty, because there isn't one.**
---
## 4. Revenue surfaces, ranked by certainty
```
A. Service fee — 0.5% soft on mint/swaps (ADR-0017) ── live mechanism, direct line
B. Issuance enablement — wizard, DRA premium (ADR-0019) ── live tooling; supply-side WTP
C. Listing & promotion marketplace (ADR-0020/0021) ── the user's core plan (§5.C) — discovery, not function
D. White-label / co-brand (ADR-0021) ── gated (Labs step 4); highest ceiling
E. Cross-chain hub rail (Build B) ── sibling; top-of-funnel inflow
F. Adjacent — relay/indexer/API, BCMR registry, premium ── the hard/B2B moat (ADR-0020)
─────────────────────────────────────────────────────────────────────────────────────
TWO BUCKETS, do not conflate: A/B/C/D/F above are **PLATFORM revenue** (treasury).
Separately, **DEA-holder yield** = a slice of every MBA *mint* fee → flagship-DEA holders,
time-weighted (§5.F, ADR-0042) → the equilibrium flywheel. Listing/creation fees do NOT go
to holders; mint fees do.
```
The deep separation that must not blur (revenue-model.md): **Deviant/Potidaea PLATFORM
revenue = creation + Olympia listing fees (in DEA) + service fee + premium + B2B** → treasury.
The **DEA-holder yield is a separate bucket**: a slice of every MBA *mint* fee, distributed
pro-rata + time-weighted to flagship-DEA holders (§5.F, ADR-0042). The platform may also hold
DEA and earn that yield like anyone (no privileged skim, ADR-0008); the Olympic promotion
share (point §5.C) is the founder voluntarily directing more of their fee to holders.
---
## 5. The surfaces in detail
### 5.A — Protocol swap/mint fee (≈0.3–0.5%) — mostly the holder-yield engine
- **≈0.3–0.5%** appended on **every mint/swap** (any tier — Stoa/Olympia/unlisted; spec §4.4
reserves the output slot). Soft, from the user's **free BCH**, never skims the backing.
- **SPLIT (pinned): NOW 0.5% = 0.3% → DEA holders + 0.2% → platform** (direct, transitional);
**FUTURE: reduce to 0.3% = 100% → DEA holders** (platform 0 direct). The cut comes out of the
*platform's* slice — holders earn the **same absolute yield** (0.3% of volume) across it while
users pay less. So this fee is primarily the **DEA-holder yield (§5.F)**; the platform's
direct take is thin now and sunsets to zero. (Distribution pro-rata + time-weighted, ADR-0042.)
- Disclosed as a separate **raw-BCH** line, distinct from any founder `feeBps` (D-4).
- **Positioning (revenue-model.md §1):** 0.5% sits *above* Cauldron's all-in 0.3% — fully
defensible on **MBA swaps/mints** (no competitor, no slippage, oceanic depth). On
**Token-class** market swaps it would stack on Cauldron → **lighter-or-zero there**
(ADR-0020: external-DEX routing carries ~0% Potidaea retention — a funnel, not a tax).
- **Recommended posture:** **redeem waived · mint/swap 0.5% · genesis a flat creation fee.**
### 5.B — Issuance enablement (the founder funnel)
We don't earn the founder's `feeBps`, but we own the factory:
- **Genesis wizard** (Artemision) — a one-time **creation fee in DEA, FREE for the first 100
assets**, then `10·⌊A/100⌋` DEA: **10** for assets 100–200, **20** for 200–300, **30** for
300–400 … escalating but slow + low (Σ first 300 = 3,000 DEA). It rises gently with
ecosystem size but never enough to deter creation — the free Stoa swap economy *needs*
assets to exist. Far gentler than the Olympia curve (§5.C). Modeled in
`scripts/potidaea-economics-sim.mjs`.
- **Fee-schedule templates** as the differentiator: `DeviantOpenMintFT` (flat within a
`maxFeeRate` cap, ADR-0011), `DeviantDescFeeSupply` (de-escalating launch incentive,
ADR-0029), `DeviantEscFeeSupply` (escalating), `DeviantBondingCurveFT` (DRA curve).
- **DRA premium (ADR-0019):** gate the *tooling*, not the capability — a one-time BCH
unlock that also selects the DRA fee band (**pay more → 0.5%, pay less → 1%**;
quantity-based bundles, no subscriptions). This stream is **captured at setup, before
volume exists** — it de-risks the early, low-throughput years. (**DRA = Dynamic-Rate
Asset**, ADR-0015; its monotonic-up subset is the **ERA — Elevating Rate Asset**, ADR-0038.)
### 5.C — Listing & promotion marketplace (the user's core plan)
The clean monetization of **attention**, never function. Three rules up front:
1. **Swap is always free & permissionless for every asset — listed or not.** You pay for
*featured visibility*, never for the *right to swap*. An unlisted asset is fully swappable
**and still discoverable on the free Stoa list**; paying only buys *featured* placement on
Olympia.
2. **Safety is never for sale (ADR-0020).** Canonical/peg-verified status is mechanical,
free, and shown dominantly. Non-canonical assets are **risk-flagged and cannot be
promoted at all.** Potidaea **sells visibility, never truth** (ADR-0008).
3. **Listing ≠ promotion — two separate paid products.** *Listing* (paid in **DEA**) moves
an asset onto **Olympia** (the premium featured tab). *Promotion* (the **Olympic** ladder,
paid by sharing founder-fee) ranks an asset already on Olympia. You can list without
promoting; you cannot promote without listing.
**The escalation / rollout plan** — the free always-on **Stoa** list, plus the paid
**Olympia** tab that only *switches on* once the ecosystem reaches 100 created assets (both
live inside the **Metroon**, the full directory):
| | **Stoa** — free list (always on) | **Olympia** — competition list (paid, optional) |
|---|---|---|
| **< 100 assets created** | every canonical/peg-verified asset listed **free**; keeps going as-is | **does not exist yet** — there is no paid option |
| **≥ 100 assets created** | **unchanged** — still free, still all assets | **activates as an option**; pay (in flagship DEA) to be featured. **May stay empty** if nobody pays |
| **listing price over time** | n/a (free) | **always rising** — base grows with every asset *created* (100→200→300…) **even while the tab is empty**, *plus* a step per new *listing*, *plus* the velocity multiplier (below) |
| **promotion** (within Olympia) | n/a | optional **Olympic ladder** (Free→Bronze→Silver→Gold→Laurel) via founder-fee share — **separate** from the listing fee |
- **Stoa is the spine; Olympia is the upsell.** **Stoa** (free, permissionless, every
canonical asset) **keeps going as it is** and is where all assets are discoverable +
swappable at no cost. **Olympia** is a *separate, paid, optional* **featured placement**
that **does not exist until 100 assets have been created**; after that it is purely an
option and **may remain empty** indefinitely. Paying buys *featured placement* — never the
right to swap (rule 1), never a safety claim (rule 2).
> **Surface naming (resolved).** Inside **Agora** (the marketplace hub) the asset directory
> is the **Metroon** — the Athenian state *archive/registry* of all official records, apt for
> the full registry of every asset (**replaces "Spectrum"**, which is retired here so the
> sibling product **CoinSpectrum** is the sole owner of that name). Within the Metroon:
> **Stoa** = the free public colonnade (free list); **Olympia** = the Games sanctuary (the
> paid competition list, ranked by the **Olympic** ladder for the **Laurel** wreath, flagship
> DEA ≈ Δίας/Zeus presiding). **Olympus** stays the dApp *platform layer* — distinct. Five
> names, five distinct things: **Agora · Metroon · Stoa · Olympia · Olympus**.
- **The listing fee is paid in the flagship DEA — *Deviant Equilibrium Asset*** (Δίας/Zeus;
the named market-driver instance of the DEA = Direct Emission Asset class, §5.F). This is
deliberate: it routes listing demand into the flagship, and because the flagship is itself
acquired through Potidaea's own `BCH→DEA` claim, **every listing creates DEA buy-demand and
recycles into swap volume → more §5.A service fee.** The collected listing fees are
**PLATFORM revenue** (treasury) — **not** holder yield (that comes from *mint* fees, §5.F)
and **not** burned. A self-reinforcing loop, not a side fee.
- **Auto-escalating listing price — two upward drivers × a velocity multiplier.** The fee
rises with **both** ecosystem growth and listing demand, and accelerates during hype:
```
OlympiaFee(DEA) = ( cA·A + cL·L ) · M with cA = cL = 1 (PINNED)
A = total assets CREATED (monotonic; 100 → 200 → 300 …)
→ +1 DEA per asset; rises with EVERY new asset — even while Olympia is EMPTY.
Starting fee = 100 DEA (= 1·A at the activation point A=100).
L = number of Olympia listings → +1 DEA each (demand-driven, on top of A)
M = velocity multiplier, baseline ×1.0, UNCAPPED:
· +0.1 per listing — NO peak; lister pays the POST-bump rate
→ 1st ×1.1, 2nd ×1.2 … 5th ×1.5 … 10th ×2.0 …
· cooldown = SEQUENTIAL, DWELL ∝ HEIGHT: removing the notch at height h takes
cph·h BLOCKS, cph = 144 (= 1 day; BCH 10-min blocks → 24h·60/10 = 144/day),
top-first, each waiting for the previous → the TOP lingers longest (slow at the
top); a small burst clears fast. Block-denominated on-chain (CLTV), so exact.
5-burst → ×1.5, holds 720 blocks (~5 days), full relax 2160 (~15 days);
10-burst → ×2.0, holds 1440 (~10 days), full relax 7920 (~55 days) (= cph·Σh).
```
**Stoa stays FREE** — no listing fee, ever (the open colonnade; free swap + free discovery).
Only Olympia, the spotlight, is paid.
Two ratchets, one accelerator: **creation** (`A`, every genesis, +1 DEA) and **listing
demand** (`L`, every Olympia listing, +1 DEA), multiplied by the uncapped `M`. The base
climbs with creations **whether or not anyone ever pays** — so by the time Olympia activates
at `A = 100` the price already reflects a 100-asset ecosystem (100 DEA), and it keeps rising
as `A → 200 → 300`. `M` has **no peak** — a longer burst just climbs higher (×2.0 at the
10th, ×3.0 at the 20th). Afterwards `M` decays, but **dwell scales with height** (sequential,
cph = 144 blocks ≈ 1 day): the highest notch holds the *longest* and lower notches clear
faster (slow at the top), while a one-listing blip at ×1.1 clears in ~144 blocks (~1 day).
Congestion pricing, **not** a permanent ratchet (`A`/`L` only ever rise; only `M` decays).
*Pinned + the contract-enforced-in-DEA path is built & validated
(`contracts/PotidaeaOlympiaListing.cash`, `scripts/potidaea-economics-sim.mjs`).*
- **The Olympic 5-tier promotion ladder.** Once listed (paid), a founder competes for top
placement by **voluntarily sharing a % of their founder fee** — higher share → higher
tier → higher placement. This is the **visibility arm of the voluntary marketing
revenue-share** (ADR-0021); lineage in `BCH_Token_Economy_Concept_Paper.md` §5.3 ("tiers
from basic listing to flagship"), `wiki/revenue-model.md` §2, and the public copy
(`Parameters/webfiles/deviant_website.html`).
| Tier | Olympic name | Founder-fee share | Maps to (revenue-model.md §2) |
|---|---|---|---|
| 1 (base) | **Free** | 0% — listed, unpromoted | base listing |
| 2 | **Bronze** | 10% | Starter |
| 3 | **Silver** | 15% | Plus |
| 4 | **Gold** | 20% | Pro |
| 5 (top) | **Laurel** 🏛 | 25% (hard cap 50%) | Elite |
**Laurel** = the laurel crown (Greek **στέφανος / *stephanos***) — the flagship slot.
Keep three names distinct: **Olympia** (the premium tab) · **Olympic** (the ladder *within*
it) · **Olympus** (the dApp *platform* layer) — do not merge the words.
- **Immutability fit (ADR-0021):** the tier/share at the time of each referred genesis is
**baked permanently into that token**; climbing changes the % on *future* placements only
— not farmable, redistributes an existing fee, self-referral is an economic no-op.
### 5.D — (folded into 5.C) — *see the tier ladder above.*
### 5.E — White-label / co-brand + cross-chain inflow
- **White-label (Labs step 4, gated):** license safety-verified swap rails to partner
surfaces with **"Powered by / Verified by Potidaea"** co-brand (keep the safety wedge
visible downstream). Revenue via the same on-chain ADR-0021 split — paid for *your
covenant*, not for brokering a trade. Highest ceiling because it rides volume our own
distribution never touches.
- **Cross-chain hub (Build B, sibling — both flavors validated):** external value arrives
(lock→mint τUSDt, or atomic-swap into USDm) → trades natively → that native trade is
largely Potidaea's MBA swaps. The bridge rail may carry its own fee, but model it as
**customer-acquisition for Potidaea.** Keep the brand wall — don't stretch "Potidaea"
across the cross-chain rail.
### 5.F — The flagship DEA (Deviant Equilibrium Asset): value-accrual + the ecosystem flywheel (ADR-0042)
**Terminology (glossary-authoritative).** **DEA = *Direct Emission Asset*** — the asset
*class* (permissionless-mint, fixed-rate, **unlimited-supply**, BCH-backed; used everywhere
in the taxonomy). **"Deviant Equilibrium Asset"** is **not** the class — it is the project's
**flagship DEA instance**, a specific named asset (Δίας/Zeus resonance) launched and traded
in Agora. It is the ecosystem's **market driver** and **value-accrual asset**, and the
currency the listing fee is paid in. Below, "the flagship DEA" = Deviant Equilibrium Asset;
"DEA-class" = any Direct Emission Asset.
This flagship is the spine that ties the whole model together — and the reason it is named
***Equilibrium***: it distributes one asset's success across the entire ecosystem (the
flywheel at the end of this section).
- **No burn — burning is a non-mechanic for MBAs.** An MBA's supply is already bounded by
its **backing**: every unit is 1:1 BCH-backed, so MBAs inherit the scarcity of the
protocol's unit-of-account / unit-of-exchange (satoshis). Burning only matters for
*unbacked* tokens an issuer prints from nothing (flood supply) — for a backed MBA there is
no oversupply to correct. **Value accrues through fee distribution, not supply reduction.**
- **The holder yield = the DEA-holder share of the protocol swap/mint fee.** Every mint/swap
(any tier — Stoa/Olympia/unlisted) charges the fee; holders get **0.3% of the 0.5% NOW**
(→ **100% of a reduced 0.3% LATER**), pro-rata + time-weighted via a consensus-locked
**DistributionCovenant** (ADR-0042: pool-free, flash mint→redeem captures nothing). Because
it's on *all* swap volume, holders earn a cut of the whole ecosystem's throughput. **The
asset's own founder `feeBps` (0–5%) is separate — the founder's income. Creation + Olympia
listing fees are PLATFORM revenue — not the holder yield.**
- **Time-weighted, no claimable pool (anti-drain).** Fees are attributed to whoever holds
*at the moment each fee occurs*, weighted by time held — you earn by **holding while
activity happens**, and a flash mint→redeem captures nothing (it actually *donates* to
existing holders). No drainable pot; in-flight amounts are earmarked per holder
(ADR-0042 Decisions 5–7).
- **Dilution is irrelevant** (DEA = unlimited, permissionless mint): new units can't dilute
the **floor** (you can't print backing); unlimited mint dilutes only *yield*, which
self-corrects because fee revenue scales with activity ≈ `activity ÷ supply`
(ADR-0042 Decision 3).
- **How Potidaea/Deviant earns — and is an active DEA holder.** *Revenue:* creation + Olympia
listing fees (in DEA — it **accumulates DEA**) + a **transitional 0.2% direct slice** of the
swap fee (sunsets when the fee → 0.3%/100%-to-holders) + premium + B2B. **It holds the DEA it
accumulates and earns the swap-fee yield pro-rata on that balance**, like any holder (share =
platformDEA ÷ total DEA; no privileged skim, ADR-0008). **End state:** it earns from swaps
*only* as a holder — fully aligned. Creation/listing fees do **not** route to the holder
distribution; the swap-fee share does.
- **The equilibrium flywheel — why one asset's success lifts the whole ecosystem.** The
flagship DEA is the **market driver** by construction:
1. Any MBA's **mint** activity → a fee slice → **flagship-DEA holders** (yield, above).
2. Earning accrues *only to holders* → **demand for the flagship DEA rises**.
3. Creation + listing fees are **paid in flagship DEA** → **additional, structural buy-demand**
(founders must acquire DEA; that DEA is platform treasury, not redistributed).
4. Flagship DEA is **BCH-backed**, so buying it **locks more BCH** → direct **BCH price
support** (more backing demand, less float).
5. Because *any* asset's success feeds the flagship, **success is redistributed across the
whole ecosystem** — not captured by one token. **BCH and every MBA's value rise
together.** That shared-success balancing is the literal meaning of *Equilibrium*.
> Net: there is no zero-sum "this coin wins, that coin loses." A single asset's adoption
> pulls the common floor (BCH) and the common driver (flagship DEA) up for everyone — the
> opposite of a memecoin economy where attention is extracted pair-by-pair.
- **Status:** the payout covenant is **built + validated** — `contracts/DistributionVault.cash`
(oracle-attested `(recipient, amount)` via `checkDataSig`, replay-bound to the vault outpoint,
operator can't redirect; 4/4 local VM tests, `test/regtest/distribution-vault.test.ts`).
Remaining: the **collection-side** `shareholderBps` split from each mint/swap (chipnet
prototype pending) and a Merkle-snapshot liveness backstop. Throughput note: payouts are
sequential per vault outpoint — parallelize with K vaults if holder count warrants (see the
contention/sharding design in ADR-0048).
---
## 6. Liquidity — "deep as an ocean" (this corrects rev. 1)
**MBA↔MBA depth is guaranteed from the very first moment. There is no bootstrap, no
cold-start, no thin pairs, no treasury seeding, and no capped swap size.** Rev. 1 of this
doc wrongly hedged about "seeding thin pairs" — that framing is **wrong** for the canonical
elastic MBA and is retracted here.
**Why the depth is oceanic — the mechanism:**
- Every MBA unit is backed **1:1 by exactly the BCH that minted it.** Minting is **elastic**
— it scales *immediately and automatically* with incoming BCH. There is no pre-funded pool
to draw down; **the backing mechanism *is* the liquidity.**
- Bring **1000 BCH** → claim **exactly 1000 BCH** of *any* MBA (minted fresh against your
BCH). Redeem it → 1000 BCH back. Swap MBA-a → MBA-b = `redeem(a) + claim(b)` = your same
BCH re-expressed as MBA-b — **regardless of how much MBA-b existed before, or who minted
it.** A brand-new MBA with zero prior supply has full depth on day one.
- So the available depth of *any* pair equals **the BCH the user brings**, drawn from the
entire BCH base. That is the literal meaning of **Potidaea / "deep as an ocean"** (§0):
the liquidity floor is the whole ocean of BCH, not a puddle an LP deposited.
**Contrast with an AMM:** an AMM's depth is whatever LPs funded — finite, drains, slips,
and suffers cold-start on every new pair. Potidaea's depth is the backing itself —
**regenerating, never-slipping, infinite from moment zero, zero LP, zero bootstrap.** The
cold-start problem that kills most DEX listings simply does not exist here. **This is a
headline GTM asset, not a footnote.**
**The one narrow exception** (does not touch the brand promise): a deliberately
**fixed/managed-supply** variant (non-elastic, pre-parked reservoir) can show "temporarily
unavailable" if its finite supply is exhausted — a *quantity* cap, never a rate/slippage
effect (ADR-0007). The canonical open-mint MBA family — and everything Potidaea markets — is
elastic and therefore unbounded. **Operator/treasury capital is never required to provide
depth.**
---
## 7. Go-to-market
**Positioning:** *"The exchange function without the market — swap your money at a
consensus-guaranteed rate, zero slippage at any size, no counterparty, no spread, and depth
as deep as the ocean."* Say "not a P2P exchange," never "not an exchange."
**Two structural headlines (lead with these):**
1. **Oceanic, zero-cold-start liquidity (§6).** Day-one full depth on any MBA — the thing
no AMM/CLOB can offer.
2. **Frozen rate at any size (§1).** The live demo (`design/mockups/bch-dex.html` — enter
`1` then `1,000,000`, watch the rate not move) is our most persuasive artifact.
Demonstration over argument.
**Wedge:** **safety-verified BCH swap gateway** — D-1/D-2 verification ("canonical /
underbacked") that no competing wallet surfaces. It makes any curated list a *safety*
feature, not gatekeeping, and it is what white-label partners pay for.
**Sequence:** (1) **issuers** — wizard + DRA premium (no assets → no swaps); (2)
**swappers** — the swap UX, led by the two headlines + free redeem, all on the free canonical
list; (3) at **100 created assets the premium tab switches on** → optional paid listing +
Olympic promotion (§5.C); (4) **white-label** partners (gated, R-016); (5) **cross-chain
inflow** to fill the funnel.
**The real constraint is distribution** (the service fee is avoidable, so direct revenue
tracks *our* surface's volume) — which is exactly why §5.C listing/promotion and §5.E
white-label matter: they monetize attention and ride volume we don't custody.
---
## 8. Risk & regulatory posture
- **Service-market, not broker.** Paid for *constructing a tx* (§5.A), *featuring an asset*
(§5.C), and *our covenant* (§5.E) — not for matching buyers/sellers (there are none). The
counterparty-free structure is itself the strongest non-exchange argument.
- **Avoidability is the shield.** Soft, disclosed, opt-out-able fees can't be a rent
authority. **Reject any consensus-baked unavoidable platform fee** — it creates the
ADR-0008 rent-authority and forks the canonical bytecode (the verification wedge). The
ADR-0021 promo split *is* on-chain, but it is the founder's **voluntary** share for
placement, a different object from a captive tax.
- **No skimming the backing.** Every fee from free BCH; the peg is sacrosanct.
- **Sells visibility, never truth.** Safety status is always shown truthfully and
dominantly; promotion never overrides or implies it (ADR-0020).
- **Liquidity is not a risk surface** (§6) — there is no LP capital to lose, no pool to
drain, no impermanent loss, no bootstrap exposure.
- **No deflationary/burn games** (§5.F) — MBA supply is backing-bounded (it inherits the
protocol UoA/UoE scarcity), so there is no oversupply to burn away. Value accrues via the
ADR-0042 fee distribution, which is time-weighted and pool-free (drain-resistant by
construction).
- **Cross-chain trust caveat stays on the sibling** (Build B Flavor 2 = trust-minimized);
Potidaea itself is trustless to the extent BCH consensus is.
---
## 9. Illustrative unit economics (directional, not a forecast — see revenue-model.md)
Posture: **redeem waived · 0.5% on mint/swap · flat genesis fee · listing/promotion per §5.C.**
**PLATFORM revenue (treasury):**
| Driver | Example | Platform take |
|---|---|---|
| Service fee (§5.A) | $100M/yr swap/mint throughput | ~$500K @0.5% |
| | $1B/yr (SmartBCH-peak-class) | ~$5M |
| DRA premium (§5.B) | 500 paying founders/yr × $50 unlock | ~$25K (captured pre-volume) |
| Creation (§5.B) | free first 100, then `10·⌊A/100⌋` DEA | grows in DEA with ecosystem size |
| Olympia listing (§5.C) | `(A+L)·M` in DEA; 300 assets + 30 listings ≈ 9,045 DEA total w/ creation | **platform treasury** (in DEA) |
| White-label (§5.E) | per active partner × ADR-0021 split | volume we don't custody |
**DEA-holder yield (separate — NOT platform revenue):**
| Source | Example (1,500 BCH/mo volume) | To holders |
|---|---|---|
| **Swap-fee share — NOW** (0.3% of the 0.5%, every tier) | 1,500 × 0.3% | ≈ **4.5 BCH/mo (54 BCH/yr)** |
| **Swap-fee share — FUTURE** (100% of a reduced 0.3%) | 1,500 × 0.3% | ≈ 4.5 BCH/mo (same; platform's 0.2% slice → 0) |
| Olympic promotion (§5.C) | founders sharing 10–25% of their `feeBps` | voluntary top-up to holders |
| *(platform direct slice, for contrast — NOW only)* | 1,500 × 0.2% | 3 BCH/mo → platform (sunsets) |
Shape to internalize: **§5.A is linear in *our* surface's volume and capped by
avoidability; §5.B captures supply-side willingness-to-pay before volume arrives; §5.C and
§5.E are the leverage** — multiply assets, attention, and surfaces. Honest caveat
(revenue-model.md): current BCH token-DeFi TAM is ~$27M/yr and cyclical; even capturing all
of it at 0.5% is ~$150K/yr. **The case is an adoption bet, not a pricing bet** — the
ceiling-break is the use-case platform (real-economy GMV), not the token TAM.
---
## 10. Open decisions (for the ADR / follow-up)
1. ~~Lock the Potidaea brand in `wiki/decisions.md`.~~ **Done — ADR-0048.** Remaining: fold
this doc into / cross-link with `wiki/revenue-model.md` so there is one canonical model.
2. **Premium-tab activation gate** — confirm **100 *created* assets** is the hard trigger
that switches the paid tab on; define what counts toward it (canonical assets only? all
created?) and whether the gate is one-way (irreversible once crossed).
3. **Fee constants — PINNED** (this turn, §5.B/§5.C): creation **free first 100** then
`10·⌊A/100⌋` DEA; Olympia `(1·A + 1·L)·M`, start 100 DEA, **uncapped** `M` (+0.1/listing);
**sequential** cooldown, dwell ∝ height, **cph = 144 blocks** (= 1 day); **Stoa free**. Creation + Olympia
fees → **platform treasury** (NOT holder yield — that's the MBA *mint*-fee share, §5.F), not
burned. **Contract-enforced-in-DEA path built & validated** —
`contracts/PotidaeaOlympiaListing.cash` + `test/regtest/potidaea-olympia.test.ts` (4/4 on
the local VM); `scripts/potidaea-economics-sim.mjs` for the economics.
4. **Holder yield — RESOLVED.** Baseline = the **majority of the 0.3–0.5% protocol swap/mint
fee** on **every** mint/swap (any tier) → flagship-DEA holders (0.4 of 0.5%, or 0.2 of
0.3%); platform keeps the minority. The **Olympic ladder** (Free→Bronze→Silver→Gold→Laurel,
0/10/15/20/25%) is a **voluntary top-up**: a founder additionally directs part of their *own*
`feeBps` to holders to climb the placement ranking. Both → DEA holders, distinct sources.
5. **Surface naming + sweep — DONE.** Inside **Agora**: **Metroon** (full directory, replaced
"Spectrum" → sole-owned by **CoinSpectrum**), **Stoa** (free list), **Olympia** (paid,
ranked by the **Olympic** ladder), **Olympus** (dApp platform layer). The "Spectrum"→
"Metroon" rename of live shells/mockups/spec is complete (snapshots, KnowledgeBase,
CoinSpectrum untouched).
6. **Fee posture** — confirm "redeem waived / mint+swap 0.5% / genesis flat / Token-class
~0%" as the shipping default.
7. **Creation & listing fees** — flat sats vs % of seed; whether each is itself avoidable.
---
## Cross-references
- Project-wide model: [`wiki/revenue-model.md`](../../wiki/revenue-model.md) ·
[use-case-platform](../../wiki/use-case-platform.md) (the ceiling-break)
- Brand: [potidaea_orbital-swap-asm.md](../OrbitalSwapASM_Potidaea/potidaea_orbital-swap-asm.md)
· Mechanism: [ASM · Orbital Swap](ASM-Orbital-Swap-native.md) · Taxonomy: [README](README.md)
- Sibling rail: [Build B scope](build-B_usdt-tron-bch_scope.md) · [concept & state](cross-chain-asm-concept-and-state.md)
- Lineage of the rev-share/tier model: `BCH_Token_Economy_Concept_Paper.md` §5.3–5.4
- ADRs: **0007** (reservoir/elastic mint) · **0008** (verifier-not-authority) · **0011**
(two-knob fee) · **0015** (DRA) · **0016** (route by asset class) · **0017** (0.5% service
fee) · **0019** (premium tier) · **0020** (promoted/standard/risk-flagged registry + B2B +
external-DEX 0%) · **0021** (voluntary referral/promotion revenue-share) · **0029**
(supply-stepped fee schedules) · **0042** (MBA shares — fee revenue-share to DEA holders,
the value-accrual spine; full record `Parameters/MBA_Shares/`)
- Memory: [[deviant-labs-initiative]] · [[exchange-is-a-function-not-p2p-venue]] ·
[[bch-interchain-settlement-hub]] · [[demonstration-over-reasoning-captured-audience]]

View file

@ -127,6 +127,8 @@ Athenae is not a metaphor borrowed from Athens; it is the Athenian apparatus reb
**The improvement.** Athens lost citizens to forged rolls, bought juries, and demagogues who escaped *euthynai*. Athenae closes each of those: the *Katalogos* cannot be padded (covenant‑enforced), the *Kleroterion* draws after the votes are committed (no jury can be pre‑bought), and *Euthynai* is automatic and unforgettable. `StakeVoteTote` has no Athenian analogue because Athens was one‑citizen‑one‑vote — and **that remains the default here**. Stake‑weighting is an **option the protocol makes available but never enforces**: a creator may choose it for treasury or shareholder votes where weight by stake is the honest model, but every poll defaults to 1p1v unless explicitly created otherwise.
> **Reserved candidate name — *Ephebia* (ἐφηβεία).** Not yet a covenant; reserved here for a future **probationary / onboarding stage** of citizenship. In Athens an applicant passed *Dokimasia* (scrutiny) → served as an **ephebe** (the 18–20 civic‑military training cohort, on the threshold of full rights) → was enrolled (*Engraphe*) into the *Katalogos*. So **Ephebia** is the natural name for a *not‑yet‑full* member state — e.g. a vouched‑but‑probationary citizen who can be present/observe but does not yet hold the full vote, or a time/track‑gated step inside the `ApplicationCovenant` (A→E) admission machine between *Dokimasia* and full enrolment. **Provenance:** the name surfaced in the Potidaea business‑model session (ADR‑0048) as a candidate for the asset‑marketplace's free list; it was **rejected there** (that belongs to the commercial register — the free list is *Stoa*) and **reserved for Athenae**, where it fits the citizenship register exactly. Decide its concrete binding (probationary‑member token vs. an admission sub‑stage) if/when the onboarding flow is designed.
---
## 6. The Covenant Stack (implementation) — Phases A–F + EndorsementCovenant ✅ on chipnet

View file

@ -0,0 +1,400 @@
# Bitcoin Cash Existing Ecosystem — reference catalogue
_A snapshot of the BCH ecosystem as it touches the Deviant project: wallets,
nodes, dApps, dev tooling, NFT infrastructure, explorers, merchant rails, and
the connector protocols that bind them. Compiled 2026-06-29 from primary
sources (project repos, official docs, github topic indexes,
research fetches during this session); curated for what's load-bearing for
Deviant — what the wallet can connect to, what dApps may want to integrate, what
standards to follow._
**This is NOT a blessed list (ADR-0008).** Deviant ships no canonical registry
of dApps; the wallet's Apps tab is user-extensible (`shells/app/src/screens/
AppsScreen.tsx` Discover panel). This document is internal reference material,
not a curated endorsement.
**Live source pages (kept here so cataloguers can refresh):**
- <https://1kbch.com/ecosystem/> — community-curated, voting-based directory
- <https://tokenaut.cash/dapps> — CashTokens-era dApp index
- <https://cashonize.com/> — flagship modern BCH wallet
- <https://app.bch.guru/> — BCH Guru prediction game
- <https://paryonusd.com/> — ParyonUSD stablecoin
- <https://docs.riftenlabs.com/wizardconnect/> — WizardConnect protocol docs
- <https://github.com/topics/cashtokens> — github topic index (CashTokens projects)
- <https://cashtokens.org/> — CashTokens spec & docs (community)
- <https://anyhedge.com/> — AnyHedge synthetic-derivative protocol
- <https://flipstarter.cash/> — Assurance-contract crowdfunding
---
## 1. Wallets
### Cashonize ★ (flagship modern wallet — Deviant pairs with this in test)
- URL: <https://cashonize.com/> · Repo: <https://github.com/cashonize/cashonize-wallet>
- **Builder:** mainnet-cash / Cashonize team
- **Stack:** Vue + TypeScript + Quasar framework; minimal deps; open-source non-custodial
- **Platforms:** Web wallet, installable PWA, Windows + Linux desktop, Android APK
- **Connectors:** **WalletConnect v2** + **CashConnect** — speaks both (native CashConnect implementation reference is `cash-connect.js` in the `cashonize/legacy-wallet` repo)
- **CashTokens:** full FT + NFT support, BCMR metadata parsing, "parsable NFTs"
- **BCH features:** sweep paper wallets / private keys; token search & hide; no multisig or hardware-wallet support per current README; no PIN/password lock per docs
- **Used by Deviant:** the on-device validation target for both WC (snapshots 0055, 0057) and the (pending) CashConnect handshake
### Paytaca
- URL: <https://paytaca.com/> · Repo: <https://github.com/paytaca/paytaca-app>
- **Builder:** Paytaca team (commercial + open-source)
- **Stack:** Quasar + Capacitor (Vue) — similar tech to Cashonize
- **Platforms:** Web, Android, iOS, browser extension
- **Connectors:** WalletConnect v2 + **WizardConnect** (per the CashScript guide, Paytaca is the reference wallet honouring the `inputPaths` HD-key-mapping extension on top of standard WC2 tx objects); the Paytaca extension supports parallel broadcast via watchtower
- **CashTokens / BCMR:** yes; Paytaca runs the **BCMR indexer** (`paytaca/bcmr-indexer`) used by other projects
- **POS:** Paytaca POS (web register for shops) — separate product, same brand
- **Notes:** the main alternative to Cashonize for end-users; WizardConnect support is the primary reason the Deviant project tracks this
### Electron Cash (and Electron Cash SLP)
- URL: <https://electroncash.org/> · Repo: <https://github.com/Electron-Cash/Electron-Cash>
- **Builder:** Calin Culianu / community fork of Electrum
- **Stack:** Python (Qt desktop) + Android (Java fork)
- **Platforms:** Linux, macOS, Windows, Android
- **Connectors:** none of the modern BCH dApp connectors today (no WC2 / CashConnect / WizardConnect support last surveyed) — it's a "hold and send" wallet, not a dApp client
- **BCH features:** hardware-wallet support (Ledger, Trezor, KeepKey), multisig, **CashFusion** (privacy coin-join — Electron Cash is the canonical CashFusion implementation), SLP token support (pre-CashTokens)
- **Notes:** the dominant pre-CashTokens wallet; still widely used for cold storage, multisig, and CashFusion; the project Deviant interoperates with for **CashFusion** in particular
### Bitcoin.com Wallet
- URL: <https://www.bitcoin.com/wallet/>
- **Builder:** Bitcoin.com (commercial)
- **Stack:** React Native + native bridges
- **Platforms:** iOS, Android, browser extension
- **Connectors:** WalletConnect v1/v2 for BCH and other chains
- **CashTokens:** limited / unclear from public materials
- **Notes:** large user base by virtue of brand; tends to lag on protocol features
### Other wallets in the ecosystem (less central)
- **Edge** (multi-chain mobile) — supports BCH; WC support TBD
- **Coinbase Wallet, Trust Wallet** — WC support; BCH-CashTokens features limited or absent
- **Cashstack / community forks** — assorted
---
## 2. Connector protocols (deep-dive lives in `../WalletConnect/`)
| Protocol | Docs in repo | Status |
|---|---|---|
| **WalletConnect v2 (`wc2-bch-bcr`)** | [`../WalletConnect/WalletConnect_integration_reference.md`](../WalletConnect/WalletConnect_integration_reference.md) | Deviant implements both sides; on-device validated (0057) |
| **CashRPC / CashConnect** — CashRPC is the transport-agnostic protocol; CashConnect is CashRPC + WC2 | [`../WalletConnect/CashConnect_reference.md`](../WalletConnect/CashConnect_reference.md) | BCH community (@jimtendo, Dec 2023 BCR proposal). Deviant implements multi-protocol responder; covenant-co-sign blocked at V0 → routes through WC. **v-next `bch_executeAction` design fixes this** |
| **WizardConnect** — HD-aware signing extension | [`../WalletConnect/WizardConnect_reference.md`](../WalletConnect/WizardConnect_reference.md) | Riften Labs. **Two layers**: shipping = WC2 extension with `inputPaths` (Paytaca); vision = Nostr NIP-17 stack. **Deferred**; build Path A when a wallet-required dApp uses `inputPaths` |
See the folder-level README in `../WalletConnect/` for the full four-column
comparison table (WC / CC / WizardConnect-shipping / WizardConnect-vision).
---
## 3. dApps — DeFi & exchange
### Cauldron
- URL: <https://cauldron.quest/> · Docs: <https://docs.riftenlabs.com/cauldron/> · Builder: Riften Labs
- **Type:** *"A decentralized exchange on Bitcoin Cash that lets users swap
and provide liquidity to native cash tokens"* (per docs). On-chain
UTXO-based (the AMM math is in the locking script of pool UTXOs — not an
off-chain orderbook).
- **BCH primitives:** CashScript covenants on CashTokens
- **Connectors (verified from docs 2026-07-04 — previous version of this
entry was wrong):** **WizardConnect (recommended) + WalletConnect
(fallback) + Cauldron's own browser wallet.** Cauldron does NOT support
CashConnect — the docs list only these three options. From the docs:
*"any wallet that supports Bitcoin Cash and WalletConnect. If your wallet
supports WizardConnect, that is the recommended option."*
- **Wallets that pair with it today:** Cashonize (WalletConnect only —
Cashonize's CashConnect support goes unused for Cauldron), Paytaca
(both WC + WizardConnect), Selene (WizardConnect)
- **Cauldron's own wallet uses BIP44** `m/44'/145'/0'` with separate child
chains for receive/change/DeFi addresses — matching the WizardConnect
standard exactly (SLIP-0044 branches 0/1/7).
- **Library reuse:** `@cashlab/cauldron` is the canonical SDK
- **Why it matters for Deviant:** Cauldron is a strong live example of the
WizardConnect-primary pattern the Deviant Task 3 adapter will interop
with. If Deviant ships a WizardConnect adapter, Cauldron is the highest-
signal test target.
### BCH Bull
- URL: <https://bchbull.com/> · Repo: not currently surveyed
- **Type:** Hedge & leverage product built on **AnyHedge**
- **BCH primitives:** AnyHedge contracts (mutual settlement at a future price between hedger and counterparty)
- **Connectors:** WalletConnect v2 (web); listed as `Web` in the Deviant Discover panel
- **Notable:** one of the production AnyHedge consumer products
### AnyHedge protocol
- URL: <https://anyhedge.com/> · Built by General Protocols
- **Type:** Synthetic-derivative protocol — fix a BCH-denominated value against a price oracle for a duration
- **BCH primitives:** CashScript covenant; oracle attestations via `checkDataSig`
- **Notable:** the underlying contract family that BCH Bull and other hedging products sit on; chipnet-validated reference implementation in the Deviant `Path C` track (snapshots 0097, 0098)
### jedex — Joint-Execution Decentralized Exchange
- Repo: <https://github.com/bitjson/jedex> · Builder: Jason Dreyzehner (bitjson)
- **Type:** Technical demonstration of an on-chain DEX
- **BCH primitives:** CashScript covenants; CashTokens
- **Notable:** primarily an educational reference for what's possible
### ParyonUSD
- URL: <https://paryonusd.com/> · App: <https://paryonusd.com/app>
- **Type:** Stablecoin (per project name)
- **Connectors:** **WizardConnect** — the named precedent that motivates the
WizardConnect adapter. Per the CashScript guide, WizardConnect at the shipping
layer is a WC2 extension attaching `inputPaths` HD-key metadata to
`WcTransactionObject`s; ParyonUSD is the reference dApp using it in production.
- **Notable:** the concrete reason WizardConnect support in the Deviant wallet
is worth building (Path A per `../WalletConnect/WizardConnect_reference.md` §4)
---
## 4. dApps — Prediction & games
### BCH Guru
- URL: <https://app.bch.guru/> · Docs: <https://docs.bch.guru>
- **Type:** Price-prediction game; player makes predictions about future BCH price, mints/buys NFT positions. Game state managed by a CashScript covenant.
- **BCH primitives:** CashTokens NFTs; oracle attestations; game covenant
- **Connectors:** **CashConnect (only).** Direct quote from their docs:
*"BCH Guru uses the CashConnect protocol to connect the application to
compatible CashToken wallets. Currently, Cashonize is the only
compatible wallet with CashTokens."* Users pair by *"copying the
CashConnect URL or simply opening Cashonize via the link. Thereafter,
sign to approve the session and return to the BCH Guru application."*
- **Proof-by-existence for CC-over-covenants:** BCH Guru is a live
production dApp using CashConnect V0 with a CashScript covenant. This
disproves the previous "CC V0 cannot carry covenants" claim in
`../WalletConnect/CashConnect_reference.md` — the fix is in the doc
(§6, corrected 2026-07-04): CC V0 CAN carry covenants when the dApp
ships its libauth `WalletTemplate` at session proposal AND the
covenant is position-tolerant to wallet-added inputs/change.
- **Notable:** the flagship CC-only dApp; validates the CC integration
target for Deviant.
### BCHess
- Repo: <https://github.com/SayoshiNakamario/BCHess>
- **Type:** On-chain chess
- **BCH primitives:** CashScript game-state covenants
- **Notable:** novel use-case; small audience
### CashStarter
- Repo: <https://github.com/SayoshiNakamario/CashStarter>
- **Type:** Crowdfunding (assurance-contract derivative); BCH-native
- **Notable:** complements Flipstarter
---
## 5. dApps — Crowdfunding
### Flipstarter
- URL: <https://flipstarter.cash/>
- **Type:** **Assurance-contract crowdfunding** — Kickstarter mechanic using BCH's all-or-nothing pledge tx (each backer signs a `SIGHASH_ALL | SIGHASH_ANYONECANPAY` input, all inputs are atomically combined on success)
- **BCH primitives:** native sighash mechanics — no covenant required
- **Notable:** the canonical BCH crowdfunding tool, predates CashTokens
---
## 6. NFT infrastructure
### Cash-Ninjas
- Repo: <https://github.com/cashninjas> · Site: <https://ninjas.cash>
- **Type:** Flagship CashTokens NFT collection + minting infra
- **Components:**
- `cashninjas/minting-contract` — multi-threaded minting CashScript contract
- `cashninjas/ninjas.cash` — frontend / minting website
- `cashninjas/cashninjas-api-server` — API server for the mint
- `cashninjas/shinobi-art-engine` — image-layer NFT collection generator
- **Notable:** the most-cited reference implementation for CashTokens NFT minting
### Tapswap / NFT marketplaces
- Various early-stage CashTokens NFT marketplaces; ecosystem still consolidating
### OpenTokenRegistry (OTR)
- URL: <https://otr.cash> · Repo: <https://github.com/OpenTokenRegistry/otr.cash>
- **Type:** Open volunteer-maintained registry of BCH tokens (NFT collections, FTs)
- **Notable:** a non-blessed registry — adopts ADR-0008's posture by community default
---
## 7. Dev tools
### CashScript
- URL: <https://cashscript.org/> · Repo: <https://github.com/CashScript/cashscript>
- **What:** High-level language compiling to BCH Script bytecode for covenants
- **License:** MIT
- **Notable:** the canonical toolchain for every CashTokens-era dApp; Deviant depends on it
### libauth (`@bitauth/libauth`)
- Repo: <https://github.com/bitauth/libauth> · Builder: Jason Dreyzehner
- **What:** Pure-JS BCH primitives — secp256k1, transaction encoding, signing serialization, VM, templates
- **License:** MIT
- **Notable:** Deviant uses it via the noble crypto shim (ADR-0023); the responder's `signer.ts` is libauth-based
### mainnet-js
- Repo: <https://github.com/mainnet-cash/mainnet.js>
- **What:** TypeScript SDK for BCH wallets — `TestNetWallet`, Electrum network provider, send/broadcast helpers
- **Notable:** Deviant chipnet scripts use it; the Cashonize team is the primary maintainer
### Chaingraph
- URL: <https://chaingraph.cash> · Repo (TS client): <https://github.com/mr-zwets/chaingraph-ts>
- **What:** GraphQL-style indexer on top of BCH node data; powerful queries for token holders, tx history, etc.
- **License:** server is BCHN-derived; clients vary
- **Notable:** the high-level indexer most modern dApps use instead of raw Electrum
### Fulcrum
- Repo: <https://github.com/cculianu/Fulcrum>
- **What:** Fast Electrum protocol server; reads from a BCH node
- **License:** GPL-3
- **Notable:** the production-grade Electrum backend; underpins most wallet network access (Cashonize, Electron Cash, etc.)
### BCMR (Bitcoin Cash Metadata Registry)
- Spec / community: <https://cashtokens.org/docs/bcmr/chip/>
- **Tooling:**
- `paytaca/bcmr-indexer` — Python indexer + validator
- `mr-zwets/bcmr-generator` — UI form → BCMR JSON
- `mr-zwets/AuthUpdate` — TS program to update BCMR metadata on-chain
- **What:** the metadata registry standard for CashTokens (token name, ticker, icon, decimals, NFT attributes)
- **Notable:** the BCH equivalent of EIP-721 metadata. Deviant depends on this for token display.
### Other dev tools
- `mr-zwets/airdrop-tool` — CashTokens airdrop tool
- `mr-zwets/token-explorer` — BCMR token explorer (TS)
- `bitjson/chip-paypro` — Standard for BCH + CashToken payment URIs (parsed by Deviant's `qr-uri.ts`)
- `mrtnetwork/bitcoin_base` — Dart multi-chain library (covers BCH too)
---
## 8. Explorers
### Token explorer (BCMR-aware)
- URL: <https://tokenexplorer.cash> · Repo: `mr-zwets/token-explorer`
- **Notable:** the most modern CashTokens-aware explorer
### BCH Explorer
- URL: <https://bchexplorer.cash>
- **Notable:** blocks, tx, tokens — community-built
### BCH Block Explorer
- URL: <https://bchblockexplorer.com>
- **Notable:** general-purpose block + tx explorer
### Salemon / Blockchair (and other multi-chain explorers)
- General-purpose; cover BCH alongside many other chains
---
## 9. Merchant / POS
### Paytaca POS
- URL: <https://pos.paytaca.com/> · Repo: <https://github.com/paytaca/paytaca-pos>
- **What:** Web-based point-of-sale register for accepting BCH (+ CashTokens) at shops
- **Connectors:** WC v2; integrates with Paytaca wallets
- **Notable:** the production BCH POS most cited in 2024–2026
### BCH Bull (also a merchant feature) and CoinTracking
- Various third-party services accept BCH; covered in 1kbch directory
---
## 10. Nodes & infrastructure
### BCHN (Bitcoin Cash Node)
- URL: <https://bitcoincashnode.org/> · Repo: <https://gitlab.com/bitcoin-cash-node/bitcoin-cash-node>
- **What:** The dominant BCH full node implementation (~95% of network)
- **License:** MIT
- **Notable:** Deviant's chipnet validation runs against BCHN-hosted relay infra
### Knuth
- URL: <https://kth.cash/> · Builder: kth.cash team
- **What:** Alternative BCH node — multi-language, performance-focused
- **Notable:** secondary implementation; helps with consensus diversity
### Bitcoin Verde
- URL: <https://bitcoinverde.org/>
- **What:** Java-based BCH node + Stratum pool software
- **Notable:** academic; tertiary by deployment
---
## 11. Specifications & standards
| Spec | Where | Relevant to |
|---|---|---|
| **CashTokens (CHIP-2022-02)** | <https://cashtokens.org/> · `cashtokens/cashtokens` repo | Every modern dApp |
| **BCMR (Bitcoin Cash Metadata Registry)** | `cashtokens.org/docs/bcmr/chip/` | Token display, NFT metadata |
| **wc2-bch-bcr** (WalletConnect BCH namespace) | `bitcoincashresearch.org` thread | All WC2 BCH integrations |
| **chip-paypro** (BIP-21-style payment URIs) | `bitjson/chip-paypro` | Receive/Send flows; Deviant's `qr-uri.ts` |
| **AnyHedge** | `anyhedge.com/docs` | Hedging contract |
| **Cashfusion** | Electron Cash docs | Privacy coin-join |
| **PMv3 / CHIPs** | <https://bitcoincashresearch.org> | Forward-looking consensus / protocol proposals |
---
## 12. Ecosystem aggregators
| Site | What | Notes |
|---|---|---|
| <https://1kbch.com/ecosystem/> | Community-voting-based BCH directory (0.001 BCH per vote, 30-day records, top-six leaderboard) | Page may render empty when scraped — content is JS-loaded |
| <https://tokenaut.cash/dapps> | CashTokens-era dApp index | Same JS-rendered caveat |
| <https://github.com/topics/cashtokens> | GitHub topic index for CashTokens repos | The most reliable scraping target for dev tooling |
| <https://github.com/topics/bitcoin-cash> | Broader BCH GitHub topic | Older but wider |
---
## 13. Where this catalogue is incomplete
- **Centralized exchanges** that list BCH (Kraken, Bitstamp, Coinbase, etc.) — out of scope, not project-relevant
- **Mining pools** — out of scope, infra-tier
- **Closed-source consumer wallets** beyond the listed flagships — present but not load-bearing for Deviant
- **CashFusion ops infra** — Electron Cash + community fusion servers; not surveyed in depth here
- **Telegram / forum / podcast ecosystem** — present but not engineering-relevant
Refresh by re-fetching the live source pages listed at the top + the GitHub topic index. JS-rendered SPAs (1kbch, tokenaut.cash) need a real browser or their underlying API to scrape; static GitHub pages render in WebFetch fine.
---
## 14. Relationship to Deviant
Mapping which ecosystem pieces matter to which Deviant track:
| Deviant track | Ecosystem dependencies |
|---|---|
| Multi-protocol wallet responder (ADR-0043) | Cashonize (target), Paytaca (target), Reown/WalletKit (transport), `cashconnect-js` (CC), `@wizardconnect/*` (future) |
| FIATμ family (USDm/EURm/…) | CashScript, libauth, AnyHedge oracle pattern, BCMR for display |
| Prediction-market / tote / checkers | CashScript covenants, the connector responder above for non-custodial co-sign |
| QR scanner + URI routing (snapshot 0153) | `chip-paypro` spec, jsqr (web), `@capacitor-mlkit/barcode-scanning` (mobile) |
| CashFusion (Settings → Privacy) | Electron Cash CashFusion implementation as reference |
| Token registry (Deviant's local + portable) | BCMR spec, OTR posture (non-blessed) |
| Network providers | Fulcrum (Electrum), Chaingraph (GraphQL) |
The connector folder (`../WalletConnect/`) is the most directly used cross-reference: every dApp in §3–§5 above pairs with the Deviant wallet through one of the three protocols documented there.

View file

@ -0,0 +1,651 @@
# Anthemus DEX — Business Model
*The monetization, liquidity, and go-to-market model for **Anthemus**, the Deviant BCH
**DEX** (P2P exchange engine): AMM (`SharePairAMM`) + order book + cross-chain atomic swaps,
the **trading floor** of the interchain settlement hub. Brand & scope (authoritative):
[anthemus_dex.md](anthemus_dex.md). Taxonomy: [ASM vs DEX README](../ASM%20DEX/README.md).
Sibling product whose model this **complements (does not duplicate)**:
[orbital-swap_business-model.md](../ASM%20DEX/orbital-swap_business-model.md) (Potidaea / ASM,
ADR-0048). Project-wide companion: [`wiki/revenue-model.md`](../../wiki/revenue-model.md).*
**Date:** 2026-06-29 · rev. 1 · **Status:** design — **recommends a new ADR-0049** (Anthemus
business model) and **two `SharePairAMM` contract changes** (curve fee; fractional LP tokens).
Neither is written yet — see §10. **Scope:** the BCH DEX venue inside Agora. Potidaea (ASM)
and the bridge mechanics are touched only where they feed Anthemus.
---
## 0. Executive summary
**Both venues charge for the swap, and both can charge a consensus-enforced fee —
counterparty-freeness never meant fee-freeness (ADR-0017). What Anthemus can charge that
Potidaea can't is a fee that prices a *real counterparty* and a *real service*.** Potidaea
already monetizes its swap two ways: a **0.5% platform service fee** (soft, avoidable, from free
BCH — payment for *facilitation*, ADR-0017) *and* the **founder `feeBps`** (0–5%, **consensus-
enforced** on the mint/claim covenant, ADR-0011) — the issuer's own income, which per ADR-0021
is **splittable, covenant-enforced, between founder and any recipient**, including the **service
provider** (the same mechanism the ADR-0048 Olympic ladder uses to route founder-fee shares for
placement). So a consensus-baked fee on the swap *does* exist in Potidaea; what ADR-0008 bars is
only a **mandatory, platform-imposed rake** on the swap itself (that would fork the canonical
bytecode, D-1, and become a rent-authority) — **not** a founder's own fee, voluntarily elected
at genesis and split however the founder chooses. What Potidaea genuinely *cannot* charge is a
**counterparty cost**: there is no counterparty, no pool, no spread to levy. Anthemus charges
the same service + founder-fee options **plus two fee classes that only a real market has**: an
**earned counterparty cost** (the LP/curve fee — a real pool of LPs bears inventory + IL risk)
and a **bridge service fee** (a real, costly bonded-signer operation). These too can be
**consensus-enforced and bounded** (published, D-1-verified) — but the point is *what they pay
for*: a counterparty and a venue operation, the earned costs a counterparty-free ASM has nothing
to charge for.
The honest-posture test changes shape accordingly. Potidaea keeps its posture by
**avoidability** (every fee is forkable, from free BCH). Anthemus keeps it by **disclosure +
labeling**: a market swap is rendered as a *"Market swap"* with slippage and fee shown, never
disguised as a counterparty-free protocol swap (ADR-0016 Decision 5). No hidden spread; the
spread *is* the product and it is shown.
**The differentiator is the interchain hub, not the AMM.** A constant-product AMM on BCH is
not novel (Cauldron, TangoSwap exist). What no one else offers is a **no-KYC, self-custodial
venue where ~half of all USDT — USDT-on-Tron, structurally outside Circle's CCTP and with no
native presence on BCH since Tether left SLP (Sep 2025) — can be deposited, traded against
εUSDC / USDμ / Token-class assets, and withdrawn home.** Anthemus is where that value *moves*;
the source chains become vaults. Lead with the assets nobody else brings to BCH trustlessly.
**Five fee surfaces, ranked by honesty-fit and certainty:**
| Surface | Who pays → earns | Posture fit | Size (recommended) |
|---|---|---|---|
| **Bridge entry/exit toll** (§2c) | depositor/redeemer → platform (bond cost) | ✅ honest service fee | **0.10–0.20%** each way, capped flat |
| **AMM curve fee** (§2a) | swapper → **LPs** (+ thin protocol slice) | ✅ earned counterparty cost, disclosed | **0.30%** to LP, **0.05%** protocol *optional* |
| **Order-book maker/taker** (§2b) | taker → maker (+ thin protocol slice) | ✅ honest, taker pays for immediacy | **0 maker / 0.10–0.20% taker** |
| **Listing / Olympia** (§2e) | issuer → platform (in DEA) | ✅ pays for attention, never function | shared with Potidaea (ADR-0048) |
| **Atomic-swap fee** (§2d, List A) | hard to fee — trustless, no covenant in the middle | ⚠️ maker spread only | **~0%**, monetize via the book |
**The flywheel interlock (§4):** every cross-chain trade routes through **USDμ as the base
pair**; a USDμ leg is an MBA mint/swap → it pays the Potidaea protocol fee → that feeds the
**flagship-DEA holder yield + BCH backing demand** (ADR-0048 §9). So **Anthemus volume powers
the Potidaea/DEA equilibrium flywheel** while Anthemus itself earns the bridge toll + curve
skim + listing. Two products, one compounding loop, **no double-charge** (§8).
**Wedge & sequence (§5):** USDT-Tron in → stable pairs (τUSDt/εUSDC/USDμ, near-zero IL) →
Token-class issuers seeking a venue → volatile cross-chain IOUs (BTCas, εETH). **The first
constraint is liquidity, not demand** — and liquidity is the load-bearing problem Potidaea
never had (§3).
---
## 1. Value proposition & positioning
### 1.1 The structural inversion vs Potidaea (read this first)
**Both venues charge a service fee — that is not the difference.** Potidaea's 0.5% service fee
(soft, avoidable, from free BCH — ADR-0017/0048) applies to its swaps, and Anthemus can levy a
service-type fee too. The difference is the **extra fee classes a real market unlocks**: every
line below that is *zero by construction* in Potidaea is **present and legitimately chargeable**
in Anthemus, because the thing that created the zero (no counterparty) is exactly what Anthemus
adds back (a counterparty). The service fee is the shared baseline; these are the additions:
| Fee line | Potidaea (ASM) | Anthemus (DEX) | Why the difference |
|---|---|---|---|
| **Service fee** (facilitation) | **0.5%** — soft, avoidable, from free BCH | present too (but see §2a — folded into the curve fee, not stacked) | Both build the tx; a facilitator may charge either way (ADR-0017) |
| **Founder `feeBps`** (consensus-enforced) | **0–5%** — baked in the mint/claim covenant; **splittable founder ↔ service-provider** (ADR-0011/0021) | same — an Anthemus-routed Token-class asset can carry it too | The issuer's own fee; a voluntary genesis election, not a platform rake |
| Consensus-enforced fee that pays a **counterparty/venue**? | **No** — nothing to pay (no LP, no bridge) | **Yes** — curve fee → LPs, bridge toll → bond | Only a real market has a counterparty/venue to fund |
| Spread / slippage | **0** — one consensus rate | **present** — price discovered on a curve/book | A market has two sides to clear between |
| LP / curve fee | **0** — no LP, backing *is* liquidity | **0.30%** — paid to LPs who fund the pool | LPs bear inventory + IL risk; the fee is their wage |
| Maker/taker | **0** | **taker fee** for immediacy | Someone posts the resting order |
| Bridge toll | n/a (native only) | **0.10–0.20%** each way | The bonded signer set carries real cost/risk |
| Mandatory platform rake on the swap? | **No** — rent-authority, barred (ADR-0008) | **No** — same bar | The anti-rent stance is shared; earned costs ≠ a rake |
| Pay-to-list *gate* | **0** (visibility only) | **0** (visibility only) | Same anti-rent stance (ADR-0008/0020) |
This is the cleanest articulation of why the two products don't cannibalize: **they charge for
different structures.** Both charge for facilitation; only Anthemus additionally charges for the
*market* it provides — the liquidity rented from LPs and the bridge it operates. Potidaea sells
the *absence* of a market (zero slippage at any size, a soft fee for the service); Anthemus
sells *access to a market* for assets that have no consensus rate and must be priced by one.
### 1.2 Differentiation vs the field
**vs other BCH DEXs (Cauldron, TangoSwap).** Cauldron is the incumbent native CashTokens AMM
(~$1.2M TVL, 0.3% all to LP, permissionless free listing, no pay-to-list — ADR-0016). On a
like-for-like native Token-class pool, Anthemus has **no structural edge** and should not try
to win there — ADR-0016 already routes native Token-class swaps *to* Cauldron at ~0% retention
(a funnel, not a tax). **Anthemus's edge is the inventory Cauldron does not carry:**
cross-chain IOUs (τUSDt, εUSDC, BTCas, εETH…). No other BCH DEX has a bridge feeding it
trust-minimized, publicly-reconcilable (`Σ minted == Σ locked`) foreign assets. The DEX is
commodity; **the hub feeding it is the moat.**
**vs CEXs (Binance, etc.).** No KYC, no custody, no account, no withdrawal queue, no delisting
desk. The user holds keys throughout; deposit/trade/withdraw are covenant- and script-mediated.
The honest trade-off: a CEX has deeper books and instant fiat ramps. Anthemus competes on
**sovereignty + censorship-resistance + the long tail of assets a CEX won't list**, plus
**<$0.001, seconds, offline-receivable** settlement on BCH that a CEX's internal ledger can't
match for self-custodial finality.
**vs other-chain DEXs (Uniswap, Jupiter, etc.).** Those are deeper and richer *on their home
chain* — Anthemus does not out-Uniswap Uniswap on Ethereum. The pitch is **the bridge
destination**: trade Tron-USDT against Ethereum-USDC **without paying Ethereum gas or Tron
energy**, on a chain where the fee is a rounding error. You enter and exit through the source
chains' fees once; everything in between is BCH-cheap. **The settlement floor is the product;
the source chains are vaults.**
### 1.3 The one-line positioning
> *"Bring any chain's assets to Bitcoin Cash and trade them for a fraction of a cent — no KYC,
> no custodian, your keys throughout. The world's stablecoins and coins, on one cheap floor."*
Lead every external narrative with the **USDT-Tron wedge** (§5): the single largest pool of
stablecoin value that the incumbent rails (CCTP) structurally refuse to serve.
---
## 2. Monetization — every fee surface, mapped honestly
**Governing principle.** Potidaea's constraint was never "no fee," nor even "no consensus fee":
it charges a soft 0.5% service fee **and** carries the founder's consensus-enforced `feeBps`
(splittable to the service provider, ADR-0011/0021). Its one hard bar is a **mandatory,
platform-imposed rake on the swap** — that would fork the canonical bytecode and become a
rent-authority (ADR-0008). Anthemus inherits both the facilitation fee and the founder-fee
mechanism **and** unlocks the fees only a real market justifies (which pay a counterparty/venue,
not the issuer):
> **You may charge for the market — the liquidity you rent, the bridge you operate, the book
> you host, the attention you sell — plus the ordinary facilitation fee for building the tx.
> Every charge must be a *disclosed line item* and must correspond to a real service or a real
> counterparty. No hidden spread; the rate shown is the rate paid plus a named fee. Where a fee
> is consensus-enforced (curve/bridge), it must be bounded and published (D-1) because it prices
> real risk/service — never a discretionary skim.**
### 2a. AMM swap fee — the curve fee (`SharePairAMM` change required)
**Current state:** `SharePairAMM` enforces `y2 * n2 >= y * n` — the `>=` absorbs *slippage
only* and leaves room for an overpay, but charges **no explicit fee**. Today 100% of the
trader's price impact accrues to the pool as a whole (i.e. to the single LP as inventory), with
no defined skim and no LP wage beyond reserve drift. That is fine for a validated spike;
production needs a real, bounded fee.
**Design — charge the fee on the input, Uniswap-style, enforced by the invariant.** Standard
constant-product venues take a fee `f` (e.g. 0.30%) by crediting only `(1−f)` of the input
toward the swap. Equivalently, require the post-swap product to exceed the pre-swap product by
the fee factor on the traded leg. In `SharePairAMM` terms, replace the bare invariant with a
fee-inclusive one so the realized `k` grows by ~`f` of the input each trade, and that growth
accrues to LPs as a claim on reserves (see §3 fractional LP tokens). **This is a covenant
change → flag for ADR-0049 / a `SharePairAMM` v2** (§10). Two sub-options:
- **(i) Pure LP skim (recommended default).** The entire fee stays in the pool, raising `k`;
LPs withdraw it pro-rata. **0% to platform.** Cleanest legal/honesty posture — identical to
Cauldron — and the strongest LP-attraction story (every basis point goes to the people taking
the risk). Matches ADR-0016/0020's "external-DEX 0% retention" instinct applied to our *own*
venue: don't out-rake the incumbent.
- **(ii) LP skim + thin protocol slice.** Split the curve fee, e.g. **0.30% = 0.25% LP +
0.05% protocol**, the protocol slice routed to a fee output (the same mechanic as the
Potidaea service-fee output, ADR-0017). Honest *if disclosed*, but it makes Anthemus
fractionally more expensive than Cauldron on like pools and weakens LP attraction. **Reserve
this only for the cross-chain pools** (τUSDt/εUSDC/USDμ) where there is **no competing venue**
— there, a small protocol slice is defensible because Anthemus is the *only* place those
assets trade. On any pair Cauldron also lists, use option (i).
**Recommended:** **0.30% curve fee, 100% to LP on native pairs; on hub-exclusive cross-chain
pairs, optionally 0.25% LP + 0.05% protocol.** Posture fit: ✅ — the curve fee is a *counterparty
cost paid to LPs*, the honest archetype of "someone provided the other side." It does **not**
risk the no-hidden-spread posture **provided** the UI shows price impact and fee as distinct
disclosed lines under the "Market swap" label (ADR-0016 Decision 5).
> **Sizing note vs the project's 0.5% service fee.** Do **not** stack the Potidaea 0.5% service
> fee on top of the Anthemus curve fee. On a market swap the curve fee *is* the venue fee; an
> additional 0.5% would double-charge and push past Cauldron (revenue-model.md §1, ADR-0020's
> "don't teach users to bypass"). The 0.5% stays reserved for **Potidaea-native MBA events**;
> Anthemus market swaps carry the curve fee **only**.
### 2b. Order-book maker/taker fees
For assets that want a book (CLOB) rather than a pool — resting limit orders via
`ShareLimitOrderBCH` (ADR-0030) with off-chain relay matching at launch (ADR-0044/0045).
**Design:** **maker 0% / taker 0.10–0.20%.** The maker provides liquidity (posts the resting
order, bears adverse selection) and should be *rewarded* with zero fee to attract depth; the
taker pays for immediacy. A thin protocol slice of the taker fee is defensible because Anthemus
hosts the relay/matching infrastructure (a real "hard" service, ADR-0020). Posture fit: ✅ —
classic, transparent, the taker knowingly pays for instant fill. **Caveat:** if matching is
off-chain (relay), the taker fee is enforced by the relay/wallet (soft, bypassable like the
service fee); if/when matching moves on-chain into the order covenant, it can be consensus-baked
and bounded. Start soft, harden later.
### 2c. Bridge / wrap fee — the hub's entry/exit toll (the primary entry surface)
This is the toll for **deposit (lock-and-mint → τUSDt/εUSDC) and withdraw (redeem)** — the
`DeviantWrapMintIOU` path validated in ADR-0047 (B3/B4/B5). **It is the single most defensible
fee in the whole Anthemus model**, because it pays for a real, costly service: the **bonded
M-of-N signer set** (B6) that locks the foreign asset, posts a slashable BCH bond, and bears
the trust-minimized custody risk until a light client / zk verifier removes it.
**Design — a consensus-enforceable spread inside the mint, bounded and published.** The wrap
covenant already enforces `mint ≤ locked`. Make the toll the gap: mint `locked · (1 − tollBps)`,
the `tollBps` slice accruing to the bond/treasury, with `tollBps` a **published constructor
parameter** (D-1 verified, rendered openly — never a hidden haircut). Redeem symmetrically.
**Size:** **0.10–0.20% each way, with a flat cap** (so large transfers aren't over-taxed — a
$1M deposit shouldn't pay $2,000 for a signature attestation). Benchmark: this is *far* below
CEX deposit/withdraw friction and competitive with bridge norms (0.05–0.30%). It is the hub's
margin and the bond's funding source.
Posture fit: ✅ — a service fee for an explicit, disclosed, *optional* operation (you choose to
bridge; List A atomic swap is the trust-free, toll-free alternative). It is **not** taxing a
trade — it's pricing the bridge. **This is the surface to lean on**, because it is (a) honest,
(b) consensus-enforceable, (c) charged once at the high-value entry/exit moment, and (d) tied
to a cost the user can see (the bond).
### 2d. Cross-chain atomic-swap fee (List A)
List A (HTLC, trustless, value *leaves* the source — BTC→BCH) is **the hardest surface to
monetize and should mostly not be.** There is no covenant in the middle to skim: the swap is a
shared hashlock between two parties, settled by a preimage reveal. Any fee can only be a
**maker's spread** (the counterparty quotes a rate that embeds their margin) or an
**off-chain matching/relay fee** if Anthemus brokers the introduction.
**Design:** **~0% protocol fee; monetize via the order book** that lists the atomic-swap offers
(the taker fee, §2b) and via the *funnel* — an atomic-swapper who lands native BCH/USDμ then
trades on Anthemus and pays the curve fee there. Posture fit: keep it honest by **not** pretending
to fee a trustless primitive. The value of List A is *retention and the trust-free option*, not
direct revenue — the same logic as ADR-0020's external-DEX 0%.
### 2e. Listing / promotion — Olympia + the Olympic ladder (shared with Potidaea)
Anthemus does **not** invent a parallel listing economy. Asset **discovery is unified in the
Metroon** (the in-Agora directory): **Stoa** (free, every canonical asset, always on) +
**Olympia** (paid featured tab, gated at 100 created assets), ranked by the **Olympic** ladder
(Free→Bronze→Silver→Gold→Laurel, ADR-0048 §6). A cross-chain IOU or Token-class asset that
wants featured placement pays the **same Olympia fee in flagship DEA** as any MBA, with the same
`(cA·A + cL·L)·M` curve and the same anti-rent rules (swap always free/permissionless; safety
never for sale; visibility ≠ truth — ADR-0020/0048).
**Why this is correct and not a double-charge:** listing pays for **attention inside the shared
Metroon directory**, an asset-level act that is independent of which *engine* (Potidaea or
Anthemus) the asset routes its trades to. One asset, one listing fee, one directory — regardless
of venue. Posture fit: ✅ — identical to Potidaea; it sells visibility, never the right to trade.
**Boundary (§8):** Olympia/listing revenue is **directory revenue**, attributed to neither
engine exclusively; the **curve/book/bridge fees are Anthemus's**, the **MBA service/mint fees
are Potidaea's**.
### 2f. Honesty scorecard
| Fee | Honest, no-hidden-spread? | Risk to the posture | Mitigation |
|---|---|---|---|
| Curve fee (LP) | ✅ Yes — counterparty cost | Hidden if shown as "rate" only | Disclose price-impact + fee separately; "Market swap" label |
| Curve protocol slice | ⚠️ Only on no-competitor pools | Out-raking Cauldron → bypass | 0% on shared pairs; ≤0.05% on hub-exclusive |
| Maker/taker | ✅ Yes | Soft/bypassable if relay-matched | Keep maker 0%; thin taker; harden on-chain later |
| Bridge toll | ✅ Yes — bond-funded service | Hidden haircut if not published | `tollBps` is a D-1 published param, rendered openly, flat-capped |
| Atomic-swap fee | ✅ (by charging ~0) | Pretending to fee trustless prim. | Don't; monetize the book + funnel |
| Listing/Olympia | ✅ Yes — attention only | — | Same ADR-0020 rules; swap stays free |
---
## 3. Liquidity — the load-bearing problem (Potidaea never had it; Anthemus owns it)
**This is the chapter that most distinguishes the two products.** Potidaea's depth is
*oceanic* — the entire BCH base, regenerating, zero cold-start, zero IL, zero LP (its
business-model §6). **None of that is true for Anthemus.** A real market needs real liquidity,
deposited by real LPs who take real risk. The cold-start problem that "does not exist" for
Potidaea is **Anthemus's central operational challenge.** State it plainly internally and
never import Potidaea's "deep as an ocean" copy onto Anthemus — it is false here.
### 3.1 The contract gap — `SharePairAMM` is a single-LP spike
`SharePairAMM(yesCategory, noCategory, lpPkh)` has **one** LP, set in the constructor;
`manageLiquidity` is a bare signature check (`hash160(lpPk) == lpPkh`), and the LP owns all
reserves. There are **no fractional LP tokens**: a second party *cannot* add liquidity and
later withdraw their pro-rata share + earned fees. For production this must change.
**Required change (flag for ADR-0049 / `SharePairAMM` v2):** mint **fungible LP-share tokens**
proportional to deposited reserves (the standard `sqrt(x·y)` / pro-rata mint), redeemable for a
pro-rata slice of both reserves **including accrued curve fees**. This converts the pool from a
single-operator vault into a **permissionless, fractional liquidity venue** — the precondition
for attracting third-party LPs at all. It also interacts with the curve fee (§2a): the fee
accrues to `k`, and LP tokens are the claim on the grown `k`. **Build order recommendation:
fractional LP tokens + curve fee are one coupled change — design and validate them together.**
### 3.2 Attracting & rewarding LPs
- **Fee share is the wage.** The 0.30% curve fee → LPs (§2a option i) is the baseline yield.
On a hub-exclusive stable pair with steady cross-chain flow, even modest volume produces a
real APY because the pair barely moves (low IL — §3.4).
- **No native token emissions / liquidity mining (recommended).** Resist the Uniswap/Sushi
"mercenary liquidity" playbook of bootstrapping TVL with inflationary reward tokens — it
attracts capital that flees the moment emissions stop (§3.3) and it would require a new
unbacked emission, contradicting the project's backed-asset thesis. **If** an incentive is
ever wanted, route it through the **existing flagship DEA** (an LP could earn a slice of DEA
yield), not a new mercenary token — keeping the equilibrium-flywheel alignment (§4).
- **USDμ-anchored pairs lower the barrier.** Make **USDμ the canonical base/quote** (§3.5): an
LP holds a stable, BCH-backed asset on one side, minimizing the "what do I even provide"
friction and the IL on stable-stable pools.
### 3.3 Mercenary liquidity & pool fragmentation
- **Mercenary liquidity:** capital chasing the highest transient yield, gone at the first dip.
Mitigation: **fee-yield from real volume** (sticky) over emissions (fugitive); concentrate
depth in a few canonical pairs rather than spreading thin.
- **Fragmentation:** the same pair pooled across multiple covenants/venues splits depth and
worsens slippage everywhere. Mitigation: **a canonical pool per pair** surfaced in the
Metroon, with the router (ADR-0016 `MarketProvider`) preferring it; discourage redundant
pools by not featuring them. **Anthemus + Cauldron fragmentation is real** — for *native*
Token-class pairs, route to Cauldron (don't fork its liquidity); reserve Anthemus pools for
**cross-chain assets Cauldron doesn't have**, where there's nothing to fragment against.
### 3.4 Impermanent loss — a genuine exposure (the first in the project)
IL is **new to the project** — Potidaea, MBAs, and the ASM have none. Anthemus LPs bear it.
The mitigation is **pair selection**:
- **Stable-stable pools (τUSDt / εUSDC / USDμ)** all track ~$1 → **near-zero IL** by
construction. These should be the **first and deepest pools** — the wedge (§5) and the
low-risk LP on-ramp simultaneously.
- **Volatile pools (BTCas/USDμ, εETH/USDμ)** carry full IL. Surface IL risk honestly in the
LP UI (the same transparency discipline as the DRA range, ADR-0015/0030). Don't market these
as low-risk; let the curve fee compensate for the IL the LP accepts.
### 3.5 Is USDμ the natural base/quote? — Yes.
**Recommend USDμ as the hub numéraire and canonical base pair.** Rationale:
1. **It's ours, backed, and stable** — a BCH-backed MBA (ADR-0048 family), not a foreign IOU
with bridge trust. Using *our* stable as the base keeps the trust surface minimal on the
leg every trade touches.
2. **It routes Anthemus volume into the Potidaea/DEA flywheel** (§4): a USDμ leg is an MBA
mint/swap → Potidaea protocol fee → flagship-DEA holder yield + BCH backing demand.
3. **It minimizes IL** for the largest, most-used pools (stable base).
4. **It's the clean settlement unit** for the hub's "trade Tron-USDT for Ethereum-USDC"
story — both legs price against USDμ, and the user can exit to a backed BCH-native dollar
without touching a bridge.
The alternative (BCH as base) keeps everything native but imports BCH volatility into every
pair's IL and doesn't give the hub a dollar unit traders expect. **USDμ base, BCH and the
IOUs as quotes.** (Offer BCH pairs too, but make USDμ the spine.)
---
## 4. The hub flywheel — how Anthemus + bridge + Potidaea + CoinSpectrum compound
The four products form one revenue loop where **value compounds at the USDμ junction**:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ CoinSpectrum (separate aggregator) — discovery/attention top-of-funnel │
│ ↓ surfaces assets, drives users toward the venue │
│ BRIDGE (ADR-0047) — deposit lock-and-mint → ENTRY TOLL 0.1–0.2% (§2c) │
│ ↓ τUSDt / εUSDC / BTCas land on BCH │
│ ANTHEMUS (DEX) — trade against USDμ base → CURVE FEE 0.3% to LPs (§2a)│
│ ↓ every trade has a USDμ leg = an MBA mint/swap │
│ POTIDAEA (ASM) — the USDμ leg → PROTOCOL FEE → DEA yield │
│ ↓ flagship-DEA holder yield + BCH backing demand (ADR-0048 §9) │
│ METROON / OLYMPIA — listing in DEA → DIRECTORY REVENUE (§2e) │
│ ↓ DEA buy-demand → locks BCH → BCH price support → lifts all MBAs │
│ BRIDGE — withdraw redeem → EXIT TOLL 0.1–0.2% (§2c) │
└─────────────────────────────────────────────────────────────────────────┘
```
**Where value compounds:** the **USDμ base pair** is the keystone. By making every Anthemus
trade route through a USDμ leg, Anthemus *manufactures Potidaea volume* — and Potidaea volume
is what drives the flagship-DEA yield and the BCH backing demand (the equilibrium flywheel,
ADR-0048 §9). So the two products are not just non-competing; **Anthemus is a volume pump for
Potidaea's value-accrual engine, and Potidaea's DEA is the (optional) incentive asset that can
reward Anthemus LPs** without a new mercenary token (§3.2).
**Revenue capture points (distinct, non-overlapping):**
1. **Bridge toll** (entry + exit) — Anthemus/hub treasury, the high-margin entry tax.
2. **Curve/book fees** — mostly LPs/makers (their wage), thin optional protocol slice on
hub-exclusive pairs.
3. **Potidaea protocol fee on the USDμ leg** — flagship-DEA holders (yield) + transitional
thin platform slice (ADR-0048 §2).
4. **Olympia listing** (in DEA) — directory/platform treasury, creates DEA buy-demand.
**CoinSpectrum's role** ([[spectrum-crypto-aggregator]]): the separate all-crypto aggregator is
**top-of-funnel attention**, not an Agora component. It can surface "this asset trades on
Anthemus" and route users in, but ships independently and earns on its own (data/affiliate).
Keep the brand wall — CoinSpectrum is not an Anthemus revenue line, it's a discovery feeder.
---
## 5. Target customers & GTM — sequence the wedge
**The wedge is USDT-on-Tron** — the highest-value, least-served target in all of crypto
settlement: ~45–52% of all USDT (~$90B+), **structurally outside Circle's CCTP**, and with **no
native USDT on BCH** since Tether ended SLP support (Sep 2025). Nobody serves this no-KYC and
self-custodially. That's the opening.
**Customer segments, in order of acquisition:**
1. **Cross-chain stablecoin holders (the wedge).** USDT-Tron holders who want out of a
surveilled, freezable, energy-metered rail into a cheap self-custodial floor. Acquisition:
bridge in τUSDt → trade against εUSDC / USDμ on a **stable-stable pool** (deep, low-IL,
their first comfortable LP opportunity too). **This segment seeds both sides of the market**
— they are simultaneously the first traders *and* the first natural LPs (stable inventory).
2. **Liquidity providers.** Without LPs there is no market (§3). Sequence them *with* segment 1
by making the first pools stable-stable (low IL) and the yield real (curve fee on genuine
bridge-driven volume). This is the chicken-and-egg break: **the wedge traders are the wedge
LPs.**
3. **Token-class issuers seeking a venue.** Memecoins, shares, project tokens with no
consensus rate that need price discovery (ADR-0016). They list in the Metroon, optionally
pay for Olympia, and route trades to Anthemus (or Cauldron for native). Acquisition: the
Artemision genesis wizard → "your asset needs a market → here it is."
4. **Volatile cross-chain holders (BTC, ETH, SOL).** Once stable pools prove the hub, add
BTCas/USDμ, εETH/USDμ. Higher IL, but the "trade BTC on BCH rails without LN/custody" story
([[btc-on-bch-rails-not-channel]]) is a strong narrative for the captured-audience BTC
crowd ([[demonstration-over-reasoning-captured-audience]]).
5. **Traders / arbitrageurs.** Arrive once depth exists; they tighten spreads and add volume.
They're a *result* of liquidity, not a way to bootstrap it — don't target them first.
**GTM principle:** **liquidity-first, demand-second.** Unlike a typical product launch, you
cannot lead with demand generation — a thin pool with high slippage *repels* the demand you
attract. Win the stable-stable pool depth first (segments 1+2 together), prove tight execution,
then widen.
---
## 6. Unit economics (directional — not a forecast)
**Posture modeled:** bridge toll 0.15% each way (capped) · curve fee 0.30% (LP) + 0.05%
protocol on hub-exclusive pairs · taker 0.15% · USDμ base. All figures illustrative; the BCH
market is small and cyclical (revenue-model.md caveats apply in full).
**Per-flow worked example — a $10,000 round trip (Tron-USDT → trade → withdraw):**
| Leg | Fee | Who earns | $ on $10k |
|---|---|---|---|
| Deposit (lock-and-mint τUSDt) | 0.15% (capped) | hub treasury (bond) | $15 |
| Trade τUSDt → εUSDC (curve) | 0.30% LP + 0.05% protocol | LPs + thin platform | $30 + $5 |
| USDμ leg (if routed via USDμ) | Potidaea protocol fee ~0.3% | flagship-DEA holders | ~$30 → DEA yield |
| Withdraw (redeem) | 0.15% (capped) | hub treasury (bond) | $15 |
| **Total to platform/treasury** | | | **~$35** (toll + slice) |
| **Total to LPs** | | | **~$30** |
| **Total to DEA holders** | | | **~$30** |
**Annualized, by daily volume + TVL (protocol/treasury take only — toll + 0.05% slice):**
| Daily DEX volume | Annual volume | Bridge toll (½ of vol bridged, 0.30% RT) | Protocol curve slice (0.05%) | Platform/treasury ≈ |
|---|---|---|---|---|
| $50k | ~$18M | ~$27k | ~$9k | **~$36k** |
| $250k | ~$91M | ~$137k | ~$46k | **~$183k** |
| $1M | ~$365M | ~$548k | ~$183k | **~$730k** |
| $5M | ~$1.8B | ~$2.7M | ~$913k | **~$3.7M** |
*(Bridge toll assumes ~50% of trade volume is fresh bridge in/out at 0.30% round-trip;
mature pools recycle internally so the toll/volume ratio falls over time — the toll is an
**entry tax, not a throughput tax**, which is the honest framing.)*
**LP economics & break-even:** an LP earns `0.30% × (annual volume / TVL) = 0.30% × turnover`.
A stable-stable pool with **5× annual turnover** yields **~1.5% APY** from fees with near-zero
IL — modest but real and risk-light. A volatile pool needs higher turnover to out-earn its IL;
that's the LP's risk decision, surfaced honestly. **Break-even for the venue is governed by
the bridge toll** (covers the bond + ops) more than the curve slice — which is why §2c is the
load-bearing surface and §2a is mostly the LPs'.
**The honest ceiling (same as revenue-model.md):** current BCH token-DeFi is a ~$27M/yr,
cyclical TAM. **The case is an adoption bet, not a pricing bet.** Anthemus's specific
adoption thesis is *new* TAM — the cross-chain settlement volume the bridge imports, which is
not bounded by today's tiny native CashTokens market. The ceiling-break is **"how much
foreign stablecoin/coin value chooses a no-KYC BCH floor,"** not "how big is BCH DeFi today."
---
## 7. Risks
1. **Liquidity fragmentation + cold-start (the #1 operational risk).** A thin pool is worse
than no pool — it slips and repels. Mitigation: liquidity-first GTM (§5), canonical pool per
pair, route native Token-class to Cauldron rather than fork its depth.
2. **Impermanent loss (new to the project).** Real LP capital can lose to IL on volatile
pairs. Mitigation: stable-stable pools first; honest IL disclosure; never import Potidaea's
"no IL / oceanic" copy onto Anthemus.
3. **`SharePairAMM` caveats (un-productionized).** (a) **Single-LP spike** — no fractional LP
tokens; production-blocking for third-party liquidity (§3.1). (b) **int64 overflow** — the
contract comment notes reserves ~1e8 are safe but "a production pool must bound overflow";
large reserves × the constant-product multiply can overflow int64 → must be bounded/checked
in v2. (c) **No explicit fee** — §2a. All three are pre-production must-fixes, not shipped.
4. **MEV / front-running (new — a public-chain market).** Unlike Potidaea (one rate, nothing to
front-run), an AMM on a public mempool is exposed to sandwich/front-run. BCH's smaller MEV
ecosystem and fast cheap blocks reduce but don't eliminate it. Mitigation: slippage limits
in the swap UX (ADR-0015 execution-time quote), consider commit-reveal or batch options for
the book later. **Disclose it** — don't claim MEV-immunity.
5. **Bridge trust — trust-minimized, NOT trustless (be honest).** List B (lock-and-mint) sits
behind bonded M-of-N signers / an oracle (the VM wall, ADR-0047). The locked foreign asset
is only as safe as the signer set + the slashable bond + public `mint == lock`
reconciliation. Say **"trust-minimized," never "trustless,"** for the bridged path; List A
(atomic swap) is the trustless alternative for purists. The toll (§2c) funds the bond that
minimizes this trust.
6. **Regulatory exposure (higher than Potidaea).** A **DEX + a bridge** is a more sensitive
regulatory object than a single-party covenant swap. The bridge especially can read as money
transmission / VASP activity in some jurisdictions; an order book + maker fees can read as
exchange operation. Anthemus's defenses: non-custodial throughout (no custody-of-record), no
KYC desk because there's no account, on-chain + key-controlled. But **per-jurisdiction
counsel is required before any mainnet/fiat-adjacent pilot** — this is R-016-class and the
bridge sub-bucket is likely *more* sensitive than the ASM. Do not market "regulation-proof";
market "self-custodial and on-chain," and gate jurisdictions accordingly.
7. **Hidden-spread temptation (posture risk).** The structural pressure on any market venue is
to widen the spread and pocket the difference. **Reject it.** The moment Anthemus earns from
an undisclosed spread, it forfeits the honesty wedge that distinguishes it from a CEX. Every
fee disclosed, every "Market swap" labeled (ADR-0016 Decision 5).
---
## 8. Boundary with Potidaea — what each monetizes (no cannibalization, no double-charge)
The single rule (ADR-0016 routing, restated as a revenue boundary):
> **Pegged MBAs → Potidaea. No-rate Token-class + cross-chain IOUs → Anthemus. An asset is
> charged by exactly one engine for its trade; the directory (Metroon/Olympia) and the bridge
> are shared, asset-level surfaces charged once regardless of engine.**
| Surface | Potidaea earns | Anthemus earns | Shared? |
|---|---|---|---|
| **MBA fixed-rate swap/mint** | ✅ 0.5% service fee (→ DEA yield + thin platform) | ✗ never | — |
| **Token-class / cross-chain market swap** | ✗ never | ✅ curve fee (LP) + thin slice / taker fee | — |
| **Bridge deposit/withdraw** | ✗ | ✅ entry/exit toll | hub treasury |
| **USDμ leg inside an Anthemus trade** | ✅ protocol fee on the MBA leg | (curve fee on the pool leg) | the flywheel junction (§4) |
| **Founder `feeBps`** (0–5%, consensus-enforced) | the founder's income on the MBA leg | the founder's income on a Token-class asset | ✅ **asset-level** — the issuer's own fee; **splittable to the service provider** (ADR-0021), voluntary, charged once regardless of engine |
| **Olympia listing (in DEA)** | — | — | ✅ **directory revenue**, attributed to neither engine |
| **Genesis / creation fee (in DEA)** | ✅ (Artemision, ADR-0048) | — | platform treasury |
**The non-obvious case — the USDμ leg — is a *handoff, not a double-charge*.** When an Anthemus
trade routes τUSDt → USDμ → εUSDC, the **pool leg** (τUSDt↔USDμ, εUSDC↔USDμ) carries the
**Anthemus curve fee** (paid to LPs), and **if** the USDμ is freshly minted/redeemed via
Potidaea, that mint carries the **Potidaea protocol fee** (→ DEA yield). These are **two
different operations on two different legs** — one is "trade against a pool" (Anthemus), the
other is "mint a backed dollar" (Potidaea). The user sees both as disclosed lines. This is the
**flywheel working as designed** (§4), not a stacked tax — but the UI must show them distinctly
so it never *looks* like double-dipping. **If** USDμ is simply held and swapped pool-to-pool
(no fresh mint), only the Anthemus curve fee applies. Net: **the curve fee and the MBA service
fee never both apply to the *same leg*.**
**Anti-cannibalization guarantees:**
- A **pegged MBA never routes to an Anthemus pool** (ADR-0016) — a pool would only add slippage
+ IL + LP-rug risk to something with a consensus floor. So Anthemus can't undercut or
cannibalize Potidaea's MBA swaps; it isn't allowed to touch them.
- An asset **pays the curve fee XOR the service fee on a given trade**, never both on the same
leg.
- **Listing is charged once** (directory-level), independent of trade venue.
---
## 9. Open decisions (for ADR-0049 / follow-up)
1. **New ADR recommended — ADR-0049 (Anthemus business model).** This document is its source.
It should (a) lock the **Anthemus brand** as user-facing (the brand doc flags this as
pending), (b) **refine ADR-0016's "no first-party AMM"** for the cross-chain hub case (see
§10), and (c) ratify the fee posture below. **Recommend writing ADR-0049 after the two
contract changes (§10) are scoped, so the ADR can reference real bytecode.**
2. **Curve-fee split — confirm:** 0.30% 100%-to-LP on shared pairs; **whether** to take the
0.05% protocol slice on hub-exclusive pairs, or stay 0% everywhere for maximum LP
attraction + cleanest posture. (Lean: 0% to start, add the slice only once depth is proven.)
3. **Bridge toll — confirm:** 0.10–0.20% each way + the flat cap value; whether `tollBps` is
per-asset (riskier chains cost more) or uniform; bond-funding split.
4. **USDμ as canonical base — confirm** (§3.5 recommends yes) and decide whether to *force*
routing through USDμ (manufactures Potidaea volume) or merely *prefer* it.
5. **LP incentive policy — confirm "no mercenary token."** Decide whether DEA-yield-for-LPs is
ever offered as the aligned alternative (§3.2), and if so, the mechanism.
6. **Order-book launch scope** — relay-matched (soft taker fee) first vs on-chain matched
(hard taker fee); reuses `ShareLimitOrderBCH` (ADR-0030/0044/0045).
7. **Atomic-swap (List A) — confirm ~0% protocol fee**, monetize via book + funnel only.
8. **Jurisdiction gating** for the bridge + book (R-016 counsel) — which jurisdictions, what
gating mechanism, before any mainnet pilot.
9. **Founder-fee split to the service provider — activate or keep declining?** The `feeBps`
split mechanism (ADR-0021) is already covenant-enforced and can route a founder-elected
slice of the founder's own fee to the **service provider**, not only a referrer (the same
path the ADR-0048 Olympic ladder uses for placement). This is a **consensus-enforced revenue
surface Deviant currently declines by policy** — ADR-0021 Decision 5 ("Deviant takes
nothing") kept the platform's *direct* take soft (the ADR-0017 service fee) to avoid any
rent-authority appearance. It stays ADR-0008-compatible **only while voluntary** (the
founder's own fee, opted-in at genesis, avoidable via non-Deviant tooling) — a *mandatory*
platform slice would cross into the barred rent-rake. **Decision for ADR-0049:** whether
Anthemus-issued Token-class assets should *offer* a founder→service-provider split (e.g. in
exchange for wizard/promotion/white-label value), and if so, that it is surfaced as an
explicit, optional founder election — never a default captive cut.
---
## 10. Contract changes flagged (recommend BEFORE writing ADR-0049)
The prompt asks to flag where the model needs a `SharePairAMM` change or a new ADR. **Two
coupled contract changes are prerequisites for the production Anthemus**, and both should be
**scoped/validated before ADR-0049 is finalized** so the ADR cites real, tested bytecode (the
same discipline ADR-0048 used — it pinned constants only after
`PotidaeaOlympiaListing.cash` validated):
1. **`SharePairAMM` v2 — fractional LP tokens + curve fee (one coupled change).**
- Mint fungible **LP-share tokens** on `addLiquidity` (pro-rata / `sqrt(x·y)`), burnable on
`removeLiquidity` for a pro-rata slice of both reserves **including accrued fees**.
Replaces the single-`lpPkh` spike (§3.1).
- Add a **bounded curve fee** to the swap invariant (fee on input → `k` grows by the fee;
LP tokens are the claim on the grown `k`) (§2a).
- **Bound int64 overflow** on the reserve-product multiply for production reserve sizes
(risk §3.3 / contract comment).
- These three are interdependent (the fee accrues to the thing LP tokens redeem against) →
**design + chipnet-validate as one contract**, mirroring the `chipnet-hub-dex.mjs` harness.
2. **`DeviantWrapMintIOU` — published `tollBps` parameter (bridge toll).** Add a bounded,
D-1-verified `tollBps` so `mint = locked·(1−tollBps)` and redeem symmetrically, the slice
accruing to the bond/treasury (§2c). Small change to an already-validated covenant; the toll
must be a **published constructor param rendered openly**, never a hidden haircut.
**Recommendation:** scope + validate change (1) first (it's the production-blocker for any
real liquidity), add change (2) alongside (it's small), **then** write ADR-0049 to ratify the
posture against the validated bytecode. Until then, this doc is the design of record and
SharePairAMM remains a **validated spike, not production**.
---
## Cross-references
- Brand & scope: [anthemus_dex.md](anthemus_dex.md) · Taxonomy:
[ASM vs DEX README](../ASM%20DEX/README.md)
- Sibling business model (complements, do not duplicate):
[orbital-swap_business-model.md](../ASM%20DEX/orbital-swap_business-model.md) (ADR-0048)
- Project-wide model: [`wiki/revenue-model.md`](../../wiki/revenue-model.md)
- Hub & chain landscape: [cross-chain-send-receive_chain-landscape.md](../ASM%20DEX/cross-chain-send-receive_chain-landscape.md)
· validated trade: `scripts/chipnet-hub-dex.mjs` · AMM: `contracts/SharePairAMM.cash`
- ADRs: **0016** (route by asset class — refined here for the hub AMM) · **0017** (soft service
fee) · **0020** (promoted registry / external-DEX 0% / sells-visibility-never-truth) ·
**0021** (voluntary rev-share ladder) · **0030** (Family D AMM + `ShareLimitOrderBCH` order
book) · **0042** (DEA fee distribution) · **0044/0045** (P2P market + dispute) · **0047**
(interchain hub / bridge) · **0048** (Potidaea business model — the sibling) · **0049**
(Anthemus business model — recommended, this doc)
- Memory: [[anthemus-dex-naming]] · [[poseidon-orbital-swap-naming]] ·
[[bch-interchain-settlement-hub]] · [[chipnet-dex-and-cross-chain-wrap-plan]] ·
[[potidaea-oceanic-liquidity]] · [[wrapped-asset-source-chain-marker]] ·
[[tether-returns-false-balance-delta]] · [[spectrum-crypto-aggregator]] ·
[[exchange-is-a-function-not-p2p-venue]]

View file

@ -29,20 +29,27 @@ Agora (marketplace / trading hub — where you exchange)
├─ Orbital Swap (ASM) → POTIDAEA — backed MBAs, fixed rate, no pool, ∞ depth
└─ DEX → ANTHEMUS — Token-class + cross-chain IOUs, price discovery
CoinSpectrum (SEPARATE product, own repo D:\Dev\coinspectrum) — the crypto market-data
aggregator / asset directory (the "see"). Was the in-wallet "Spectrum"; split out of
Deviant into its own product. Feeds/embeds asset + market data into Agora but ships
independently — NOT an Agora sub-component.
Metroon (the asset directory *inside* Agora — renamed from "Spectrum", ADR-0048). Holds
Deviant's OWN assets in two lists: the free Stoa list + the paid Olympia competition list.
This is the in-wallet "see" of Deviant-created assets.
CoinSpectrum (SEPARATE product, own repo D:\Dev\coinspectrum) — a broad ALL-crypto
market-data aggregator (CoinGecko-rival; the whole market, not just Deviant assets). May
feed/embed market data into Agora but ships independently — it is NOT the Deviant asset
directory and NOT an Agora sub-component.
```
Anthemus is **not** a separate surface — it is the DEX venue *inside Agora*, beside the
Orbital Swap (Potidaea). A Token-class or cross-chain asset routes its trade to Anthemus,
a pegged MBA to Potidaea (ADR-0016). Asset discovery/market data is **CoinSpectrum**
([[spectrum-crypto-aggregator]]), a standalone product (the old "Spectrum" tab, split out).
a pegged MBA to Potidaea (ADR-0016). Discovery of Deviant assets is **Metroon** (the in-Agora
directory, ADR-0048); broad cross-market data/explorer is the separate **CoinSpectrum**
product ([[spectrum-crypto-aggregator]]) — distinct, different-scope directories.
> **Glossary divergence:** `wiki/glossary.md` still describes "Spectrum" as the asset list
> *inside* Agora — that is stale; Spectrum became the standalone **CoinSpectrum**. The
> glossary should be updated to reflect the split.
> **Reconciled (ADR-0048 / glossary v2.9):** the in-Agora asset directory **persists** — it
> is renamed **Metroon** (Stoa + Olympia), *not* dissolved into CoinSpectrum. "Spectrum" is
> retired from Deviant, leaving the name sole-owned by the separate **CoinSpectrum**
> aggregator. So there is no glossary divergence to fix: **Metroon** = Deviant's own assets
> in Agora; **CoinSpectrum** = the whole crypto market, a standalone product.
## The engine pair (Potidaea vs Anthemus)

View file

@ -91,8 +91,8 @@ Broader framing (from the chats): the problem isn't only crypto — it's the **s
- **The "Zero Properties"** (marketing): zero counterparty / intermediary / trust / liquidity-ceiling / fungibility-barrier risk, plus zero complexity·maintenance·contribution·dependence·friction. Marketing-only register; tech spec keeps qualifiers.
- **Positioning:** vs conventional tokens (ERC-20, BEP-20, and the like — financially and structurally disconnected from their base layer; MBAs are tied to it, so one asset's success amplifies the whole ecosystem); vs stablecoins (the **bankruptcy-protection floor** — on-chain recoverable value if an issuer fails, where a conventional stablecoin recovers $0); vs DEX (no order matching, no liquidity bootstrap, no counterparty wait). The **volatility paradox** (ADR-0033): the asset marketed as safe fails its advertised property under stress; an MBA promises nothing it cannot keep.
- **Slogan:** *Power Your Ideas.*
- **Naming lexicon (Greek / cosmic):** **CosmoGenesis** = the name of the default visual **theme/style** (dark cosmic — starfield + acid‑green; *cosmo* = world/universe + *genesis* = birth). **NOTE:** previously the wallet‑shell name; the wallet/client is now simply **Deviant**. The alternate style is **Editorial** (Fraunces serif). Toggle label reads "Style · CosmoGenesis / Editorial." · **Agora** = the asset launchpad, discovery, **and swap/exchange marketplace** — the space where users meet and trade (the ancient open marketplace; **absorbs the former "Orbital" swap-space role**) · **Orbital** = the asset **explorer** — market-cap-style data, rankings, and per-asset detail (the "charted heavens"; cf. coinmarketcap / cashtokensmarketcap) · **Spectrum** = the filterable **asset list** (primary filter backed/non-backed; secondary: subcategory e.g. stablecoins, use-case e.g. game currency / tickets / memberships / collectibles; scope: mine / all) · **Avalanche** = the create/mint UI · **Poseidon** = the infinite-liquidity layer (deep as an ocean — the protocol-as-immutable-liquidity-provider, always available, unlimited) · **Athena (Athenae)** = the voting-system protocol (goddess of **wisdom and protection** — apt for capture-resistant collective decision-making) · **Themis (Themidae)** = the Tier-4 Elders (goddess of divine law, justice, and order). Voting docs additionally use **Athenian Democracy** terminology (sortition, citizens, chambers) as synonyms.
- **Olympus** = the **dApp platform layer** — the home where Deviant's first-party dApps reside (gods by domain). **DECIDED MODEL:** **Deviant** = the encompassing environment / project / identity (the whole house); the **CosmoGenesis wallet** = the client / front door; **Olympus** = the dApp platform layer that sits on top; **dApps** = residents of Olympus. **Athenae · Politeia Protocol = dApp #1** (governance, most built-out). **Agora** (ASM market) and **Avalanche** (MBA launch) are **candidate dApps**; future ones too (e.g. a prediction-market dApp ≈ Apollo). **NOT dApps — wallet-native surfaces/views:** Spectrum (asset list), Orbital (explorer), Transition (B→A soft→hard upgrade). So the spec's "five interfaces" splits into **dApps** vs **wallet views**. Strict-pantheon note: Agora (a place) and Avalanche (non-Greek) sit outside the deity logic — kept deliberately for evocativeness (Agora ties to the Athenian thread); the strict forms would be Hermes (commerce) / Hephaestus (the forge). "Olympus, the shining one." The **Olympic tier ladder** (Free → Bronze → Silver → Gold → Laurel) is a separate medal-podium marketing motif, not the platform layer.
- **Naming lexicon (Greek / cosmic):** **CosmoGenesis** = the name of the default visual **theme/style** (dark cosmic — starfield + acid‑green; *cosmo* = world/universe + *genesis* = birth). **NOTE:** previously the wallet‑shell name; the wallet/client is now simply **Deviant**. The alternate style is **Editorial** (Fraunces serif). Toggle label reads "Style · CosmoGenesis / Editorial." · **Agora** = the asset launchpad, discovery, **and swap/exchange marketplace** — the space where users meet and trade (the ancient open marketplace; **absorbs the former "Orbital" swap-space role**) · **Orbital** = the asset **explorer** — market-cap-style data, rankings, and per-asset detail (the "charted heavens"; cf. coinmarketcap / cashtokensmarketcap) · **Metroon** = the filterable **asset list** (primary filter backed/non-backed; secondary: subcategory e.g. stablecoins, use-case e.g. game currency / tickets / memberships / collectibles; scope: mine / all) · **Avalanche** = the create/mint UI · **Poseidon** = the infinite-liquidity layer (deep as an ocean — the protocol-as-immutable-liquidity-provider, always available, unlimited) · **Athena (Athenae)** = the voting-system protocol (goddess of **wisdom and protection** — apt for capture-resistant collective decision-making) · **Themis (Themidae)** = the Tier-4 Elders (goddess of divine law, justice, and order). Voting docs additionally use **Athenian Democracy** terminology (sortition, citizens, chambers) as synonyms.
- **Olympus** = the **dApp platform layer** — the home where Deviant's first-party dApps reside (gods by domain). **DECIDED MODEL:** **Deviant** = the encompassing environment / project / identity (the whole house); the **CosmoGenesis wallet** = the client / front door; **Olympus** = the dApp platform layer that sits on top; **dApps** = residents of Olympus. **Athenae · Politeia Protocol = dApp #1** (governance, most built-out). **Agora** (ASM market) and **Avalanche** (MBA launch) are **candidate dApps**; future ones too (e.g. a prediction-market dApp ≈ Apollo). **NOT dApps — wallet-native surfaces/views:** Metroon (asset list), Orbital (explorer), Transition (B→A soft→hard upgrade). So the spec's "five interfaces" splits into **dApps** vs **wallet views**. Strict-pantheon note: Agora (a place) and Avalanche (non-Greek) sit outside the deity logic — kept deliberately for evocativeness (Agora ties to the Athenian thread); the strict forms would be Hermes (commerce) / Hephaestus (the forge). "Olympus, the shining one." The **Olympic tier ladder** (Free → Bronze → Silver → Gold → Laurel) is a separate medal-podium marketing motif, not the platform layer.
- **Metaphor — Asterae / Planitae (the invisible-force motif):** **Asterae** (stars; lit. "without support") + **Planitae** (planets; lit. "wandering"). The marvel is not disorder — it is that bodies with **no visible support** and **no visible force pushing them** nonetheless hold precise, stable trajectories. The force is real but unseen: gravity. This describes **Deviant's own paradigm**, *not* legacy tokens — an MBA has no visible support (no custodian, issuer, counterparty, or authority) and no visible operator directing it, yet holds its value and moves within exact order, held by an invisible-but-real force: the **mathematical backing / gravitational center** (the base-layer rate) enforced by consensus. Legacy unbacked assets have no such force at all — they simply fall. Use as the elevated narrative/visual motif for how *mathematics, not authority,* holds the system.
- **Resonance:** **DEA ≈ Dias / Zeus** (Δίας, king of the gods; shared root with *dyeus → day / light*, Latin *dies*) — a fortunate resonance for the **flagship** MBA (the "king" asset, seated atop Olympus) and a tie into the *light/shining* thread (Olympus = "the shining one"). *Decided:* Olympus = the **dApp platform layer** (above); the marketing ladder is **Olympic** (Bronze/Silver/Gold medals + **Laurel** crown), distinct from "Olympus."

View file

@ -0,0 +1,618 @@
# DRA — Dynamic Rate Asset (Design Record)
> **Status: design draft, 2026-06-28.** Authoritative once user accepts the open-design-question recommendations below. Companion to `fiatm_complete-design_FOR-design-record.md` and `fiatm-sovereign-asset.md`; departs structurally from those by introducing a yield-bearing shared-pool architecture, reflective rate dynamics, and adoption-driven convergence to a fixed-rate transactional unit.
>
> **Public class name:** *Dynamic Rate Asset* (DRA). **Hidden identity:** *DRAm* — the Greek drachma restored at the **357 GRD/ECU ERM-entry parity** of 16 March 1998. The class name is the operational soul (the rate *is* dynamic by construction); the cultural reference is held back per the project's surprise-features pattern.
---
## 0. What DRA is
DRA is a **sovereign yield-bearing reflective FIATm-family asset**, distinct from USDm / EURm / GBPm / JPYm / INRm / ARSm / TRYm in five structural ways:
1. **Fee revenue distribution.** All FIATm-family transaction fees accrue to DRA holders via shared-pool NAV accretion (no manual claim, no airdrop).
2. **Class name kept; identity held back.** Public name remains *Dynamic Rate Asset*. The drachma cultural reveal is an easter egg that emerges from parameter recognition (357), not from corporate naming.
3. **Reflective rate.** DRA's mint rate moves *opposite* to EURm's — bounded by stabilization layer (§5).
4. **Stabilization instruments.** Deadband, persistence, max-step (borrowed from §12), plus amplitude clamp on the reflective coupling and time-averaging on the EUR oracle reference.
5. **Equalizer toward fixed-rate maturity.** As adoption grows, the reflective coupling decays and the rate converges to a fixed transactional unit calibrated to *daily average spending purchasing power*.
DRA inherits the §0.5 family properties (sovereign, base-layer backed, atomically swappable, no liquidation, no over-collateralization) from the FIATm template, but **re-architects the backing model** from per-unit lock to shared-pool NAV to enable fee accrual.
---
## 1. Architecture — shared backing pool (the structural departure)
USDm / EURm / etc. each lock backing **per-unit, in the unit's own UTXO**. That model is incompatible with shared fee accrual: there's no covenant-level place to deposit a fee that lifts every existing unit's value.
DRA uses a **single shared-backing pool covenant**:
- All DRA mints contribute BCH to one pool.
- Mints emit DRA tokens (CashTokens) as **pro-rata claims** on the pool.
- Redemptions burn DRA tokens and withdraw the holder's pro-rata share.
- **Fees from other FIATm-family operations flow INTO this pool** without minting matching DRA tokens — so per-DRA NAV rises.
Per-DRA backing at time *t*:
```
B(t) = pool_balance(t) / total_DRA_supply(t)
```
- **Fee inflows** → `pool_balance` rises, `supply` unchanged → `B(t)` rises.
- **Mint** at oracle-set price → both `pool_balance` and `supply` rise; `B(t)` change depends on whether the mint priced above, at, or below current NAV.
- **Redeem** at oracle-set price → both fall; same NAV-vs-price logic.
**Key consequence:** existing DRA holders benefit from every FIATm transaction in the wider family, monotonically, with no claim transaction required. Holding DRA is structurally yield-bearing against the family's transaction volume.
**Trade-off vs per-unit lock:** DRA loses the "redeem your own exact locked BCH at any time" property of EURm/USDm. A DRA redemption is at *current pool NAV*, not at *mint-time lock*. This is the price of shared yield. (See §10 for why this isn't a regression — DRA is a different category of instrument.)
---
## 2. Fee revenue distribution
### Architectural baseline: every FIATm currency feeds DRA (decided 2026-06-28)
**ALL FIATm-family currencies route a fee slice to the DRA pool, by design.** The DraFee variant (`DeviantOracleMBA_DraFee`) is the **mainnet default for the FIATm family**, not an opt-in variant. The original `DeviantOracleMBA` (no fee redirector) remains as the chipnet-validated historical covenant for prior demonstrations; mainnet FIATm currencies deploy on the DraFee variant uniformly.
Consequence: the DRA pool aggregates fee flow across **the whole family — USDm, EURm, GBPm, JPYm, INRm, ARSm, TRYm, CHFm, HUFm, ILSm, DRAm (when revealed), and every future state currency**. Holders of DRA receive a continuous yield stream from the family's collective transaction volume. The asset's value-accretion mechanism is *structurally inseparable* from the family's adoption.
This is what makes the DRA simultaneously a *yield-bearing asset* (fee aggregator) and a *measurement instrument* for FIATm-family adoption velocity. The pool NAV per DRA tells you, in BCH terms, how much value the family has produced per unit DRA outstanding.
### Source of fees
Every FIATm-family operation contributes a small BCH slice to the DRA pool:
| Operation | Fee rate | Paid by | Routed to |
|---|---|---|---|
| FIATm mint (e.g. mint USDm) | **0.05%** of locked BCH | Minter (added to mint cost) | DRA pool |
| FIATm redeem (e.g. burn EURm) | **0.05%** of recovered BCH | Redeemer (subtracted from payout) | DRA pool |
| FIATm ↔ FIATm swap (e.g. USDm → EURm) | **0.10%** of swap notional | Swapper | DRA pool |
| DRA mint | **0%** | — | n/a (recursive) |
| DRA redeem | **0%** (or 0.05% recycled into pool) | Redeemer | n/a or recycled |
Rationale for differentiated fees:
- **Swap = 0.10%**: highest-frequency operation, lowest sensitivity (swappers already accept a small spread). Carries most of the revenue.
- **Mint/redeem = 0.05%**: one-time entry/exit; lower friction matters for first-touch UX.
- **DRA self-fees = 0%**: avoid recursive distortion. A DRA mint shouldn't pay a fee that just sits in the same pool it's joining.
### Distribution mechanism — NAV accretion (= "retention ratchet" of MBA Shares §253)
**Direct backing accretion, not periodic claim.** Fees enter the pool; pool NAV per DRA rises immediately. Every existing holder benefits proportionally and continuously without any transaction.
This is the **retention ratchet** distribution model that the [MBA Shares design record §253](../MBA_Shares/mba-shares_complete-design_FOR-design-record.md) explicitly names "= DRA." The two corpora describe the same load-bearing mechanism — *"retained revenue raises the per-unit rate; value accrues inside the unit, realized on ASM redeem/swap; trustless — no oracle, no pool; form: appreciation."* What we call **NAV accretion** here is the working name for the same mechanism; the MBA Shares §253 term **retention ratchet** is the formal corpus name. They are interchangeable.
### Why NAV accretion is the only oracle-free, distribution-tx-free, silent value-add mechanism
Of the four distribution models §253 catalogues (Registered NFT, Merkle snapshot, Retention ratchet = DRA, Oracle attest), **only the retention ratchet has all three properties simultaneously**:
| Distribution model | Oracle-free? | Distribution-tx-free? | Silent value-add (no holder action)? |
|---|---|---|---|
| Registered NFT | Yes | No (claim per fee) | No (claim required) |
| Merkle snapshot | No (root publication) | No (proof submission) | No (pull required) |
| **Retention ratchet (NAV accretion)** | **Yes** | **Yes** | **Yes** |
| Oracle attest | No (oracle attests) | No (batch settlement tx) | No (batch payout) |
The value travels *with* the unit, encoded in its on-chain claim on a known pool. The holder takes no action; no oracle is consulted; no distribution transaction occurs; no off-chain registry is needed. **Per-DRA NAV is computable by anyone reading two on-chain values** (pool BCH balance + DRA token supply) at any block height — no protocol cooperation required.
This is the structural advantage. It is also the structural cost: holders see no "income" line item — the value is **embedded, not paid out**. That's the trade-off for everything-on-chain, nothing-to-claim. For DRA's design, this matches Odyssean concealment: a steady visible "DRA pays X% monthly" surface would attract attention and make the asset easier to price; silent NAV accretion keeps the asset's true yield character less legible from the outside.
### Relationship to existing DEA flagship — coexist (decided 2026-06-28)
The existing corpus has [`mba-no-burn-dea-value-accrual`](memory note) describing the **DEA = Direct Emission Asset** class with the *Deviant Equilibrium Asset* as flagship instance receiving time-weighted MBA fees per ADR-0042.
**Decision: DRA and DEA coexist.** DEA continues unchanged per ADR-0042 as the MBA-Shares-family fee-receiver / market-driver flagship. DRA is a *new, separate* fee-bearing asset that collects from the FIATm-family transaction stream. The two operate on different fee categories from different asset families:
| Asset | Fee source | Family |
|---|---|---|
| **DEA** (flagship instance: *Deviant Equilibrium Asset*) | MBA-Shares-family operations | MBA Shares (ADR-0042) |
| **DRA** (NEW — *Dynamic Rate Asset*) | FIATm-family operations (mint / redeem / swap) | FIATm |
The split is structurally clean: each family has its own internal fee-bearing flagship, no cross-family fee plumbing required. DEA's design and ADR-0042 remain canonical for the MBA Shares family. DRA's design (this document) governs FIATm-family fee accrual exclusively.
### Fee schedule — chipnet-tunable, mainnet-reshapeable
The **0.05% mint/redeem + 0.10% swap** schedule is a starting point for chipnet validation. **Final mainnet calibration will be reshaped from chipnet observations** of fee-flow dynamics, pool growth rates, and user behavior. Treat the numbers in this section as a *plausible chipnet parameterization*, not a commitment.
What stays fixed across calibrations:
- Fee routing target (DRA pool)
- Differentiated by operation type (swap > mint/redeem)
- DRA self-operations exempt (no recursive fees)
What is reshapeable on mainnet:
- Exact percentages per operation type
- Whether mint and redeem share a rate or differ
- Whether some user classes are exempt (e.g. arbitrageurs operating the catalytic-converter mechanism)
- Whether the schedule is constant or adoption-dependent
---
## 3. Class name — *Dynamic Rate Asset* (Odyssean concealment)
Public name: **Dynamic Rate Asset (DRA)** from launch, *and through the entire adoption arc*. The drachma identity is never formally announced.
### The Odyssean framing (decided 2026-06-28)
When Odysseus returned to Ithaca after twenty years (ten at Troy, ten wandering), his home was occupied by suitors consuming his estate. **He did not reveal himself on arrival.** He came ashore disguised as a beggar — recognized only by his old dog Argos, his faithful swineherd Eumaeus, and eventually his son Telemachus — and remained concealed *until the moment of decisive action*: the bow contest, the slaughter of the suitors, the restoration of his household. The disguise was strategic, not modest. **Premature revelation would have meant the suitors organizing against him; concealment let him act on his own timing.**
DRA's relationship to the modern monetary order is structurally identical. The drachma's home — Greek monetary sovereignty, the cultural lineage from Artemision through the classical-Athens silver standard — is occupied by strangers (the euro, modern CBDC frameworks, the broader fiat regime that absorbed the drachma in 2002). **Announcing DRA as "the drachma restored" at launch would invite institutional opposition before the asset has structural weight.** Keeping the public class name as *Dynamic Rate Asset* lets DRA accumulate FIATm-family adoption invisibly until the maturity ratio (§6) is reached and the design *acts* — at which point the reveal is the action, not an announcement.
What this means operationally:
- All documentation refers to DRA as *Dynamic Rate Asset* — never as DRAm, never as drachma, in user-facing copy
- Design records (this file, the Parameters/ corpus) carry the drachma signature freely — they're for the project's own continuity
- Parameters embed the drachma (357 launch ratio, ERM-entry anchor, classical-Athens maturity ratio §6)
- Readers who recognize 357 GRD/ECU discover the lineage independently (Argos-the-dog recognitions)
- Shell UI surfaces display "DRA" without classical-Athens branding
- The maturity event — **1 DRA = €357**, the reverse of the surrender ratio — is the reveal. By the time it happens, DRA has the structural weight Odysseus had at the bow contest.
**No adoption-milestone announcement, no equalizer-endpoint statement, no rebranding ceremony.** The asset is *Dynamic Rate Asset* until the design has done what it set out to do. After that, the name *Dynamic Rate Asset* still works (the rate WAS dynamic across the arc; at maturity it just stops being dynamic) — so the brand never needs to change. The drachma identity is *carried* by the asset's behavior, not declared by its label.
Consistent with the project's surprise-features pattern ([rollout-and-surprise-features](memory note)): static MBA public first, dynamic-rate elevation held back. DRA is the same playbook taken to its sharpest form — *concealment as a structural-strategic feature, not just a marketing surprise*.
---
## 4. Reflective rate — doubly asymmetric: counter on negative side, gain on positive side
### The rule
DRA's mint rate moves **inversely** to EURm's, *linearly* in raw form, with **two adoption-strengthening modifiers**:
- **Counter on the EUR-strength side** — dampens DRA's downside response, hardens with adoption (existing).
- **Gain on the EUR-weakness side** — amplifies DRA's upside response, strengthens with adoption (NEW, user revision 2026-06-28).
User's design quote: *"with adoption on each 210,000 minted coins strengthening DRA positions and accelerating positive rate direction."* This requires both modifiers tied to the catalyst counter.
The asymmetry is doubly intentional and load-bearing: **DRA is fully exposed to EUR weakness AND PROGRESSIVELY ACCELERATED in that direction** (gain rises with adoption) **AND progressively insulated from EUR strength** (counter hardens with adoption). The asset's trajectory becomes increasingly biased toward maturity as adoption progresses. This is the structural meaning of the §6 catalyst progression.
### The formula
```
⎧ DRA_rate_0 − α × (EUR_rate − EUR_rate_0) / counter(r, A) if r > 1
DRA_rate = ⎨ (negative side — EUR strengthens, dampened)
⎩ DRA_rate_0 − α × (EUR_rate − EUR_rate_0) × gain(A) if r ≤ 1
(positive side — EUR weakens, amplified)
where:
r = EUR_rate / EUR_rate_0 (EUR-strength ratio vs genesis)
A = adoption catalysts fired so far (capped at MAX_CATALYSTS = 30)
counter(r, A) = max(r × (1 + A × β), 1) (hardens negative side)
gain(A) = 1 + A × γ (accelerates positive side)
α = base reflection amplitude (recommended 1.0)
β = per-catalyst counter-strengthening factor (recommended 0.10)
γ = per-catalyst gain-accelerating factor (recommended 0.05)
```
**Then mint price = max(reflective DRA_rate, current NAV per DRA).** See §4a below — this is the load-bearing structural property that makes the asset actually appreciate.
**Interpretation:**
- **r > 1** (EUR strengthens against BCH): counter dampens. At r=2, A=0: counter=2 (effect halved). At r=2, A=30: counter=8 (effect cut to 1/8). EUR-strength shocks become progressively less damaging as adoption matures.
- **r ≤ 1** (EUR weakens against BCH): gain amplifies. At A=0: gain=1 (linear mirror). At A=30: gain=2.5 (response 2.5× the linear amount). EUR-weakness opportunities become progressively more powerful as adoption matures.
### Catalyst — 210,000 cumulative mints per catalyst, capped at A=30
Matching §12a.2 of the FIATm controller (`MBA family-template`), DRA catalysts fire **every 210,000 cumulative DRA mints**, with a **hard cap at A = MAX_CATALYSTS = 30** (asymptote — matches the design's §6 saturation target). Once A=30, further mints continue but do not strengthen the counter or gain further. The mature controller state is reached at A=30 and remains the operating state thereafter — exactly the §12a.2 pattern, applied to DRA's distinct three-force composition.
### The catalyst IS the bonding-curve escalation (Reflective DRA's structural equivalent)
The existing DRA covenants (`DeviantExpDRA` / `DeviantStepDRA` / `DeviantSuperExpDRA`) implement *"bonding-curve escalation per issuance"* as the per-mint rate advancing along a fixed curve — each new mint locks more sats than the prior one. The Reflective DRA's catalyst progression **IS the same structural escalation, with two simultaneous effects compounding per catalyst**:
| Per-catalyst effect | Mechanism | Direction |
|---|---|---|
| **Appreciation acceleration** | Gain γ increases: `gain(A) = 1 + A × γ` — EUR-weakness response amplifies | DRA's positive-side rate trajectory steepens |
| **Stability hardening** | Counter β strengthens: `counter(r, A) = max(r × (1 + A × β), 1)` — EUR-strength shocks dampened | DRA's negative-side rate trajectory flattens |
The two effects compose into a **bonding curve with asymmetric one-way bias**: each issuance milestone (210k mints) makes the asset *both* more appreciative-in-favorable-conditions *and* more stable-in-adverse-conditions. Unlike the per-unit bonding curves (which only escalate the rate), the Reflective DRA's escalation simultaneously raises the appreciation trajectory and lowers the volatility — **DRA becomes simultaneously more rewarding and more boring as adoption grows**. The classical bonding-curve "rate per issuance" generalizes here to "trajectory shape per issuance," with the shape biased monotonically toward maturity.
This is what makes the Reflective DRA a coherent sibling of the existing DRA class: same defining property (dynamic rate moving with issuance), different architectural mechanism (rate-shape evolution vs per-mint rate advance).
### 4a. Mint price as max(reflective rate, current NAV) — the structural pivot
**The mint price for new DRA is the MAX of the reflective DRA_rate and the current per-DRA NAV.**
```
mint_price(t) = max(DRA_rate(t), pool_balance(t) / supply(t))
```
This is **load-bearing**. Without it, the design fails — three properties break:
1. **No dilution of existing holders.** If new mints could pay reflective `DRA_rate` when that rate is below current NAV, every new entrant would dilute existing holders by adding backing below the prevailing NAV. The max-of-two pricing prevents this structurally.
2. **Reflective premium becomes the NAV-lift mechanism.** During EUR-weakness events post-catalyst, the gain-amplified reflective rate exceeds NAV. New mints pay the *premium*, which adds backing *above* current NAV per DRA, lifting per-DRA NAV for ALL holders. This is the structural channel by which the gain function actually accretes value to the pool — without max-of-two pricing, the gain has no path to per-DRA NAV.
3. **One-way NAV ratchet.** Combined with the always-positive fee accrual, the max-of-two mint pricing means per-DRA NAV strictly monotonically increases. NAV cannot decrease through normal operation (only through redemptions, which take a holder's pro-rata share without affecting per-DRA NAV for others). The asset *ratchets* toward maturity.
The reflective rate's role is therefore **not** to directly price mints — it is to set the *floor* on mint price below which the system structurally refuses to operate. Mints either pay current NAV (when reflective is below NAV) or pay the reflective premium (when reflective rises above NAV via gain × EUR-weakness). In both cases, existing holders are non-dilutable; in the second case, they actively benefit from the new entrant.
Empirical confirmation from simulator (Stage-1 test, 2026-06-28): adding `max(reflective, NAV)` lifted 5y sustained-debasement NAV from €2.86 to **€35.67** (12× improvement), and 15y NAV from €2.86 to **€41.83**. The mechanism was structurally broken without this property; it works after the property is added.
### How the reflective rate moves under the new formula
| Scenario | EUR_rate | r | A | gain or counter | Raw DRA_rate (sats) | Mint price after max(·, NAV) |
|---|---|---|---|---|---|---|
| Launch baseline | 500,000 | 1.00 | 0 | counter=1.0 | 1,401 | NAV ≈ 1,401 |
| BCH up 50% (EUR weakens, early) | 333,333 | 0.67 | 0 | gain=1.0 | 1,401 + 166,667 = 168,068 | clamped to 2× = 2,802; mint pays 2,802 (premium) |
| BCH up 50% (EUR weakens, mature) | 333,333 | 0.67 | 30 | gain=2.5 | 1,401 + 416,668 = 418,069 | clamped to 5× = 7,005; mint pays 7,005 (premium) |
| BCH down 50% (EUR strengthens, early) | 1,000,000 | 2.00 | 0 | counter=2.0 | 1,401 − 250,000 = −248,599 | clamped to 0.5× = 700; mint pays max(700, NAV) — NAV likely |
| BCH down 50% (EUR strengthens, mature) | 1,000,000 | 2.00 | 30 | counter=8.0 | 1,401 − 62,500 = −61,099 | clamped to 0.05× = 70; mint pays max(70, NAV) — NAV always |
Compare: under EUR weakness, mature DRA mint pays 7,005 sats — that's a premium of ~5× over baseline that lifts NAV. Under EUR strength, mature DRA mint pays NAV (not the depressed reflective rate), so existing holders are protected from dilutive entry.
### Why linear mirror with this combination, not constant product
Constant product (`EUR × DRA = K`) is symmetric. The design needs **double-asymmetric** dynamics (full positive *acceleration*, hardening negative *dampening*). Linear mirror with the gain/counter pair produces this directly. The "can go negative" risk in raw form is bounded by the §5 amplitude clamp; the mint-price max-of-two ensures the negative-going raw rate doesn't matter for holder economics (NAV pricing prevails when reflective is depressed).
### Genesis math (BCH ≈ €200, drachma anchor 357)
```
EUR_rate(0) = 1 / 200 BCH = 500,000 sats per EURm (oracle baseline)
DRA_rate(0) = 500,000 / 357 = 1,401 sats per DRA (1 EURm = 357 DRA at genesis)
α = 1.0 (full linear mirror at launch)
β = 0.1 (10% counter strengthening per catalyst)
A(0) = 0 (no catalysts at genesis)
```
### How the reflective rate moves
| Scenario | EUR_rate | r | A | counter | DRA_rate (sats/DRA) | Direction |
|---|---|---|---|---|---|---|
| Launch baseline | 500,000 | 1.00 | 0 | 1.0 | **1,401** | Baseline |
| BCH appreciates 50% (EUR weakens) | 333,333 | 0.67 | 0 | 1.0 | **1,401 − 1.0 × (−166,667)/1.0 ≈ 168,068** | Strong DRA appreciation (full mirror) |
| BCH depreciates 50% (EUR strengthens), early | 1,000,000 | 2.00 | 0 | 2.0 | **1,401 − 1.0 × 500,000/2.0 ≈ −248,599** | Would go negative; amplitude clamp (§5) holds it at floor |
| BCH depreciates 50% (EUR strengthens), mature | 1,000,000 | 2.00 | 30 | 8.0 | **1,401 − 1.0 × 500,000/8.0 = −61,099** | Still goes negative without clamp, but much less so — adoption protects |
**Yes — DRA_rate can go negative in raw form when EUR strengthens dramatically and the counter hasn't fully matured.** That's why the amplitude clamp in §5 catches it. The clamp + the adoption-strengthening counter together ensure that as DRA matures, the negative-side trajectory becomes structurally bounded, and at high catalyst count the negative-side response is small enough that the clamp rarely activates.
### What this gives holders
**Across the adoption arc, DRA is increasingly a one-way bet on EUR weakness.** In early days, DRA's reflective response is symmetric (full linear mirror in both directions; tight amplitude clamp protects holders during bootstrap). As adoption catalysts fire, the negative-side counter strengthens — the rate becomes preferentially insulated from EUR-strength shocks while remaining fully exposed to EUR-weakness gains. **At maturity, DRA is structurally configured to appreciate against any EUR weakening event with minimal pullback on EUR rallies.**
This composes with the fee accrual (§2): the pool grows monotonically from fees, and the reflective rate adds asymmetric upside from EUR cycles. The combined trajectory is what allows the §6 maturity target (1 DRA = €357 — the surrender-ratio inverted) to be reachable across the adoption arc.
### Why linear mirror, not constant product
Constant product (`EUR × DRA = K`) is mathematically clean but *symmetric* — it dampens both directions equally. The design here needs *asymmetric* dampening (full positive exposure, hardening negative dampening). Linear mirror with the counter formula above produces that asymmetry directly. The "can go negative" risk is real but managed by the §5 amplitude clamp, which under mature catalyst conditions rarely activates because the negative-side response has already been dampened by the counter.
---
## 5. Stabilization instruments — amplitude widens with adoption
The reflective rate needs bounding, but the design choice is the *opposite* of the conventional one: **the amplitude clamp is TIGHT at launch and WIDENS as adoption matures.**
Reasoning: at launch the DRA pool is shallow, the holder base is small, and wild rate swings would punish bootstrap participants. The system protects them with a tight band. As adoption grows, the pool deepens, the holder base broadens, fees accrue continuously, and the system gains the structural capacity to absorb wider rate swings — which become arbitrage opportunities rather than user-facing volatility. **The stronger the adoption, the stronger the amplitude.**
### The five stabilization layers
| Layer | DRA-specific behavior | Source |
|---|---|---|
| **Deadband** | EUR_rate move must exceed ±2% before DRA_rate updates | §12 (§12b damping) |
| **Persistence** | Move must hold ≥3 windows (~30 min) | §12 (§14 stack) |
| **Max-step** | Per-window DRA_rate change ≤5% (tightens to 2% at full maturity) | §12 (§12b) |
| **Amplitude clamp (adoption-scaled)** | DRA_rate ∈ [lower(A), upper(A)] × DRA_rate_0 — widens with each catalyst | NEW (DRA-specific) |
| **Time-averaged EUR reference** | DRA uses 6-hour moving average at launch, widening to 24h at A=10, 7-day at A=30 | NEW (DRA-specific) |
### Amplitude clamp — adoption-dependent band
```
lower_clamp(A) = max(0.5 − A × δ_lower, 0.05) # widens DOWN with adoption, floored at 5% of baseline
upper_clamp(A) = 2.0 + A × δ_upper # widens UP with adoption, unbounded
```
Recommended initial:
- `δ_lower = 0.015` (per catalyst, the lower bound drops 1.5% of baseline)
- `δ_upper = 0.10` (per catalyst, the upper bound rises 10% of baseline)
At A=0 (launch): band = **[0.5×, 2.0×]** — tight, protects bootstrap holders.
At A=10: band = **[0.35×, 3.0×]** — wider; pool has depth.
At A=30 (maturity): band = **[0.05×, 5.0×]** — very wide; system absorbs swings as arbitrage opportunities.
The asymmetry between `δ_lower` and `δ_upper` (lower bound widens 1.5%/catalyst, upper bound widens 10%/catalyst) bakes in the same one-way preference as the reflective formula: more room to appreciate, less room to drop. The combined effect with §4's counter-amplifier means DRA's effective trajectory becomes increasingly biased toward the §6 maturity target as adoption progresses.
### Time-averaged EUR reference
DRA reads a moving average of EUR_rate, not spot. The averaging window also lengthens with adoption:
| Adoption phase | Catalysts | Averaging window |
|---|---|---|
| Launch | 0 | 6 hours |
| Growing | 1–9 | 6 → 24 hours |
| Maturing | 10–29 | 24h → 7 days |
| Mature | 30+ | 7 days → ∞ (frozen, §6) |
This decouples DRA from intraday oracle noise — DRA holders shouldn't see their pool NAV jitter every block — and ratchets toward maturity-state stability through adoption rather than at genesis.
---
## 6. Equalizer — convergence to the *opposite reverse* (decided 2026-06-28; multi-decade horizon)
### The maturity target — 1 DRA = €357 (long-arc)
**Maturity goal: 1 DRA = €357 (or $357) of nominal purchasing power.** This is the *opposite reverse* of the launch ratio.
| | DRA-to-EUR ratio | DRA per EUR | EUR per DRA |
|---|---|---|---|
| **Genesis (launch)** | 1 : 357 | 357 DRA = 1 EUR | €0.0028 per DRA |
| **Maturity (target)** | 357 : 1 | 1 DRA = 357 EUR | **€357 per DRA** |
| Required appreciation | — | — | **127,449×** |
This is the literal inversion of the drachma's surrender ratio. The 1998 ERM-entry parity (1 ECU = 357 GRD) was the moment the drachma committed to absorption. The maturity ratio (1 DRA = €357) is the value the drachma reaches by virtue of *not* having been absorbed this time — restored on a substrate the euro cannot capture.
### Multi-decade horizon — the trajectory is the asset (decided 2026-06-28)
**The €357 maturity envelope is a multi-decade goal, not a 5-year goal.** User's framing: *"any outcome within 5 years is good, even if the rate stays below 1 eur / 1 dra, let it be long term goal, with adoption on each 210,000 minted coins strengthening DRA positions and accelerating positive rate direction."*
The trajectory itself is the asset, not the endpoint. Stage-1 simulator (2026-06-28) confirms multi-decade adoption arcs produce continued NAV growth without finding a steady-state plateau:
| Horizon (sustained debasement, medium adoption) | Per-DRA NAV (sim Stage-1) | Multiple from genesis €0.0028 |
|---|---|---|
| 5 years | €35.67 | 12,739× |
| 10 years | €39.56 | 14,128× |
| 15 years | €41.83 | 14,939× |
| 30 years | €45.73 | 16,332× |
| 50 years | €48.60 | 17,357× |
**The simulator's 50-year point still grows — the asset has not asymptoted. The arc to €357 is structurally on-track over realistic adoption horizons** (50 to 200+ years given the scenarios tested). This matches the design's symbolic horizon: the classical-Athens drachma's value position took centuries of adoption to establish; restoring it is a multi-generational project.
**The asset embodies the design's success.** Adoption *is* the only path to maturity. The asset and the project's success share one trajectory.
### What "any 5y outcome is fine" means structurally
The user's relaxed 5y target is consistent with the design's load-bearing claim: **what holders pay for is the trajectory, not the endpoint**. Stage-1 sim results confirm (single-currency feeder mode, stress-test calibration):
- 5y calm trajectory at medium adoption: NAV **€8.41** (well above €1)
- 5y EUR-strengthens-50% (adversarial bear case): NAV **€4.60** (still well above €1)
- 5y sustained debasement: NAV **€35.67**
- 5y adversarial with 70% crash then recovery: NAV **€31.62** (survives drawdown to €2.59, recovers)
All 5y outcomes exceed the user's stated minimum (€1 per DRA).
### Multi-currency adoption — the architectural baseline (T8 sim 2026-06-28, post-moderation)
Under the architectural baseline (six FIATm currencies — USDm/GBPm/CHFm/TRYm/HUFm/ILSm — feeding the DRA pool simultaneously), with moderated adoption volumes that reflect plausible 5-10y family scaling:
| Currency | Daily mint | Daily tx | Share of 5y fee flow |
|---|---|---|---|
| USDm | 10,000 | 250,000 | 72.1% |
| GBPm | 2,500 | 50,000 | 13.2% |
| CHFm | 800 | 12,000 | 4.6% |
| TRYm | 1,500 | 35,000 | 4.2% |
| HUFm | 300 | 6,000 | 1.0% |
| ILSm | 1,000 | 20,000 | 4.8% |
**5y multi-currency NAV: €241 per DRA** — 67% of the €357 maturity envelope at A=16 (catalysts still progressing toward saturation). The asset *approaches* maturity over a 5-10y arc rather than overshooting it. Multi-decade adoption produces continued slow appreciation as fees accrue post-saturation.
This is the *intended calibration*: family-wide adoption compresses the maturity timeline from 50–200y (single-currency) to 5–10y, but does not overshoot the €357 envelope until adoption sustains beyond the initial wave. The mechanism is consistent with a near-term value carrier under realistic family success, AND a generational store under single-currency mode — both valid trajectories from the same mechanism, depending on whether the family achieves multi-currency mainnet adoption.
### Mechanism — catalysts strengthen BOTH counter (negative-side) AND gain (positive-side)
The equalizer is not a separate decay mechanism — it is the **adoption-driven double-asymmetric accumulator of the §4 modifiers**. Each catalyst (per user revision 2026-06-28):
1. **Increments `A`** in counter formula `counter(r, A) = max(r × (1 + A × β), 1)` — making EUR-strength shocks progressively less damaging.
2. **Increments `A`** in gain formula `gain(A) = 1 + A × γ` — making EUR-weakness opportunities progressively more powerful.
3. **Widens the amplitude clamp** per §5 — letting DRA respond more freely to EUR-weakness opportunities (the lift mechanism via mint-price max-of-two in §4a).
4. **Lengthens the EUR-rate averaging window** per §5 — protecting DRA from intraday noise as the pool deepens.
Catalyst trigger: **every 210,000 cumulative DRA mints** (matching §12a.2 of the FIATm controller — the same adoption threshold that fires the FIATm catalyst). Hard cap at **A = MAX_CATALYSTS = 30** — the asymptote that matches the design's mature steady-state target. Once A=30, further mints continue but the modifiers freeze at their saturated values.
After ~30 catalysts (matching §12a.2's saturation pattern), the configuration has reached:
| Parameter | At genesis | At maturity (A=30) |
|---|---|---|
| Counter strength at r=2 | 2.0 | 8.0 (EUR strengthening 2× barely moves DRA) |
| Amplitude clamp | [0.5×, 2.0×] | [0.05×, 5.0×] (5× upper bound = €357 at €200 BCH price line) |
| EUR-rate averaging window | 6 hours | 7 days+ (effectively frozen reference) |
| Max-step on DRA decline | 5%/window | 2%/window (structural floor) |
At this state, DRA's behavior is:
- **Fully exposed** to EUR weakness → strong appreciation continues
- **Structurally insulated** from EUR strength → minimal pullback even on shocks
- **Slowly responsive** to short-term oracle noise → pool NAV moves smoothly
This is *not* a frozen rate. DRA's maturity behavior is the steady-state of the dynamic mechanism — the rate continues moving, but its trajectory is structurally biased toward (and capped near) the €357 maturity ratio. The asset remains *Dynamic* in name and behavior; the dynamics just settle into the maturity envelope.
### Why the maturity ratio matters historically
The drachma in classical Athens was the standard unit of value across the Greek world for centuries. The Attic drachma (4.3 g of silver) was a skilled craftsman's day-wage in 5th-century Athens; the tetradrachm (4 drachmas) was the standard merchant transaction unit. **The drachma at maturity held a value equivalent to a meaningful day of human labor.**
In 1998 the drachma at 357 GRD/ECU was a faded shadow of that — a year of work was tens of thousands of drachmas. The 2002 euro absorption ended the unit entirely.
**1 DRA = €357 at maturity is the design's bid that the drachma's value can be restored to its classical-era position on a substrate the euro cannot reach.** A 127,449× nominal lift from the surrender ratio puts DRA's purchasing power back in the order of magnitude where the drachma originally lived. **Not by recreating Athens — by restoring the unit's *role* in a digital monetary system the drachma's history points toward.**
This is the symbolic payload Odysseus's bow contest carries. The reveal isn't a declaration — it's the design *acting* once it has the structural weight to. At €357 per DRA, the lineage from Artemision (7th c. BCE coinage genesis) → classical-Athens silver standard → 1998 surrender → BCH-backed restoration is complete on the public record without a single line of marketing copy.
---
## 7. The drachma identity — symbolic stack
At every layer the design carries the drachma signature, but never names it:
| DRA design element | Drachma resonance |
|---|---|
| Public name *Dynamic Rate Asset* | The drachma's name (δραχμή = "to grasp", from *drassomai*) was about value-holding — the asset grasps value across cycles |
| Launch ratio **357 per EUR** | 1998 ERM entry parity (16 March 1998, 357 GRD/ECU after 12.3% devaluation) — the moment of surrender |
| Per-unit nominal at genesis ~€0.0028 | Late-1990s drachma's order of magnitude vs DEM, USD — the diminished currency entering ERM |
| Fee-bearing pool | Greek poleis' silver reserves accrued from minting fees — the *technē* of emission paid for the substrate |
| Reflective vs EUR, asymmetric | Drachma's history is *against* the euro (entered ERM under pressure, absorbed reluctantly, retired in 2002, debt crisis 2010–2018); the asymmetric counter encodes that one-sided relationship |
| Odyssean concealment under *Dynamic Rate Asset* | Odysseus returned to Ithaca disguised; the home (Greek monetary heritage) is occupied by strangers (the euro, modern fiat order). Premature revelation = the suitors organizing against him. Concealment = strategic, until the moment of decisive action |
| **Maturity ratio inversion: 1 EUR : 357 DRA → 1 DRA : 357 EUR** | The drachma's surrender ratio inverted *exactly*. The moment DRA reaches €357, the design has restored the drachma's classical-era position (skilled craftsman's day-wage) on a substrate the euro cannot reach |
| Equalizer = catalysts strengthen the negative-side counter | The classical drachma's stability was earned through silver-standard adoption across the Greek world over centuries; DRA's stability is earned through FIATm-family adoption across the global monetary order |
| Maturity = *dynamic steady-state*, not frozen | The drachma in its mature classical era continued to circulate dynamically — its *trustworthiness* was the constant, not its instantaneous rate. DRA at maturity behaves the same way: the trajectory is structurally biased toward €357 but the rate continues moving within the mature envelope |
The cultural payoff: **DRA is the drachma restored at the rate of its last sovereign hour, with the structural correction the drachma's history needed for 2,600 years** (Artemision 7th c. BCE coinage genesis → classical-Athens silver standard → Hellenistic-period debasement → Roman absorption → Byzantine forms → 1832 modern paper drachma → 2002 euro absorption → 2026+ BCH-backed restoration). **The reveal is the bow contest** — when DRA reaches €357 per unit, the lineage is complete on the public record without a single line of marketing.
---
## 8. Concrete holder math — three example paths
Assume:
- Genesis: 100 DRA minted by user A at 1,401 sats per DRA → user A locks 140,100 sats
- Pool starts with: 140,100 sats and 100 DRA → NAV = 1,401 sats/DRA
### Path 1 — adoption grows, EUR drifts mildly
After 6 months:
- Other FIATm transactions have generated 3M sats in fees → pool now 3,140,100 sats
- 1,000 more DRA minted at various rates around launch baseline → supply now ~1,100 DRA
- Pool NAV per DRA: 3,140,100 / 1,100 ≈ **2,855 sats/DRA**
- User A's 100 DRA: 100 × 2,855 = 285,500 sats (was 140,100) — **+103%** in 6 months from fee accrual alone
### Path 2 — EUR strengthens 20%
- EUR_rate falls from 500k to 400k (BCH/EUR rises to €250)
- DRA mint rate rises: K / 400k = 1,751 sats per DRA (was 1,401)
- New mints contribute more sats per DRA → pool NAV per DRA rises faster than fee-only path
- User A's redemption value at this moment: pool_NAV / supply × 100 = depends on net flow
### Path 3 — EUR weakens 20%, near amplitude clamp
- EUR_rate rises from 500k to 600k
- DRA mint rate falls: K / 600k = 1,167 sats per DRA
- New mints contribute fewer sats per DRA → pool NAV per DRA grows slower from mints, but fees still accrue
- Amplitude clamp active if EUR_rate >2× launch (at 1M sats) — DRA mint rate floors at K / (2 × launch_EUR_rate) = 700.5M / 1M = 700 sats per DRA
---
## 9. Asymmetric three-force interaction (the property hypothesis)
Three forces act on per-DRA NAV simultaneously, **all biased toward the §6 maturity ratio of €357 per DRA**:
| Force | Direction | Source | Adoption behavior |
|---|---|---|---|
| **Fee accrual** | Always positive | FIATm-family transaction volume | Compounds with adoption (more family-wide transactions → more fees) |
| **Reflective rate on mints** | Asymmetric: full positive, dampened negative | EUR_rate movement vs DRA_rate_0 | Negative-side dampening *hardens* with each catalyst (§4 counter); positive-side remains fully exposed |
| **Equalizer = catalyst-driven counter strengthening** | Bias-amplifier | Adoption catalysts (~every 100k DRA mints) | Each catalyst strengthens negative-side counter (§4) and widens amplitude clamp (§5) |
### The three forces compose into a one-way trajectory
**The composition is structurally biased toward maturity.** Each force individually has an asymmetric or always-positive contribution; together they produce a per-DRA NAV trajectory that grows monotonically across realistic adoption arcs:
- **Fee accrual** alone would lift per-DRA NAV ~10–50× over a multi-year adoption window (depending on FIATm-family transaction volume), assuming the simulator confirms these magnitudes. By itself this is insufficient to reach the §6 maturity target of 127,449× appreciation.
- **Reflective dynamics** add asymmetric lift: full positive response to EUR weakness produces strong DRA appreciation during EUR-debasement episodes; dampened negative response to EUR strength prevents corresponding pullback. The asymmetry compounds over multiple EUR cycles.
- **Catalysts** make this compounding accelerate: at A=30, EUR strengthening 2× barely affects DRA (counter = 8); EUR weakening 2× still produces full mirrored response.
Net effect: the per-DRA NAV walks toward the maturity ratio along a path the three forces have all aligned in the same direction. The §6 maturity is *reachable* under the design's mechanism — *if* adoption produces the required transaction volume and catalyst accumulation.
### What "adoption" means here, precisely
The §6 phrase *"adoption is crucial element on this path"* is operationalized as four measurable substrate flows:
1. **FIATm-family transaction count** → fees into the DRA pool (fee force)
2. **DRA mint volume** → catalyst progression (equalizer force)
3. **EUR-cycle exposure** → reflective dynamics over time
4. **Holder base breadth** → amplitude clamp can widen safely (§5 needs depth for breadth)
All four are *correlated* with the same underlying driver — population-scale FIATm-family use. **The asset is gated by a single substrate property**: whether FIATm achieves adoption. If yes, all four flows feed DRA to maturity; if no, all four stall together. The asset has one structural dependency, sharply named.
### Hypothesised steady-state
Per-DRA NAV grows monotonically across the adoption arc, with **fee accrual carrying the baseline**, **reflective dynamics producing asymmetric step-ups during EUR-weakness episodes**, and **catalysts ratcheting the asymmetry deeper** so that EUR-strength episodes produce smaller pullbacks each cycle. The trajectory's shape is a step-function-with-rising-floor, not a smooth exponential.
### Open simulation work (Stage-1 required)
The asymmetric three-force interaction is **labelled hypothesis-stage** until simulator validates:
| Question | Test |
|---|---|
| Does fee accrual at plausible FIATm-family volumes lift per-DRA NAV by enough order-of-magnitude to be on track? | Simulate fee inflows at varied FIATm transaction volumes (low / medium / high adoption scenarios). |
| Does the asymmetric reflective add the required additional lift across realistic EUR-cycle distributions? | Simulate reflective response under historical EUR/BCH trajectory; compare with constant-product alternative. |
| Does catalyst-driven counter strengthening actually converge to the §6 maturity envelope, or overshoot / undershoot? | Sweep β values (0.05 to 0.20); identify what β produces convergence by A=30. |
| What's the worst-case adversarial trajectory the three-force composition survives? | Adversarial sims: rapid EUR strengthening early in adoption, sustained EUR strengthening through maturity, EUR-volatility-only with no trend. |
| Does the asset trajectory remain holders-fair across catalyst progression? | Vintage analysis: holder at A=0, A=10, A=20, A=30 — does each get a structurally-acceptable trajectory? |
Stage-2 robustness tests (after Stage-1 confirms): synthetic-attack scenarios, multi-currency competition (DRA + ARSm + TRYm together), FIATm-family fee-revenue volatility, oracle-failure scenarios for the underlying EUR_rate reference.
This is the DRA equivalent of the §11 catalytic-converter hypothesis from `catalytic-converter-hypothesis_FOR-testing.md` — labelled hypothesis-stage, Stage-1 simulation required, Stage-2 robustness before formal §12e / ADR promotion.
---
## 10. Why this isn't a regression from the per-unit-lock FIATm
DRA loses the "redeem your own exact locked BCH at mint-rate floor" property of USDm/EURm. That property is the *single most important* structural feature of those assets. So why is shared-pool acceptable for DRA?
Because DRA is a **different category of instrument**:
| Property | USDm / EURm / etc. | **DRA** |
|---|---|---|
| Backing model | Per-unit locked at mint | Shared NAV pool |
| Redemption value | Exact mint-time backing | Current pool NAV per DRA |
| Yield | None (face stays at $1) | Yes (fees accrue to NAV) |
| Hedging utility | None (single-currency exposure) | Reflective to EURm direction |
| Maturity behavior | Continuous DRA-rate (always dynamic) | Equalizer convergence to fixed maturity |
| Failure mode | Backing BCH dips with BCH price (transient) | Pool dilution if reflective rate is exploited (manageable via stabilizers §5) |
| Position in family | Denomination instrument | Yield + hedge instrument |
DRA is the **family's yield + counter-cyclical-hedge instrument**, while USDm/EURm/etc. remain the denomination instruments. Holders should think of DRA as the *Berkshire Hathaway share* of the FIATm family — accumulating value through the family's transaction volume — while USDm/EURm/etc. remain the *cash*.
This is the right trade-off for an asset that doesn't need to be 1=1 face-stable. **The drachma identity carries this exactly**: DRAm isn't aspiring to be "stable to a fiat face" because the fiat face it inherits (drachma) is *historical*, not active. The whole point of restoring it on-substrate is *not* to track a current fiat exchange rate — it's to provide a *fixed transactional unit at maturity*, which is what the drachma originally was in classical Athens.
---
## 11. Design decisions (user-confirmed 2026-06-28)
1. **DEA relationship** (§2): **Coexist.** DEA continues per ADR-0042 as MBA-Shares-family flagship; DRA is the new FIATm-family flagship. Two separate fee streams from two separate families.
2. **Fee schedule** (§2): **TBD on mainnet, reshapeable from chipnet observations.** Chipnet starting point: 0.05% mint/redeem + 0.10% swap. Final calibration deferred.
3. **Reveal mechanism** (§3): **Odyssean concealment.** Never explicit. The public class name *Dynamic Rate Asset* carries through the entire adoption arc. The reveal is the maturity event, not an announcement. Concealment is *structural-strategic*, not modesty.
4. **Reflective formula** (§4): **Linear mirror with doubly-asymmetric adoption-strengthening modifiers.** Counter on EUR-strength side (dampens negative), gain on EUR-weakness side (accelerates positive). Both tied to the same catalyst counter.
5. **Mint price** (§4a): **`max(reflective DRA_rate, current NAV per DRA)`.** Load-bearing — without this, gain has no path to NAV accretion and the design fails. Empirically lifts 5y NAV from €2.86 to €35.67 in the sustained-debasement test.
6. **Amplitude clamp** (§5): **Widens with adoption, not tightens.** Tight at launch (protect bootstrap holders); wide at maturity (mature pool absorbs swings as arbitrage). Asymmetric widening.
7. **Maturity target** (§6): **1 DRA = €357 on multi-decade horizon.** The *opposite reverse* of the launch ratio. 127,449× appreciation; horizon is generational, not 5y. Any positive 5y trajectory is on-design.
8. **Equalizer catalysts** (§6): **Every 210,000 cumulative DRA mints, hard-capped at A=30** (matching §12a.2 of FIATm controller). Each catalyst strengthens BOTH counter and gain simultaneously.
9. **Catalyst MAX = 30** (§4/§6): Hard cap on adoption counter at A=30 — the saturated mature state, matches §12a.2's asymptotic target. Beyond A=30, mints continue but modifiers freeze.
All decisions committed. Stage-1 simulator results (2026-06-28):
- 5y calm: €8.41 per DRA
- 5y EUR strengthening (adversarial bear): €4.60
- 5y sustained debasement: €35.67
- 5y adversarial crash+recovery: €31.62
- 15y sustained debasement: €41.83
- 50y sustained debasement: €48.60 (still growing — multi-decade arc on-track)
All Stage-1 outcomes exceed the user's relaxed bar (€1 per DRA at 5y). Mechanism is internally consistent and validates as a labelled-hypothesis-passes-Stage-1 result. Stage-2 robustness testing (adversarial trajectories, multi-currency competition, oracle-failure scenarios) before ADR promotion.
---
## 12. Implementation plan
### Phase 1 — Design (this document)
- [x] Design record draft (this file)
- [ ] User review of open design questions
- [ ] Memory entries: DRA introduction + design record pointer
- [ ] Synthesis doc cross-reference
### Phase 2 — Simulator
- [ ] `scripts/sim-dra-controller.mjs` — five-property simulator (pool NAV evolution, reflective mint pricing, equalizer decay, fee accrual integration)
- [ ] Single-currency validation (DRA in isolation)
- [ ] Family-volume scenarios (DRA pool growth under varying FIATm transaction volume)
- [ ] Reflective-stress test (adversarial EUR_rate trajectories)
- [ ] Equalizer convergence test (does maturity actually converge?)
### Phase 3 — Chipnet covenant
- [ ] `contracts/DraSharedPool.cash` — shared-backing pool covenant (mint/redeem at oracle-derived prices, accept fee deposits)
- [ ] `contracts/FiatmFeeRedirector.cash` — modification to existing FIATm covenants to route 0.05/0.10% to DRA pool
- [ ] `scripts/chipnet-dra-genesis.mjs` — genesis test (mint initial DRA pool, validate per-DRA NAV calculation)
- [ ] `scripts/chipnet-dra-fee-accretion.mjs` — multi-currency FIATm transactions feeding DRA pool
- [ ] `scripts/chipnet-dra-redeem.mjs` — pro-rata redemption at current NAV
### Phase 4 — Integration
- [ ] Wallet UI: DRA appears as a distinct asset class
- [ ] DRA-specific surface: pool NAV display, fee accrual rate, equalizer progress
- [ ] FIATm UI: small "fee → DRA pool" indicator on mint/redeem/swap confirmations
---
## 13. Cross-references
- `Parameters/FIATm/fiatm_complete-design_FOR-design-record.md` — parent design record (§0.5 family properties inherited by DRA).
- `Parameters/FIATm/fiatm-sovereign-asset.md` — sovereign-vs-IOU taxonomy DRA satisfies via shared-pool design.
- `Parameters/FIATm/fiatm_synthesis_FOR-reading-first.md` — to be updated with DRA cross-reference.
- `wiki/decisions.md` — ADR-0042 (DEA value-accrual); will need either revision or new ADR to formalize DRA-as-flagship.
- `wiki/glossary.md` — to be updated with DRA / Dynamic Rate Asset / DRAm entries.
- `Parameters/FIATm/catalytic-converter-hypothesis_FOR-testing.md` — companion hypothesis-stage doc; DRA's three-force convergence is structurally similar (labelled hypothesis until Stage-2 validates).
- `Parameters/MBA_Shares/naming-and-branding_FOR-design-record.md` — source of truth for the Greek pantheon (DRA's drachma identity sits in this lineage).
- `scripts/sim-fiatm-trajectory.mjs` — adjacent simulator; will need extension to model DRA pool evolution.
---
## 14. Closing — why DRA matters structurally
USDm/EURm/etc. are the family's **denomination instruments** — they answer "what do I price in?" DRA is the family's **value-accrual instrument** — it answers "what do I save in?" The two together cover the monetary stack: cash + endowment, in BCH-backed form, on-chain, without an issuer.
The Greek drachma carried both functions historically — face value for daily transactions, silver content for cross-generational store. The euro's absorption of the drachma in 2002 retained the first function (face) and severed the second (silver gone, replaced by ECB liability). **DRAm restores the second function on the substrate that already provides the first (BCH-backed FIATm denomination), and adds a third the historical drachma never had: yield from family-wide transaction volume.**
Three functions in one asset, anchored to a 2,600-year cultural lineage, hidden under the operational class name *Dynamic Rate Asset*. The reveal belongs to readers who recognize 357.

View file

@ -15,6 +15,7 @@
> **Date:** 2026-06-15 (FIATm synthesis, post snapshot 0163); refreshed 2026-06-20 (sovereign-asset framing + simultaneity).
> **Companion files in this folder:**
> - `fiatm-sovereign-asset.md` — **NEW (2026-06-20)** sovereign-asset design record: asset taxonomy (sovereign-native MBA vs cross-chain IOU), gold-certificate-collapse-inverted thesis, reverse-lock (scarcity is locked, fiat is only projected face), "unstablecoin" framing with three-honest-outcomes table, reflexive volatility properties (backing demand + basket diversification + stablecoins-as-tailwind), §6 controller-simultaneity canonical statement. Complements rather than supersedes the design record below.
> - `dra_dynamic-rate-asset_FOR-design-record.md` — **NEW (2026-06-28)** Dynamic Rate Asset (DRA) — yield-bearing reflective FIATm-family flagship. Shared-pool architecture (departure from per-unit lock); asymmetric reflective formula vs EUR (one-way: full positive, dampened negative); adoption-catalyst-strengthening counter; amplitude clamp widens with adoption; maturity envelope at 1 DRA = €357 (the *opposite reverse* of the launch ratio 1 EUR = 357 DRA). Coexists with DEA (different families). Public-facing name stays *Dynamic Rate Asset* through entire adoption arc (Odyssean concealment of the hidden drachma identity).
> - `fiatm_complete-design_FOR-design-record.md` — the canonical design record (§0–§22 + appendices).
> - `catalytic-converter-hypothesis_FOR-testing.md` — the labelled hypothesis with structural elaboration.
> - `usdm_sound-money-note_FOR-design-record.md` — the original USDm note (absorbed into the design record; retained as historical artefact).
@ -850,7 +851,7 @@ What is and is not a structural claim:
**Wiki:**
- `wiki/sound-money-primer.md` — public-facing explainer (Greek tetradrachm parallel, fiat
history in reverse, visible-inflation gauge, political fairness argument).
- `wiki/glossary.md` — Artemision, Agora, Spectrum, Orbital Swap, Akone definitions.
- `wiki/glossary.md` — Artemision, Agora, Metroon, Orbital Swap, Akone definitions.
- `wiki/asset-landscape.md` — NBA/MBA taxonomy reader's guide.
- `wiki/usd-rail.md` — BCH as USD payment rail framing.
- `wiki/index.md` — wiki entry point.

View file

@ -0,0 +1,73 @@
# Session prompt — Potidaea: run the tests, then build the visual interface
*Paste the block below into a fresh session. It links the authoritative Potidaea docs, runs the
validated covenant tests, and builds a UI mockup. Record findings back into this folder
(`D:\Dev\Deviant\Parameters\OrbitalSwapASM_Potidaea`).*
---
## PROMPT (copy from here)
We're continuing the **Potidaea** (native-BCH Orbital Swap / ASM) build. Two jobs this session:
**(1) run the covenant tests and confirm they pass, (2) build a visual interface (mockup) for the
Potidaea marketplace.** Record everything important back into
`Parameters/OrbitalSwapASM_Potidaea/`.
### Read first (authoritative, in order)
1. `Parameters/OrbitalSwapASM_Potidaea/potidaea_build-state.md` — the state record: names, pinned
economics, every covenant + test + finding. **Start here.**
2. `wiki/decisions.md` → **ADR-0048** (the ratified model + covenant records).
3. `Parameters/ASM DEX/orbital-swap_business-model.md` — full economics (§5 fees, §5.F yield/flywheel).
4. `Parameters/OrbitalSwapASM_Potidaea/potidaea_orbital-swap-asm.md` — brand/naming hierarchy.
5. `wiki/glossary.md` (v2.9 naming pass) — Agora / Metroon / Stoa / Olympia / Olympus / Orbital Swap.
### Job 1 — run the covenant tests
The covenants run locally on libauth's VM via cashscript's `MockNetworkProvider` (no node needed).
The npm `test:regtest` script is Windows-broken (`REGTEST=1` prefix), so run directly via the Bash
tool (git-bash env syntax):
```
REGTEST=1 node --test --import tsx \
test/regtest/potidaea-olympia.test.ts \
test/regtest/distribution-vault.test.ts \
test/regtest/share-mba-loop.test.ts \
test/regtest/potidaea-contention.test.ts \
test/regtest/potidaea-sharding.test.ts \
test/regtest/potidaea-epoch.test.ts
```
Expect **14 passing** across these 6 files (21/21 including the 7 pre-existing regtest cases). If a
contract changed, recompile first: `npm run compile:contracts`. Report pass/fail; if anything fails,
diagnose against the covenant source and `potidaea_build-state.md`.
### Job 2 — build the visual interface (mockup)
Build an interactive HTML mockup of the Potidaea marketplace, consistent with the existing
**CosmoGenesis** design system: `design/cosmo-genesis.css` and the mockups in `design/mockups/`
(study `agora.html`, `assets-tab.html`, `asset-detail.html`, and `bch-dex.html` — the ASM live model).
Verify it in the browser preview (use the preview tools; share a screenshot).
Surfaces to render (match the pinned economics in `potidaea_build-state.md` §2 exactly):
- **Agora** hub → **Metroon** directory with two views: **Stoa** (free list) and **Olympia** (paid
competition list, ranked by the **Olympic** ladder Free→Bronze→Silver→Gold→Laurel).
- **Orbital Swap** panel — the counterparty-free swap; show the 0.3–0.5% fee split (majority → DEA
holders, minority → platform); "deep as an ocean" depth (frozen rate at any size).
- **Olympia listing flow** — the DEA fee curve `(A+L)·M`, the uncapped multiplier climbing +0.1 per
listing, and the sequential cooldown (cph = 144 blocks ≈ 1 day; peak lingers longest). The
`scripts/potidaea-economics-sim.mjs` output is the source of truth for the numbers.
- **DEA-holder yield dashboard** — the flagship DEA (Deviant Equilibrium Asset) as the value-accrual
asset; swap-fee majority + Olympic shares → holders (pro-rata + time-weighted); the equilibrium
flywheel (any asset's success lifts BCH + all MBAs).
- Keep **Olympia** (tab) ≠ **Olympus** (platform layer); **Metroon** ≠ **CoinSpectrum** (separate product).
Design-only mockup (no wallet keys, no live chain). It should *look* like the real product and make
the economics legible.
### Job 3 — record findings
Write results back into `Parameters/OrbitalSwapASM_Potidaea/`: test results, any covenant fixes, the
mockup file path(s), and screenshots/notes. Update `potidaea_build-state.md` if state changes, and add
an ADR to `wiki/decisions.md` only if a *decision* changes.
### Model guidance (per project CLAUDE.md)
Declare a model on line 1. UI mockup work + running tests → **Sonnet**. Novel covenant/economics
reasoning → **Opus**.
## (end prompt)

View file

@ -0,0 +1,134 @@
# Potidaea — build state & authoritative record
*Snapshot 2026-07-03. The single place that captures what Potidaea IS, its pinned economics,
and every covenant/test/sim built so far. New sessions: read this first, then the linked docs.*
Authoritative ADR: **ADR-0048** in [`wiki/decisions.md`](../../wiki/decisions.md). Full model:
[`orbital-swap_business-model.md`](../ASM%20DEX/orbital-swap_business-model.md). Brand/naming:
[`potidaea_orbital-swap-asm.md`](potidaea_orbital-swap-asm.md). Mechanism:
[`ASM-Orbital-Swap-native.md`](../ASM%20DEX/ASM-Orbital-Swap-native.md). Taxonomy:
[`ASM DEX README`](../ASM%20DEX/README.md). Glossary (v2.9): [`wiki/glossary.md`](../../wiki/glossary.md).
---
## 1. Names (glossary v2.9 — keep distinct)
- **Potidaea** = the brand of our native-BCH Orbital Swap (ASM). Ποτίδαια, "city of Poseidon" →
"deep as an ocean" liquidity (backing IS the liquidity; no bootstrap/LP/thin-pairs).
- **Agora** = the marketplace hub (bottom tab). **Orbital Swap** = the swap UI (the ASM).
**Artemision** = asset genesis/creation.
- **Metroon** = the asset directory inside Agora (**renamed from "Spectrum"** — Spectrum is now
sole-owned by the separate **CoinSpectrum** product). Holds two lists:
- **Stoa** = the FREE list (always on, every canonical asset).
- **Olympia** = the PAID competition list (activates at 100 created assets), ranked by the
**Olympic** ladder.
- **Olympus** = the dApp *platform layer* (distinct from Olympia the tab).
- **DEA class** = *Direct Emission Asset* (permissionless-mint, fixed-rate, unlimited, BCH-backed).
**Flagship DEA** = *Deviant Equilibrium Asset* (Δίας/Zeus) — the market driver + value-accrual
asset; the fee currency for creation/listing.
## 2. Pinned economics (ADR-0048)
- **Protocol swap/mint fee** on every mint/swap (any tier): **0.5% NOW = 0.3% → DEA holders +
0.2% → platform**; **FUTURE 0.3% = 100% → holders** (cut comes out of the platform slice; holders
keep the same yield). Majority of the fee is the **DEA-holder yield**, pro-rata + time-weighted.
- **Creation fee** (DEA, genesis): **FREE first 100**, then `10·⌊A/100⌋` (10/20/30 per 100-band).
→ platform treasury.
- **Stoa** listing: **FREE** (discovery is free; only the Olympia spotlight costs).
- **Olympia listing fee** (DEA): `(cA·A + cL·L)·M` with `cA=cL=1` → **starting 100 DEA** (=1·A at
A=100), +1/asset, +1/listing. **M uncapped**, +0.1/listing (1st ×1.1 … 10th ×2.0), lister pays
post-bump. Cooldown **sequential, dwell ∝ height**, **cph = 144 blocks** (= 1 day @ 10-min blocks;
a 5-burst peak ×1.5 holds 720 blocks/~5d, full relax 2160/~15d). → platform treasury.
- **Olympic promotion ladder** (voluntary): **Free/Bronze/Silver/Gold/Laurel = 0/10/15/20/25%** of
the founder's fee (hard cap 50%) → DEA holders. Ranks assets *within* Olympia.
- **No burn** for MBAs (supply is backing-bounded). **Buckets:** platform revenue = creation +
Olympia + platform swap slice + premium + B2B; **holder yield = the swap-fee majority + Olympic
shares**. Platform is an *active DEA holder* (earns yield on its own DEA, no privileged skim).
- Sim of all of the above: [`scripts/potidaea-economics-sim.mjs`](../../scripts/potidaea-economics-sim.mjs)
(`node scripts/potidaea-economics-sim.mjs`).
## 3. Covenants — built + validated locally (libauth VM via `MockNetworkProvider`)
| Covenant | Role | Test file | Status |
|---|---|---|---|
| [`PotidaeaOlympiaListing.cash`](../../contracts/PotidaeaOlympiaListing.cash) | listing-fee state machine (`A\|L\|notches\|lastHeight`): `list()` fee `(A+L)·M`, climb + sequential height-∝ decay; `create()` `10·⌊A/100⌋` | [`potidaea-olympia.test.ts`](../../test/regtest/potidaea-olympia.test.ts) | 4/4 |
| [`DistributionVault.cash`](../../contracts/DistributionVault.cash) | swap-fee → DEA-holder payout (ADR-0042): oracle-attested, replay-bound, operator can't redirect | [`distribution-vault.test.ts`](../../test/regtest/distribution-vault.test.ts) | 4/4 |
| [`DeviantShareMBA.cash`](../../contracts/DeviantShareMBA.cash) | collection side: mint fee splits `companySats`→treasury + `shareholderSats`→vault | [`share-mba-loop.test.ts`](../../test/regtest/share-mba-loop.test.ts) (end-to-end into the vault) | 2/2 |
| [`PotidaeaListingRequest.cash`](../../contracts/PotidaeaListingRequest.cash) + [`PotidaeaEpochState.cash`](../../contracts/PotidaeaEpochState.cash) | epoch-batch: independent request UTXOs + one fold; M climbs by batch size | [`potidaea-epoch.test.ts`](../../test/regtest/potidaea-epoch.test.ts) | 2/2 |
| — | contention proven + sharding fix | [`potidaea-contention.test.ts`](../../test/regtest/potidaea-contention.test.ts) · [`potidaea-sharding.test.ts`](../../test/regtest/potidaea-sharding.test.ts) | 1 + 1 |
**Total: 21/21 passing** (with the 7 pre-existing regtest cases). Run command in the session prompt.
Re-run 2026-07-03: the 6 Potidaea files = **14/14** green (11.8 s).
## 3b. Visual interface — built + verified (2026-07-03)
Interactive mockup: [`design/mockups/potidaea.html`](../../design/mockups/potidaea.html) — CosmoGenesis
phone-frame (starfield, acid accent, Fraunces/JetBrains Mono), pattern lifted from
[`agora.html`](../../design/mockups/agora.html). Serve via the `design-preview` launch config
(`npx serve design -l 5180`) → **http://localhost:5180/mockups/potidaea.html**. Four testable surfaces
via the top segmented control, every number wired to [`scripts/potidaea-economics-sim.mjs`](../../scripts/potidaea-economics-sim.mjs):
| View | What it shows | Interactivity |
|---|---|---|
| **Metroon** | Stoa (free list) ⇄ Olympia (paid, ranked by the Olympic ladder Laurel›Gold›Silver›Bronze›Free with rev-share %) | sub-toggle swaps list + note |
| **Swap** | Orbital Swap legs + live fee-split bar (0.3% holders / 0.2% platform) + "zero slippage at any size" | editable amount → recalcs fee/net live |
| **List** | **asset selector** (which of your assets gets the slot) + Olympia fee gauge `(A+L)·M`, multiplier climb, sequential cooldown, running ledger | source toggle **My assets** (dropdown) ⇄ **Add external** (manual) · **List** (+0.1 M, post-bump) · **Wait 144 blk** decays |
| **Yield** | Flagship DEA holdings, swap-fee yield, Olympic shares, the equilibrium flywheel | **Now 0.5%** ⇄ **Future 0.3%** phase toggle |
Verified against the sim (browser preview + eval): List fees climb **110 → 121.2 → 132.6** (next-fee
preview 144.2 = sim #4), cooldown line reports peak/relax blocks, decay steps M back down. Yield toggle
proves the key invariant — **holder yield is identical NOW vs FUTURE (4.50 BCH); only the platform's
direct slice drops 3.00 → 0**. Screenshots captured for all five surfaces (Metroon Stoa + Olympia,
Swap, List post-fix, Yield).
## 4. Findings
- **Block-height fee DECAY is enforceable on BCH; escalation is not** (CLTV gives only a lower
height bound → for decay the spender rationally claims the true height). This is why the Olympia
cooldown works on-chain.
- **State-UTXO contention is real** (proven: two writers on one state UTXO → "UTXO not found for
input 0"). **Decision: epoch-batch > shard** for Potidaea (the fee is global + order-sensitive via
M; sharding forces cross-shard reads = re-serialization). Launch single-UTXO (low freq), epoch-batch
when volume warrants.
- **cashscript gotcha:** the SDK `token.category` field is byte-reversed vs the VM's
`OP_*TOKENCATEGORY`; a constructor `bytes` category must be `txidToConsensusBytes(fieldHex)` (the
reverse of the field). Full notes: memory `[[cashscript-covenant-findings]]`.
- **UI economics (from the mockup build):** the Olympia lister pays **post-bump** — the fee gauge's
"next listing costs" must preview `(A + L)·(M+0.1)`, not `(A+L)·M`. Getting this right reproduces the
sim's 110 / 121.2 / 132.6 sequence (pre-bump gives 100 / 111 / 122, which is wrong). Also: don't put
live-updated `id`s inside an element whose own `innerHTML` you later rebuild — the rebuild deletes the
child spans and the next `getElementById(child).textContent=` throws, silently aborting the render
mid-function (the tell: some fields update, later ones freeze).
## 4b. Listing UX — asset selection (design decision, 2026-07-03)
The List surface must let a founder pick **which** asset to list (they may control several), so the
mockup adds an asset selector above the fee gauge. Source of the list:
- **My assets → dropdown, auto-read.** Two reliable on-device sources: (a) the wallet's **Artemision
genesis history** (assets minted in-app, in the local SQLite registry — provenance `owned`); (b) any
category whose **mint baton (authority NFT)** the wallet currently holds in its UTXO set (provenance
`baton`). Both are enumerable without an external indexer.
- **Add external → manual entry.** For an asset *not* created/controlled here: user pastes the **token
category (genesis txid)** + name/class; app verifies the category exists on-chain and pulls BCMR if
present, but can't attest the issuer. This is a **sponsor/boost** of someone else's asset.
Why not "read every asset this address created": a CashToken **category = the genesis txid**; there is
no on-chain creator field. Generic "created-by" needs an indexer with genesis-input lookup, and even
then "creator" = whoever held the genesis UTXO (often throwaway). So auto-list only what the wallet
**created in-app or currently controls**; everything else is manual by design.
**Open governance question (not blocking the mockup):** ADR-0048's Olympic ladder shares *the founder's*
fee with DEA holders. Self-listing → lister = founder. Boosting someone else's asset needs a decision:
does the ladder share still apply, and does attribution/consent route to the asset's actual issuer?
Candidate for a follow-up ADR.
## 5. Open / next (not blocking)
- Production **epoch-batch arity** (demo folds a fixed 2; ship `settle1/2/4/8…` or an accumulator;
compute fee from state-at-settle, not request-time-locked).
- **Time-weighted** attribution + **Merkle-snapshot liveness backstop** in the oracle accounting
(ADR-0042 refinements).
- Confirm the collection-side `shareholderBps` **scope** (all MBAs route to the flagship by default,
or opt-in) — the point-7 scope dial.
- Fold this doc + `orbital-swap_business-model.md` into `wiki/revenue-model.md` as one canonical model.

View file

@ -0,0 +1,511 @@
# P2P Order Book — Telegram Bot
**Status:** **LIVE** as [@FiatMoneyExchangeBot](https://t.me/FiatMoneyExchangeBot), running as a self-healing **Windows Scheduled Task** `DeviantOrderBookBot` (auto-start at logon, survives reboots, restarts on crash; runs compiled `dist/index.js` via `run-service.cmd`, logs to `logs/bot.log`). Logic smoke-tested; typechecks.
**Code location:** [`telegram-orderbook/`](../../telegram-orderbook/) (repo root, self-contained sub-project).
**Date:** 2026-07-02 (v2 — guided form, EUR/USD scope).
**Markets:** **EUR/USD** and **EUR/RUB** (multi-pair; add more via the `QUOTES` map in `src/index.ts`). Base is always EUR; the quote is chosen per order. See §6c.
**Entry model:** a bot-controlled **guided form** (buttons + prompts), so every order looks identical.
**Trading layer:** orders carry **inventory** that is reserved by a timed, two-party-confirmed **deal** handshake, with **5-star reputation**, a **cash+city** option, and **fixed or floating** (central-bank ± offset) rates. See §6.
**Relationship:** the stripped-down **catalogue / discovery layer** (§3.1) of
[`Parameters/DEX_P2P/fiat-ramp-product.md`](../DEX_P2P/fiat-ramp-product.md).
**Recent (built after v2, all LIVE — see code for detail):**
- **Full RU + EN i18n** — per-user language (`/language`, auto-offer on first `/start`), 350 keys × 2 locales in `src/locales/{en,ru}.ts`, `src/i18n.ts` `t()`/`plural()`; recipient-language DMs (`dmLang`); `time.ts`/`format.ts` take `lang`. Greek scaffolding not started. Terminology: order→«заявка», Cash→«Наличные» (label only; id stays `Cash`).
- **Rate view** curated to CBR·Yahoo·Tinkoff (Tinkoff shows real buy/sell) in `/rate`; book header decluttered.
- **Recipient address book** (`accounts` table, multi-per-method incl. third parties; migrated from `payout_methods`) — maker order picks which account receives (`orders.account_id`, snapshotted to deal); taker reuses saved accounts.
- **Text reviews** (`reviews` table) after completed deals; **optional per-deal minimum** (`orders.min_amount`, wizard step + taker enforcement).
- **Mixed orders — receive & pay methods fully decoupled** (supersedes the old single-method "settlement mode"). Each order carries two independent lists: `orders.recv_methods` (JSON `{method,account_id}[]` — how the maker gets paid, receive-currency rails/cash) and `orders.pay_methods` (JSON `{method}[]` — how the maker pays out, pay-currency rails/cash; the taker supplies their own account for rails). There is **no "same method"** shortcut — the two legs are different currencies with disjoint rails (e.g. sell-EUR = receive RUB-by-bank + pay EUR-cash). Wizard = two currency-scoped multi-selects (recv → pay → city-if-cash → confirm); the taker single-selects one method per leg from what the maker offers (`xr:`/`xp:`; `afterTakeAmount`/`afterRecvChosen`/`choosePay`). Deal snapshots `maker_recv_method` + `city`. Legacy `method`/`account_id` kept as denormalized primary; **null arrays ⇒ legacy single-method behavior** (no data migration). This also subsumes the settlement fix: takers can only request a pay method the maker actually offered. Non-makers still hit the request-access gate immediately on 🧾 Make order.
- **Legal notes** in `/help`: currency-exchange law (consultant.ru 173-ФЗ) + exchange≠transfer (VTB guide).
---
## 1. What was requested
> "P2P order book on Telegram. Just an order book with entries / intents. No wiring
> with banking apps. 1 Telegram user can have 1 entry at a time on **each side**
> (e.g. selling EUR or buying EUR). Other users can private-message them."
Deliberately minimal: a **directory of buy/sell intents**, contact happens by DM,
the bot brokers nothing.
## 2. What it is — and deliberately is not
| It **is** | It is **not** |
|---|---|
| a directory of buy/sell **intents** (discovery) | an escrow / custodian of funds |
| a contact hand-off (`@username` / `tg://user?id=`) | a party to any trade |
| a per-user, per-side order book with replace semantics | a converter / money transmitter |
| pure software, no funds ever touched | wired to any banking / payment rail |
This is the catalogue reduced to the bone — **no escrow, no payment oracle, no
compliance, no banking integration**. Those are the engine of the larger
fiat-ramp product and are explicitly out of scope here.
## 3. Core rule — one entry per side per user
Each Telegram user may hold **one BUY and one SELL entry at a time**. Posting again
on the same side **replaces** the previous entry (it does not add a second).
Enforced *structurally*, not in application logic: the SQLite table uses a
composite **`PRIMARY KEY (user_id, side)`**, and inserts use
`ON CONFLICT (user_id, side) DO UPDATE`. So the invariant can't be violated even
by a bug in the handler.
- `user 10` can have `(10, 'sell')` **and** `(10, 'buy')` simultaneously — different sides, allowed.
- A second `/sell` from `user 10` overwrites `(10, 'sell')`; entry count stays at 2.
## 4. Data model
Single table, SQLite (`orderbook.db`, override with `DB_PATH`). Fields are
**structured** — the form fills each one, so orders are uniform:
```sql
CREATE TABLE orders (
user_id INTEGER NOT NULL, -- Telegram user id
username TEXT, -- @handle, may be null
display_name TEXT, -- "First Last", fallback for contact
side TEXT NOT NULL CHECK (side IN ('buy','sell')), -- relative to base (EUR)
base TEXT NOT NULL, -- 'EUR'
quote TEXT NOT NULL, -- 'USD'
amount REAL NOT NULL, -- amount of base (EUR), > 0
rate REAL NOT NULL, -- quote per 1 base (USD per 1 EUR), > 0
method TEXT NOT NULL, -- payment method (chosen from buttons / typed)
created_at INTEGER NOT NULL, -- unix seconds
PRIMARY KEY (user_id, side)
);
CREATE INDEX idx_orders_pair ON orders (base, quote, side);
```
The counter amount (e.g. USD received) is **derived** = `amount × rate`, not
stored — so it can never drift from the rate. `base`/`quote` are stored (not
hard-coded) so opening a second pair later is a constants change, not a schema
change.
## 5. The guided form (bot-controlled entry)
`/sell` (have EUR, want USD) or `/buy` (want EUR, pay USD) starts a short wizard.
State lives in a per-**user** grammy session (`getSessionKey = from.id`, so
drafts don't collide when several people fill the form in the same group):
1. **amount** — text step; positive number of EUR.
2. **rate** — text step; USD per 1 EUR.
3. **method** — inline-button step. The `method` is **where the MAKER receives funds** —
i.e. the rail the counterparty sends to — so the options key off the **currency the
maker receives** (`makerReceives(side) = sell→quote, buy→base`), NOT the quote:
*Buy EUR (pay RUB)* → EU rails (IBAN · Revolut · Wise · N26 · IRIS · Cash);
*Sell EUR (get RUB)* → RU rails (SBP · Sberbank · Tinkoff · Alfa-Bank · VTB · Cash).
*Other* → one free-text step, capped at 40 chars. Principle: **each party only ever
states how they want to RECEIVE** (that's what the counterparty needs); how you send is
your own business. The taker mirrors this via `trm:` (`takerReceives`).
4. **confirm** — summary with the computed USD total + ✅ Confirm / ✖ Cancel buttons; only Confirm writes the row (upsert = replace-per-side).
All user text is HTML-escaped before rendering. Every posted order prints from
the same formatter, e.g. `🔴 SELL 500.00 EUR for 542.50 USD · 1.0850 · SEPA — @alice`.
## 5c. Market reference rate & book navigation
- **Reference rate** (`src/rates.ts`): a live, no-key, mid-market rate
**aggregated as the MEDIAN** of several sources polled concurrently, chosen
**per pair** so one bad/stale/throttled feed can't skew it (median of whatever
responds):
- **EUR/USD** — ECB/frankfurter.dev, open.er-api.com, fawaz currency-api
(jsDelivr), floatrates (floatrates is best-effort).
- **EUR/RUB** — **curated to 3 reliable sources**: **CBR** (cbr-xml-daily.ru, the
central-bank anchor + official rate), **Tinkoff** (api.tinkoff.ru — the one source
with a **real buy/sell spread**, kept in `SourceQuote.buy/sell`; its mid feeds the
median), and **Yahoo Finance** (one international mid). ECB dropped RUB in 2022, so
CBR — not ECB — is the anchor. *Why only 3:* RU per-bank feeds (Sberbank/Alfa/banki.ru/
sravni) are JS-rendered + bot-gated → only fragile scrapers, deliberately avoided;
Google Finance has no public JSON. Wise/Coinbase/er-api/currency-api were dropped from
RUB as redundant mids.
10-min cache, 5 s timeout, UA header (browser UA for Yahoo/Wise),
stale-cache-on-failure. For floating orders the pair's **central-bank** rate
(ECB / CBR) is tracked separately from the median. Shown on `/book` (median only,
clean header) and a dedicated **`/rate`** command that lists each source — **Tinkoff
as `buy · sell`**, the central bank tagged *(official)*, others as a single mid.
The reference is an *indicative mid*; only Tinkoff exposes a real bid/ask.
- **Cash / online filter:** the book top row toggles **All · 💵 Cash · 🌐 Online**
(`bk:<quote>:<filter>`; Cash = `method==='Cash'`, Online = everything else), active
one marked ●, with the header showing the active filter. So users who only want
face-to-face cash meetups (or only online rails) see just those.
- **Interactive book** (`/book`): a reference-rate header, then a **two-column
tappable grid** headed **`€ Buy EUR`** | **`$ Buy USD`** (two buttons per keyboard
row — Telegram can't render real tables, so the 2-per-row inline keyboard IS the
two columns). **Columns are the TAKER's action, so a maker's order goes in the
opposite column:** a maker **selling EUR** (`side='sell'`) appears under **Buy EUR**
(taker gets EUR); a maker **buying EUR / selling USD** (`side='buy'`) appears under
**Buy USD** (taker gets USD). Each cell shows the amount **in that column's
currency** — Buy EUR = base amount (EUR), Buy USD = base × rate (USD) — plus
`@rate` (`~` marks floating), and stays tappable → detail. Uneven columns padded
with `·`; header row shows per-column counts. Rate stays USD-per-EUR for
comparability. (Detail view remains maker-oriented as the order's source of truth.)
Cells carry **currency symbols** and **2-decimal** values (amount in the column's
currency, rate prefixed with the quote symbol) — e.g. `€1,000.00 @₽89.50`.
- **Column headers double as "post your own order" buttons** (`new:<side>:<quote>`):
tapping **€ Buy EUR** starts a BUY-EUR order, **₽ Buy RUB / $ Buy USD** starts a
SELL-EUR order, in that pair — jumping straight to the amount step. An **empty
book** shows the same two create buttons instead of a dead end.
- **Tap an order → detail view** (`callback o:<user_id>:<side>` → fresh DB lookup):
the requested one-value-per-row layout —
`Sell: 200.00 EUR / For: 226.00 USD / Rate: 1.13 (−0.9% vs market) / Method: SEPA
/ Trader: @handle` — plus a **💬 Message trader** URL button (t.me/handle, or a
`tg://user?id=` fallback) and a **📖 Back to book** button. If the order was
removed meanwhile, the tap answers "That order was removed."
- **Sorted closest-to-reference first** (`|rate − ref|` ascending; falls back to
price order when the reference is unavailable). Capped at 15 orders/side with an
"…and N more" note (an honest, non-silent cap).
## 5b. Commands
| Command | Effect |
|---|---|
| `/sell` | Start the guided form to sell EUR for USD |
| `/buy` | Start the guided form to buy EUR with USD |
| `/book` | Show the order book, grouped by pair → side |
| `/mine` | Show caller's active orders |
| `/deals` | Show caller's active deals (requested/accepted) with countdown + confirm state |
| `/cancel` | Abort the form if mid-fill; else remove a posted order (`/cancel buy\|sell\|all`) |
| `/rate` | Live EUR/USD reference with the per-source breakdown + median |
| `/start`, `/help` | Usage |
**Menu UI:** the bot sets the bottom-left **menu button** to the commands list
(`setChatMenuButton`, restored on startup) **and** shows a **persistent reply
keyboard** (`🧾 Make order · 📖 Book · 📋 My orders · 🤝 My deals · 💱 Rate · 💳 Payment · ❓ Help`).
**🧾 Make order** opens a "what do you want to buy?" chooser (`buyc:<cur>`): Buy EUR
(→ `buye:<quote>` pay-with sub-choice → buy-EUR order), Buy USD / Buy RUB (→ sell-EUR
order in that pair). Replaces the old Sell/Buy buttons (the `/sell` `/buy` commands
remain as shortcuts). `startNewOrder(side, quote)` is the shared entry.
Button taps arrive as plain text and are routed before the wizard-step logic, so
they work from any state.
The command list is registered with Telegram on startup (`setMyCommands`) so it
appears in the client's command menu. Works in a private chat with the bot or in
any group the bot is added to.
## 6. Contact hand-off
The only "contact" the bot provides is a clickable link to the poster:
- has a username → `<a href="https://t.me/USERNAME">@USERNAME</a>`
- no username → `<a href="tg://user?id=ID">Display Name</a>` (id deep-link fallback)
Rendering uses Telegram **HTML parse mode** with all user-supplied text escaped
(`&`, `<`, `>`) to prevent markup injection. Link previews are disabled on `/book`.
## 6b. Trading layer — timed deals, freeze, ratings, floating rate, cash
Added in the deals build. **Design decisions (chosen by the operator):** owner
approves before anything freezes; **both** parties confirm completion; **5-star**
reputation.
**Order inventory.** An order has `amount` (total) and `frozen`. `available =
amount − frozen`. The book and detail show `available`; detail shows total +
in-progress when `frozen > 0`.
**Deal lifecycle** (table `deals`, states `requested → accepted → completed`, plus
`declined / expired / cancelled`):
1. Taker taps **Start exchange** → picks an amount (**25/50/75/100%** of available
quick-share buttons `tk:<value>`, or ✏️ Other amount to type; ≤ available, clamped)
→ **picks where they receive funds** (`trm:<method>` in the currency they'll get —
`takerReceives` = base for a sell-EUR order, quote for a buy-EUR order; RUB rails vs
EU rails) → **enters the recipient address** (phone / IBAN / card / email; Cash skips
it). Stored on the deal (`taker_recv_method`/`taker_recv_value`) and revealed to the
maker once the taker has paid (`formatTakerRecv`).
→ **picks a window**:
presets **1h / 3h / 12h / 24h**, or **📅 Custom** (a duration like `90m`/`2h`/`3d`,
or an absolute meet date/time `YYYY-MM-DD HH:MM` — good for cash meetups). A
`requested` deal is created (NO freeze yet) storing `window_sec` XOR `meet_at`,
and the **owner** is DM'd Accept/Decline with the proposed completion time. The
effective rate is **locked** at request time. (`time.ts` parses/validates the
window: 10 min–14 days, absolute must be >5 min ahead.)
**Timezone:** an absolute meet time is resolved in the **meet city's timezone**
(offline `city-timezones` lookup → IANA zone; `luxon` for DST-correct math),
falling back to the server zone if the order has no city. The zone is stored on
the deal (`tz`) so both parties always see the same wall-clock time for the meet
place (e.g. `Sun 5 Jul 18:00 GMT+2`). Telegram exposes no user timezone, and
relative windows need none — so city-derived is both sufficient and semantically
correct (a meet time is local to where you meet).
The taker can **✖ Withdraw** a still-`requested` deal (`dw:` → `cancelled`).
2. Owner **Accept** → `freeze(amount)`, state `accepted`. The **taker pays FIRST**:
they're DM'd the **maker's** payout details + **✅ I've sent the money** (`dc:`) /
**✖ Cancel**; the maker is told to **wait** and only gets a Cancel button. Owner
**Decline** → `declined`.
3. **Taker taps "I've sent"** → `taker_confirmed`; now the **maker** is DM'd the
**taker's** payout details + **✅ Completed**. The maker's confirm is **blocked
until the taker has sent** ("wait for the taker to send first"). This enforces the
taker-pays-first order and reveals payout details **sequentially** (maker's on
accept, taker's only after the taker pays).
4. **Maker taps Completed** → `finalize`: `amount −= deal.amount`, `frozen −= deal.amount`
(order deleted if drained), state `completed`; both DM'd 1–5⭐ rating buttons.
**Cancel** (either party, while accepted) → `unfreeze`, state `cancelled`.
All these actions are re-surfaced in **`/deals`** (one message per deal with its
role-appropriate buttons via `dealActionKeyboard`), so an open deal is never a
dead end even if the original DM scrolled away.
5. **Sweeper** (`setInterval` 60 s): `requested` older than 30 min → `expired`;
`accepted` past `expires_at + 24 h` → auto-`cancelled` + `unfreeze` (releases
stuck freezes). Window expiry itself does **not** force-cancel — parties confirm
after it; the 24 h grace is the backstop.
**Two paths, one deducts.** Deduction of an order's listed amount happens ONLY
through the tracked **Start exchange** deal flow (taker amount → approve → freeze →
both confirm → finalize). The **Message trader** button is contact-only and does
NOT deduct — so a trade arranged purely by DM would leave the order listed at full
size. To keep the book honest, the maker has **✏️ Mark filled** in `/mine`
(`red:<side>:<base>:<quote>` → enter an amount in the order's denom currency →
`reduceOrder` deducts it, up to the unfrozen `available`; closes the order if
drained). This is the maker-side answer to "how does the amount go down if the
taker never entered one".
**Recipient accounts — address book** (table `accounts(id PK, user_id, method, value,
name, created_at)`; migrated from the old one-per-method `payout_methods`, which is
dropped on first run). A user keeps **multiple named recipient accounts** — their own OR
a **third party's** they're paying — and **several of the same method** (e.g. two IBANs).
Each account = method (rail) + value (IBAN/phone/card) + **recipient name** (the
account-holder the counterparty sends to). Add flow: method → value → recipient name
(`👤`). Managed via **💳 Payment** (`/payment`): list + 🗑 remove-by-id; each new entry is
a new row (not an overwrite).
- **Maker side:** the order **picks which account receives** — the wizard's receive step
shows the maker's saved accounts (♻️ reuse) or a rail to add a new one; `orders.account_id`
stores the choice (null for Cash). At deal creation the account is **snapshotted** into
the deal (`deals.maker_recv_value/name`) so it's stable even if later edited/deleted.
- **Taker side:** the take flow's receive step likewise offers saved accounts (`tacc:<id>`,
one-tap reuse) or a new entry (auto-saved to the book); snapshotted to `deals.taker_recv_*`.
- On **accept** the taker is DM'd the maker's one chosen account (`formatMakerRecv`); when
the taker has paid, the maker is DM'd the taker's (`formatTakerRecv`). Never shown in the
public book. Accessors: `getAccounts` / `getAccount` / `addAccount` / `deleteAccount`.
**Ratings** (table `ratings`, PK `(deal_id, rater_id)` → one per rater per deal,
re-rating overwrites). Only on `completed` deals; each party rates the other.
Trader shows `⭐ avg (count) · N trades` on the order detail — **N** =
`completedTradesForUser` (completed deals as maker OR taker). **`/me`** shows your own
rating, completed trades, active orders, approval status, and ID. The web board's
Rating column also shows `· Nt`.
**Text reviews** (table `reviews`, PK `(deal_id, reviewer_id)` → one per reviewer per
deal, upsert). After a deal completes, the star-rating message carries a **✍️ Write a
review** button (`rv:<dealId>`) → the user types a short note (≤300 chars, HTML-escaped)
→ `addReview` stores it with the reviewer's `@username`. On an order detail, a **📝
Reviews (N)** button (`rvw:<userId>`) opens the trader's latest 10 — each shown as
`⭐<stars> @reviewer · <date>` + text (stars come from a LEFT JOIN on that reviewer's
rating for the same deal). `/me` shows your review count. Review-writing is its own
session state (`session.review`), cleared on menu tap / cancel.
**Floating rate.** `rate_mode ∈ {fixed, floating}`. Fixed: `rate` is the number.
Floating: `rate` is an **offset added to the central-bank (ECB) rate** — effective
rate is recomputed live from the ECB quote in the multi-source breakdown (falls
back to the median; a floating order whose rate can't be resolved is shown as
"floating" and can't be taken until it resolves). Offset may be negative.
**Cash + city.** `Cash` method opens a two-step **country → city picker** (`src/cities.ts`):
pick a country (🇬🇷/🇷🇺/🇨🇾/🇩🇪/🇪🇸/🇵🇹, or 🌍 Other / ⏭ Skip) → pick a curated popular city
or type your own. Typed input is **canonicalized** — normalized (diacritics stripped,
Latin+Cyrillic), matched against an alias table + Levenshtein fuzzy match, and **snapped to
one canonical name** so a place never fragments across spellings/scripts ("Athens" / "Athena"
/ "Athina" / "Афины" → `Athens`; "Москва" → `Moscow`). Genuinely new places pass through
title-cased. Callbacks `cc:<code|__other|__skip|__back>` and `ci:<city|__other>`; the city is
stored canonical (`city`), feeds `cityToZone` for meet-time resolution, and shows in the book.
**New session state.** Two flows share the per-user session: `draft` (order form,
now with `rate_mode`/`rate_float`/`city` steps) and `take` (entering a deal amount).
**Caveat — not machine-tested:** the live two-user tap-through (approve, dual
confirm, rate) needs real humans; the DB state machine + money math + floating +
ratings are unit-covered (see §9).
## 6e. Maker gating (approved makers only)
When `ADMIN_ID` is set, **only approved users may POST orders** (taking a deal is
open to everyone). `canPostOrders(id) = ADMIN_ID===0 || isAdmin || isApprovedMaker`,
enforced at the two draft creators (`startWizard`, `startNewOrder`) via
`requireMaker`. A blocked user gets a **🙋 Request access** button → DMs the admin an
**Approve / Deny** (`apr:` / `deny:`); approval writes `approved_makers` and notifies
the user. Admin can also `/approve <id>`. `/id` reveals your Telegram ID (to set
`ADMIN_ID`). `ADMIN_ID=0`/unset ⇒ gating off (anyone posts). This underpins
taker-pays-first: makers are vetted, reputable users.
## 6c. Multi-pair (EUR/USD + EUR/RUB)
The bot supports multiple markets sharing base **EUR**; the quote is chosen per
order (`QUOTES` map in `src/index.ts`, each with its own payment methods and
central-bank source).
- **Order key:** primary key moved from `(user_id, side)` to
`(user_id, side, base, quote)` — a user can hold e.g. a sell EUR/USD **and** a
sell EUR/RUB at once. (SQLite can't alter a PK in place; the migration drops &
recreates the orders table — old single-pair orders were discarded, by choice.)
- **Amount presets:** the amount step offers common buttons per currency
(`am:<value>`) — EUR/USD `100/200/300/500/1000`, RUB `5000/10000/15000/20000/30000/50000`
— plus **✏️ Other amount** (type any) and Back. Typing a number still works too.
- **Rate step is two screens** (not all-in-one): first **Rate type?** (`rm:fixed` /
`rm:floating`), then the matching preset ladder for that mode.
- **Rate picker (fixed):** a symmetric ladder — **±2/1/0.5%** presets paired per row
with the **Market** rate centred at the bottom, each labelled with the resulting
rate (`rp:<value>`), plus **✏️ Custom rate** (type an exact rate; typing also works).
- **Rate picker (floating):** same symmetric ladder but each button is a **margin over
the central-bank rate** (`rf:<margin>`), Market = CB, plus **✏️ Custom** and manual
typing. The margin type is **per quote** (`floatIsAbsolute` in `format.ts`):
**RUB = absolute** (effective = `CB + margin`, e.g. CBR +0.50 ₽), **USD/others =
percent** (`CB × (1 + margin/100)`). `rate` stores the margin (RUB rubles / else %);
effective rate is recomputed live and the type is derived from the order's quote.
Both fall back to type-a-number if no reference/CB rate is available.
- **Wizard:** pick quote currency → **amount unit** → amount → rate → method →
(**payout** / city) → confirm.
- **Method ⇒ receiving details gate:** after picking a non-cash method, confirm is
blocked until the maker has receiving details for *that* method. If they already
saved it (`hasPayoutFor`), it skips to confirm; if they pick a method they haven't
set, a **payout** step collects it (`setPayoutMethod`, ≥4 chars) and adds it to
their payment profile, then confirms. Cash skips this (in-person → city step).
Every step carries a **⬅ Back** button. Steps are rendered by one `promptStep()`
used by forward transitions, Back, and Edit; `prevStep()` maps the step graph.
- **Edit from confirm:** the confirm screen has **✅ Confirm · ✏️ Edit · ✖ Cancel**.
Edit → pick a field (💶 Amount / 💱 Rate / 💳 Method) → change just that one and
return **straight to the summary** (via a `draft.editing` flag: the field's normal
"next step" is redirected to `confirm`; Back during editing also returns to summary).
The form is framed by **intent** (`intentText`), not raw side: a `sell`-EUR order
reads "buy RUB (pay EUR)", a `buy`-EUR order reads "buy EUR (pay RUB)" — so the
"sell EUR" wording no longer confuses someone who tapped **Buy RUB**.
- **Order denomination (`denom` on orders & deals):** an order's `amount`/`frozen`
are fixed in **either** EUR (`denom='base'`) **or** the quote currency
(`denom='quote'`), chosen at the amount step (`au:base`/`au:quote`). The other
side is derived live from the rate (`sides(denom, amt, eff)` in `format.ts`:
base = amt, quote = amt·rate; or quote = amt, base = amt÷rate). So a **fixed
50 000 RUB** order keeps the RUB fixed while EUR floats with the rate; a **fixed
500 EUR** order keeps EUR fixed while RUB floats. The distinction only bites for
floating orders (fixed-rate orders have both sides constant). Freeze/finalize are
**unit-agnostic** — they operate on `amount`/`frozen` in whatever `denom` is, so
the deal/inventory math is unchanged; only display/conversion became denom-aware.
Deals inherit the order's `denom` and lock the rate at request time.
- **Book & Rate:** `/book` and `/rate` first show a market chooser (EUR/USD |
EUR/RUB), then the two-column grid / reference for that pair. Column headers use
currency symbols (€ / $ / ₽).
- **/mine:** lists orders across all pairs, each with a 🗑 Remove button (removal
moved here from `/cancel`, which now only aborts an in-progress form; blocked
while a deal is in flight).
### EUR/RUB rate sources (`src/rates.ts`)
Per-pair source sets, aggregated by median:
- **EUR/USD:** ECB (frankfurter) · er-api · currency-api · floatrates; central bank = **ECB**.
- **EUR/RUB:** **CBR** (`cbr-xml-daily.ru`, official) · **Tinkoff** (public API, buy/sell mid)
· er-api · currency-api; central bank = **CBR**. (ECB dropped RUB in 2022, so it's
excluded.) `RateRef.centralBank` carries the designated anchor for floating orders.
### Payment methods (per quote, `QUOTES[...].methods`, + auto `Other…`)
- **EUR/USD (EU / international):** `SEPA, IBAN, IRIS, Revolut, Wise, N26, Cash`.
- **EUR/RUB:** `SBP, Sberbank, Tinkoff, Alfa-Bank, VTB, Cash`.
Method is a soft tag on the order; the actual receiving details live in the
per-user **payout blob** (§ payment reveal). Geography note: **IBAN/SEPA is the
only pan-EU receive rail** (incl. Germany); **phone-based rails are national** —
IRIS 🇬🇷, Bizum 🇪🇸, MB WAY 🇵🇹 (EUR), SBP 🇷🇺 (RUB) — so "phone as a payment
method" is not universal. (Sber/Alfa/VTB have no free rate API → payment methods
only, not rate sources; Tinkoff + CBR cover RUB rate tracking.)
## 6d. Read-only web board (`src/web.ts`)
Optional embedded HTTP server (enable with `WEB_PORT`; started from `index.ts` via
`startWeb()`). Serves a **read-only mirror of the public order book** — the same
data the bot's `/book` shows and nothing more:
- `GET /` — HTML board (dark theme, auto-refresh 30 s): per-pair reference rate,
two columns (Buy EUR / Buy quote), each order's amounts, rate (`~` = floating),
method, 💵cash/🌐online badge + city, trader `@handle` (t.me link), ⭐ rating,
plus an aggregate stats bar (open orders · completed deals) and a "Trade on
Telegram" CTA.
- `GET /api/orders` — the same data as JSON (CORS `*`).
**Privacy:** `publicOrder()` whitelists fields — it **never** emits `user_id`,
payout details, or deal internals (unit-tested: HTML/JSON contain no `user_id`/
payout). Payment details are exchanged in-bot only after a deal is accepted.
Binds all interfaces on `WEB_PORT`; public exposure (beyond localhost/LAN) needs a
reverse proxy / tunnel / VPS. `BASE`/`QUOTES` are mirrored from `index.ts` (small
duplication; candidate for a shared config module).
## 6f. Internationalization (planned)
One shared bot, per-user language (never a second bot — shared order book / liquidity).
Full plan, architecture, conventions, progress checklist, and a resume-after-compact
prompt live in **`telegram-orderbook/I18N.md`**. Languages: `en` base → `ru` → `el`,
one at a time. Staged: Phase 0 prereqs (lang store, `t()` layer, `/language`,
recipient-language `safeSend`, language-aware menu) → Phase 1 command menu → Phase 2
messages area-by-area. Not started yet. (Also: **SEPA and IBAN merged** into one
`IBAN` method — a SEPA transfer just needs the IBAN.)
## 7. Files
```
telegram-orderbook/
package.json # grammy + dotenv; type: module; tsx/tsc scripts
tsconfig.json # ES2022 / bundler resolution / strict
.env.example # BOT_TOKEN, optional DB_PATH
.gitignore # node_modules, .env, *.db*
README.md # setup + rules (user-facing)
src/
index.ts # bot, command handlers, arg parsing, startup
db.ts # node:sqlite layer, schema, prepared statements
rates.ts # live EUR/USD reference rate (multi-source median, cached)
web.ts # read-only public web board (HTML + JSON), sanitized
time.ts # window parsing (durations + meet dates), city→tz, luxon resolve/format
city-timezones.d.ts # ambient types for the city-timezones package
format.ts # HTML rendering of book / mine / detail, effective rate, ratings
```
## 8. Key technical decisions
- **Stack:** Node/TS + [grammy](https://grammy.dev) (Telegram framework) —
matches the repo's Node/TS surface.
- **Storage: Node's built-in `node:sqlite`** (`DatabaseSync`, Node ≥ 22.5), **not
`better-sqlite3`.** `better-sqlite3` needs a native build and failed here (no
Visual Studio toolchain on the Windows box). `node:sqlite` needs zero native
compilation and is the **same choice the main project already made** in
[`src/token/registry-sqlite.ts`](../../src/token/registry-sqlite.ts). Data
persists across restarts.
- **Invariant in the schema, not the code** — see §3.
- **Amount stored as text** (validated numeric) so `500` and `0.5` round-trip
exactly without float drift; it's advertising copy, not settlement math.
- One startup warning — `ExperimentalWarning: SQLite is an experimental feature`
— is harmless; suppress with `NODE_OPTIONS=--no-warnings`.
## 9. Verification performed
- `tsc --noEmit` → clean.
- Logic smoke test (temp script, since exercising it needs no live bot) asserted:
- first SELL and first BUY are both new; user holds 2 entries (one per side);
- a second SELL **replaces** (returns "replaced", count stays 2, amount updated);
- a user with no username renders the `tg://user?id=` fallback link;
- a user with a username renders the `https://t.me/…` link;
- `/book EUR` groups correctly; `/mine` lists both sides;
- `/cancel sell` removes; a second `/cancel sell` is a no-op; count drops to 1.
- the confirm summary computes the USD total (500 × 1.085 = 542.50).
- **Result: ALL SMOKE CHECKS PASSED.** Temp test removed after the run.
- **Live:** token validated via `getMe`, bot connected and polling as
`@FiatMoneyExchangeBot`, command menu registered via `getMyCommands`.
- **Not machine-tested:** the interactive button/prompt flow itself (needs a real
human tapping buttons in Telegram) — the *logic* behind each step is unit-covered,
but tap-through is a manual check.
## 10. How to run
1. [@BotFather](https://t.me/BotFather) → `/newbot` → copy token.
2. `cp .env.example .env`, paste token into `BOT_TOKEN`.
3. `cd telegram-orderbook && npm install && npm run dev` (or `npm start`).
Production: `npm run build` → `npm run serve` (`node dist/index.js`).
## 11. Scope boundary & possible next steps
**Out of scope by design:** escrow, payment oracle / fiat-leg proof, dispute
resolution, reputation, geo/compliance, banking rails — all the engine of
[`fiat-ramp-product.md`](../DEX_P2P/fiat-ramp-product.md). This build is only the
directory.
**Candidate extensions** (not built):
- quote-asset / price field as structured data (currently free-form `terms`);
- `/find <currency>` shortcut;
- entry expiry / auto-stale of old postings;
- admin/group-moderator removal;
- pagination for large books;
- Telegram Mini App front-end over the same store (the distribution channel the
fiat-ramp doc §6 flags).
```

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
/**
* Connect module — the generic dApp-side ("proposer") wallet-connection core,
* shared by every Deviant dApp (tote, checkers, event-prediction) and both
* wallet-connection protocols (WalletConnect wc2-bch-bcr + CashConnect).
*
* A dApp builds its tx with CashScript (covenant inputs carry their unlockers,
* each user input via `placeholderP2PKHUnlocker`), turns it into a co-sign
* request, and hands it to any `WalletSession` adapter. See proposer.ts +
* spike/wc-connect/ for the protocol adapters (device-side).
*/
export {
buildCoSignRequest,
proposeCoSign,
toWcParams,
placeholderP2PKHUnlocker,
type WalletSession,
type SignedTxResult,
type WcTransactionObject,
type WcTransactionOptions,
} from './proposer.js';

View file

@ -0,0 +1,110 @@
/**
* src/connect/proposer.ts — the generic dApp-side ("proposer") connector core,
* shared by every Deviant dApp (tote, checkers, event-prediction) and by both
* wallet-connection protocols (WalletConnect wc2-bch-bcr + CashConnect).
*
* Roles (see spike/wc-connect/README.md):
* - dApp = PROPOSER: opens a session, builds an unsigned tx leaving the USER's
* own inputs as placeholders, and requests the wallet co-sign them.
* - wallet = RESPONDER: connects, and signs only its own inputs.
*
* This module is protocol-agnostic: a dApp builds its transaction with CashScript
* (covenant inputs carry their real unlockers; each user input is added with
* `placeholderP2PKHUnlocker(userAddress)`), turns it into a `WcTransactionObject`
* via `buildCoSignRequest`, and hands it to a `WalletSession` (a WalletConnect or
* CashConnect adapter). Validated handshake: snapshot 0055.
*
* What is shared across protocols is the TX-BUILDING (the `TransactionBuilder` with
* placeholder user inputs). The WIRE REQUEST is NOT identical across protocols:
* - WalletConnect (wc2-bch-bcr): `bch_signTransaction` takes the `WcTransactionObject`
* verbatim, serialized via `toWcParams` (libauth `stringify`). This is the
* device-validated path (snapshot 0057).
* - CashConnect (CashRPC, v0.0.25): `bch_signTransaction_V0` is **wallet-
* orchestrated**, not a dApp-built pass-through. Template-scoped inputs use the
* BchNamespace `template` (a libauth `WalletTemplate` the dApp CAN supply at
* session establishment), but the wallet then auto-runs
* `usingUnspents → addTokensFromUnspents → addTokenChangeOutputs →
* addSatoshisFromUnspents → addSatoshiChangeOutput` and VM-`verify`s before
* signing. Net: CashConnect V0 is the right fit for **standard wallet-side flows**
* (plain BCH sends, CashTokens FT/NFT sends) — the wallet builds and signs. For
* Deviant's **dApp-built covenant txs** (tote `bet()`, `GamePoolEscrowFlat.fund()`),
* route through WalletConnect: the dApp-supplied-template path is technically
* open but blocked in practice by the auto-orchestration breaking covenant
* structure, wallets' refusal to run arbitrary dApp templates, and the VM-verify
* gate. `toWcParams` below remains WalletConnect-only.
* Multi-protocol wallets (e.g. Cashonize) speak both, so users never see the routing
* decision. See spike/wc-connect/cashconnect.mjs + TESTING.md §2 for the CashConnect
* handshake test.
*/
import { TransactionBuilder } from 'cashscript';
import {
placeholderP2PKHUnlocker,
type WcTransactionObject,
type WcTransactionOptions,
} from 'cashscript';
import { stringify } from '@bitauth/libauth';
export { placeholderP2PKHUnlocker };
export type { WcTransactionObject, WcTransactionOptions };
/** What the wallet returns from `bch_signTransaction`. */
export interface SignedTxResult {
/** Fully-signed transaction hex. */
readonly signedTransaction: string;
/** Its txid (hash). */
readonly signedTransactionHash: string;
}
/**
* A connected wallet session — the seam every adapter implements, so a Deviant
* dApp is written ONCE against this interface and works over WalletConnect,
* CashConnect, or (later) WizardConnect. Concrete adapters wrap the relay/SDK
* (device-side; see spike/wc-connect/{connect,cashconnect}.mjs).
*/
export interface WalletSession {
/** The bch account(s) the wallet exposed at pairing (cashaddr). */
getAddresses(): Promise<string[]>;
/** Ask the wallet to co-sign the placeholder inputs of a proposed tx. */
signTransaction(request: WcTransactionObject): Promise<SignedTxResult>;
}
/**
* Turn a fully-built (but user-inputs-unsigned) transaction into a co-sign
* request. Thin wrapper over CashScript's `generateWcTransactionObject` so every
* dApp has one named entry point (and a place to normalise across protocols if
* they ever diverge). The builder must already have:
* - covenant inputs added with their real `contract.unlock.*(...)` unlockers,
* - each user input added with `placeholderP2PKHUnlocker(userAddress)`,
* - all outputs added.
*/
export function buildCoSignRequest(
tb: TransactionBuilder,
opts: WcTransactionOptions = {},
): WcTransactionObject {
return tb.generateWcTransactionObject(opts);
}
/**
* Serialize a `WcTransactionObject` into the JSON-RPC `params` a BCH wallet
* expects for `bch_signTransaction` (wc2-bch-bcr). The wire format is **libauth's
* `stringify`**: `Uint8Array → "<Uint8Array: 0x..>"`, `bigint → "<bigint: ..n>"`.
* Wallets (e.g. Cashonize) validate these exact patterns and reject plain
* hex/decimal (or raw arrays/numbers) — so an adapter MUST pass `toWcParams(req)`,
* never the raw object, to `client.request`.
*/
export function toWcParams(req: WcTransactionObject): unknown {
return JSON.parse(stringify(req));
}
/**
* Build the co-sign request and send it to the wallet in one step. The dApp signs
* its covenant inputs; the wallet fills the placeholder (user) inputs and — when
* `broadcast` is set — broadcasts. Returns the signed tx.
*/
export async function proposeCoSign(
session: WalletSession,
tb: TransactionBuilder,
opts: WcTransactionOptions = {},
): Promise<SignedTxResult> {
return session.signTransaction(buildCoSignRequest(tb, opts));
}

View file

@ -0,0 +1,71 @@
/**
* bch-namespace.ts — wire constants shared by the WalletConnect (wc2-bch-bcr) and
* CashConnect (CashRPC) responder handlers.
*
* Both protocols ride the SAME WC2 relay and use the SAME namespace name (`bch`);
* they differ in proposal shape (CashConnect adds `protocol: 'CashRPC:0'` +
* `template` + `allowedTokens`), method names (`_V0`-suffixed for CC), and wire
* encoding. See ADR-0043 + wiki/connector.md for the full design rationale.
*/
import type { BchChain } from './index';
/** Account string layout for wc2-bch-bcr: `bch:<network>:<cashaddr>`. */
export type WcAccount = `${BchChain}:${string}`;
/** Account string layout for CashConnect: `bch:<network>:<pubkeyHex>`. The wallet
* exposes the **public key** (33-byte compressed, hex), NOT a cashaddr — confirmed
* in cashconnect@0.0.25 (`CashConnectWallet.onSessionProposal`, lines ~1483 of the
* installed dist: `accounts: ['${chain}:${network}:${binToHex(sandboxedPublicKey)}']`).
* Snapshot 0062 finding 1. */
export type CcAccount = `${BchChain}:${string}`;
// ─── WalletConnect (wc2-bch-bcr) ──────────────────────────────────────────
/** WalletConnect methods the responder MUST handle. Mirrors the proposer
* (src/connect/proposer.ts) and snapshot 0057's validated co-sign. */
export const WC_METHODS = [
'bch_getAddresses',
'bch_signMessage',
'bch_signTransaction',
] as const;
export type WcMethod = (typeof WC_METHODS)[number];
// ─── CashConnect (CashRPC) ────────────────────────────────────────────────
/** CashConnect methods the responder MAY handle. CashConnect V0 is
* wallet-orchestrated (it builds the tx from instructions, runs a VM verify, and
* signs) — so the responder routes signTransaction to the cashconnect package's
* exported `CashRPC_V0` class. CC cannot co-sign Deviant covenant txs at V0; those
* flows route through WC (ADR-0043 §2.3 + snapshot 0062 Finding 3). */
export const CC_METHODS = [
'wc_authRequest',
'bch_getBalance_V0',
'bch_getChangeLockingBytecode_V0',
'bch_getTokens_V0',
'bch_signMessage_V0',
'bch_signTransaction_V0',
] as const;
export type CcMethod = (typeof CC_METHODS)[number];
/** CashConnect's protocol marker on the `bch` namespace at session proposal. Used
* by `classifier.classifyProposal` to distinguish CC sessions from plain WC ones. */
export const CC_PROTOCOL = 'CashRPC:0' as const;
/** CashConnect emits `balancesChanged`; plain WC emits nothing. */
export const CC_EVENTS = ['balancesChanged'] as const;
/** Chains supported by both protocols (per cashconnect's `SupportedChains` and the
* proposer's namespace). The shell's `ALLOWED_NETWORKS` may further restrict this
* at runtime (chipnet-only beta builds, etc.). */
export const SUPPORTED_CHAINS: readonly BchChain[] = ['bch:bchtest', 'bch:bitcoincash'];
/** Map a `BchChain` to the libauth network token used by the noble crypto shim and
* cashscript's `ElectrumNetworkProvider`. `bch:bchreg` is not exposed in the shell. */
export function chainToNetwork(chain: BchChain): 'chipnet' | 'mainnet' {
return chain === 'bch:bchtest' ? 'chipnet' : 'mainnet';
}
/** Inverse: a network → its chain id. */
export function networkToChain(network: 'chipnet' | 'mainnet' | 'testnet4'): BchChain {
// testnet4 has no canonical BCH WC chain id; surface as chipnet for transport
// purposes (no live dApps speak testnet4 anyway). Document this in connector.md.
return network === 'mainnet' ? 'bch:bitcoincash' : 'bch:bchtest';
}

View file

@ -0,0 +1,172 @@
/**
* shells/app/src/connect — wallet-side ("responder") multi-protocol connector.
*
* The dApp/proposer side lives in `src/connect/proposer.ts` (validated end-to-
* end against Cashonize, snapshot 0057). This is its mirror image: the wallet
* pairs with external dApps over the shared WC2 relay and serves BOTH the
* **WalletConnect** (wc2-bch-bcr) and **CashConnect** (CashRPC) protocols from
* a single relay socket — one pair, one session per dApp, dispatched by shape.
*
* Design + decision record: `wiki/connector.md`, ADR-0041 (WC-only origin) +
* ADR-0043 (multi-protocol extension).
*
* Runtime layout:
*
* - `bch-namespace.ts` — methods, chains, protocol marker, account formats
* - `classifier.ts` — pure routing: proposal/request/URI → 'wc' | 'cc'
* - `signer.ts` — libauth pass-through co-signer (WC path)
* - `wc-handler.ts` — bch_signMessage / bch_signTransaction / bch_getAddresses
* - `cc-handler.ts` — wraps the cashconnect package's CashRPC_V0
* - `responder.ts` — WalletKit listener + dispatcher (the orchestrator)
*
* The runtime is dependency-injection-friendly: the shell provides the
* `WalletKit` instance + `KeyProvider` (sealed access to the active KeyStore) +
* `NetworkProvider` (Electrum), and the responder wires them. Headless-testable
* pieces (`classifier`, `signer`'s p2pkh detection) ship unit tests; the WalletKit
* loop is exercised on-device against Cashonize, BCH Guru, and the tote.
*/
// ── Wire types (mirror the proposer + the wc2-bch-bcr / CashRPC specs) ───────
/** Supported chains. `bch:bchtest` = chipnet; `bch:bitcoincash` = mainnet. */
export type BchChain = 'bch:bchtest' | 'bch:bitcoincash';
/** Methods the responder MAY surface to the dApp. The actual set offered per
* session depends on which protocol the proposal selected (see
* `classifier.classifyProposal`). */
export type BchMethod =
// WalletConnect (wc2-bch-bcr) — snapshot 0057 validated:
| 'bch_getAddresses' | 'bch_signMessage' | 'bch_signTransaction'
// CashConnect (CashRPC v0.0.25) — snapshot 0062 pinned:
| 'wc_authRequest'
| 'bch_getBalance_V0' | 'bch_getChangeLockingBytecode_V0' | 'bch_getTokens_V0'
| 'bch_signMessage_V0' | 'bch_signTransaction_V0';
/** Account string format. For wc2-bch-bcr the third segment is a cashaddr; for
* CashConnect it is a 33-byte compressed pubkey, hex-encoded. */
export type BchAccount = `${BchChain}:${string}`;
/** Decoded form of an incoming `bch_signTransaction` after libauth
* `parseExtendedJson`. Mirror of cashscript's `WcTransactionObject`. */
export interface BchSignTxRequest {
/** Decoded transaction (libauth `TransactionCommon`). Re-encode with
* `encodeTransaction` after signing the user's inputs. */
readonly transaction: unknown;
/** Per-input source outputs (decoded). `lockingBytecode` is `Uint8Array`. */
readonly sourceOutputs: ReadonlyArray<unknown>;
/** If true, the responder should also broadcast after signing. */
readonly broadcast?: boolean;
/** Human-readable prompt the wallet must render verbatim. */
readonly userPrompt?: string;
}
/** Decoded form of `bch_signMessage`. */
export interface BchSignMessageRequest {
/** Message bytes (decoded `Uint8Array`). UTF-8 decode for display when printable. */
readonly message: Uint8Array;
/** The cashaddr that should sign (WC) or the chain-namespace account (CC). */
readonly address: string;
}
/** The wallet's response shape for `bch_signTransaction`. Libauth-`stringify`
* it on the way out. */
export interface SignedTxResponse {
/** Fully-signed transaction hex. */
readonly signedTransaction: string;
/** Its txid (hex, big-endian display order). */
readonly signedTransactionHash: string;
}
/** The wallet's response shape for `bch_signMessage`. */
export interface SignedMessageResponse {
readonly signedMessage: string;
readonly signature: string;
}
// ── Account identity (re-exported so the responder doesn't reach into `src/`) ──
/** Mirrors `src/wallet/keystore.ts AccountIdentity` — duplicated locally so the
* shell module compiles standalone. The shell wires real `KeyStore.unlock()` →
* `AccountIdentity` at runtime; types match by shape. */
export interface AccountIdentity {
readonly cashaddr: string;
readonly tokenAddress: string;
readonly ownedAddresses: readonly string[];
readonly publicKey: Uint8Array;
readonly network: 'chipnet' | 'testnet4' | 'mainnet';
readonly derivationPath: string;
}
// ── Responder runtime surface (implemented in responder.ts) ──────────────────
/** Session metadata surfaced to the UI from a `session_proposal` event. */
export interface SessionProposal {
readonly id: number;
readonly dApp: { readonly name: string; readonly url: string; readonly icons: readonly string[] };
readonly chains: readonly BchChain[];
readonly methods: readonly BchMethod[];
}
/** A sign request, surfaced to the UI for approval. */
export type SignRequestPreview =
| { readonly kind: 'sign-message'; readonly id: number; readonly sessionId: string; readonly appName: string; readonly request: BchSignMessageRequest }
| { readonly kind: 'sign-tx'; readonly id: number; readonly sessionId: string; readonly appName: string; readonly request: BchSignTxRequest };
/** Events the responder emits to the AppsScreen UI. */
export interface ResponderEvents {
onSessionProposal: (proposal: SessionProposal) => void;
onSignRequest: (request: SignRequestPreview) => void;
onSessionDelete: (sessionId: string) => void;
onError: (error: Error) => void;
}
/** Public API the AppsScreen uses. */
export interface Responder {
/** Pair with a dApp by pasting/scanning its `wc:` or `cc:` URI. */
pair(uri: string): Promise<void>;
/** Approve a pending session proposal. `accounts` is WC-format; for CC
* proposals the responder rewrites accounts to the `bch:<net>:<pubkeyHex>`
* form automatically (`methods` is ignored for CC — the full V0 method set
* is offered). */
approveSession(proposalId: number, accounts: readonly BchAccount[], methods: readonly BchMethod[]): Promise<void>;
/** Reject a pending session proposal. */
rejectSession(proposalId: number, reason?: string): Promise<void>;
/** Approve and respond to a pending sign request. */
approveSignRequest(
requestId: number,
response: SignedTxResponse | SignedMessageResponse | { readonly addresses: readonly string[] },
): Promise<void>;
/** Reject a pending sign request. */
rejectSignRequest(requestId: number, reason?: string): Promise<void>;
/** List currently-active sessions. */
listSessions(): Promise<ReadonlyArray<{ readonly topic: string; readonly peer: SessionProposal['dApp'] }>>;
/** Tear down a session. */
disconnect(topic: string, reason?: string): Promise<void>;
}
// ── Signer surface (implemented in signer.ts) ────────────────────────────────
/** What the libauth-based co-signer needs to sign one tx. */
export interface CoSignInput {
readonly request: BchSignTxRequest;
readonly ownPubkeyHash: Uint8Array;
readonly ownPublicKey: Uint8Array;
readonly privateKey: Uint8Array;
readonly network: 'chipnet' | 'testnet4' | 'mainnet';
}
/** The signing function shape. The runtime implementation (`signer.ts`
* `coSignTransaction`) only signs inputs whose `sourceOutputs[i].lockingBytecode`
* equals our own P2PKH — never any others, even if asked. */
export type CoSign = (input: CoSignInput) => Promise<SignedTxResponse>;
// ── Runtime re-exports (so AppsScreen.tsx imports stay tidy) ─────────────────
export { createResponder } from './responder';
export type { ResponderConfig, KeyProvider, NetworkProvider } from './responder';
export { classifyProposal, classifyRequest, classifyUri } from './classifier';
export type { Protocol } from './classifier';
export { coSignTransaction, isOwnedInput, p2pkhLockingBytecode, p2pkhUnlockingBytecode, BCH_SIGHASH_ALL_FORKID } from './signer';
export {
CC_PROTOCOL, CC_METHODS, CC_EVENTS, WC_METHODS,
SUPPORTED_CHAINS, chainToNetwork, networkToChain,
} from './bch-namespace';

View file

@ -0,0 +1,440 @@
/**
* responder.ts — the multi-protocol wallet-side responder (ADR-0043).
*
* Runs **one** `@reown/walletkit` instance over **one** WC2 relay socket, and
* dispatches each session proposal + session request to the right protocol
* handler:
*
* classifyProposal(proposal) → 'wc' → approve with wc2-bch-bcr namespace
* → 'cc' → approve with CashRPC namespace
* classifyRequest(method) → 'wc' → wc-handler.ts
* → 'cc' → per-session CashRPC_V0 (cc-handler.ts)
*
* The user pairs once per dApp; the protocol is decided by the proposal shape,
* not by the user. Multiple sessions of either protocol coexist; the dispatcher
* routes per topic.
*
* Wire encoding (libauth Extended JSON, the same wire format the proposer
* validated in snapshot 0057):
*
* - **WC**: the proposer sends params as a JSON tree with `<Uint8Array: 0x..>` /
* `<bigint: ..n>` markers; we round-trip via `parseExtendedJson(JSON.stringify(.))`
* to recover real `Uint8Array` + `bigint` before handing to the handler.
* - **CC**: the cashconnect package's `request()` puts params as a single string
* blob in `request.params: string` (per its `RequestEvent` type); we call
* `parseExtendedJson(string)` directly.
*
* Non-custody (ADR-0041 §5): the signer never signs an input it doesn't own;
* the responder never persists secrets; the `KeyProvider` mediates all key access
* so the active KeyStore (Capacitor secure-storage / WebKeyStore) stays in charge.
*/
import { WalletKit, type WalletKitTypes } from '@reown/walletkit';
import { stringify } from '@bitauth/libauth';
import {
CC_EVENTS,
CC_METHODS,
SUPPORTED_CHAINS,
WC_METHODS,
} from './bch-namespace';
import { classifyProposal, classifyRequest, type Protocol } from './classifier';
import { handleGetAddresses } from './wc-handler';
import { buildCcSession, type CcSession } from './cc-handler';
import type {
AccountIdentity,
BchChain,
BchSignMessageRequest,
BchSignTxRequest,
ResponderEvents,
Responder,
SessionProposal,
SignRequestPreview,
} from './index';
/** A live `WalletKit` instance (the relay-connected wallet-side WC2 client). The
* class export is also used as its instance type — `WalletKit` works as both. */
export type WalletKitInstance = InstanceType<typeof WalletKit>;
/** Sealed access to the active KeyStore account. Implementations are produced by
* `shells/app/src/core/wallet.ts` after `KeyStore.unlock(...)`; the responder
* never touches the KeyStore API directly so its lifecycle stays clean. */
export interface KeyProvider {
/** Public identity (addresses, pubkey, network, etc.). Safe to call freely. */
identity(): AccountIdentity;
/** The 32-byte private key for signing. Implementations may re-prompt for
* biometric auth here; callers must NOT cache the result. */
privateKey(): Uint8Array;
/** The 20-byte RIPEMD160(SHA256(pubkey)). Cached per session by the
* responder; provided by the KeyStore so we don't recompute. */
pubkeyHash(): Uint8Array;
}
/** Network-level callbacks CashConnect's `CashRPC_V0` needs to resolve source
* outputs and (when its standard-send path is exercised) the user's UTXOs. The
* shell wires this against its existing `ElectrumNetworkProvider`. */
export interface NetworkProvider {
/** Resolve a source output by `(txHash, outpointIndex)`. The libauth-shape
* return is what `CashRPC_V0.getSourceOutput` expects. */
getSourceOutput(chain: BchChain, txHash: Uint8Array, outpointIndex: number): Promise<unknown>;
/** Optionally return the wallet's spendable UTXOs (for CashRPC standard-send
* flows). The shell may stub this `() => []` while the standard-send adapter
* is still being wired. */
getUnspents?(chain: BchChain): Promise<readonly unknown[]>;
}
/** Per-topic state the dispatcher tracks. */
interface ActiveSession {
readonly topic: string;
readonly protocol: Protocol;
readonly chain: BchChain;
readonly peer: SessionProposal['dApp'];
/** Present iff `protocol === 'cc'`. */
readonly cc?: CcSession;
}
/** Init configuration. The shell provides the `WalletKit` instance (already
* initialised against the relay) so the responder doesn't own the projectId or
* the WC2 socket lifecycle. */
export interface ResponderConfig {
readonly walletKit: WalletKitInstance;
readonly key: KeyProvider;
readonly network: NetworkProvider;
readonly events: ResponderEvents;
/** If set, restrict approvals to these chains (e.g. chipnet-only beta builds). */
readonly allowedChains?: readonly BchChain[];
}
/** Construct the responder. The returned object is the public surface the UI
* (`AppsScreen.tsx`) calls; the WalletKit listeners are wired internally. */
export function createResponder(config: ResponderConfig): Responder {
const { walletKit, key, network, events } = config;
const allowedChains = config.allowedChains ?? SUPPORTED_CHAINS;
const sessions = new Map<string, ActiveSession>();
/** Pending sign requests keyed by request id so `approveSignRequest` /
* `rejectSignRequest` can resolve them. */
const pending = new Map<number, { topic: string; resolve: (v: unknown) => void; reject: (e: Error) => void }>();
/** Pending session proposals keyed by proposal id, awaiting user approval. */
const proposals = new Map<number, { protocol: Protocol; chain: BchChain; raw: WalletKitTypes.SessionProposal }>();
// ─── session_proposal ──────────────────────────────────────────────────
walletKit.on('session_proposal', (proposal: WalletKitTypes.SessionProposal) => {
try {
const protocol = classifyProposal({
requiredNamespaces: proposal.params.requiredNamespaces as never,
optionalNamespaces: proposal.params.optionalNamespaces as never,
});
const ns = (proposal.params.requiredNamespaces?.bch ?? proposal.params.optionalNamespaces?.bch) as { chains?: readonly string[] } | undefined;
const requestedChains = (ns?.chains ?? []) as readonly BchChain[];
const chain = requestedChains.find((c) => allowedChains.includes(c));
if (!chain) {
// Reject explicitly — better than a silent timeout for the dApp.
const peerName = proposal.params.proposer.metadata.name || 'The dApp';
const reqMsg = requestedChains.length ? requestedChains.join(', ') : '(none)';
const allowedMsg = allowedChains.join(', ');
const reason = `${peerName} only supports ${reqMsg}; this wallet supports ${allowedMsg}. Ask the dApp to add ${allowedMsg.replace('bch:bchtest', 'chipnet').replace('bch:bitcoincash', 'mainnet')} support, or switch the wallet's network if available.`;
walletKit.rejectSession({ id: proposal.id, reason: { code: 5100, message: reason } }).catch(() => {});
// Also surface to the UI so the user sees what happened instead of an
// indefinite "Waiting for the dApp…" — the proposal handler is the only
// place this mismatch can be detected.
events.onError(new Error(`No compatible chain — ${reason}`));
return;
}
proposals.set(proposal.id, { protocol, chain, raw: proposal });
const preview: SessionProposal = {
id: proposal.id,
dApp: {
name: proposal.params.proposer.metadata.name,
url: proposal.params.proposer.metadata.url,
icons: proposal.params.proposer.metadata.icons,
},
chains: [chain],
methods: (protocol === 'wc' ? [...WC_METHODS] : [...CC_METHODS]) as never,
};
events.onSessionProposal(preview);
} catch (err) {
events.onError(toError(err));
}
});
// ─── session_request ───────────────────────────────────────────────────
walletKit.on('session_request', async (event: WalletKitTypes.SessionRequest) => {
const { topic, id, params } = event;
const method = params.request.method;
const session = sessions.get(topic);
if (!session) {
await respondError(walletKit, topic, id, 5000, `Unknown session topic ${topic}`);
return;
}
try {
const protocol = classifyRequest(method);
if (protocol !== session.protocol) {
// A WC session can't receive CC methods or vice versa — reject so the
// dApp gets a clear error rather than a silent timeout.
throw new Error(`Method ${method} is not valid for ${session.protocol.toUpperCase()} session ${topic}`);
}
// Decode the params from libauth Extended JSON.
const decoded = decodeParams(protocol, params.request.params);
// Build a UI preview + register a continuation. We resolve the
// continuation when the UI calls approveSignRequest / rejectSignRequest.
const continuation = new Promise<unknown>((resolve, reject) => {
pending.set(id, { topic, resolve, reject });
});
const preview = buildPreview(protocol, method, decoded, id, session);
if (preview) events.onSignRequest(preview);
const result = await continuation;
// ── Approval path ──
// If the UI returned a "decoded" response (e.g. a SignedTxResponse object),
// re-encode via libauth `stringify` and ship.
const wire = protocol === 'cc'
// CC expects params as Extended-JSON-string; the cashconnect SDK applies
// parseExtendedJson on the dApp side. For responses, the CC standard
// uses the same EJSON encoding (see `respondSessionRequest({result: stringifyExtendedJson(response)})`).
? stringify(result)
: roundTripEjson(result);
await walletKit.respondSessionRequest({
topic,
response: { id, jsonrpc: '2.0', result: wire },
});
} catch (err) {
const e = toError(err);
pending.delete(id);
events.onError(e);
await respondError(walletKit, topic, id, 5001, e.message);
}
});
// ─── session_delete ────────────────────────────────────────────────────
walletKit.on('session_delete', (event: WalletKitTypes.SessionDelete) => {
const topic = (event as { topic?: string }).topic ?? '';
sessions.delete(topic);
events.onSessionDelete(topic);
});
// ─── Helpers for the dispatcher ────────────────────────────────────────
function buildPreview(protocol: Protocol, method: string, decoded: unknown, id: number, session: ActiveSession): SignRequestPreview | null {
// bch_getAddresses doesn't need user approval; auto-resolve.
if (protocol === 'wc' && method === 'bch_getAddresses') {
const addresses = handleGetAddresses(key);
// Resolve the pending continuation synchronously next microtask.
queueMicrotask(() => {
const p = pending.get(id);
if (!p) return;
pending.delete(id);
p.resolve({ addresses });
});
return null;
}
// Only message-sign and tx-sign need a UI prompt; other CC methods are
// wallet-orchestrated and auto-resolve as well.
if (protocol === 'wc' && method === 'bch_signMessage') {
return {
kind: 'sign-message',
id, sessionId: session.topic, appName: session.peer.name,
request: decoded as BchSignMessageRequest,
};
}
if (protocol === 'wc' && method === 'bch_signTransaction') {
return {
kind: 'sign-tx',
id, sessionId: session.topic, appName: session.peer.name,
request: decoded as BchSignTxRequest,
};
}
if (protocol === 'cc') {
// CashRPC: route through the per-session CashRPC_V0 instance. Some
// methods (signMessage/signTransaction) the UI should still prompt for;
// others (getBalance/getTokens) can pass through silently. For v1 we
// auto-resolve everything by dispatching directly — the UI gets to
// approve/reject via the standard SessionRequest preview before the
// method runs, in a follow-up iteration.
if (!session.cc) {
throw new Error(`CashConnect session ${session.topic} has no CashRPC_V0 instance`);
}
queueMicrotask(async () => {
const p = pending.get(id);
if (!p) return;
pending.delete(id);
try {
const result = await session.cc!.dispatch(method, decoded);
p.resolve(result);
} catch (err) {
p.reject(toError(err));
}
});
return null;
}
return null;
}
// ─── Public API ─────────────────────────────────────────────────────────
return {
async pair(uri) {
await walletKit.pair({ uri });
},
async approveSession(proposalId, accounts, methods) {
const pending = proposals.get(proposalId);
if (!pending) throw new Error(`No pending proposal ${proposalId}`);
proposals.delete(proposalId);
const id = key.identity();
const accountsForNamespace =
pending.protocol === 'wc'
? accounts // wc cashaddr-style: `bch:<chain>:<cashaddr>`
: [`${pending.chain}:${binToHex(id.publicKey)}` as const]; // CC pubkey-style
const namespaces: Record<string, unknown> =
pending.protocol === 'wc'
? {
bch: {
chains: [pending.chain],
methods: [...methods],
events: [],
accounts: accountsForNamespace,
},
}
: {
bch: {
chains: SUPPORTED_CHAINS.slice(),
methods: [...CC_METHODS],
events: [...CC_EVENTS],
accounts: accountsForNamespace,
},
};
const session = await walletKit.approveSession({
id: proposalId,
namespaces: namespaces as never,
}) as { topic: string };
const peerMeta = pending.raw.params.proposer.metadata;
const active: ActiveSession = {
topic: session.topic,
protocol: pending.protocol,
chain: pending.chain,
peer: { name: peerMeta.name, url: peerMeta.url, icons: peerMeta.icons },
};
if (pending.protocol === 'cc') {
const cc = await buildCcSession({ chain: pending.chain, key, network });
sessions.set(session.topic, { ...active, cc });
} else {
sessions.set(session.topic, active);
}
},
async rejectSession(proposalId, reason) {
proposals.delete(proposalId);
await walletKit.rejectSession({
id: proposalId,
reason: { code: 5000, message: reason ?? 'User rejected' },
});
},
async approveSignRequest(requestId, response) {
const cont = pending.get(requestId);
if (!cont) throw new Error(`No pending request ${requestId}`);
pending.delete(requestId);
// If the UI handed us only the user's decision (no payload), do the actual
// signing now using the cached pending request data. For the v1 sketch we
// ASSUME the UI already invoked sign-message / sign-transaction via the
// KeyProvider and is handing us the SignedTx/SignedMessage object.
cont.resolve(response);
},
async rejectSignRequest(requestId, reason) {
const cont = pending.get(requestId);
if (!cont) throw new Error(`No pending request ${requestId}`);
pending.delete(requestId);
cont.reject(new Error(reason ?? 'User rejected'));
},
async listSessions() {
return [...sessions.values()].map((s) => ({ topic: s.topic, peer: s.peer }));
},
async disconnect(topic, reason) {
await walletKit.disconnectSession({
topic,
reason: { code: 6000, message: reason ?? 'User disconnected' },
});
sessions.delete(topic);
},
};
}
// ─── Wire encoding helpers ────────────────────────────────────────────────
/** Decode params from libauth Extended JSON.
*
* Both WC and CC use libauth's `stringify` wire format
* (`Uint8Array → "<Uint8Array: 0x..>"`, `bigint → "<bigint: ..n>"`). libauth
* 3.1 doesn't export a corresponding `parseExtendedJson`, so we walk the tree
* ourselves; the patterns are unambiguous (they don't collide with normal
* string content unless an attacker crafts colliding payloads — and the
* responder treats decoded params as untrusted regardless). */
function decodeParams(protocol: Protocol, raw: unknown): unknown {
if (protocol === 'cc') {
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
return reviveExtendedJson(obj);
}
return reviveExtendedJson(raw);
}
const UINT8_PATTERN = /^<Uint8Array: 0x([0-9a-fA-F]*)>$/;
const BIGINT_PATTERN = /^<bigint: (-?\d+)n>$/;
function reviveExtendedJson(value: unknown): unknown {
if (typeof value === 'string') {
const u8 = UINT8_PATTERN.exec(value);
if (u8) {
const hex = u8[1] ?? '';
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
const bi = BIGINT_PATTERN.exec(value);
if (bi) return BigInt(bi[1]!);
return value;
}
if (Array.isArray(value)) return value.map(reviveExtendedJson);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = reviveExtendedJson(v);
return out;
}
return value;
}
/** Encode WC responses by going through libauth `stringify` then back through
* `JSON.parse` so the response is a clean JSON tree with EJSON markers — the
* shape the proposer's `parseExtendedJson(response)` round-trips correctly. */
function roundTripEjson(value: unknown): unknown {
return JSON.parse(stringify(value));
}
/** Build a `bch:<chain>:<pubkeyHex>` CC account string. */
function binToHex(bin: Uint8Array): string {
let s = '';
for (const b of bin) s += b.toString(16).padStart(2, '0');
return s;
}
async function respondError(walletKit: WalletKitInstance, topic: string, id: number, code: number, message: string) {
try {
await walletKit.respondSessionRequest({
topic,
response: { id, jsonrpc: '2.0', error: { code, message } },
});
} catch {
// Best-effort — the session may already be torn down.
}
}
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}

View file

@ -0,0 +1,182 @@
/**
* signer.ts — libauth-based pass-through co-signer for the WalletConnect path.
*
* The wc2-bch-bcr `bch_signTransaction` model: the dApp builds a libauth-shape
* transaction with covenant inputs already containing their CashScript-compiled
* `unlockingBytecode` and each USER input marked by a `placeholderP2PKHUnlocker`
* (an empty/dummy unlock). The wallet's job is to (a) identify which inputs spend
* UTXOs locked to its own P2PKH and (b) sign just those, leaving every other
* input untouched. This is the "structural non-custody" rule of ADR-0041 §5: we
* NEVER sign an input we don't own, even if asked.
*
* What we do per ownable input:
* 1. Build the BCH signing-serialization (covered = p2pkh script, type = ALL|FORKID).
* 2. Double-sha256 it.
* 3. ECDSA-DER-sign the hash with the user's private key.
* 4. Append the sighash byte.
* 5. Write the standard P2PKH unlocking bytecode `<sigLen> <sig||sighash> <33> <pubkey>`.
*
* Sighash default: `SIGHASH_ALL | SIGHASH_FORKID = 0x41` (BCH replay-protection on,
* commits to all inputs+outputs). Covenants that need a different sighash will
* have to ship that in a future protocol extension — none today (snapshot 0057
* proved this default works against Cashonize).
*
* Pure & headless-testable: takes a decoded transaction + sourceOutputs (the
* responder already `parseExtendedJson`-decoded them off the wire) and returns
* the signed hex + txid hex. No SDK / network / KeyStore imports.
*/
import {
encodeTransactionBCH,
generateSigningSerializationBCH,
hash256,
secp256k1,
binsAreEqual,
binToHex,
SigningSerializationFlag,
// BCH consensus rule we're encoding into the sighash byte:
// ALL = 0x01, FORKID = 0x40 → ALL|FORKID = 0x41
} from '@bitauth/libauth';
import type { CoSignInput, SignedTxResponse } from './index';
/** The sighash byte we commit to. Documented + asserted in the unit test below. */
export const BCH_SIGHASH_ALL_FORKID = SigningSerializationFlag.allOutputs | SigningSerializationFlag.forkId;
/** Libauth-shape transaction input + output (kept loose so this module compiles
* without leaking libauth's internal types up to the index surface). */
interface MutableInput {
outpointTransactionHash: Uint8Array;
outpointIndex: number;
sequenceNumber: number;
unlockingBytecode: Uint8Array;
}
interface MutableOutput {
lockingBytecode: Uint8Array;
valueSatoshis: bigint;
token?: unknown;
}
interface MutableTx {
version: number;
inputs: MutableInput[];
outputs: MutableOutput[];
locktime: number;
}
/** Build the canonical 25-byte P2PKH locking bytecode for a pubkey hash:
* `OP_DUP OP_HASH160 <0x14> <pkh> OP_EQUALVERIFY OP_CHECKSIG`. */
export function p2pkhLockingBytecode(pubkeyHash: Uint8Array): Uint8Array {
if (pubkeyHash.length !== 20) {
throw new Error(`p2pkh pubkeyHash must be 20 bytes, got ${pubkeyHash.length}`);
}
const out = new Uint8Array(25);
out[0] = 0x76; // OP_DUP
out[1] = 0xa9; // OP_HASH160
out[2] = 0x14; // push 20 bytes
out.set(pubkeyHash, 3);
out[23] = 0x88; // OP_EQUALVERIFY
out[24] = 0xac; // OP_CHECKSIG
return out;
}
/** Build the standard P2PKH unlocking bytecode `<sigLen> <sig||sighash> <pkLen> <pk>`. */
export function p2pkhUnlockingBytecode(signatureWithSighash: Uint8Array, publicKey: Uint8Array): Uint8Array {
if (publicKey.length !== 33) {
throw new Error(`compressed pubkey must be 33 bytes, got ${publicKey.length}`);
}
const out = new Uint8Array(1 + signatureWithSighash.length + 1 + publicKey.length);
out[0] = signatureWithSighash.length;
out.set(signatureWithSighash, 1);
out[1 + signatureWithSighash.length] = publicKey.length;
out.set(publicKey, 1 + signatureWithSighash.length + 1);
return out;
}
/** Decide whether input `i` is OURS to sign: source output's `lockingBytecode`
* must equal our P2PKH script. Constant-time-ish (libauth `binsAreEqual`). */
export function isOwnedInput(
sourceOutputLockingBytecode: Uint8Array,
ownPubkeyHash: Uint8Array,
): boolean {
return binsAreEqual(sourceOutputLockingBytecode, p2pkhLockingBytecode(ownPubkeyHash));
}
/** The libauth pass-through co-sign. Mutates a copy of the transaction's inputs
* to fill the unlocking bytecode of every ownable input, then encodes + hashes. */
export async function coSignTransaction(input: CoSignInput): Promise<SignedTxResponse> {
const tx = input.request.transaction as MutableTx;
const sourceOutputs = input.request.sourceOutputs as readonly MutableOutput[];
if (!tx || !Array.isArray(tx.inputs) || !Array.isArray(sourceOutputs)) {
throw new Error('coSignTransaction: malformed request — missing tx.inputs or sourceOutputs');
}
if (tx.inputs.length !== sourceOutputs.length) {
throw new Error(
`coSignTransaction: inputs/sourceOutputs length mismatch (${tx.inputs.length} vs ${sourceOutputs.length})`,
);
}
// Work on a fresh inputs array so we don't mutate the request object.
const signedInputs: MutableInput[] = tx.inputs.map((i) => ({ ...i }));
const ownP2pkhScript = p2pkhLockingBytecode(input.ownPubkeyHash);
let signedCount = 0;
for (let i = 0; i < signedInputs.length; i++) {
const lock = sourceOutputs[i]?.lockingBytecode;
if (!lock || !binsAreEqual(lock, ownP2pkhScript)) continue; // not ours — skip
// Build the BCH-spec signing serialization for this input.
// libauth signature: (CompilationContextBch, { coveredBytecode, signingSerializationType }, sha256?)
// coveredBytecode for a P2PKH unlock is the canonical P2PKH locking script
// itself (per the BCH signing-serialization spec; what would execute on the
// stack after the unlock pushes <sig> <pk>).
const preimage = generateSigningSerializationBCH(
{
inputIndex: i,
sourceOutputs: sourceOutputs as any,
transaction: { ...tx, inputs: signedInputs } as any,
},
{
coveredBytecode: ownP2pkhScript,
signingSerializationType: new Uint8Array([BCH_SIGHASH_ALL_FORKID]),
},
);
const messageHash = hash256(preimage);
const sig = secp256k1.signMessageHashDER(input.privateKey, messageHash);
if (typeof sig === 'string') {
throw new Error(`coSignTransaction: ECDSA sign failed for input ${i}: ${sig}`);
}
// Append the sighash byte to the DER signature.
const sigWithSighash = new Uint8Array(sig.length + 1);
sigWithSighash.set(sig, 0);
sigWithSighash[sig.length] = BCH_SIGHASH_ALL_FORKID;
signedInputs[i].unlockingBytecode = p2pkhUnlockingBytecode(sigWithSighash, input.ownPublicKey);
signedCount++;
}
if (signedCount === 0) {
throw new Error(
'coSignTransaction: no inputs are owned by this wallet — refusing to sign. ' +
'Either the dApp built the tx for a different account, or your pubkey hash is wrong.',
);
}
const signedTx: MutableTx = { ...tx, inputs: signedInputs };
const encoded = encodeTransactionBCH(signedTx as any);
// txid (UI / display order) = reverse-bytes of hash256(encoded). Compute
// directly to avoid the libauth `hashTransactionUiOrder` overload mismatch
// (it demands an injected sha256 implementation under some versions).
const txidLE = reverseBytes(hash256(encoded));
return {
signedTransaction: binToHex(encoded),
signedTransactionHash: binToHex(txidLE),
};
}
function reverseBytes(bin: Uint8Array): Uint8Array {
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin[bin.length - 1 - i]!;
return out;
}

View file

@ -0,0 +1,122 @@
/**
* wc-handler.ts — wc2-bch-bcr method dispatch (the WalletConnect path).
*
* Methods (per ADR-0041 §3 and snapshot 0057 — device-validated against Cashonize):
* - `bch_getAddresses` → returns the wallet's cashaddr(s)
* - `bch_signMessage` → ECDSA-DER over sha256(message) ⚠ format TBD on-device
* - `bch_signTransaction` → libauth pass-through co-sign (delegated to signer.ts)
*
* The signTransaction handler is the load-bearing one for Deviant covenant flows
* (tote `bet()`, `GamePoolEscrowFlat.fund()`, checkers stake) per ADR-0043 §2.3:
* CashConnect V0 cannot carry dApp-compiled covenant unlockers, so all covenant
* co-signs route through this handler.
*
* Signing discipline (ADR-0041 §5): the signer NEVER signs an input it doesn't
* own — even if asked. See signer.ts `isOwnedInput`.
*
* The wire formatting (libauth `stringify` / `parseExtendedJson`) is the
* responder's responsibility. By the time we get here, requests are decoded
* objects with `Uint8Array` bytes and `bigint` values.
*/
import { sha256, ripemd160, secp256k1, binToHex } from '@bitauth/libauth';
import { coSignTransaction } from './signer';
import type {
BchSignMessageRequest,
BchSignTxRequest,
SignedMessageResponse,
SignedTxResponse,
} from './index';
import type { KeyProvider } from './responder';
import { p2pkhLockingBytecode, isOwnedInput } from './signer';
/** Result of `bch_getAddresses`. The wc2-bch-bcr spec returns a string list. */
export type GetAddressesResponse = readonly string[];
/** `bch_getAddresses` — the wallet exposes the cashaddr(s) it controls. We surface
* only the active account's `cashaddr` (singular) at v1; multi-account is a future
* enhancement (the KeyStore is single-account today — `wiki/wallet-ui-design.md`).
*/
export function handleGetAddresses(key: KeyProvider): GetAddressesResponse {
const id = key.identity();
return [id.cashaddr];
}
/** `bch_signMessage` — ECDSA-DER signature over `sha256(message)`.
*
* ⚠ Wire format pending on-device confirmation against Cashonize (ADR-0041 "What
* this ADR does NOT resolve" §2). Two candidates exist on BCH:
* (a) **Bitcoin Cash Signed-Message format** (classical): recoverable ECDSA over
* `hash256("\x18Bitcoin Signed Message:\n" || varint(len(msg)) || msg)`,
* base64-encoded with a 1-byte recovery header. Used by historical wallets.
* (b) **Simple `ecdsa(sha256(msg))` DER** — what most modern WC2 BCH wallets
* appear to do; matches CashConnect's `signMessage` (Schnorr) in shape.
*
* We ship (b) since the proposer in `spike/wc-connect/connect.mjs` issues a plain
* message and Cashonize returned a sig (snapshot 0055) — and the simpler path
* keeps the responder unblocked. On-device validation will confirm; if Cashonize
* actually expects (a), switch this function (NOT the wider responder) and bump
* the wire-format note in ADR-0041.
*
* SECURITY: we always sign with the active account's key — there is no
* per-address routing yet, since the wallet is single-account at v1. */
export function handleSignMessage(
request: BchSignMessageRequest,
key: KeyProvider,
): SignedMessageResponse {
// request.address: the dApp may name an address; we don't currently disambiguate
// between accounts. If/when multi-account lands, look it up against the
// KeyStore's account list and fail closed if the address isn't ours.
const digest = sha256.hash(request.message);
const sig = secp256k1.signMessageHashDER(key.privateKey(), digest);
if (typeof sig === 'string') {
throw new Error(`bch_signMessage failed: ${sig}`);
}
// The "signedMessage" field carries a UTF-8 echo of what was actually signed,
// so the dApp can verify it presented the right text to the user.
const printable = isProbablyUtf8(request.message);
return {
signedMessage: printable ? new TextDecoder('utf-8', { fatal: false }).decode(request.message) : binToHex(request.message),
signature: binToHex(sig),
};
}
/** `bch_signTransaction` — libauth pass-through co-sign. Fills in the unlocking
* bytecode for every input owned by this wallet (sourceOutput.lockingBytecode
* matches our P2PKH); leaves all other inputs untouched.
*
* This is the path snapshot 0057 device-validated; it's also the ONLY path for
* Deviant covenant co-sign (ADR-0043 §2.3). */
export async function handleSignTransaction(
request: BchSignTxRequest,
key: KeyProvider,
): Promise<SignedTxResponse> {
const id = key.identity();
return coSignTransaction({
request,
ownPubkeyHash: hash160OfPubkey(id.publicKey),
ownPublicKey: id.publicKey,
privateKey: key.privateKey(),
network: id.network,
});
}
// ── Helpers ────────────────────────────────────────────────────────────────
function isProbablyUtf8(bin: Uint8Array): boolean {
try {
new TextDecoder('utf-8', { fatal: true }).decode(bin);
return true;
} catch {
return false;
}
}
/** RIPEMD160(SHA256(pubkey)) — the 20-byte pubkey hash used as the P2PKH key.
* Two-primitive form (not the helper alias) to stay portable across libauth
* versions. The responder caches the result per-session via `KeyProvider.pubkeyHash()`. */
function hash160OfPubkey(publicKey: Uint8Array): Uint8Array {
return ripemd160.hash(sha256.hash(publicKey));
}
// Re-export so the signing-discipline check is callable from tests + responder.
export { isOwnedInput, p2pkhLockingBytecode };

View file

@ -0,0 +1,119 @@
pragma cashscript ^0.12.0;
// DeviantOpenMintFT — open-mint covenant for a single CashTokens FT category.
// Per ADR-0006 (native CashTokens + CashScript composite), corrected by
// the chipnet finding in snapshot 0008 (FT supply is fixed at genesis).
//
// Architecture: RESERVOIR MODEL.
// CashTokens consensus (CHIP-2022-02-CashTokens) fixes FT supply at
// genesis. A minting NFT lets you create additional NFTs of the
// category, but NOT additional FTs. So "open mint" here means a
// permissionless transfer-from-reservoir, not on-demand FT creation.
//
// At genesis a single UTXO is parked at the contract address carrying:
// - the category's entire FT supply (typically 10^18 units, just
// under the consensus cap 2^63-1)
// - a minting NFT (capability 0x02) — included so future
// upgrades/governance can issue new NFTs of the same category if
// desired; not used by mint() itself
//
// Each call to mint() transfers `tokenAmount` FT units from the
// reservoir to the caller in exchange for `tokenAmount * rate` sats of
// BCH. The reservoir UTXO is re-output to the contract with its FT
// amount reduced and the NFT preserved.
//
// Self-backed FT UTXOs:
// Each FT output to a user carries `tokenAmount * backingRateSats` BCH
// sats inside it. Redemption is therefore a normal P2PKH spend — the
// holder spends their FT UTXO and consensus releases the embedded BCH.
// No contract path needed for redeem. The protocol swap consumes a
// source-token FT UTXO this way and feeds the released BCH into the
// target-token contract's mint() in the same atomic transaction.
//
// Output layout (mint):
// [0] reservoir UTXO back to contract: minting NFT + (remaining_FT)
// at dust-or-greater value
// [1] FT to recipient, self-backed at tokenAmount * rate sats
// [2] founder fee, if feeSats > 0
// [3+] BCH change (unconstrained)
//
// Category byte order:
// The `tokenCategory` constructor parameter must be in CashTokens
// consensus order (little-endian = reverse of the display TXID).
// Libauth byte-reverses string `category` fields when encoding
// outputs, so the on-chain field is consensus order; the OP_*TOKEN-
// CATEGORY opcodes return consensus order; constructor bytes must
// match. The off-chain scripts handle the reversal.
//
// Capability propagation:
// tx.inputs[i].tokenCategory / tx.outputs[i].tokenCategory carry the
// 32-byte category id with the capability byte appended (0x02 for
// minting NFT) and no suffix for FT-only outputs. We propagate the
// reservoir's full tokenCategory to the reservoir-out output —
// consensus validates capability preservation. The bare 32-byte
// constructor `tokenCategory` parameter is compared only against the
// FT output, which carries no capability suffix.
//
// Spec divergence noted:
// Spec §4.3 illustrative sketch used a `tokenCapability` field (does
// not exist) and a "mint creates new FT supply" model (CashTokens
// forbids). This contract is the consensus-correct realisation. See
// snapshot 0008.
contract DeviantOpenMintFT(
bytes tokenCategory, // bare 32-byte category id == genesis TXID
int backingRateSats, // sats per FT unit, >= 1000 per spec §1.3
int founderFeeBps, // 0..500 (0% .. 5%)
bytes founderLockingBytecode // 25-byte P2PKH bytecode of fee recipient
) {
// claim() — permissionless transfer-from-reservoir.
//
// Despite the historical "mint" naming in the CashTokens / spec
// literature, this function does NOT create new FT supply (that
// happened once at genesis and is consensus-fixed thereafter).
// It transfers `tokenAmount` FT units from the reservoir UTXO at
// this contract to `recipientLockingBytecode`, in exchange for
// `tokenAmount * backingRateSats` of BCH that the caller provides.
// Anyone holding BCH can call it.
function claim(int tokenAmount, bytes recipientLockingBytecode) {
require(tokenAmount > 0);
int grossSats = tokenAmount * backingRateSats;
int feeSats = grossSats * founderFeeBps / 10000;
// [0] reservoir back to this contract. Three invariants:
// - same lockingBytecode → stays at this contract address
// - same tokenCategory → preserves NFT category+capability
// - FT amount reduced by exactly tokenAmount (transferred to [1])
// Consensus enforces that the FT difference equals the amount
// appearing in [1]; we double-check by hand here so the contract
// can give a clear failure message rather than a generic token-
// validation error.
require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
require(tx.outputs[0].tokenAmount == tx.inputs[this.activeInputIndex].tokenAmount - tokenAmount);
require(tx.outputs[0].value >= 546);
// [1] FT to recipient, self-backed.
// tokenCategory on FT outputs has no capability suffix — it is
// exactly the 32-byte category id we hold as the constructor
// parameter.
require(tx.outputs[1].lockingBytecode == recipientLockingBytecode);
require(tx.outputs[1].tokenCategory == tokenCategory);
require(tx.outputs[1].tokenAmount == tokenAmount);
require(tx.outputs[1].value == grossSats);
// [2] Founder fee — only constrained when non-zero. If zero, the
// tx may have additional outputs (change, etc.) starting at
// index 2 with no contract restriction.
if (feeSats > 0) {
// Anti-dust: sub-dust fees are forbidden. Rounding noise must
// not produce non-standard outputs. Off-chain math must mirror.
require(feeSats >= 546);
require(tx.outputs[2].lockingBytecode == founderLockingBytecode);
require(tx.outputs[2].value == feeSats);
// Fee output is pure BCH — no token category.
require(tx.outputs[2].tokenCategory == 0x);
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,86 @@
pragma cashscript ^0.12.0;
// GamePoolEscrowFlat — flat-fee variant of GamePoolEscrow.
//
// Identical to GamePoolEscrow except the arbiter's operational fee is a FLAT
// amount of pooled FTs (`feeAmount`), not a proportional `arbiterFeeBps` cut.
// Rationale (2026-05-22 conversation): a fixed per-match infrastructure fee
// reads as a service charge rather than a proportional wager rake — a more
// defensible structure for real-money skill-gaming.
//
// STATUS: DRAFT — NOT YET COMPILED OR CHIPNET-TESTED. Run `npm run
// compile:contracts` then a chipnet test (mirror scripts/chipnet-escrow-fee-test.mjs)
// before ANY real funds. The proportional GamePoolEscrow remains the tested path.
//
// As in GamePoolEscrow: 2-party winner-take-all. The pool UTXO holds
// `poolAmount` FTs of `tokenCategory` backed by `poolSats` sats (= 2 × stake).
// The fee is a slice of the pooled FTs (peg-preserving): both winner and fee
// outputs are fully backed at the pool's per-FT rate. Hashlock/refund are
// fee-free and unchanged from GamePoolEscrow.
contract GamePoolEscrowFlat(
bytes tokenCategory, // the MBA being staked (same category both sides)
int poolAmount, // total pooled FTs = 2 × stakeAmount
int poolSats, // total backing = 2 × stakeSats
int stakeAmount, // per-player stake (for the refund split)
int stakeSats, // per-player backing (for the refund split)
bytes20 partyA, // player A pkh (refund recipient)
bytes20 partyB, // player B pkh (refund recipient)
pubkey arbiterPk, // arbiter for resolveArbiter (+ fee recipient)
bytes32 secretHash, // sha256(secret) for resolveHashlock
int deadline, // CLTV height/time for refund
int feeAmount // FLAT arbiter fee in FTs; 0..<poolAmount
) {
// ---- Arbiter declares the winner: (pool − feeAmount) → winner, feeAmount → arbiter ----
//
// feeAmount is a FLAT count of FTs (not a percentage). The winner and fee
// outputs are each fully backed at the pool's per-FT rate, so the peg holds.
// The fee goes to the arbiter's own P2PKH (hash160 of arbiterPk).
function resolveArbiter(sig arbiterSig, bytes winnerLockingBytecode) {
require(checkSig(arbiterSig, arbiterPk));
int rate = poolSats / poolAmount; // exact: poolSats == poolAmount * rate
require(feeAmount < poolAmount); // winner must receive a positive amount
int winnerAmount = poolAmount - feeAmount;
// Output 0: winner share, fully backed.
require(tx.outputs[0].lockingBytecode == winnerLockingBytecode);
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == winnerAmount);
require(tx.outputs[0].value == winnerAmount * rate);
// Output 1: flat arbiter fee to the arbiter's P2PKH, fully backed.
// Either zero, or at least dust (mirrors R-002).
if (feeAmount > 0) {
require(feeAmount * rate >= 546);
require(tx.outputs[1].lockingBytecode == new LockingBytecodeP2PKH(hash160(arbiterPk)));
require(tx.outputs[1].tokenCategory == tokenCategory);
require(tx.outputs[1].tokenAmount == feeAmount);
require(tx.outputs[1].value == feeAmount * rate);
}
}
// ---- Winner reveals the committed secret: entire pool → revealer ----
function resolveHashlock(bytes secret, bytes winnerLockingBytecode) {
require(sha256(secret) == secretHash);
require(tx.outputs[0].lockingBytecode == winnerLockingBytecode);
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == poolAmount);
require(tx.outputs[0].value == poolSats);
}
// ---- No resolution by the deadline: split stakes back to both ----
function refund() {
require(tx.time >= deadline);
bytes25 lbA = new LockingBytecodeP2PKH(partyA);
bytes25 lbB = new LockingBytecodeP2PKH(partyB);
require(tx.outputs[0].lockingBytecode == lbA);
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == stakeAmount);
require(tx.outputs[0].value == stakeSats);
require(tx.outputs[1].lockingBytecode == lbB);
require(tx.outputs[1].tokenCategory == tokenCategory);
require(tx.outputs[1].tokenAmount == stakeAmount);
require(tx.outputs[1].value == stakeSats);
}
}

View file

@ -0,0 +1,195 @@
{
"contractName": "GamePoolEscrowFlat",
"constructorInputs": [
{
"name": "tokenCategory",
"type": "bytes"
},
{
"name": "poolAmount",
"type": "int"
},
{
"name": "poolSats",
"type": "int"
},
{
"name": "stakeAmount",
"type": "int"
},
{
"name": "stakeSats",
"type": "int"
},
{
"name": "partyA",
"type": "bytes20"
},
{
"name": "partyB",
"type": "bytes20"
},
{
"name": "arbiterPk",
"type": "pubkey"
},
{
"name": "secretHash",
"type": "bytes32"
},
{
"name": "deadline",
"type": "int"
},
{
"name": "feeAmount",
"type": "int"
}
],
"abi": [
{
"name": "resolveArbiter",
"inputs": [
{
"name": "arbiterSig",
"type": "sig"
},
{
"name": "winnerLockingBytecode",
"type": "bytes"
}
]
},
{
"name": "resolveHashlock",
"inputs": [
{
"name": "secret",
"type": "bytes"
},
{
"name": "winnerLockingBytecode",
"type": "bytes"
}
]
},
{
"name": "refund",
"inputs": []
}
],
"bytecode": "OP_11 OP_PICK OP_0 OP_NUMEQUAL OP_IF OP_12 OP_ROLL OP_8 OP_PICK OP_CHECKSIGVERIFY OP_ROT OP_2 OP_PICK OP_DIV OP_10 OP_PICK OP_3 OP_PICK OP_LESSTHAN OP_VERIFY OP_ROT OP_10 OP_PICK OP_SUB OP_0 OP_OUTPUTBYTECODE OP_13 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_3 OP_PICK OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENAMOUNT OP_OVER OP_NUMEQUALVERIFY OP_0 OP_OUTPUTVALUE OP_SWAP OP_2 OP_PICK OP_MUL OP_NUMEQUALVERIFY OP_9 OP_PICK OP_0 OP_GREATERTHAN OP_IF OP_9 OP_PICK OP_OVER OP_MUL 2202 OP_GREATERTHANOREQUAL OP_VERIFY OP_1 OP_OUTPUTBYTECODE 76a914 OP_8 OP_PICK OP_HASH160 OP_CAT 88ac OP_CAT OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_2 OP_PICK OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENAMOUNT OP_10 OP_PICK OP_NUMEQUALVERIFY OP_1 OP_OUTPUTVALUE OP_10 OP_PICK OP_2 OP_PICK OP_MUL OP_NUMEQUALVERIFY OP_ENDIF OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_DROP OP_1 OP_ELSE OP_11 OP_PICK OP_1 OP_NUMEQUAL OP_IF OP_12 OP_ROLL OP_SHA256 OP_9 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTBYTECODE OP_12 OP_ROLL OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENAMOUNT OP_NUMEQUALVERIFY OP_0 OP_OUTPUTVALUE OP_NUMEQUALVERIFY OP_2DROP OP_2DROP OP_2DROP OP_2DROP OP_1 OP_ELSE OP_11 OP_ROLL OP_2 OP_NUMEQUALVERIFY OP_9 OP_ROLL OP_CHECKLOCKTIMEVERIFY OP_DROP 76a914 OP_6 OP_ROLL OP_CAT 88ac OP_CAT 76a914 OP_7 OP_ROLL OP_CAT 88ac OP_CAT OP_0 OP_OUTPUTBYTECODE OP_ROT OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENCATEGORY OP_2 OP_PICK OP_EQUALVERIFY OP_0 OP_OUTPUTTOKENAMOUNT OP_5 OP_PICK OP_NUMEQUALVERIFY OP_0 OP_OUTPUTVALUE OP_6 OP_PICK OP_NUMEQUALVERIFY OP_1 OP_OUTPUTBYTECODE OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENCATEGORY OP_EQUALVERIFY OP_1 OP_OUTPUTTOKENAMOUNT OP_3 OP_ROLL OP_NUMEQUALVERIFY OP_1 OP_OUTPUTVALUE OP_3 OP_ROLL OP_NUMEQUALVERIFY OP_2DROP OP_2DROP OP_DROP OP_1 OP_ENDIF OP_ENDIF",
"source": "pragma cashscript ^0.12.0;\n\n// GamePoolEscrowFlat — flat-fee variant of GamePoolEscrow.\n//\n// Identical to GamePoolEscrow except the arbiter's operational fee is a FLAT\n// amount of pooled FTs (`feeAmount`), not a proportional `arbiterFeeBps` cut.\n// Rationale (2026-05-22 conversation): a fixed per-match infrastructure fee\n// reads as a service charge rather than a proportional wager rake — a more\n// defensible structure for real-money skill-gaming.\n//\n// STATUS: DRAFT — NOT YET COMPILED OR CHIPNET-TESTED. Run `npm run\n// compile:contracts` then a chipnet test (mirror scripts/chipnet-escrow-fee-test.mjs)\n// before ANY real funds. The proportional GamePoolEscrow remains the tested path.\n//\n// As in GamePoolEscrow: 2-party winner-take-all. The pool UTXO holds\n// `poolAmount` FTs of `tokenCategory` backed by `poolSats` sats (= 2 × stake).\n// The fee is a slice of the pooled FTs (peg-preserving): both winner and fee\n// outputs are fully backed at the pool's per-FT rate. Hashlock/refund are\n// fee-free and unchanged from GamePoolEscrow.\n\ncontract GamePoolEscrowFlat(\n bytes tokenCategory, // the MBA being staked (same category both sides)\n int poolAmount, // total pooled FTs = 2 × stakeAmount\n int poolSats, // total backing = 2 × stakeSats\n int stakeAmount, // per-player stake (for the refund split)\n int stakeSats, // per-player backing (for the refund split)\n bytes20 partyA, // player A pkh (refund recipient)\n bytes20 partyB, // player B pkh (refund recipient)\n pubkey arbiterPk, // arbiter for resolveArbiter (+ fee recipient)\n bytes32 secretHash, // sha256(secret) for resolveHashlock\n int deadline, // CLTV height/time for refund\n int feeAmount // FLAT arbiter fee in FTs; 0..<poolAmount\n) {\n // ---- Arbiter declares the winner: (pool − feeAmount) → winner, feeAmount → arbiter ----\n //\n // feeAmount is a FLAT count of FTs (not a percentage). The winner and fee\n // outputs are each fully backed at the pool's per-FT rate, so the peg holds.\n // The fee goes to the arbiter's own P2PKH (hash160 of arbiterPk).\n function resolveArbiter(sig arbiterSig, bytes winnerLockingBytecode) {\n require(checkSig(arbiterSig, arbiterPk));\n\n int rate = poolSats / poolAmount; // exact: poolSats == poolAmount * rate\n require(feeAmount < poolAmount); // winner must receive a positive amount\n int winnerAmount = poolAmount - feeAmount;\n\n // Output 0: winner share, fully backed.\n require(tx.outputs[0].lockingBytecode == winnerLockingBytecode);\n require(tx.outputs[0].tokenCategory == tokenCategory);\n require(tx.outputs[0].tokenAmount == winnerAmount);\n require(tx.outputs[0].value == winnerAmount * rate);\n\n // Output 1: flat arbiter fee to the arbiter's P2PKH, fully backed.\n // Either zero, or at least dust (mirrors R-002).\n if (feeAmount > 0) {\n require(feeAmount * rate >= 546);\n require(tx.outputs[1].lockingBytecode == new LockingBytecodeP2PKH(hash160(arbiterPk)));\n require(tx.outputs[1].tokenCategory == tokenCategory);\n require(tx.outputs[1].tokenAmount == feeAmount);\n require(tx.outputs[1].value == feeAmount * rate);\n }\n }\n\n // ---- Winner reveals the committed secret: entire pool → revealer ----\n function resolveHashlock(bytes secret, bytes winnerLockingBytecode) {\n require(sha256(secret) == secretHash);\n require(tx.outputs[0].lockingBytecode == winnerLockingBytecode);\n require(tx.outputs[0].tokenCategory == tokenCategory);\n require(tx.outputs[0].tokenAmount == poolAmount);\n require(tx.outputs[0].value == poolSats);\n }\n\n // ---- No resolution by the deadline: split stakes back to both ----\n function refund() {\n require(tx.time >= deadline);\n bytes25 lbA = new LockingBytecodeP2PKH(partyA);\n bytes25 lbB = new LockingBytecodeP2PKH(partyB);\n require(tx.outputs[0].lockingBytecode == lbA);\n require(tx.outputs[0].tokenCategory == tokenCategory);\n require(tx.outputs[0].tokenAmount == stakeAmount);\n require(tx.outputs[0].value == stakeSats);\n require(tx.outputs[1].lockingBytecode == lbB);\n require(tx.outputs[1].tokenCategory == tokenCategory);\n require(tx.outputs[1].tokenAmount == stakeAmount);\n require(tx.outputs[1].value == stakeSats);\n }\n}\n",
"debug": {
"bytecode": "5b79009c635c7a5879ad7b5279965a7953799f697b5a799400cd5d7a8800d153798800d3789d00cc7c5279959d597900a06359797895022202a26951cd0376a9145879a97e0288ac7e8851d152798851d35a799d51cc5a795279959d686d6d6d6d6d7551675b79519c635c7aa8597a8800cd5c7a8800d18800d39d00cc9d6d6d6d6d51675b7a529d597ab1750376a914567a7e0288ac7e0376a914577a7e0288ac7e00cd7b8800d152798800d355799d00cc56799d51cd8851d18851d3537a9d51cc537a9d6d6d75516868",
"sourceMap": "39:4:61:5;;;;;40:25:40:35;;:37::46;;:8::49:1;42:19:42:27:0;:30::40;;:19:::1;43:16:43:25:0;;:28::38;;:16:::1;:8::40;44:27:44:37:0;:40::49;;:27:::1;47::47:28:0;:16::45:1;:49::70:0;;:8::72:1;48:27:48:28:0;:16::43:1;:47::60:0;;:8::62:1;49:27:49:28:0;:16::41:1;:45::57:0;:8::59:1;50:27:50:28:0;:16::35:1;:39::51:0;:54::58;;:39:::1;:8::60;54:12:54:21:0;;:24::25;:12:::1;:27:60:9:0;55:20:55:29;;:32::36;:20:::1;:40::43:0;:20:::1;:12::45;56:31:56:32:0;:20::49:1;:53::97:0;:86::95;;:78::96:1;:53::97;;;:12::99;57:31:57:32:0;:20::47:1;:51::64:0;;:12::66:1;58:31:58:32:0;:20::45:1;:49::58:0;;:12::60:1;59:31:59:32:0;:20::39:1;:43::52:0;;:55::59;;:43:::1;:12::61;54:27:60:9;39:4:61:5;;;;;;;;64::70::0;;;;;65:23:65:29;;:16::30:1;:34::44:0;;:8::46:1;66:27:66:28:0;:16::45:1;:49::70:0;;:8::72:1;67:27:67:28:0;:16::43:1;:8::62;68:27:68:28:0;:16::41:1;:8::57;69:27:69:28:0;:16::35:1;:8::49;64:4:70:5;;;;;;73::85::0;;;;74:27:74:35;;:8::37:1;;75:22:75:54:0;:47::53;;:22::54:1;;;76::76::0;:47::53;;:22::54:1;;;77:27:77:28:0;:16::45:1;:49::52:0;:8::54:1;78:27:78:28:0;:16::43:1;:47::60:0;;:8::62:1;79:27:79:28:0;:16::41:1;:45::56:0;;:8::58:1;80:27:80:28:0;:16::35:1;:39::48:0;;:8::50:1;81:27:81:28:0;:16::45:1;:8::54;82:27:82:28:0;:16::43:1;:8::62;83:27:83:28:0;:16::41:1;:45::56:0;;:8::58:1;84:27:84:28:0;:16::35:1;:39::48:0;;:8::50:1;73:4:85:5;;;;21:0:86:1;",
"logs": [],
"requires": [
{
"ip": 20,
"line": 40
},
{
"ip": 30,
"line": 43
},
{
"ip": 39,
"line": 47
},
{
"ip": 44,
"line": 48
},
{
"ip": 48,
"line": 49
},
{
"ip": 55,
"line": 50
},
{
"ip": 67,
"line": 55
},
{
"ip": 77,
"line": 56
},
{
"ip": 82,
"line": 57
},
{
"ip": 87,
"line": 58
},
{
"ip": 95,
"line": 59
},
{
"ip": 115,
"line": 65
},
{
"ip": 120,
"line": 66
},
{
"ip": 123,
"line": 67
},
{
"ip": 126,
"line": 68
},
{
"ip": 129,
"line": 69
},
{
"ip": 142,
"line": 74
},
{
"ip": 159,
"line": 77
},
{
"ip": 164,
"line": 78
},
{
"ip": 169,
"line": 79
},
{
"ip": 174,
"line": 80
},
{
"ip": 177,
"line": 81
},
{
"ip": 180,
"line": 82
},
{
"ip": 185,
"line": 83
},
{
"ip": 190,
"line": 84
}
]
},
"compiler": {
"name": "cashc",
"version": "0.12.1"
},
"updatedAt": "2026-06-28T08:28:08.204Z"
}

View file

@ -0,0 +1,166 @@
pragma cashscript ^0.12.0;
// GamePoolEscrowFlatV2 — flat-fee escrow with per-side refund.
//
// Identical to GamePoolEscrowFlat (existing artifact) except for ONE new
// resolution path: `refundParty(int side)`. This enables the Pari_p2p
// **Flow A** (independent deposits) safely — a single-side deposit can be
// reclaimed by its owner after the deadline if the opponent never joined.
// Without this, a Flow A deposit by A with B-never-shows leaves A's funds
// permanently stuck at the escrow address (existing refund() requires a
// combined pool UTXO with poolAmount FTs).
//
// All other paths (resolveArbiter, resolveHashlock, refund) are byte-for-
// byte identical to GamePoolEscrowFlat and validate the same output
// structure. They work transparently with either:
// - one combined pool UTXO (Flow B atomic co-sign), OR
// - two separate deposit UTXOs (Flow A independent deposits)
// because the covenant only constrains outputs, not the input layout.
//
// IMPORTANT: in Flow A, EACH deposit UTXO is its own covenant instance
// spending; resolution txs (resolveArbiter / refund) spend BOTH UTXOs as
// inputs in the same tx and consensus enforces the constraints on each.
//
// Status: CHIPNET-VALIDATED by scripts/chipnet-pari-p2p-test.mjs across four
// scenarios on live chipnet consensus. Latest run (with cancel()) — CHE
// category ebd7c46a…0be36: mint+stake atomic (db444c0d…, 729578e5…),
// resolveArbiter on 2 separate deposits (a73c008e…), refundParty(0) on stuck
// single-side after deadline (ba66a060…), cancel(0, sigA, pubA, sigArbiter)
// immediate user-initiated reclaim with no deadline wait (391fdb18…). Full
// reference: Parameters/Pari_p2p/.
contract GamePoolEscrowFlatV2(
bytes tokenCategory, // the MBA being staked
int poolAmount, // total pooled FTs = 2 × stakeAmount
int poolSats, // total backing = 2 × stakeSats
int stakeAmount, // per-player stake (for refund / refundParty)
int stakeSats, // per-player backing
bytes20 partyA, // player A pkh (refund recipient)
bytes20 partyB, // player B pkh (refund recipient)
pubkey arbiterPk, // arbiter for resolveArbiter (+ fee recipient)
bytes32 secretHash, // sha256(secret) for resolveHashlock
int deadline, // CLTV height/time for refund + refundParty
int feeAmount // flat arbiter fee in FTs; 0..<poolAmount
) {
// ---- Arbiter declares the winner: pool − feeAmount → winner, feeAmount → arbiter ----
function resolveArbiter(sig arbiterSig, bytes winnerLockingBytecode) {
require(checkSig(arbiterSig, arbiterPk));
int rate = poolSats / poolAmount;
require(feeAmount < poolAmount);
int winnerAmount = poolAmount - feeAmount;
require(tx.outputs[0].lockingBytecode == winnerLockingBytecode);
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == winnerAmount);
require(tx.outputs[0].value == winnerAmount * rate);
if (feeAmount > 0) {
require(feeAmount * rate >= 546);
require(tx.outputs[1].lockingBytecode == new LockingBytecodeP2PKH(hash160(arbiterPk)));
require(tx.outputs[1].tokenCategory == tokenCategory);
require(tx.outputs[1].tokenAmount == feeAmount);
require(tx.outputs[1].value == feeAmount * rate);
}
}
// ---- Winner reveals the committed secret: entire pool → revealer ----
function resolveHashlock(bytes secret, bytes winnerLockingBytecode) {
require(sha256(secret) == secretHash);
require(tx.outputs[0].lockingBytecode == winnerLockingBytecode);
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == poolAmount);
require(tx.outputs[0].value == poolSats);
}
// ---- Combined refund: both stakes back to A and B (Flow B or paired Flow A) ----
function refund() {
require(tx.time >= deadline);
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyA));
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == stakeAmount);
require(tx.outputs[0].value == stakeSats);
require(tx.outputs[1].lockingBytecode == new LockingBytecodeP2PKH(partyB));
require(tx.outputs[1].tokenCategory == tokenCategory);
require(tx.outputs[1].tokenAmount == stakeAmount);
require(tx.outputs[1].value == stakeSats);
}
// ---- per-side refund (Pari_p2p Flow A safety net) ----
//
// After the deadline, a single-side deposit can be reclaimed by its
// owner. Permissionless — anyone can trigger it (they just pay the
// miner fee), but the output is structurally forced to the specified
// side's P2PKH. The input must be a SINGLE-STAKE-SIZED UTXO; this
// prevents calling refundParty on a combined pool UTXO that the
// existing refund() should handle.
//
// `side` selects which party's P2PKH receives:
// side = 0 → partyA
// side = 1 → partyB
function refundParty(int side) {
require(tx.time >= deadline);
require(side == 0 || side == 1);
// Constrain the input to a single-stake-sized deposit. This
// closes off a perverse spend where someone tries to refundParty
// a combined pool UTXO (which would otherwise pay only one side
// their stake's worth and "lose" the other side's stake to fees).
require(tx.inputs[this.activeInputIndex].tokenAmount == stakeAmount);
require(tx.inputs[this.activeInputIndex].value == stakeSats);
if (side == 0) {
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyA));
} else {
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyB));
}
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == stakeAmount);
require(tx.outputs[0].value == stakeSats);
}
// ---- USER-SIGNED EARLY CANCEL — both depositor and arbiter must sign ----
//
// Lets a single-side depositor reclaim BEFORE the deadline, without waiting,
// when the arbiter (game server) co-signs an attestation that the match
// was indeed cancelled / no opponent joined.
//
// Why both signatures are required:
// - depositor's sig alone → A could grief mid-game by cancelling after B
// deposited (the covenant cannot see other UTXOs at this address).
// - arbiter's sig alone → server could rug deposits unilaterally.
// - both together → arbiter attests cancellation conditions, depositor
// confirms they want their funds back. Server cooperation is required,
// and refundParty(side) after the deadline remains the no-server fallback.
//
// Caller passes `partyPk` (the depositor's compressed pubkey) so the contract
// can verify checkSig(partySig, partyPk) AND that hash160(partyPk) matches
// the right party's pkh stored at construction.
function cancel(int side, sig partySig, pubkey partyPk, sig arbiterSig) {
require(side == 0 || side == 1);
// Arbiter co-signs the cancellation attestation.
require(checkSig(arbiterSig, arbiterPk));
// Verify partyPk hashes to the right party's stored pkh, AND that this
// party signed.
if (side == 0) {
require(hash160(partyPk) == partyA);
} else {
require(hash160(partyPk) == partyB);
}
require(checkSig(partySig, partyPk));
// Same single-stake-input guard as refundParty.
require(tx.inputs[this.activeInputIndex].tokenAmount == stakeAmount);
require(tx.inputs[this.activeInputIndex].value == stakeSats);
if (side == 0) {
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyA));
} else {
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyB));
}
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == stakeAmount);
require(tx.outputs[0].value == stakeSats);
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,250 @@
#!/usr/bin/env node
/**
* chipnet-che-escrow-perside-test.mjs — GamePoolEscrowFlat with a per-side fee model:
*
* pot >= $1 → feeAmount = floor(1% of pot) (proportional regime)
* pot < $1 → feeAmount = 2 × $0.01 in FT ($0.01/side flat regime)
*
* Currency: CheckersCoin (CHE) — 1 CHE = 0.001 BCH = 100,000 sats, decimals 2,
* atomic FT = 1 "cent" = 1,000 sats. Players A & C, arbiter B.
*
* Two scenarios:
* A) big pot 10.00 CHE (~$3.55 @ $355) → pot >= $1 → 1% = 10 cents (0.10 CHE)
* B) small pot 1.00 CHE (~$0.36 @ $355) → pot < $1 → $0.01/side = 6 cents (0.06 CHE)
* (proportional would give only 1 cent = 0.01 CHE; per-side floor gives 6× more)
*
* Guard: server refuses to create a match if feeAmount >= poolAmount
* (pot too tiny for the double flat fee — effective minimum match size).
*
* Contract (GamePoolEscrowFlat.cash) is UNCHANGED — the per-side logic lives
* entirely in the off-chain computeFlatFee() and is re-derived by the client
* before co-signing.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
const ROOT = join(import.meta.dirname, '..');
const STATE = join(ROOT, 'ChipnetTests', '.chipnet-che-perside-test.json');
const MBA_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
const FLAT_ARTIFACT = join(ROOT, 'contracts/dist/GamePoolEscrowFlat.json');
const KEYS = { a: join(ROOT, '.chipnet-keys.json'), b: join(ROOT, '.chipnet-keys-b.json'), c: join(ROOT, '.chipnet-keys-c.json') };
const RESERVOIR = 1_000_000_000_000_000_000n;
const RATE = 1000n; // per-atomic (cent) backing rate, sats/cent
const DECIMALS = 2n; // CHE has 2 decimals → 100 cents per CHE
const WHOLE_UNIT_RATE = RATE * (10n ** DECIMALS); // 100,000 sats per whole CHE
const GENESIS_FEE = 0n;
// Stakes per player, in atomic FTs (cents)
const SCENARIOS = [
{ key: 'big', stake: 500n, label: 'big pot 10.00 CHE (~$3.55) → pot >= $1 → 1% regime' },
{ key: 'small', stake: 50n, label: 'small pot 1.00 CHE (~$0.36) → pot < $1 → $0.01/side regime' },
];
// --- CHE display helpers (decimals = 2) ---
function che(cents) {
const neg = cents < 0n; const v = neg ? -cents : cents;
const whole = v / 100n, frac = v % 100n;
return `${neg ? '-' : ''}${whole}.${frac.toString().padStart(2, '0')} CHE`;
}
function hexToBin(h){const o=new Uint8Array(h.length/2);for(let i=0;i<o.length;i++)o[i]=parseInt(h.slice(i*2,i*2+2),16);return o;}
function txidLE(h){const d=hexToBin(h);const o=new Uint8Array(d.length);for(let i=0;i<d.length;i++)o[i]=d[d.length-1-i];return o;}
function p2pkh(pkh){const o=new Uint8Array(25);o[0]=0x76;o[1]=0xa9;o[2]=0x14;o.set(pkh,3);o[23]=0x88;o[24]=0xac;return o;}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function divider(c='='){console.log(c.repeat(80));}
function step(t){console.log(''); divider(); console.log(t); divider();}
function load(){return existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {s:{}};}
function save(s){writeFileSync(STATE, JSON.stringify(s, (k,v) => typeof v==='bigint' ? v.toString() : v, 2));}
function pickBch(utxos, need){const p=[];let t=0n;for(const u of utxos){p.push(u);t+=BigInt(u.satoshis);if(t>=need)break;}return{p,t};}
async function getBchUsd(){
if(process.env.BCH_USD) return Number(process.env.BCH_USD);
try{const ac=new AbortController();const t=setTimeout(()=>ac.abort(),8000);
const r=await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin-cash&vs_currencies=usd',{signal:ac.signal});
clearTimeout(t);const j=await r.json();const p=j?.['bitcoin-cash']?.usd;
if(p>0){console.log(` BCH price (CoinGecko): $${p}`);return Number(p);}
}catch(e){console.log(` (price fetch failed: ${e.message?.split('\n')[0]})`);}
console.log(' BCH price fallback: $500'); return 500;
}
/**
* Server-side fee model (per-side variant):
* pot >= $1 → feeAmount = floor(1% of pot in atomic FT)
* pot < $1 → feeAmount = 2 × ceil($0.01 in atomic FT) ← each side pays $0.01
*
* Guard: throws if feeAmount >= poolAmount (pot too small to sustain the fee).
*/
function computeFlatFee(poolAmount, rateSats, bchUsd){
const potUsd = Number(poolAmount) * Number(rateSats) / 1e8 * bchUsd;
const centSats = Math.ceil(0.01 / bchUsd * 1e8); // $0.01 in sats
const centFt = BigInt(Math.ceil(centSats / Number(rateSats))); // $0.01 in atomic FT (ceil)
let fee, regime;
if(potUsd >= 1.0){
fee = poolAmount * 100n / 10000n; // floor(1%)
regime = '1%';
} else {
fee = centFt * 2n; // $0.01 per side → $0.02 total
regime = '$0.01/side';
}
// Guard: fee must be < poolAmount (winner must receive something positive)
if(fee >= poolAmount){
throw new Error(
`pot too small: feeAmount=${fee} cents >= poolAmount=${poolAmount} cents ` +
`(pot ${che(poolAmount)} ≈ $${potUsd.toFixed(4)} is below minimum match size)`
);
}
return { fee, regime, potUsd, centFt, onePctFt: poolAmount * 100n / 10000n };
}
async function main(){
const mbaArt = JSON.parse(readFileSync(MBA_ARTIFACT, 'utf8'));
const flatArt = JSON.parse(readFileSync(FLAT_ARTIFACT, 'utf8'));
const A = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.a,'utf8')).wif);
const B = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.b,'utf8')).wif);
const C = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.c,'utf8')).wif);
const provider = new ElectrumNetworkProvider('chipnet');
const sigA = new SignatureTemplate(A.privateKey);
const sigB = new SignatureTemplate(B.privateKey);
const sigC = new SignatureTemplate(C.privateKey);
const flbC = p2pkh(C.publicKeyHash);
let state = load(); state.s = state.s || {};
step(`BCH price + CHE MBA (1 CHE = ${WHOLE_UNIT_RATE} sats, decimals ${DECIMALS}) — per-side fee model`);
const bchUsd = await getBchUsd(); state.bchUsd = bchUsd; save(state);
// --- Deploy CHE MBA ---
if(!state.mba){
const u = await (async()=>{
let x = await C.getUtxos();
let e = x.filter(y=>y.vout===0&&!y.token);
if(e.length) return e[0];
const bad = x.filter(y=>!(y.vout===0&&!y.token));
await C.send([{cashaddr:C.cashaddr,value:2000n,unit:'sat'}], bad.length?{utxoIds:bad}:undefined);
await sleep(12000);
x = await C.getUtxos();
return x.filter(y=>y.vout===0&&!y.token)[0];
})();
const cat = u.txid;
const c = new Contract(mbaArt, [txidLE(cat), RATE, GENESIS_FEE, flbC], {provider});
const r = await C.tokenGenesis({cashaddr:c.tokenAddress,amount:RESERVOIR,value:1000n,nft:{capability:'minting',commitment:''}},[],{utxoIds:[u]});
console.log(` CHE genesis ${r?.txId??r?.txid} — ${cat}`);
state.mba = {categoryId:cat, contractTokenAddress:c.tokenAddress}; save(state); await sleep(10000);
} else {
console.log(` CHE (existing): ${state.mba.categoryId}`);
}
const mba = new Contract(mbaArt, [txidLE(state.mba.categoryId), RATE, GENESIS_FEE, flbC], {provider});
// --- Mint stakes ---
async function mint(who, sig, amount, label){
const ru = await provider.getUtxos(mba.tokenAddress);
const res = ru.find(x=>x.token?.nft?.capability==='minting'&&x.token?.category===state.mba.categoryId&&x.token?.amount>0n);
const gross = amount * RATE;
const bch = (await provider.getUtxos(who.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const mf = 6000n; const {p,t} = pickBch(bch, gross+mf+1000n);
if(t < gross+mf+1000n) throw new Error(`${label}: insufficient BCH ${t}`);
const tb = new TransactionBuilder({provider});
tb.addInput(res, mba.unlock.claim(amount, p2pkh(who.publicKeyHash)));
for(const x of p) tb.addInput(x, sig.unlockP2PKH());
tb.addOutput({to:mba.tokenAddress,amount:1000n,token:{amount:res.token.amount-amount,category:state.mba.categoryId,nft:{capability:'minting',commitment:''}}});
tb.addOutput({to:who.tokenaddr,amount:gross,token:{amount,category:state.mba.categoryId}});
tb.addOutput({to:who.cashaddr,amount:BigInt(res.satoshis)+t-1000n-gross-mf});
await tb.send(); console.log(` ${label}`); await sleep(10000);
}
const totalStake = SCENARIOS.reduce((a,s)=>a+s.stake, 0n);
state.mints = state.mints || {};
if(!state.mints.a){ await mint(A, sigA, totalStake, `A claims ${totalStake} cents (${che(totalStake)})`); state.mints.a=true; save(state); }
if(!state.mints.c){ await mint(C, sigC, totalStake, `C claims ${totalStake} cents (${che(totalStake)})`); state.mints.c=true; save(state); }
// --- Run scenarios ---
const summary = [];
for(const sc of SCENARIOS){
const STAKE=sc.stake, POOL=STAKE*2n, POOL_SATS=POOL*RATE, STAKE_SATS=STAKE*RATE;
const { fee, regime, potUsd, centFt, onePctFt } = computeFlatFee(POOL, RATE, bchUsd);
const winnerAmount = POOL - fee;
state.s[sc.key] = state.s[sc.key] || {}; const S = state.s[sc.key];
step(`SCENARIO ${sc.key} — ${sc.label}`);
console.log(` pot=${che(POOL)} (${POOL} cents, ${POOL_SATS} sats, ~$${potUsd.toFixed(3)})`);
console.log(` 1% of pot = ${onePctFt} cents (${che(onePctFt)})`);
console.log(` $0.01/side = ${centFt} cents/side → total ${centFt*2n} cents (${che(centFt*2n)})`);
console.log(` regime: ${regime} → feeAmount = ${fee} cents (${che(fee)})`);
// Client re-derives and asserts before co-signing
const check = computeFlatFee(POOL, RATE, bchUsd).fee;
if(check !== fee) throw new Error('client fee re-derivation mismatch');
console.log(` client re-derived feeAmount=${check} cents (${che(check)}) ✓ (would co-sign)`);
const contract = new Contract(flatArt, [
txidLE(state.mba.categoryId), POOL, POOL_SATS, STAKE, STAKE_SATS,
A.publicKeyHash, C.publicKeyHash, B.publicKeyCompressed,
hexToBin('00'.repeat(32)), 9999999n, fee,
], {provider});
if(!S.funded){
const aFt = (await provider.getUtxos(A.cashaddr)).find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount>=STAKE);
const cFt = (await provider.getUtxos(C.cashaddr)).find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount>=STAKE);
if(!aFt||!cFt) throw new Error(`missing stake (A=${!!aFt} C=${!!cFt})`);
const aRem=aFt.token.amount-STAKE, cRem=cFt.token.amount-STAKE, mf=6000n;
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(cBch, mf+POOL_SATS+1000n);
const cChange=BigInt(aFt.satoshis)+BigInt(cFt.satoshis)+t-POOL_SATS-aRem*RATE-cRem*RATE-mf;
const tb=new TransactionBuilder({provider});
tb.addInput(aFt,sigA.unlockP2PKH()); tb.addInput(cFt,sigC.unlockP2PKH());
for(const x of p) tb.addInput(x,sigC.unlockP2PKH());
tb.addOutput({to:contract.tokenAddress,amount:POOL_SATS,token:{amount:POOL,category:state.mba.categoryId}});
if(aRem>0n) tb.addOutput({to:A.tokenaddr,amount:aRem*RATE,token:{amount:aRem,category:state.mba.categoryId}});
if(cRem>0n) tb.addOutput({to:C.tokenaddr,amount:cRem*RATE,token:{amount:cRem,category:state.mba.categoryId}});
tb.addOutput({to:C.cashaddr,amount:cChange});
await tb.send();
console.log(` funded pot ${che(POOL)} (feeAmount=${fee} cents baked)`);
S.funded=true; save(state); await sleep(10000);
}
if(!S.resolved){
const pool=(await provider.getUtxos(contract.tokenAddress)).find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount===POOL);
if(!pool) throw new Error('pool UTXO not found');
const mf=6000n;
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(cBch,mf+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(pool, contract.unlock.resolveArbiter(sigB, p2pkh(A.publicKeyHash)));
for(const x of p) tb.addInput(x,sigC.unlockP2PKH());
tb.addOutput({to:A.tokenaddr,amount:winnerAmount*RATE,token:{amount:winnerAmount,category:state.mba.categoryId}});
if(fee>0n) tb.addOutput({to:B.tokenaddr,amount:fee*RATE,token:{amount:fee,category:state.mba.categoryId}});
tb.addOutput({to:C.cashaddr,amount:BigInt(pool.satoshis)+t-winnerAmount*RATE-(fee>0n?fee*RATE:0n)-mf});
const sent=await tb.send();
const txid=typeof sent==='string'?sent:sent?.txid??sent?.txId;
console.log(` resolved → A wins ${che(winnerAmount)}, B earns ${che(fee)} — ${txid}`);
S.resolved={txid,fee:fee.toString(),winner:winnerAmount.toString(),regime}; save(state); await sleep(10000);
}
summary.push({key:sc.key,pool:POOL,onePctFt,centFt,fee,winnerAmount,regime,potUsd});
}
// --- Summary ---
step(`SUMMARY — CHE (1 CHE=${WHOLE_UNIT_RATE} sats, 2 dec) · GamePoolEscrowFlat per-side fee @ BCH $${bchUsd}`);
console.log(' Fee model: pot >= $1 → 1% of pot | pot < $1 → $0.01/side (= $0.02 total)');
console.log('');
console.log(' | scenario | pot | pot $ | 1% | $0.01/side | feeAmount | regime | winner |');
console.log(' |----------|-------------|----------|-----------|-------------|-------------|--------------|-------------|');
for(const s of summary){
const sideFee = `${che(s.centFt)}/side`;
console.log(` | ${s.key.padEnd(8)} | ${che(s.pool).padStart(11)} | ${('$'+s.potUsd.toFixed(3)).padStart(8)} | ${che(s.onePctFt).padStart(9)} | ${sideFee.padStart(11)} | ${che(s.fee).padStart(11)} | ${s.regime.padEnd(12)} | ${che(s.winnerAmount).padStart(11)} |`);
}
console.log('');
console.log(' Big pot: 1% > $0.01/side → proportional regime applies (0.10 CHE fee).');
console.log(' Small pot: 1% (0.01 CHE) < $0.01/side (0.06 CHE) → per-side flat kicks in.');
console.log(' Each player contributed $0.01 to the arbiter — symmetric, predictable.');
console.log(' Contract (GamePoolEscrowFlat.cash): UNCHANGED — per-side logic is purely off-chain.');
console.log(' Client re-derived feeAmount and asserted match before co-signing both scenarios.');
divider();
}
main().catch((e)=>{console.error('FAILED:',e?.message??e);if(e?.stack)console.error(e.stack);process.exit(1);});

View file

@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* chipnet-che-escrow-test.mjs — GamePoolEscrowFlat re-run on a NEW denominated
* game currency: CheckersCoin (CHE).
*
* 1 CHE = 0.001 BCH = 100,000 sats (whole-unit backing rate — a valid tier)
* decimals = 2 → atomic FT = 1 "cent" = 1,000 sats (per-atomic rate)
*
* The contract operates in ATOMIC FTs (cents) and is rate-agnostic, so the
* on-chain math is the same shape as the rate-1000 flat test; what changes is
* the denomination layer: pots, stakes and fees are framed/reported in CHE
* (cents / 100), exercising the decimals=2 display path.
*
* Fee model unchanged from the brief: feeAmount = max( $0.01 , floor(1% of pot) ),
* computed off-chain in WHOLE atomic FTs (cents) via the price feed, baked into
* the contract, client re-derived & asserted before co-signing, enforced on-chain.
*
* Two regimes (players A & C, arbiter B):
* A) big pot 10.00 CHE (1000 cents) → 1% = 10 cents (0.10 CHE) > $0.01 floor → fee follows 1%
* B) tiny pot 0.50 CHE ( 50 cents) → 1% floors to 0 cents → $0.01 (~3 cents = 0.03 CHE) floor wins
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
const ROOT = join(import.meta.dirname, '..');
const STATE = join(ROOT, 'ChipnetTests', '.chipnet-che-escrow-test.json');
const MBA_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
const FLAT_ARTIFACT = join(ROOT, 'contracts/dist/GamePoolEscrowFlat.json');
const KEYS = { a: join(ROOT, '.chipnet-keys.json'), b: join(ROOT, '.chipnet-keys-b.json'), c: join(ROOT, '.chipnet-keys-c.json') };
const RESERVOIR = 1_000_000_000_000_000_000n;
const RATE = 1000n; // per-atomic (cent) backing rate, sats/cent
const DECIMALS = 2n; // CHE has 2 decimals → 100 cents per CHE
const WHOLE_UNIT_RATE = RATE * (10n ** DECIMALS); // 100,000 sats per whole CHE = 0.001 BCH
const GENESIS_FEE = 0n;
// stakes are PER PLAYER, in atomic FTs (cents)
const SCENARIOS = [
{ key: 'big', stake: 500n, label: 'big pot 10.00 CHE (1% dominates)' },
{ key: 'tiny', stake: 25n, label: 'tiny pot 0.50 CHE ($0.01 floor dominates; proportional → 0)' },
];
// --- CHE display helpers (decimals = 2) ---
function che(cents){ // atomic FTs (cents) → "X.YY CHE" string
const neg = cents < 0n; const v = neg ? -cents : cents;
const whole = v / 100n, frac = v % 100n;
return `${neg?'-':''}${whole}.${frac.toString().padStart(2,'0')} CHE`;
}
function hexToBin(h){const o=new Uint8Array(h.length/2);for(let i=0;i<o.length;i++)o[i]=parseInt(h.slice(i*2,i*2+2),16);return o;}
function txidLE(h){const d=hexToBin(h);const o=new Uint8Array(d.length);for(let i=0;i<d.length;i++)o[i]=d[d.length-1-i];return o;}
function p2pkh(pkh){const o=new Uint8Array(25);o[0]=0x76;o[1]=0xa9;o[2]=0x14;o.set(pkh,3);o[23]=0x88;o[24]=0xac;return o;}
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
function divider(c='='){console.log(c.repeat(80));}
function step(t){console.log('');divider();console.log(t);divider();}
function load(){return existsSync(STATE)?JSON.parse(readFileSync(STATE,'utf8')):{s:{}};}
function save(s){writeFileSync(STATE,JSON.stringify(s,(k,v)=>typeof v==='bigint'?v.toString():v,2));}
function pickBch(utxos,need){const p=[];let t=0n;for(const u of utxos){p.push(u);t+=BigInt(u.satoshis);if(t>=need)break;}return{p,t};}
async function getBchUsd(){
if(process.env.BCH_USD)return Number(process.env.BCH_USD);
try{const ac=new AbortController();const t=setTimeout(()=>ac.abort(),8000);
const r=await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin-cash&vs_currencies=usd',{signal:ac.signal});
clearTimeout(t);const j=await r.json();const p=j?.['bitcoin-cash']?.usd;
if(p>0){console.log(` BCH price (CoinGecko): $${p}`);return Number(p);}
}catch(e){console.log(` (price fetch failed: ${e.message?.split('\n')[0]})`);}
console.log(' BCH price fallback: $500');return 500;
}
/** Server-side fee model: feeAmount = max( $0.01 in atomic FT , floor(1% of pot in atomic FT) ). */
function computeFlatFee(poolAmount, rateSats, bchUsd){
const onePctFt = poolAmount * 100n / 10000n; // floor(1% of pot), atomic FT (cents)
const centSats = Math.ceil(0.01 / bchUsd * 1e8); // $0.01 in sats
const centFt = BigInt(Math.ceil(centSats / Number(rateSats)));// $0.01 in atomic FT (ceil)
const fee = onePctFt > centFt ? onePctFt : centFt;
return { fee, onePctFt, centFt, centSats };
}
async function main(){
const mbaArt=JSON.parse(readFileSync(MBA_ARTIFACT,'utf8'));
const flatArt=JSON.parse(readFileSync(FLAT_ARTIFACT,'utf8'));
const A=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.a,'utf8')).wif);
const B=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.b,'utf8')).wif);
const C=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.c,'utf8')).wif);
const provider=new ElectrumNetworkProvider('chipnet');
const sigA=new SignatureTemplate(A.privateKey), sigB=new SignatureTemplate(B.privateKey), sigC=new SignatureTemplate(C.privateKey);
const flbC=p2pkh(C.publicKeyHash);
let state=load();state.s=state.s||{};
step(`BCH price + CheckersCoin (CHE) MBA — 1 CHE = ${WHOLE_UNIT_RATE} sats (0.001 BCH), decimals ${DECIMALS}, atomic=1 cent=${RATE} sats`);
const bchUsd=await getBchUsd();state.bchUsd=bchUsd;save(state);
if(!state.mba){
const u=(await (async()=>{let x=await C.getUtxos();let e=x.filter(y=>y.vout===0&&!y.token);if(e.length)return e[0];const bad=x.filter(y=>!(y.vout===0&&!y.token));const r=await C.send([{cashaddr:C.cashaddr,value:2000n,unit:'sat'}],bad.length?{utxoIds:bad}:undefined);await sleep(12000);x=await C.getUtxos();return x.filter(y=>y.vout===0&&!y.token)[0];})());
const cat=u.txid;const c=new Contract(mbaArt,[txidLE(cat),RATE,GENESIS_FEE,flbC],{provider});
const r=await C.tokenGenesis({cashaddr:c.tokenAddress,amount:RESERVOIR,value:1000n,nft:{capability:'minting',commitment:''}},[],{utxoIds:[u]});
console.log(` CHE genesis ${r?.txId??r?.txid} — ${cat}`);
state.mba={categoryId:cat,contractTokenAddress:c.tokenAddress};save(state);await sleep(10000);
} else console.log(` CHE: ${state.mba.categoryId}`);
const mba=new Contract(mbaArt,[txidLE(state.mba.categoryId),RATE,GENESIS_FEE,flbC],{provider});
async function mint(who,sig,amount,label){
const ru=await provider.getUtxos(mba.tokenAddress);
const res=ru.find(x=>x.token?.nft?.capability==='minting'&&x.token?.category===state.mba.categoryId&&x.token?.amount>0n);
const gross=amount*RATE;
const bch=(await provider.getUtxos(who.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const mf=6000n;const {p,t}=pickBch(bch,gross+mf+1000n);
if(t<gross+mf+1000n)throw new Error(`${label}: insufficient BCH ${t}`);
const tb=new TransactionBuilder({provider});
tb.addInput(res,mba.unlock.claim(amount,p2pkh(who.publicKeyHash)));
for(const x of p)tb.addInput(x,sig.unlockP2PKH());
tb.addOutput({to:mba.tokenAddress,amount:1000n,token:{amount:res.token.amount-amount,category:state.mba.categoryId,nft:{capability:'minting',commitment:''}}});
tb.addOutput({to:who.tokenaddr,amount:gross,token:{amount,category:state.mba.categoryId}});
tb.addOutput({to:who.cashaddr,amount:BigInt(res.satoshis)+t-1000n-gross-mf});
await tb.send();console.log(` ${label}`);await sleep(10000);
}
const totalStake=SCENARIOS.reduce((a,s)=>a+s.stake,0n);
state.mints=state.mints||{};
if(!state.mints.a){await mint(A,sigA,totalStake,`A claims ${totalStake} cents (${che(totalStake)})`);state.mints.a=true;save(state);}
if(!state.mints.c){await mint(C,sigC,totalStake,`C claims ${totalStake} cents (${che(totalStake)})`);state.mints.c=true;save(state);}
const summary=[];
for(const sc of SCENARIOS){
const STAKE=sc.stake, POOL=STAKE*2n, POOL_SATS=POOL*RATE, STAKE_SATS=STAKE*RATE;
const { fee, onePctFt, centFt } = computeFlatFee(POOL, RATE, bchUsd);
const winnerAmount=POOL-fee;
state.s[sc.key]=state.s[sc.key]||{};const S=state.s[sc.key];
step(`SCENARIO ${sc.key} — ${sc.label}`);
console.log(` pot=${POOL} cents (${che(POOL)}, ${POOL_SATS} sats); 1%=${onePctFt} cents (${che(onePctFt)}); $0.01=${centFt} cents (${che(centFt)})`);
console.log(` → feeAmount=max=${fee} cents (${che(fee)})`);
const check=computeFlatFee(POOL,RATE,bchUsd).fee;
if(check!==fee)throw new Error('client fee re-derivation mismatch');
console.log(` client re-derived feeAmount=${check} cents (${che(check)}) — matches baked value ✓ (would co-sign)`);
const contract=new Contract(flatArt,[
txidLE(state.mba.categoryId), POOL, POOL_SATS, STAKE, STAKE_SATS,
A.publicKeyHash, C.publicKeyHash, B.publicKeyCompressed, hexToBin('00'.repeat(32)), 9999999n, fee,
],{provider});
if(!S.funded){
const aFt=(await provider.getUtxos(A.cashaddr)).find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount>=STAKE);
const cFt=(await provider.getUtxos(C.cashaddr)).find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount>=STAKE);
if(!aFt||!cFt)throw new Error(`missing stake (A=${!!aFt} C=${!!cFt})`);
const aRem=aFt.token.amount-STAKE, cRem=cFt.token.amount-STAKE, mf=6000n;
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(cBch,mf+POOL_SATS+1000n);
const cChange=BigInt(aFt.satoshis)+BigInt(cFt.satoshis)+t-POOL_SATS-aRem*RATE-cRem*RATE-mf;
const tb=new TransactionBuilder({provider});
tb.addInput(aFt,sigA.unlockP2PKH());tb.addInput(cFt,sigC.unlockP2PKH());
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
tb.addOutput({to:contract.tokenAddress,amount:POOL_SATS,token:{amount:POOL,category:state.mba.categoryId}});
if(aRem>0n)tb.addOutput({to:A.tokenaddr,amount:aRem*RATE,token:{amount:aRem,category:state.mba.categoryId}});
if(cRem>0n)tb.addOutput({to:C.tokenaddr,amount:cRem*RATE,token:{amount:cRem,category:state.mba.categoryId}});
tb.addOutput({to:C.cashaddr,amount:cChange});
await tb.send();console.log(` funded pot ${POOL} cents (${che(POOL)})`);S.funded=true;save(state);await sleep(10000);
}
if(!S.resolved){
const pool=(await provider.getUtxos(contract.tokenAddress)).find(x=>x.token?.category===state.mba.categoryId&&!x.token?.nft&&x.token?.amount===POOL);
if(!pool)throw new Error('pool not found');
const mf=6000n;const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(cBch,mf+1000n);
const tb=new TransactionBuilder({provider});
tb.addInput(pool,contract.unlock.resolveArbiter(sigB,p2pkh(A.publicKeyHash)));
for(const x of p)tb.addInput(x,sigC.unlockP2PKH());
tb.addOutput({to:A.tokenaddr,amount:winnerAmount*RATE,token:{amount:winnerAmount,category:state.mba.categoryId}});
if(fee>0n)tb.addOutput({to:B.tokenaddr,amount:fee*RATE,token:{amount:fee,category:state.mba.categoryId}});
tb.addOutput({to:C.cashaddr,amount:BigInt(pool.satoshis)+t-winnerAmount*RATE-(fee>0n?fee*RATE:0n)-mf});
const sent=await tb.send();const txid=typeof sent==='string'?sent:sent?.txid??sent?.txId;
console.log(` resolved (B→A): winner ${winnerAmount} cents (${che(winnerAmount)}), arbiter ${fee} cents (${che(fee)}) — ${txid}`);
S.resolved={txid,fee:fee.toString(),winner:winnerAmount.toString()};save(state);await sleep(10000);
}
summary.push({key:sc.key,pool:POOL,onePctFt,centFt,fee,winnerAmount,regime:onePctFt>centFt?'1%':'$0.01 floor'});
}
step(`SUMMARY — CHE (1 CHE=${WHOLE_UNIT_RATE} sats, 2 dec) · GamePoolEscrowFlat fee=max($0.01,1%) @ BCH $${bchUsd}`);
console.log(' | scenario | pot | 1% | $0.01 | feeAmount | regime | winner |');
console.log(' |----------|-----------|-----------|-----------|-----------|-------------|-----------|');
for(const s of summary){
console.log(` | ${s.key.padEnd(8)} | ${che(s.pool).padStart(9)} | ${che(s.onePctFt).padStart(9)} | ${che(s.centFt).padStart(9)} | ${che(s.fee).padStart(9)} | ${s.regime.padEnd(11)} | ${che(s.winnerAmount).padStart(9)} |`);
}
console.log('');
console.log(` Denomination: contract math runs in atomic FTs (cents); CHE = cents/100 (decimals 2).`);
console.log(' Big pot: 1% (0.10 CHE) > $0.01 floor → fee follows 1%.');
console.log(' Tiny pot: 1% floors to 0.00 CHE (proportional would pay the arbiter NOTHING) →');
console.log(' the $0.01 floor (0.03 CHE) kicks in, so the arbiter still earns a real fee.');
console.log(' Contract enforced the baked feeAmount; client re-derived & matched before co-signing.');
console.log(' Peg preserved on both winner and fee outputs at the per-cent rate (1000 sats).');
divider();
}
main().catch((e)=>{console.error('FAILED:',e?.message??e);if(e?.stack)console.error(e.stack);process.exit(1);});

View file

@ -0,0 +1,256 @@
#!/usr/bin/env node
/**
* chipnet-pari-p2p-test.mjs — Pari_p2p Flow A end-to-end on chipnet.
*
* 1. Genesis CHE (DeviantOpenMintFT, rate 1000, fee 0) by founder C.
* 2. NORMAL: A and B each MINT+STAKE in a single tx (BCH → CHE → escrow
* address), resolveArbiter pays the winner.
* 3. STUCK: separate escrow instance with deadline=past; A mints+stakes,
* B never shows; A calls refundParty(0) to reclaim.
* 4. CANCEL: separate escrow instance with FAR deadline; A mints+stakes,
* A wants out immediately; A calls cancel(0, sigA, pubA, sigArbiter)
* and reclaims without waiting (arbiter co-signs the cancellation).
*
* Proves four things at once:
* - mint+stake is atomic via DeviantOpenMintFT.claim(stake, escrowLB) —
* no contract change needed; claim() already accepts ANY locking
* bytecode for the recipient.
* - resolveArbiter validates against TWO separate deposit UTXOs as
* inputs (one combined pool not required).
* - GamePoolEscrowFlatV2.refundParty(side) recovers a single-side
* deposit AFTER deadline (time-locked, permissionless safety net).
* - GamePoolEscrowFlatV2.cancel(side, partySig, partyPk, arbiterSig)
* recovers a single-side deposit IMMEDIATELY without waiting for the
* deadline (arbiter + party co-signed; user-initiated cancellation).
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes, createHash } from 'node:crypto';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { cashAddressToLockingBytecode } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const STATE = join(ROOT, '.chipnet-pari-p2p-test.json');
const MBA_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
const ESC_ARTIFACT = join(ROOT, 'contracts/dist/GamePoolEscrowFlatV2.json');
const KEYS = { a: join(ROOT, '.chipnet-keys.json'), b: join(ROOT, '.chipnet-keys-b.json'), c: join(ROOT, '.chipnet-keys-c.json') };
const RESERVOIR = 1_000_000_000_000_000_000n;
const RATE = 1000n, FEE_BPS = 0n;
const STAKE = 10n; // FTs per player
const STAKE_SATS = STAKE * RATE; // 10,000
const POOL = STAKE * 2n; // 20
const POOL_SATS = STAKE_SATS * 2n; // 20,000
const FEE_AMOUNT = 0n; // simplify — no arbiter fee
const SECRET_HASH = '00'.repeat(32);
const FAR = 9_999_999n;
const MF = 6000n;
function hexToBin(h){const o=new Uint8Array(h.length/2);for(let i=0;i<o.length;i++)o[i]=parseInt(h.slice(i*2,i*2+2),16);return o;}
function binToHex(b){return Array.from(b,(x)=>x.toString(16).padStart(2,'0')).join('');}
function txidLE(h){const d=hexToBin(h);const o=new Uint8Array(d.length);for(let i=0;i<d.length;i++)o[i]=d[d.length-1-i];return o;}
function p2pkh(pkh){const o=new Uint8Array(25);o[0]=0x76;o[1]=0xa9;o[2]=0x14;o.set(pkh,3);o[23]=0x88;o[24]=0xac;return o;}
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
function divider(c='='){console.log(c.repeat(80));}
function step(t){console.log('');divider();console.log(t);divider();}
function load(){return existsSync(STATE)?JSON.parse(readFileSync(STATE,'utf8')):{};}
function save(s){writeFileSync(STATE,JSON.stringify(s,(k,v)=>typeof v==='bigint'?v.toString():v,2));}
function pickBch(utxos,need){const p=[];let t=0n;for(const u of utxos){p.push(u);t+=BigInt(u.satoshis);if(t>=need)break;}return{p,t};}
function txidOf(sent){return typeof sent==='string'?sent:sent?.txid??sent?.txId;}
async function main(){
const mbaArt=JSON.parse(readFileSync(MBA_ARTIFACT,'utf8'));
const escArt=JSON.parse(readFileSync(ESC_ARTIFACT,'utf8'));
const A=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.a,'utf8')).wif);
const B=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.b,'utf8')).wif);
const C=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.c,'utf8')).wif);
const provider=new ElectrumNetworkProvider('chipnet');
const sigA=new SignatureTemplate(A.privateKey), sigB=new SignatureTemplate(B.privateKey), sigC=new SignatureTemplate(C.privateKey);
const flbC=p2pkh(C.publicKeyHash);
let state=load();
async function eligible(w){
let u=await w.getUtxos();let e=u.filter(x=>x.vout===0&&!x.token);
if(e.length)return e[0];
const bad=u.filter(x=>!(x.vout===0&&!x.token));
const r=await w.send([{cashaddr:w.cashaddr,value:2000n,unit:'sat'}],bad.length?{utxoIds:bad}:undefined);
console.log(` splitter ${txidOf(r)}`);await sleep(12000);
u=await w.getUtxos();e=u.filter(x=>x.vout===0&&!x.token);
if(!e.length)throw new Error('splitter failed');return e[0];
}
// ---- CHE genesis ----
step('Genesis CHE (DeviantOpenMintFT, rate 1000, fee 0)');
if(!state.mba){
const u=await eligible(C);const cat=u.txid;
const c=new Contract(mbaArt,[txidLE(cat),RATE,FEE_BPS,flbC],{provider});
const r=await C.tokenGenesis({cashaddr:c.tokenAddress,amount:RESERVOIR,value:1000n,nft:{capability:'minting',commitment:''}},[],{utxoIds:[u]});
console.log(` CHE genesis ${txidOf(r)} — category ${cat}`);
state.mba={categoryId:cat};save(state);await sleep(10000);
} else console.log(` CHE already: ${state.mba.categoryId}`);
const cat=state.mba.categoryId;
const mba=new Contract(mbaArt,[txidLE(cat),RATE,FEE_BPS,flbC],{provider});
// Build an escrow instance. Pure constructor; no on-chain action.
function buildEscrow(deadline) {
return new Contract(escArt,[
txidLE(cat), POOL, POOL_SATS, STAKE, STAKE_SATS,
A.publicKeyHash, B.publicKeyHash, C.publicKeyCompressed,
hexToBin(SECRET_HASH), BigInt(deadline), FEE_AMOUNT,
],{provider});
}
// Read the P2SH32 locking bytecode for an escrow instance (mint-target).
function escrowLockingBytecode(esc) {
const r=cashAddressToLockingBytecode(esc.address);
if(typeof r==='string')throw new Error('cashAddressToLockingBytecode: '+r);
return r.bytecode;
}
// MINT+STAKE atomic: player spends BCH → reservoir UTXO releases STAKE FT
// straight to the escrow address (NOT to player's own wallet first).
async function mintAndStake(who,sig,escrow,label){
const ru=await provider.getUtxos(mba.tokenAddress);
const res=ru.find(x=>x.token?.nft?.capability==='minting'&&x.token?.category===cat&&x.token?.amount>0n);
if(!res)throw new Error(`${label}: no minting reservoir found`);
const gross=STAKE*RATE;
const bch=(await provider.getUtxos(who.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(bch,gross+MF+1000n);
if(t<gross+MF+1000n)throw new Error(`${label}: insufficient BCH ${t} < ${gross+MF+1000n}`);
const change=BigInt(res.satoshis)+t-1000n-gross-MF;
const escrowLB=escrowLockingBytecode(escrow);
const tb=new TransactionBuilder({provider});
// input 0: reservoir, unlocked with claim(STAKE, escrowLB)
tb.addInput(res, mba.unlock.claim(STAKE, escrowLB));
// inputs 1..N: player's BCH (backing + miner fee)
for(const x of p)tb.addInput(x, sig.unlockP2PKH());
// output 0: reservoir back to MBA (decremented by STAKE)
tb.addOutput({to:mba.tokenAddress,amount:1000n,token:{amount:res.token.amount-STAKE,category:cat,nft:{capability:'minting',commitment:''}}});
// output 1: STAKE FT → escrow address (the stake deposit, single-side)
tb.addOutput({to:escrow.tokenAddress, amount:gross, token:{amount:STAKE, category:cat}});
// output 2: player's BCH change
tb.addOutput({to:who.cashaddr, amount:change});
const txid=txidOf(await tb.send());
console.log(` ${label}: minted ${STAKE} FT directly to escrow — ${txid}`);
await sleep(10000);
return txid;
}
// Find a stake-sized deposit UTXO at an escrow address
async function findDeposit(escrow){
const u=await provider.getUtxos(escrow.tokenAddress);
return u.filter(x=>x.token?.category===cat&&!x.token?.nft&&BigInt(x.token?.amount)===STAKE);
}
state.results=state.results||{};
// ============================================================
// NORMAL: both deposit via mint+stake, arbiter resolves
// ============================================================
step('NORMAL — A & B each mint+stake atomic; arbiter awards pool to A');
const escNormal=buildEscrow(FAR);
console.log(` escrow address: ${escNormal.tokenAddress}`);
if(!state.results.normalDepositA){
state.results.normalDepositA=await mintAndStake(A,sigA,escNormal,'A mint+stake');save(state);
} else console.log(` A deposit already: ${state.results.normalDepositA}`);
if(!state.results.normalDepositB){
state.results.normalDepositB=await mintAndStake(B,sigB,escNormal,'B mint+stake');save(state);
} else console.log(` B deposit already: ${state.results.normalDepositB}`);
if(!state.results.normalResolve){
const deposits=await findDeposit(escNormal);
if(deposits.length<2)throw new Error(`NORMAL: expected 2 deposits, found ${deposits.length}`);
const winnerLB=p2pkh(A.publicKeyHash);
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(cBch,MF+1000n);
const tb=new TransactionBuilder({provider});
// BOTH deposit UTXOs as inputs, both with the same resolveArbiter unlock
tb.addInput(deposits[0], escNormal.unlock.resolveArbiter(sigC, winnerLB));
tb.addInput(deposits[1], escNormal.unlock.resolveArbiter(sigC, winnerLB));
// C's BCH for miner fee
for(const x of p)tb.addInput(x, sigC.unlockP2PKH());
// output 0: winner takes pool (FEE_AMOUNT=0 → winnerAmount = POOL)
tb.addOutput({to:A.tokenaddr, amount:POOL_SATS, token:{amount:POOL, category:cat}});
// C change
tb.addOutput({to:C.cashaddr, amount:BigInt(deposits[0].satoshis)+BigInt(deposits[1].satoshis)+t-POOL_SATS-MF});
const txid=txidOf(await tb.send());
console.log(` arbiter resolved → A wins ${POOL} FT — ${txid}`);
state.results.normalResolve=txid;save(state);await sleep(10000);
} else console.log(` resolve already: ${state.results.normalResolve}`);
// ============================================================
// STUCK: A deposits, B never shows; refundParty after deadline
// ============================================================
step('STUCK — A mints+stakes, B never joins; refundParty(0) recovers A funds');
const tip=await provider.getBlockHeight();
const stuckDeadline=tip-1; // already passed
const escStuck=buildEscrow(stuckDeadline);
console.log(` escrow address: ${escStuck.tokenAddress} (deadline ${stuckDeadline}, tip ${tip})`);
if(!state.results.stuckDeposit){
state.results.stuckDeposit=await mintAndStake(A,sigA,escStuck,'A mint+stake (B will never join)');save(state);
} else console.log(` A deposit already: ${state.results.stuckDeposit}`);
if(!state.results.stuckRefund){
const deposits=await findDeposit(escStuck);
if(deposits.length<1)throw new Error('STUCK: A deposit UTXO not found');
const dep=deposits[0];
const aBch=(await provider.getUtxos(A.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(aBch,MF+1000n);
const tb=new TransactionBuilder({provider});
tb.setLocktime(tip);
tb.addInput(dep, escStuck.unlock.refundParty(0n));
for(const x of p)tb.addInput(x, sigA.unlockP2PKH());
tb.addOutput({to:A.tokenaddr, amount:STAKE_SATS, token:{amount:STAKE, category:cat}});
tb.addOutput({to:A.cashaddr, amount:BigInt(dep.satoshis)+t-STAKE_SATS-MF});
const txid=txidOf(await tb.send());
console.log(` refundParty(0) → A reclaimed ${STAKE} FT — ${txid}`);
state.results.stuckRefund=txid;save(state);await sleep(10000);
} else console.log(` refundParty already: ${state.results.stuckRefund}`);
// ============================================================
// CANCEL: A wants out NOW (no waiting); arbiter co-signs cancellation
// ============================================================
step('CANCEL — A mints+stakes (FAR deadline); A + arbiter co-sign cancel; A reclaims immediately');
const escCancel=buildEscrow(FAR);
console.log(` escrow address: ${escCancel.tokenAddress} (deadline FAR — no timeout wait)`);
if(!state.results.cancelDeposit){
state.results.cancelDeposit=await mintAndStake(A,sigA,escCancel,'A mint+stake (will cancel)');save(state);
} else console.log(` A deposit already: ${state.results.cancelDeposit}`);
if(!state.results.cancelTx){
const deposits=await findDeposit(escCancel);
if(deposits.length<1)throw new Error('CANCEL: A deposit UTXO not found');
const dep=deposits[0];
const aBch=(await provider.getUtxos(A.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(aBch,MF+1000n);
const tb=new TransactionBuilder({provider});
// cancel(side=0n, partySig=sigA, partyPk=A.publicKeyCompressed, arbiterSig=sigC)
tb.addInput(dep, escCancel.unlock.cancel(0n, sigA, A.publicKeyCompressed, sigC));
for(const x of p)tb.addInput(x, sigA.unlockP2PKH());
tb.addOutput({to:A.tokenaddr, amount:STAKE_SATS, token:{amount:STAKE, category:cat}});
tb.addOutput({to:A.cashaddr, amount:BigInt(dep.satoshis)+t-STAKE_SATS-MF});
const txid=txidOf(await tb.send());
console.log(` cancel(0, sigA, pubA, sigArbiter) → A reclaimed ${STAKE} FT IMMEDIATELY — ${txid}`);
state.results.cancelTx=txid;save(state);await sleep(10000);
} else console.log(` cancel already: ${state.results.cancelTx}`);
step('SUMMARY');
console.log(` CHE category: ${state.mba.categoryId}`);
console.log(` NORMAL deposits: ${state.results.normalDepositA} | ${state.results.normalDepositB}`);
console.log(` NORMAL resolve: ${state.results.normalResolve} (A wins ${POOL} FT)`);
console.log(` STUCK deposit: ${state.results.stuckDeposit} (only A; B never joined)`);
console.log(` STUCK refundParty(0): ${state.results.stuckRefund} (A reclaimed ${STAKE} FT — time-locked)`);
console.log(` CANCEL deposit: ${state.results.cancelDeposit} (A wants out, arbiter co-signs)`);
console.log(` CANCEL tx: ${state.results.cancelTx} (A reclaimed ${STAKE} FT — IMMEDIATE, no wait)`);
console.log('');
console.log(' PROVEN: mint+stake atomic via claim(amount, escrowLB) — single tx.');
console.log(' PROVEN: resolveArbiter validates against 2 separate deposit UTXOs.');
console.log(' PROVEN: refundParty(side) recovers a single-side stuck deposit AFTER deadline.');
console.log(' PROVEN: cancel(side, partySig, partyPk, arbiterSig) recovers IMMEDIATELY with both sigs.');
divider();
}
main().catch((e)=>{console.error('FAILED:',e?.message??e);if(e?.stack)console.error(e.stack);process.exit(1);});

View file

@ -0,0 +1,138 @@
# Bitcoin Long-Term Cycle Analysis
### Peak-to-peak epochs, halvings, and monthly moving-average crosses
*Updated through June 2026*
*Data: daily reference price (Coin Metrics community dataset, `PriceUSD`), Jul 2010 – May 24 2026, plus a June 2026 close estimate (~$61k) from public price reports (Fortune/Yahoo). Cross dates cross-checked against a second independent OHLC dataset and agree. All moving averages computed on monthly closes. Timings in calendar months (30.44 days).*
---
## 1. The epochs (peak → halving → peak)
Each cycle runs from one cycle peak to the next, with exactly one halving in between. The peak lands **~12–18 months after** the halving that precedes it.
| Cycle peak | Date | Peak (USD) | Preceding halving | Halving → peak | Bottom | Drawdown |
|---|---|---:|---|---:|---:|---:|
| 2011 | 2011-06-08 | ~$31 | — | — | ~$2 | −94% |
| 2013 | 2013-11-30 | ~$1,156 | 2012-11-28 | 12.1 mo | ~$172 | −85% |
| 2017 | 2017-12-17 | ~$19,783 | 2016-07-09 | 17.3 mo | ~$3,189 | −84% |
| 2021 | 2021-11-10 | ~$69,044 | 2020-05-11 | 18.0 mo | ~$15,598 | −77% |
| 2025 | 2025-10-06 | ~$126,198 | 2024-04-20 | 17.5 mo | ~$60,000* | −52%* |
*\* 2025 bottom is provisional — as of late June 2026 BTC sits ~$60–62k, the lowest of the cycle so far, but the downtrend is not confirmed over. If the historical ~12-month peak-to-bottom rule holds, the true low would cluster nearer Q3–Q4 2026.*
Both slow drifts continue: halving-to-peak stabilised at ~17–18 months, and **drawdown depth keeps shrinking** (−94 → −85 → −84 → −77 → −52%* so far).
---
## 2. The two ratios (peak-to-peak AND peak-to-bottom)
Both the **upside multiple** between peaks and the **crash ratio** down to each bottom compress every single cycle. The asset is maturing: smaller gains *and* smaller collapses.
| Cycle | Peak | Bottom | **Peak ÷ prev peak** | **Peak ÷ own bottom** | (bottom → next peak) |
|---|---:|---:|---:|---:|---:|
| 2011 | ~$31 | ~$2 | — | **15.5×** | 578× → 2013 |
| 2013 | ~$1,156 | ~$172 | **37.3×** | **6.72×** | 115× → 2017 |
| 2017 | ~$19,783 | ~$3,189 | **17.1×** | **6.20×** | 21.6× → 2021 |
| 2021 | ~$69,044 | ~$15,598 | **3.49×** | **4.43×** | 8.1× → 2025 |
| 2025 *(now)* | ~$126,198 | ~$60,000* | **1.83×** | **2.10×*** | — |
| 2025 *(est.)* | ~$126,198 | ~$33,000† | **1.83×** | **3.85׆** | — |
\* **Current state** — lowest close so far (~−52%), not a confirmed bottom. † **Trend-following estimate** — continuing the shrinking drawdown trend (−85.1% → −83.9% → −77.4% → ~−74%) gives a ~$33k bottom; range $30–36k.
**The 2021 regime flip.** Note the relationship between the two ratio columns. In 2013 and 2017 the upside multiple was the larger of the two (37× vs 6.7×; 17× vs 6.2×) — each cycle gained far more than it later gave back. In **2021 that order reversed for the first time**: peak÷prev (3.49×) fell *below* peak÷bottom (4.43×), and 2025 continues it (1.83× < 2.10×). This is mathematically the same as saying **the cycle bottom fell below the previous cycle's peak**: the 2013 and 2017 bottoms sat 5.55× and 2.76× *above* the prior peak (the floor of each new cycle was above the ceiling of the old one), but the 2021 bottom ($15,598) landed at 0.79× of the 2017 peak ($19,783) and the 2025 low (~$60k) at 0.87× of the 2021 peak — *below* it. Practically: a buyer of the 2017 top was underwater at the 2021 bottom, and a buyer of the 2021 top is underwater at the 2025 low — something that never happened in the first two cycles. The "each floor higher than the last ceiling" property broke in 2021.
**Current vs. trend-following bottom ÷ prev peak.** The 0.87× for 2025 reflects the *current* low (~$60k), not a confirmed bottom — and it actually sits *above* 2021's 0.79×, which would *break* the declining sequence (5.55 → 2.76 → 0.79 → 0.87 ↑). But that is an artifact of the cycle being incomplete. Following the % drawdown trend instead (−74%) puts the confirmed bottom near **$33k → 0.48×**, which *continues* the decline: **5.55 → 2.76 → 0.79 → 0.48**. So the pattern-consistent ratio is ~0.48×, with the live 0.87× being the unfinished-cycle reading.
Reading the columns:
- **Peak-to-peak** falls by roughly half-to-two-thirds each time: 37× → 17× → 3.5× → 1.8×. (Your ~16× for 2017/2013 is right; exact is 17× because the 2013 top was ~$1,156, not $1,200.)
- **Peak-to-bottom** (the crash ratio) falls the same way: 15.5× → 6.7× → 6.2× → 4.4× → ~2.1×* . Equivalent drawdowns of −94%, −85%, −84%, −77%, −52%*.
- **Recovery multiple** (each bottom to the *next* peak) collapses hardest of all: 578× → 115× → 22× → 8×. Buying the bottom pays off less spectacularly every cycle.
\* Provisional — depends on whether ~$60k (June 2026) is the real low.
---
## 3. The four checkpoints and the timing between them
**Indicator definitions** (monthly chart):
- **EMA cross** — 10-month EMA crossing the 21-month EMA. *Death* = 10 below 21; *Golden* = 10 above 21.
- **MA (SMA) cross** — 10-month SMA crossing the 20-month SMA.
- **Peak** — cycle high. **Downtrend ends** — the price bottom between this peak and the next.
### Using the 10/21 monthly EMA cross
| Cycle | Peak | Death cross | Bottom | Golden cross | Peak→Death | Death→Bottom | Bottom→Golden | Peak→Bottom |
|---|---|---|---|---|---:|---:|---:|---:|
| 2013 | 2013-11 | 2015-03 | 2015-01 | 2016-01 | 16.0 | **−2.5** | 12.5 | 13.5 |
| 2017 | 2017-12 | 2019-02 | 2018-12 | 2019-05 | 14.4 | **−2.5** | 5.5 | 11.9 |
| 2021 | 2021-11 | 2022-08 | 2022-11 | 2023-11 | 9.7 | **+2.7** | 12.3 | 12.4 |
| 2025 | 2025-10 | 2026-06 | pending | pending | ~8 | pending | pending | pending |
### Using the 10/20 monthly SMA cross
| Cycle | Peak | Death cross | Bottom | Golden cross | Peak→Death | Death→Bottom | Bottom→Golden | Peak→Bottom |
|---|---|---|---|---|---:|---:|---:|---:|
| 2013 | 2013-11 | 2015-02 | 2015-01 | 2016-03 | 14.9 | −1.5 | 14.5 | 13.5 |
| 2017 | 2017-12 | 2018-12 | 2018-12 | 2019-10 | 12.5 | −0.5 | 10.5 | 11.9 |
| 2021 | 2021-11 | 2022-07 | 2022-11 | 2023-10 | 8.6 | +3.7 | 11.3 | 12.4 |
| 2025 | 2025-10 | 2026-05 | pending | pending | ~7 | pending | pending | pending |
*(A negative Death→Bottom means the price bottom arrived* before *the moving-average cross fired.)*
---
## 4. What the timings actually say
**a) Peak → bottom is the single most stable number in the whole dataset: ~12–13.5 months.**
13.5, 11.9, 12.4 months across three mature cycles, even as the *depth* of the fall shrank from −85% to −77%. The downtrend's **duration** is far more consistent than its **magnitude**. This is the one constant worth trusting — and it puts a 2025-cycle bottom near **Q3–Q4 2026** if it holds.
**b) Your core hypothesis — "the downtrend ends before the cross that confirms the new uptrend" — is confirmed every cycle.**
The bottom always precedes the golden cross, by **5.5 to 12.5 months** (EMA). The golden cross is a *lagging confirmation*: by the time it prints, price is already well off the low. It tells you the bear is over, not that it's ending.
**c) The death cross does NOT cleanly sit between the peak and the bottom — the counterintuitive result.**
You sketched the order as Peak → Death → Bottom → Golden. That held only in **2021**. In **2013 and 2017** the monthly 10/21 EMA is so slow that the **bottom arrived ~2.5 months *before* the death cross** — the real order was Peak → Bottom → Death → Golden. The faster 10/20 SMA pulls the death cross almost onto the bottom (2017: same month). So treat the monthly death cross as a *trailing confirmation of the decline*, not an early warning — and the golden cross as the reliable structural signal.
**d) Peak → death cross is shrinking: 16 → 14.4 → 9.7 → ~8 months (EMA).**
Bears get *confirmed* faster each cycle. That fits the maturing-asset / compressing-volatility picture — later tops roll over and trigger the cross sooner, even though the time to actually bottom (point a) stays put around a year.
---
## 5. The 2025 cycle, as of June 2026
- **Peak:** $126,198 on 2025-10-06 (≈17.5 months after the Apr 2024 halving — squarely in the 17–18 month window).
- **Now:** ~$60–62k (late June 2026), down ~50–52% from the peak; "extreme fear" sentiment, ETF outflows, a sub-$62k break on 5 Jun 2026 with ~$1.5B in long liquidations.
- **Death cross:** fired — **10/20 SMA in May 2026**, **10/21 EMA in June 2026**, ~7–8 months after the peak. (The EMA timing is on the knife's edge: the 10/21 gap was only +$520 in May before flipping to −$1,476 in June, so the exact month hinges on June's close.)
- **Bottom:** *not yet confirmed.* The current ~$60k is the cycle low so far. The ~12-month peak-to-bottom constant points to **Q3–Q4 2026**; the −52% drawdown so far is shallow versus the −77% to −85% of prior cycles, so either this cycle bottoms higher (maturing asset) or has further to fall.
- **Golden cross:** none yet — by the historical pattern it would trail the eventual bottom by 5–12 months, i.e. not before 2027.
- **Caveat worth weighting:** Fidelity and Morgan Stanley both argue the four-year cycle may be flattening as Bitcoin matures (ETF-driven, lower volatility). The historical constants here are a base rate, not a guarantee.
---
## 6. Where this cycle could bottom — scenarios & timing
**Major projection — pattern continuation (drawdown shallower than −77.4%).** The *direction* of the pattern is that each drawdown is shallower than the last (−85.1% → −83.9% → −77.4%), so continuing it means this cycle bottoms at **less than −77.4%** — a higher low. Major case (drawdown-trend estimate): bottom **~$35–45k**, ratio ~0.50–0.65, drawdown ~−64% to −74%; shallower (**~$50–60k**) if the shrink accelerates as the current −52% hints.
**The deep tail.** The simple −82% average of the past drops *ignores* that shrinking direction and sits *below* the pattern at **~$22,500** ($19k–$27k, ratio 0.27–0.38×). It only applies if the shrinking trend breaks. Full average-to-pattern-continuation range: **$22.5k (−82%) up to ~$36k (−71%)**.
**Four scenarios** (anchored to the 2021 peak, $69,044):
- **Deep / exogenous shock** — $16–27k, −79% to −88%, ratio 0.23–0.39. The raw bottom÷prev-peak extrapolation, and where a COVID-style cross-market shock would land it.
- **Bearish / trend holds** — $30–36k, −71% to −76%, ratio 0.43–0.52. Drawdown-% method: crashes shrink only at the gentle historical pace.
- **Blended / moderate** — ~$40–46k, −64% to −68%, ratio 0.58–0.67.
- **Moderate / maturation** — $48–60k, −52% to −62%, ratio 0.70–0.87. ETF/institutional bid holds; current ~$60k near the floor.
Central read (drawdown method): ratio ~0.50–0.65, bottom **~$35–45k**. The bearish cases are kept in because exogenous shocks override the internal pattern — March 2020 cut BTC ~50% in a day and WTI crude futures went negative (~−$37); a structural bid doesn't floor price in a shock, it can just mean patient capital waiting for lower prices.
**Bear-market & accumulation timing (months).** Decline (peak→bottom): 13.5 / 11.9 / 12.4 (avg ~12.6). Accumulation (bottom→golden cross): 12.5 / 5.5 / 12.3 (avg ~10.1, range 5.5–12.5). Total underwater (peak→golden): 26.0 / 17.4 / 24.6 (avg ~22.7). **2025 projection:** Oct 2025 peak + ~13mo decline → bottom **~Q4 2026**; + ~6–12mo accumulation → next confirmed uptrend **~mid-to-late 2027**. The scenario prices are an accumulation *range* over that window, not a single-day level.
**Where we are now (26 Jun 2026) & estimated bottom date.** We are **8.6 months past the Oct 2025 peak** ≈ 69% of the average 12.6-month decline, with ~3.5–5 months still to run to the historical bottom timing — at this point every prior cycle was still falling. Three converging "departure point" methods date the next bottom: bottom-to-bottom spacing (37.9 / 47.0 / 47.2 mo, mature ~47) → **Oct 2026**; peak→bottom from the Oct 2025 peak → **Oct–Nov 2026**; ~17 mo before the next halving (~Apr 2028) → **Oct–Nov 2026**. **Central estimate: late Oct–Nov 2026 (Q4 2026)**, wider band ~Jul 2026 → Jan 2027 if the spacing compresses.
---
## 7. Caveats
- Exact ATHs/bottoms differ ~1–3% by source; ratios are robust to that.
- The **2011 cycle** is excluded from the cross tables: at sub-$30 prices the monthly EMAs never produced a clean isolated cross (10/21 stayed bullish into 2013). Shown only in epoch/ratio tables.
- The **June 2026 monthly close is an estimate (~$61k)**; it affects only the EMA death-cross month (May vs June), not the broader conclusions.
- Monthly MA crosses are inherently lagging. Everything here is descriptive of past structure, not a trading signal or forecast.
### Methodology (reproducible)
Daily reference price → monthly closes → `EMA(10)`, `EMA(21)`, `SMA(10)`, `SMA(20)` → cross = sign change of (fast − slow). Peaks/bottoms = cycle extremes. Timings = day difference ÷ 30.44.

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -0,0 +1,65 @@
# Stablecoins: Supply, Backing, and the Bitcoin Cycle
*A companion to the interactive `stablecoin-report.html`. Figures are mid-2026 snapshots and move daily; historical series are monthly Coin Metrics data through May 2026.*
---
## 1. Eleven years of supply
Stablecoin supply has grown from effectively zero in 2015 to roughly **$300B** by mid-2026. The market is a duopoly: **USDT (Tether) ~$190B (~63%)** and **USDC (Circle) ~$74B (~25%)** together hold close to **88%** of all supply. The shape of the curve is itself the story — near-vertical expansion through the 2020–21 and 2023–25 bull markets, a plateau-and-contraction through 2022–23, and a resumption to new highs since.
## 2. The coins that cover ≥98%
Beyond the two giants, the long tail is short. The next rungs — USDe (~$5.5B), USDS (~$8.7B), USD1 (~$4.7B), DAI (~$4.4B), PYUSD (~$3.8B), FDUSD (~$3B), RLUSD (~$1.8B) — plus a cluster of Paxos-issued and minor coins, bring cumulative coverage past **98%**. Three backing models split the field: **fiat-reserve** (USDT, USDC, USD1, PYUSD, FDUSD, RLUSD), **CDP / crypto-collateralized** (DAI, USDS), and **synthetic** (USDe, a delta-neutral crypto-plus-short-hedge design with no bank reserves).
## 3. Reserves vs. supply — three trust models
Fiat coins aim for 1:1, with Tether reporting a small excess buffer. The **CDP coins over-collateralize each *debt position*, not the token's reserves** — a critical distinction (see §6). USDe is synthetic. The reported backing ratios (USDT ~104%, USDC ~100%, DAI/USDS ~140%) are not comparable across models: the fiat ratio is reserves ÷ liabilities, while the CDP ">100%" is per-vault collateral ÷ debt — a lending buffer, not a reserve surplus.
## 4. Tether: what backs USDT, and how it changed
Per the Q1 2026 BDO Italia attestation: total reserves **~$191.8B** against **~$183.5B** liabilities, an **~$8.2B** buffer. Composition: **~$141B US Treasuries, ~$20B gold, ~$15B secured loans, ~$7B Bitcoin, ~$4.5B cash, ~$4.3B other.** "Real dollars" (Treasuries + cash) are ~79% of supply; roughly **25% (~$46B) sits in non-dollar assets** (gold, BTC, loans) that must be *sold into the market* to honor redemptions — they don't redeem at par instantly.
The reserve *mix* has transformed. In early 2021, Tether's reserves were **~40–50% commercial paper** (including Chinese-bank paper); that was wound to **zero by late 2022** and replaced by Treasuries. Gold and Bitcoin bands have climbed since 2024 (gold especially, to ~$20B). Tether has only ever published quarterly attestations — limited-assurance, point-in-time — and **no Big Four firm has ever completed a full audit**, though a first KPMG audit reportedly began in Q1 2026.
## 5. USDC: simpler reserves, a sharper history
USDC's reserve is essentially **cash + short-dated US Treasuries** — today ~80% in the BlackRock-managed Circle Reserve Fund (Treasuries + overnight repo) and ~20% bank cash, with monthly Deloitte attestations. But it wasn't always: a **May 2021 attestation showed only 61% cash**, the rest in Yankee CDs (13%), commercial paper (9%) and corporate bonds (5%). Under scrutiny, Circle pledged in Aug 2021 to hold reserves 100% in cash and short-duration Treasuries.
USDC's supply curve also carries a scar the others lack: it peaked ~$54B in mid-2022, then fell to ~$28B through the **March 2023 SVB depeg** — ~$3.3B of cash reserves were briefly stranded at Silicon Valley Bank and USDC traded to **$0.87** before recovering. It has since climbed back to ~$74B.
## 6. CDP stablecoins: DAI & USDS — the line drawn apart from fiat
The two CDP coins, **DAI and its successor USDS** (both from Sky, ex-MakerDAO), are mechanically distinct. A user locks collateral in a *vault* and mints the coin against it at a 145–175% ratio. **The over-collateralization is on the borrower's debt, not the token's reserves** — a liquidation buffer for a loan, not a claim that Sky holds $1.40 per circulating dollar.
And the collateral is no longer mostly crypto. As of Q1 2026 the mix is **~40% tokenized US Treasuries, ~38% USDC (held in the Peg-Stability Module), ~22% crypto vaults**. So ~78% of "decentralized" DAI/USDS ultimately rests on USDC or Treasuries — which is exactly why **DAI depegged alongside USDC during the 2023 SVB event**. The mix evolved through three eras: crypto-only (2020) → USDC-PSM surging to a majority (2021–22) → RWA Treasuries ramping from 2023 to the largest slice.
**Scale (the line vs. fiat):** the entire CDP segment is ~**$13B** (DAI ~$4.4B + USDS ~$8.7B) — only **~4–5% of the ~$300B market** against USDT ~$190B and USDC ~$74B. The decentralized model is the conceptually different one, but a small corner of supply, and increasingly plumbed into the same Treasuries and USDC as the centralized coins.
## 7. The fractional-reserve record
A "$1 peg" is a reserve *claim*, not a law. The record shows it has broken:
- **Tether:** the CFTC found USDT was fully fiat-backed only **27.6% of days** in 2016–2018; the NY AG settlement (Feb 2021) revealed **$850M** of reserves had been lent to Bitfinex; reserves were ~40–50% commercial paper until late 2022.
- **USDC:** depegged to **$0.87** in the March 2023 SVB event.
- **DAI/USDS:** tracked USDC down by sympathy in the same event, via PSM exposure.
Attestations confirm a figure on one day; audits test controls over a period. Most issuers publish the former, not the latter.
## 8. Bitcoin price vs. stablecoin supply
Stablecoin supply is the "dry powder" of the crypto economy, and for years it amplified Bitcoin's cycle:
| Phase | BTC change | Stablecoin supply change |
|---|---|---|
| Bull 2020–21 | +314% | **+543%** ($22B → $139B) |
| Bear 2021–22 | −70% | **−21%** ($165B peak → $131B trough) |
| Bull 2023–25 | +374% | **+87%** ($141B → $264B) |
| Decline 2025–26 | −52% | **~flat** (~$264B → $270B) |
Two readings stand out. The **2021–22 bear produced the first-ever sustained contraction** in stablecoin history (−21%, −$35B), as Terra/FTX imploded and capital fled. And the **2025–26 decline is the break in the pattern**: through a −52% BTC drawdown, supply has stayed essentially flat rather than contracting.
Monthly correlation between BTC returns and supply growth is weak (~0.15); the signal is regime-level — supply grows ~**+6.0%/mo in BTC up-months vs +5.0%/mo in down-months**, and expansions have historically *led* rallies. The current flat-supply-amid-falling-price is the anomaly worth watching: either dry powder waiting to redeploy, or evidence that stablecoins are now a payments-and-yield rail first and a crypto-beta proxy second.
---
*Sources: Coin Metrics community data (supply, BTC price); Tether/BDO and Circle/Deloitte attestations; Sky/MakerBurn disclosures; CFTC (Oct 2021) and NY AG (Feb 2021) actions; DefiLlama/CoinGecko and 2026 market reporting. Intermediate-quarter reserve splits are approximate. Informational overview, not financial advice.*

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,65 @@
# Stablecoins: Supply, Backing, and the Bitcoin Cycle
*A companion to the interactive `stablecoin-report.html`. Figures are mid-2026 snapshots and move daily; historical series are monthly Coin Metrics data through May 2026.*
---
## 1. Eleven years of supply
Stablecoin supply has grown from effectively zero in 2015 to roughly **$300B** by mid-2026. The market is a duopoly: **USDT (Tether) ~$190B (~63%)** and **USDC (Circle) ~$74B (~25%)** together hold close to **88%** of all supply. The shape of the curve is itself the story — near-vertical expansion through the 2020–21 and 2023–25 bull markets, a plateau-and-contraction through 2022–23, and a resumption to new highs since.
## 2. The coins that cover ≥98%
Beyond the two giants, the long tail is short. The next rungs — USDe (~$5.5B), USDS (~$8.7B), USD1 (~$4.7B), DAI (~$4.4B), PYUSD (~$3.8B), FDUSD (~$3B), RLUSD (~$1.8B) — plus a cluster of Paxos-issued and minor coins, bring cumulative coverage past **98%**. Three backing models split the field: **fiat-reserve** (USDT, USDC, USD1, PYUSD, FDUSD, RLUSD), **CDP / crypto-collateralized** (DAI, USDS), and **synthetic** (USDe, a delta-neutral crypto-plus-short-hedge design with no bank reserves).
## 3. Reserves vs. supply — three trust models
Fiat coins aim for 1:1, with Tether reporting a small excess buffer. The **CDP coins over-collateralize each *debt position*, not the token's reserves** — a critical distinction (see §6). USDe is synthetic. The reported backing ratios (USDT ~104%, USDC ~100%, DAI/USDS ~140%) are not comparable across models: the fiat ratio is reserves ÷ liabilities, while the CDP ">100%" is per-vault collateral ÷ debt — a lending buffer, not a reserve surplus.
## 4. Tether: what backs USDT, and how it changed
Per the Q1 2026 BDO Italia attestation: total reserves **~$191.8B** against **~$183.5B** liabilities, an **~$8.2B** buffer. Composition: **~$141B US Treasuries, ~$20B gold, ~$15B secured loans, ~$7B Bitcoin, ~$4.5B cash, ~$4.3B other.** "Real dollars" (Treasuries + cash) are ~79% of supply; roughly **25% (~$46B) sits in non-dollar assets** (gold, BTC, loans) that must be *sold into the market* to honor redemptions — they don't redeem at par instantly.
The reserve *mix* has transformed. In early 2021, Tether's reserves were **~40–50% commercial paper** (including Chinese-bank paper); that was wound to **zero by late 2022** and replaced by Treasuries. Gold and Bitcoin bands have climbed since 2024 (gold especially, to ~$20B). Tether has only ever published quarterly attestations — limited-assurance, point-in-time — and **no Big Four firm has ever completed a full audit**, though a first KPMG audit reportedly began in Q1 2026.
## 5. USDC: simpler reserves, a sharper history
USDC's reserve is essentially **cash + short-dated US Treasuries** — today ~80% in the BlackRock-managed Circle Reserve Fund (Treasuries + overnight repo) and ~20% bank cash, with monthly Deloitte attestations. But it wasn't always: a **May 2021 attestation showed only 61% cash**, the rest in Yankee CDs (13%), commercial paper (9%) and corporate bonds (5%). Under scrutiny, Circle pledged in Aug 2021 to hold reserves 100% in cash and short-duration Treasuries.
USDC's supply curve also carries a scar the others lack: it peaked ~$54B in mid-2022, then fell to ~$28B through the **March 2023 SVB depeg** — ~$3.3B of cash reserves were briefly stranded at Silicon Valley Bank and USDC traded to **$0.87** before recovering. It has since climbed back to ~$74B.
## 6. CDP stablecoins: DAI & USDS — the line drawn apart from fiat
The two CDP coins, **DAI and its successor USDS** (both from Sky, ex-MakerDAO), are mechanically distinct. A user locks collateral in a *vault* and mints the coin against it at a 145–175% ratio. **The over-collateralization is on the borrower's debt, not the token's reserves** — a liquidation buffer for a loan, not a claim that Sky holds $1.40 per circulating dollar.
And the collateral is no longer mostly crypto. As of Q1 2026 the mix is **~40% tokenized US Treasuries, ~38% USDC (held in the Peg-Stability Module), ~22% crypto vaults**. So ~78% of "decentralized" DAI/USDS ultimately rests on USDC or Treasuries — which is exactly why **DAI depegged alongside USDC during the 2023 SVB event**. The mix evolved through three eras: crypto-only (2020) → USDC-PSM surging to a majority (2021–22) → RWA Treasuries ramping from 2023 to the largest slice.
**Scale (the line vs. fiat):** the entire CDP segment is ~**$13B** (DAI ~$4.4B + USDS ~$8.7B) — only **~4–5% of the ~$300B market** against USDT ~$190B and USDC ~$74B. The decentralized model is the conceptually different one, but a small corner of supply, and increasingly plumbed into the same Treasuries and USDC as the centralized coins.
## 7. The fractional-reserve record
A "$1 peg" is a reserve *claim*, not a law. The record shows it has broken:
- **Tether:** the CFTC found USDT was fully fiat-backed only **27.6% of days** in 2016–2018; the NY AG settlement (Feb 2021) revealed **$850M** of reserves had been lent to Bitfinex; reserves were ~40–50% commercial paper until late 2022.
- **USDC:** depegged to **$0.87** in the March 2023 SVB event.
- **DAI/USDS:** tracked USDC down by sympathy in the same event, via PSM exposure.
Attestations confirm a figure on one day; audits test controls over a period. Most issuers publish the former, not the latter.
## 8. Bitcoin price vs. stablecoin supply
Stablecoin supply is the "dry powder" of the crypto economy, and for years it amplified Bitcoin's cycle:
| Phase | BTC change | Stablecoin supply change |
|---|---|---|
| Bull 2020–21 | +314% | **+543%** ($22B → $139B) |
| Bear 2021–22 | −70% | **−21%** ($165B peak → $131B trough) |
| Bull 2023–25 | +374% | **+87%** ($141B → $264B) |
| Decline 2025–26 | −52% | **~flat** (~$264B → $270B) |
Two readings stand out. The **2021–22 bear produced the first-ever sustained contraction** in stablecoin history (−21%, −$35B), as Terra/FTX imploded and capital fled. And the **2025–26 decline is the break in the pattern**: through a −52% BTC drawdown, supply has stayed essentially flat rather than contracting.
Monthly correlation between BTC returns and supply growth is weak (~0.15); the signal is regime-level — supply grows ~**+6.0%/mo in BTC up-months vs +5.0%/mo in down-months**, and expansions have historically *led* rallies. The current flat-supply-amid-falling-price is the anomaly worth watching: either dry powder waiting to redeploy, or evidence that stablecoins are now a payments-and-yield rail first and a crypto-beta proxy second.
---
*Sources: Coin Metrics community data (supply, BTC price); Tether/BDO and Circle/Deloitte attestations; Sky/MakerBurn disclosures; CFTC (Oct 2021) and NY AG (Feb 2021) actions; DefiLlama/CoinGecko and 2026 market reporting. Intermediate-quarter reserve splits are approximate. Informational overview, not financial advice.*

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,106 @@
# BTC-IOU on Bitcoin Cash (Trust-Minimized Model)
**One line:** Lock BTC in a deposit governed by an **n-of-m oracle/signer set**, mint a **BTC-denominated IOU as a CashToken on BCH** that circulates and trades **on-chain on BCH** (not off-chain like LN), and let **anyone redeem by burning the IOU** — the oracle set releases the underlying BTC, and the BCH chain is the **provable record of what authorizes release**, so any signer releasing against anything else is **objectively cheating → detectable → slashable.**
**Trust model: trust-minimized, NOT trustless.** BTC's VM cannot condition a release on BCH state, so *someone* must be able to sign the BTC release. This model pushes that trust as far down as it goes without BTC opcodes: from a single custodian → to a **bonded n-of-m signer set** whose misbehavior is publicly provable on BCH and economically punished (tBTC-v2-style floor). It is **not** the trustless T6 channel model — it is a different, complementary instrument (a bearer on-ramp).
Validated on real coins (BCH chipnet + BTC signet), `cashc 0.12.1`.
---
## 1. Why this can't be trustless (and what "trust-minimized" buys)
The irreducible fact: **BTC has no covenants.** It can verify signatures, hashlocks, timelocks — nothing else. It cannot read BCH or bind a BTC release to a BCH event. So a *circulating BTC-backed IOU* — where the BTC stays locked and a claim trades freely on BCH — **cannot self-enforce its release from the BTC side.** Something with a BTC key must sign the release.
That single signature is the only trust. Everything around it is hardened:
**The trust ladder for any BTC peg:**
`2 fixed signers (gating trust)` → **`bonded M-of-N (trust-minimized, slashable)`** → `zk light client on BTC (trustless — needs opcodes BTC lacks)`.
BTC's floor is the **bonded signer set.** This model sits on that floor: an n-of-m threshold to release, plus a **BCH-side bond that slashes provable misbehavior.** Residual trust = "≥1 honest signer in the threshold + the bond makes cheating loss-making," which is a far smaller, sharper assumption than "trust the custodian."
Honest boundary vs T6: in **T6** you co-sign your own BTC 2-of-2, so you can self-exit (penalty-enforced) — trustless. In the **IOU** you hold a bearer claim on a lock you *didn't* sign, so you can't self-release the BTC → redemption trusts the signer set. Bearer convenience is paid for in the release trust. That's the deliberate trade: **T6 = trustless but channel-bound; IOU = trust-minimized but a free-circulating bearer asset with universal reach.**
---
## 2. Mechanics, end to end
### Deposit (on BTC)
- The user **deposits BTC to a BTC address** governed by the n-of-m signer set — **one address per user**, or a **fresh address per deposit** if the user prefers (unlinkable deposits).
- This is a real on-chain BTC lock, **not an off-chain channel** — there is no LN node, no liveness requirement, no sign-to-receive.
### Mint (on BCH)
- The oracle/signer set **attests the confirmed BTC deposit** (m-of-n attestation) → a **BTC-IOU CashToken** is minted on BCH, **1:1 with the locked BTC**, backing publicly verifiable.
- The IOU is an ordinary CashToken: it **transacts and trades on-chain on any BCH DEX**, held by anyone as a bearer asset (no signing, no channel membership, no online requirement).
- Validated: `DeviantWrapMintIOU` (single-attestor, 1:1 verifiable) and **`DeviantWrapMintIOUMofN.cash` (2-of-3 attestation)**.
### Redeem / release (burn on BCH → oracles release on BTC)
- **Anyone holding the IOU redeems by burning it on BCH.** The burn is the on-chain, provable authorization event.
- The **oracle set manages the BTC release**: on a valid burn, an **m-of-n threshold signs the BTC release** to the redeemer's payout (a **fresh, unlinked** BTC address for privacy). Pooled backing → a redeemer need not be the original depositor.
- Validated: **BTC 2-of-3 release on signet** (1 signature rejected by consensus, threshold enforced — `btc-signet-mofn-release.mjs`); **`BurnSink.cash`** burn↔release redemption to a fresh payout (`chipnet-signet-iou-redeem.mjs`).
### Honesty enforcement (the BCH board as the release-authorizing record)
- The **BCH chain is the provable "this is the state that authorizes release."** A release is legitimate **iff** it corresponds to a recorded IOU burn. The backing reservoir (an NFT) and the burn history are public and verifiable.
- A signer releasing BTC **against anything other than a valid burn is objectively cheating** — provable on-chain — and the **BCH-side bond slashes it.**
- Validated: **`BondedSignerSlash.cash`** — equivocation slashes the bond (5/5 checks: slash on conflicting attestations; reject identical / different-id; honest withdraw; reject early withdraw).
---
## 3. Where the trust actually sits (stated honestly)
| Component | Trust assumption | Mitigant |
|---|---|---|
| BTC deposit lock | None (plain BTC lock) | — |
| Mint attestation | m-of-n signers attest the deposit truthfully | threshold + bond; false attestation is provable on BCH |
| IOU circulation on BCH | None — it's a native CashToken | on-chain, self-custodial |
| **BTC release on burn** | **m-of-n signers sign the correct release** | **threshold** (no single signer) + **bond slashes equivocation** + **burn is the public, provable authorization** |
**Residual trust:** an m-of-n signer set could, if a *threshold colludes*, misbehave. What makes it *minimized* rather than *custodial*:
- **No single point** — needs a threshold, not one custodian.
- **Provable authorization** — every legitimate release maps to a public BCH burn; deviation is objectively detectable.
- **Economic deterrence** — equivocation is slashable (validated).
**Known gap (honest):** the bond currently slashes **equivocation** (signing conflicting things). **Full theft-deterrence** — slashing a signer who releases BTC with *no* corresponding burn — needs a **BTC-release proof fed into the bond** (item B4, pending). Until then the deterrence covers equivocation, not every release-without-burn. This is the one open edge to close for maximal trust-minimization.
---
## 4. Multiple oracles
The design generalizes to **multiple independent oracles** rather than a fixed pair:
- **Release requires an m-of-n threshold**, so compromising fewer than the threshold cannot release.
- Prefer **independence/diversity** in the oracle set (distinct operators, ideally distinct infrastructure) so collusion is hard and a single compromise is inert.
- The BCH burn record is the **shared source of truth** all oracles reference — they don't need to trust each other, only to read the same public chain; a signer diverging from it is provably wrong.
---
## 5. How it fits the wider system
- **Model 2 (HTLC swap)** — trustless, but BTC *leaves* (you end up holding a BCH-native asset). No IOU, no signers.
- **T6 (channel state on BCH)** — trustless, BTC stays in a 2-of-2 you co-sign; channel-bound, bilateral.
- **This model (BTC-IOU)** — trust-minimized; BTC stays locked and **BTC-denominated**, the IOU circulates **on-chain on BCH with universal reach** (no islands, no routing, no liveness). The **captured-audience on-ramp**: a BTC holder keeps BTC exposure while transacting freely on BCH rails.
Trading it: the IOU is a plain CashToken, so it trades on **any BCH DEX** (like a wrapped stable). Distinct from the pure-swap DEX path (Anthemus BTC-over-T6 ⇄ USDc), which needs a counterparty per fill but no signer trust — the IOU needs the signer set but gives continuous, bearer, always-available liquidity.
---
## 6. Validated on real coins
| Test | Chain | Result |
|---|---|---|
| **M3** — BTC-IOU on-chain token (`DeviantWrapMintIOU`) | chipnet | ✅ minted 100000 BTCb, transacted on-chain, backing 1:1 verifiable |
| **M-of-N mint** (`DeviantWrapMintIOUMofN.cash`, 2-of-3 attestation) | chipnet | ✅ validated |
| **M-of-N BTC release** (2-of-3 threshold) | BTC signet | ✅ `1be08e1c…`; 1-sig release rejected by consensus |
| **Bonded slashing** (`BondedSignerSlash.cash`) | chipnet | ✅ 5/5 — equivocation slashed; honest withdraw ok; early/invalid rejected |
| **Burn → redeem** (`BurnSink.cash`, fresh unlinked payout) | chipnet + signet | ✅ burn ↔ bonded pooled release |
Code: `contracts/DeviantWrapMintIOU*.cash`, `contracts/BondedSignerSlash.cash`, `contracts/BurnSink.cash`, `scripts/btc-signet-mofn-release.mjs`, `scripts/chipnet-bonded-signer-test.mjs`, `scripts/chipnet-signet-iou-redeem.mjs`.
---
## 7. One-line honest summary
**A trust-minimized wrapped BTC on BCH:** BTC locked under a bonded n-of-m oracle set, a 1:1 bearer CashToken IOU that trades on-chain on BCH with universal reach, redeemed by a public burn that authorizes an oracle-managed release — with misbehavior provable on BCH and slashable. **Not trustless** (BTC can't self-enforce release), but the trust is reduced to "a threshold of bonded, publicly-accountable signers," which is the honest floor for any wrapped BTC until Bitcoin gains the opcodes to verify BCH itself. The remaining edge to close is a BTC-release proof into the bond (B4) for full release-without-burn deterrence.

View file

@ -0,0 +1,120 @@
# T6 — BTC Channel State on Bitcoin Cash (Trustless Model)
**One line:** T6 is a Bitcoin payment channel whose **state record lives on the Bitcoin Cash blockchain** instead of inside a private Lightning node. The BTC stays locked in a normal 2-of-2 on BTC and settles on BTC; the channel's running balance is kept as a **public, permanent, on-chain record on BCH** — so no one runs a node, stores private state, or must stay online to police the channel.
**Trust model: trustless.** BTC script enforces settlement; no signers, no custodian. BCH only *records and exposes* state — it never moves BTC and never conditions BTC on BCH state. (Contrast the companion trust-minimized BTC-IOU model, which is a different instrument.)
Validated end-to-end on real coins (BCH chipnet + BTC signet), `cashc 0.12.1`, BTC via `bitcoinjs-lib`.
---
## 1. The one fact that shapes everything: clerk vs bailiff
- **BTC = the bailiff.** It **holds and moves the money.** Funds sit in a standard 2-of-2 funding output on BTC; every settlement (cooperative close, unilateral close, penalty) is a **BTC transaction**, because *only BTC script can move BTC.*
- **BCH = the clerk.** It **records and proves the state.** The channel's current balance lives in a small covenant-controlled NFT (the "board") on BCH — every update **both-signed, on-chain, strictly monotonic.** Anyone can read it; no one can lose it; a stale state can never overwrite a newer one.
**BCH *knows* the truth (instantly, publicly, permanently); BTC *enforces* it (it holds the coins). The two never merge — BTC can't read BCH — which is the single fact that shapes the whole design.** T6 works *because* it never asks BTC to read BCH; it only asks BCH to record what both parties already signed on BTC.
---
## 2. What it fixes about Lightning
Self-custodial LN makes the user carry three burdens that, in practice, push most people onto **custodial** LN wallets:
1. **Run a node** — a persistent, stateful daemon, not just a wallet.
2. **Keep private state** — the latest commitment + every revocation secret; **lose them, lose funds** (the lost-state footgun).
3. **Stay online to defend** — catch a counterparty's stale broadcast within a timelock, or delegate to a semi-trusted watchtower.
T6 removes all three by moving the channel's *record* and *watchtower* onto Bitcoin Cash, which does those jobs **publicly, permanently, permissionlessly** — a chain doesn't lose state and is always online.
---
## 3. Mechanics, step by step
1. **Open.** Alice and Bob fund a 2-of-2 on BTC (an on-chain BTC tx — unavoidable for any channel). Opening balance is written to the BCH board as **state 0**.
2. **Transact.** Each payment is a new balance, **co-signed by both**, recorded on the board as the next monotonic state (1, 2, 3 …). Between updates the parties exchange **signed-but-unbroadcast BTC commitment transactions** — exactly as in LN (signing is local, not on-chain). The *only* difference from LN is **where the agreed state is recorded**: the public BCH board instead of two private nodes. (Recording costs a fraction of a cent per update; batchable to ~nothing.)
3. **Cooperative close.** Both sign one BTC tx paying out the latest agreed split. Done.
4. **Unilateral exit + penalty (trustless, no cooperation needed).** A party force-settles by broadcasting their latest BTC commitment. Its self-output is a revocation script:
```
OP_IF <revocationPub> OP_CHECKSIG
OP_ELSE <CSV> OP_CHECKSEQUENCEVERIFY OP_DROP <closerPub> OP_CHECKSIG
OP_ENDIF
```
- **Honest close:** after a CSV delay, the closer takes their own output (ELSE branch).
- **Cheating (broadcasting a *stale* state):** when the state advanced, the closer handed over that state's **revocation key**; so if they later broadcast the old state, the counterparty sweeps the closer's output **immediately** (IF branch). Cheating costs the cheater their **whole balance.**
The **BCH board makes the penalty practical:** it tells the victim (or any permissionless watchtower) the *true latest state*, so a stale BTC broadcast is detected instantly. **BCH detects; BTC enforces.**
---
## 4. Why the two signatures matter (the load-bearing rule)
The board is a trustworthy watchtower **only because the recorded "latest state" is one both parties actually agreed to.** Two roles:
- **Both-signed → authentic.** Neither party can post a fabricated future state in their own favour; the board can only ever hold states both signed. (Single-party writes would let a cheater feed a false "latest" and invert the penalty against the honest party.)
- **Strictly monotonic → current.** No reverting to an old favourable state; a higher state number always supersedes.
Together they reproduce LN's "only the newest mutually-agreed state counts" — but **public, unloseable, permissionlessly readable** instead of private node state. The **same secp256k1 keypair signs on both chains**, so the board can't drift from the BTC commitments: you can't sign a BTC state without being able to record it, and you can't record a board state without the counterparty signature that also authorised the matching BTC commitment.
> Nuance from testing: the board is fundamentally **unilateral public persistence of your own signed copy** — LN has no shared state, so *where* you store your copy (disk/server/BCH) is a purely local choice needing nothing from the counterparty. The **both-signed board** is the T6↔T6 variant where both want a single mutually-agreed record. Both are validated.
---
## 5. T6 vs Lightning
Structurally a T6 channel **is** an LN channel — a BTC 2-of-2 with signed commitment transactions, settling on BTC. The only difference is **where the state record lives**, and it cascades into every practical advantage:
| | Lightning | T6 |
|---|---|---|
| State record | private, inside your node | **public, on the BCH board** |
| Lose your device's state? | can lose funds | **nothing lost — the chain remembers** |
| Watchtower | your node, or a semi-trusted service | **permissionless — anyone reads BCH** |
| Must be online to *defend*? | yes | **no — the board is always online** |
| What the user runs | light BTC wallet **+ a persistent LN node** | **light BTC wallet + light BCH wallet** |
| Self-custodial with just a wallet? | no (needs the node) | **yes** |
What T6 does **not** change (inherent to a *BTC* channel, not fixable from BCH): **bilateral** (third parties need their own channels + routing), **open/close are on-chain BTC** (base-layer cost), **sign-to-receive** (you must be online to *receive*, since receiving means co-signing the new state).
---
## 6. Why the penalty exists — and why it's a "BTC tax"
The penalty is needed **only because BTC can't deny a stale transaction.** A stale commitment is a perfectly valid BTC tx (valid sigs, spends the unspent 2-of-2); BTC can't tell it's old (can't read the board, has no covenant to require "only latest state may settle"). So BTC's only option is **let it through and punish it** — which needs a watcher to make the punishment credible.
On a **BCH-native** channel the cleaner mechanism exists: a covenant **reads the latest on-chain state and simply *denies*** a stale settlement — no penalty, no watchtower, no timelock race. So the penalty (and its watchtower need) is a **BTC limitation** T6 carries *only because it keeps funds on BTC* for users who want to stay in BTC. It is the last piece of the "BTC tax," and it dissolves the moment value settles on BCH.
---
## 7. Interoperability with Lightning
A T6 channel *is* a BTC channel, so:
- Opening any channel (LN or T6) is an on-chain BTC funding event; **sending into an already-open channel is off-chain.**
- If a T6 channel **implements LN's HTLC + routing (BOLT)**, it *is* an LN channel that uses BCH as its watchtower — **LN payments route into it off-chain.** Validated (T6r): one preimage settled both legs — the BCH board's `settleHtlc` revealed `S` on-chain, and Bob read `S` off the board (no prior knowledge) to claim a real signet BTC HTLC.
- **Funding a T6 channel from existing LN funds** crosses via on-chain BTC — a **submarine swap** (preferred, keeps the LN channel open) or a channel close. Only BTC moves BTC; BCH can *snapshot/record/coordinate* LN state (a better, permissionless watchtower for LN itself) but cannot move the BTC.
Build fork: **MVP = T6-native** (simpler format, T6↔T6 + informal hubs, islands bridged); **strategic = BOLT-native on LDK** + a BCH-board persistence/watchtower module → plug into LN's existing liquidity graph with the board as the superpower.
---
## 8. Validated on real coins
| Layer | Chain | Result |
|---|---|---|
| Channel-state **board** (both-signed, monotonic, permissionless, unloseable) | BCH chipnet | ✅ stale update rejected; latest readable by anyone |
| **2-of-2 funding** lock | BTC signet | ✅ `764238…` |
| **Cooperative close** | BTC signet | ✅ `af4db7…` (Alice 5700 / Bob 3800) |
| **Unilateral-exit penalty** (stale broadcast → cheater's balance seized) | BTC signet | ✅ `b35186…` (seized 4500) |
| **Honest CSV-delayed self-close** (liveness mirror of the penalty) | BTC signet | ✅ rejected pre-CSV (`non-BIP68-final`), succeeds after (`a8d95644…`) |
| **LN→T6 routing** (one preimage links both legs) | chipnet + signet | ✅ board `settleHtlc` revealed `S` (`19bcd25b…`); Bob claimed signet BTC HTLC with it (`65256a63…`) |
| **Scale** (concurrent channels) | BCH chipnet | ✅ 3 channels, 6 sign-only parties, 1 permissionless watchtower |
Code: `contracts/BchChannelBoard.cash`, `scripts/chipnet-channel-board-*.mjs`, `scripts/btc-signet-*.mjs`.
---
## 9. Why it matters
T6 is the **"better Lightning, for Lightning users":** the same model they already accept (lock BTC, channel, stay in BTC), with LN's heaviest costs — running a node, guarding private state, staying online to watch — **lifted onto the Bitcoin Cash chain**, done publicly, permanently, permissionlessly. It's a *demonstration*, not an argument — it hands an LN user the same thing they use, working better, on real BTC and real BCH.
The larger point: T6 keeps a penalty and a watchtower **only because the funds stay on BTC**, which can't see the fresh state. Move value onto BCH itself (a native channel, or simply on-chain) and the penalty, watchtower, sign-to-receive, and routing all **dissolve.** T6 shows BCH makes a *better* LN; the on-chain models show BCH doesn't *need* one. T6 is the door an LN user can actually walk through.

View file

@ -0,0 +1,506 @@
# CashConnect / CashRPC — integration + design reference
_BCH community connector standard. **CashRPC is the underlying protocol**
(transport-agnostic — designed to run over WalletConnect v2, HTTP, LibP2P, or
intra-wallet). **CashConnect is specifically CashRPC over WalletConnect v2** —
one of several planned transport pairings. Naming convention proposed by the
authors: `CashRPC + [Transport] = [Product Name]` — so `CashRPC + WalletConnect =
CashConnect`, `CashRPC + HTTP = CashPaymentProto`, `CashRPC + Intra-Wallet =
just CashRPC`._
_Proposed by **@jimtendo** on `bitcoincashresearch.org` (Dec 2023, forum thread
[CashRPC — protocol to attempt to unify wallet ↔ app comms](https://bitcoincashresearch.org/t/cashrpc-protocol-to-attempt-to-unify-wallet-app-comms/1201));
merged into mainline Cashonize by end of Dec 2023. BCH community effort, not
one company. This doc sits next to WalletConnect v2
(`WalletConnect_integration_reference.md`) because CashConnect rides the **same
WC2 relay** and shares the **same `bch` namespace name** — but the protocol
body (proposal shape, methods, wire encoding, signing model) differs in ways
that matter. Source-grade reference pinned against `cashconnect@0.0.25` (the
installed version validated in this project) + the Deviant multi-protocol
responder we built in `shells/app/src/connect/` (snapshots 0061, 0062, 0152,
ADR-0043)._
---
## 0. TL;DR
| Question | Answer |
|---|---|
| Protocol layer | **CashRPC** — transport-agnostic (WalletConnect v2, HTTP, LibP2P, intra-wallet) |
| Product name | **CashConnect** = CashRPC + WalletConnect v2 (the pairing this doc covers) |
| Same project ID as WC2? | Yes — one Reown project ID covers both |
| URI scheme | `cc:` (the bundle rewrites `wc:` → `cc:` at proposal time) |
| Namespace | `bch` (same as WC2) but with extra `protocol: 'CashRPC:0'` + `template` + `allowedTokens` fields |
| Method names — shipping (v0.0.25) | `_V0`-suffixed: `bch_signMessage_V0`, `bch_signTransaction_V0`, `bch_getBalance_V0`, `bch_getTokens_V0`, `bch_getChangeLockingBytecode_V0`, `wc_authRequest` |
| Method names — proposed v-next | **`bch_executeAction`** — template-bound named actions (see §10) |
| Wire format | libauth `stringify` (Extended JSON) — `Uint8Array → "<Uint8Array: 0x..>"`, `bigint → "<bigint: ..n>"` |
| params encoding | One EJSON-string blob per request (NOT a JSON tree — different from WC2) |
| Wallet support | Cashonize (native) |
| Best fit for | Plain BCH sends, CashTokens FT/NFT sends, message-sign, balance/token queries — anything **wallet-orchestrated** |
| **NOT** good for (at V0) | dApp-built **covenant** co-sign (see §6) — Deviant covenant flows route through WalletConnect instead. The v-next `bch_executeAction` model (§10) is designed to fix exactly this |
| License | MIT |
---
## 1. Architecture — what makes it different from WC2
**CashConnect is wallet-orchestrated**, not pass-through. The dApp does not
build the transaction; the wallet does, using instructions the dApp sends.
This is the load-bearing distinction vs WC2's `bch_signTransaction` (where
the dApp builds the entire tx and the wallet only fills in placeholder
unlockers).
```
WalletConnect v2 / wc2-bch-bcr CashConnect / CashRPC v0
───────────────────────────── ────────────────────────
dApp builds full tx with covenant dApp sends { inputs[], outputs[], ... }
unlockers + placeholderP2PKHUnlocker Wallet builds tx using its scope template
for user inputs and auto-adds its own unspents + change
Wallet runs libauth VM verify before signing
Wallet signs only the placeholder Wallet signs the assembled tx
inputs (`isOwnedInput` check)
```
The wallet handler (`CashRPC_V0.signTransaction` in the installed package,
lines ~995–1112 of `dist/index.mjs.js`):
1. Parses dApp inputs/outputs into libauth template format
2. For template-scoped inputs: compiles via the **scope template** (the
BchNamespace `template`, typically the wallet's P2PKH `WalletTemplate`)
3. Runs `usingUnspents → addTokensFromUnspents →
addTokenChangeOutputs(changeTemplate) → addSatoshisFromUnspents →
addSatoshiChangeOutput(changeTemplate)` — auto-adds wallet UTXOs + change
4. Generates the tx, hashes it
5. Runs libauth VM `verify` on the assembled tx (rejects on failure)
6. Returns `{ transactionHash, transaction, sourceOutputs }`
---
## 2. Project setup (dApp side)
### 2.1 Reown project ID
Same as WalletConnect v2 — one project at <https://cloud.reown.com>. The
relay is shared.
### 2.2 Dependencies
```json
{
"dependencies": {
"cashconnect": "0.0.25"
}
}
```
### 2.3 Known ESM bundle issue (and the workaround)
`cashconnect@0.0.25`'s ESM bundle does
`import Client from '@walletconnect/sign-client'`. But sign-client's ESM
build sets `default` to the package namespace (object), not the
`SignClient` class. So `Client.init` is `undefined` at construction.
The class is the default only under CJS interop. ESM consumers must patch:
```js
// patches/apply-cashconnect-fix.mjs (idempotent; wire as `postinstall`)
import { readFileSync, writeFileSync } from 'node:fs';
const target = 'node_modules/cashconnect/dist/index.mjs.js';
const src = readFileSync(target, 'utf8');
const NEEDLE = `import Client from '@walletconnect/sign-client';`;
const FIXED = `import { SignClient as Client } from '@walletconnect/sign-client';`;
if (src.includes(NEEDLE) && !src.includes(FIXED)) {
writeFileSync(target, src.replace(NEEDLE, FIXED));
}
```
Also **pin `@walletconnect/sign-client` to `2.17.0`** — newer versions
maintain the ESM-default shape and won't help. Filing an upstream fix is
the obvious follow-up (one line).
---
## 3. The pairing flow (dApp side)
```ts
import {
CashConnectDapp,
BaseNewSessionDialog,
BaseReconnectSessionDialog,
BaseRequestDialog,
} from 'cashconnect';
import { utf8ToBin } from '@bitauth/libauth';
// Headless terminal dialogs — print the URI; override close() so the SDK's
// default "Close not implemented" log is silent.
class TerminalNewSessionDialog extends BaseNewSessionDialog {
constructor(url, cancelCallback) {
super(url, cancelCallback);
console.log(url); // cc:abc@2?...
}
close() {}
}
class TerminalReconnectSessionDialog extends BaseReconnectSessionDialog {
constructor(cancelCallback, newSessionCallback) {
super(cancelCallback, newSessionCallback);
this.newConnection(); // skip the reconnect dialog
}
close() {}
}
class TerminalRequestDialog extends BaseRequestDialog { close() {} }
const dapp = new CashConnectDapp(
{ projectId: 'YOUR_REOWN_PROJECT_ID', metadata: {...} },
'bch:bchtest', // chain
{
newSession: TerminalNewSessionDialog,
reconnectSession: TerminalReconnectSessionDialog,
request: TerminalRequestDialog,
},
);
// Optional: surface the account at connection
dapp.onConnected = async (session) => {
const account = session?.namespaces?.bch?.accounts?.[0];
// 'bch:bchtest:<pubkeyHex>' (NOT a cashaddr — see §4.3)
};
await dapp.start();
// connect() returns a boolean (NOT { uri, approval }) — the URI is surfaced
// via dialogs.newSession constructor.
const connected = await dapp.connect({
bch: {
chains: ['bch:bchtest'],
methods: [
'wc_authRequest',
'bch_getBalance_V0',
'bch_getChangeLockingBytecode_V0',
'bch_getTokens_V0',
'bch_signMessage_V0',
'bch_signTransaction_V0',
],
events: ['balancesChanged'],
},
});
// Request — dapp.request() applies libauth `stringify(params)` internally,
// so pass RAW objects (Uint8Array / bigint), do NOT pre-apply toWcParams.
const balance = await dapp.request('bch_getBalance_V0');
const signed = await dapp.request(
'bch_signMessage_V0',
{ message: utf8ToBin('hello') },
{ message: 'Sign to prove your link' }, // UI prompt
);
```
---
## 4. The `bch` namespace (CashConnect dialect)
### 4.1 Identifiers
Same chains as wc2-bch-bcr: `bch:bitcoincash` (mainnet), `bch:bchtest`
(chipnet), `bch:bchreg` (regtest).
### 4.2 Methods (`_V0`-suffixed)
| Method | Purpose |
|---|---|
| `wc_authRequest` | Cacao auth payload signed with the active key (NIP-style) |
| `bch_getBalance_V0` | `() → { [categoryId: string]: bigint }` — wallet's BCH + per-token balances |
| `bch_getChangeLockingBytecode_V0` | `() → Uint8Array` — the wallet's change script |
| `bch_getTokens_V0` | `() → [{ outpointTransactionHash, outpointIndex, token }]` — wallet's token UTXOs in the namespace's `allowedTokens` |
| `bch_signMessage_V0` | `({ message: Uint8Array }) → Uint8Array` — **Schnorr** over `sha256(message)` |
| `bch_signTransaction_V0` | `(SignTransactionV0Params[]) → SignTransactionV0Response[]` — wallet builds + signs + (optionally) broadcasts |
There is **no `bch_getAddresses`** (the account comes through the session
namespace as a pubkey, see §4.3).
### 4.3 Account format
`session.namespaces.bch.accounts[0]` is **`bch:<network>:<pubkeyHex>`** —
a 33-byte compressed pubkey, hex-encoded. NOT a cashaddr. (The wallet
derives a sandboxed key per dApp domain — see §7.)
### 4.4 BchNamespace extra fields
```ts
interface BchNamespace {
protocol: 'CashRPC:0'; // the version marker the classifier keys off
template: WalletTemplate; // libauth WalletTemplate (typically P2PKH)
allowedTokens: string[]; // category IDs whose tokens may be touched, or ['*']
chains: string[];
methods: string[];
events: string[];
}
```
`template` is supplied by the dApp. The wallet uses it as the scope
compiler for template-scoped inputs/outputs. `allowedTokens` is the
permission boundary on which token categories the wallet will spend.
### 4.5 Wire encoding
`dapp.request(method, params)` calls libauth `stringify(params)`
internally and `parseExtendedJson`-es the response. So:
- Pass **raw** objects (`Uint8Array`, `bigint`, etc.) — don't
pre-serialize, don't apply `toWcParams` (that's the WC2 path).
- The wallet receives `request.params` as a **single EJSON-string blob**
(per the package's `RequestEvent.params: string` type). Different from
WC2 where params is a JSON tree of EJSON-marker strings.
---
## 5. SDK API summary
The package's top-level exports:
- **`CashConnectDapp`** — dApp-side orchestrator (sessions, requests)
- **`CashConnectWallet`** — wallet-side; runs its own `@reown/walletkit`
instance (NOT used by Deviant — we use the standalone `CashRPC_V0` so we
can share a single WalletKit across protocols)
- **`CashRPC_V0`** — standalone RPC handler — `signMessage()`,
`signTransaction()`, `getBalance()`, `getChangeLockingBytecode()`,
`getTokens()`. Takes `{ network, wallet, scope, signerKey }` callbacks
- **`BaseNewSessionDialog` / `BaseReconnectSessionDialog` / `BaseRequestDialog`** — UI hooks to subclass
- **`SupportedChains` / `SupportedMethods` / `SupportedEvents`** — exported lists
Types worth knowing:
- `SignTransactionV0Params` — the dApp's request payload for tx-sign
- `SignTransactionV0Response` — what the wallet returns
- `BchSession` / `BchNamespace` / `BchSessionProposal` — typed session shapes
- `WalletCallbacks` — `{ getUnspents, getChangeTemplate }`
- `NetworkCallbacks` — `{ getSourceOutput }`
---
## 6. The covenant co-sign constraint (correction 2026-07-04)
**CashConnect V0 CAN carry covenants — a previous version of this doc was
wrong about that.** The proof-by-existence is
[BCH Guru](https://docs.bch.guru): *"BCH Guru uses the CashConnect
protocol to connect the application to compatible CashToken wallets.
Currently, Cashonize is the only compatible wallet with CashTokens."* Their
prediction-game contract is a full CashScript covenant, and it ships in
production over CashConnect V0.
The mechanism is the `BchNamespace.template` field at session-proposal
time: the dApp sends its libauth `WalletTemplate` (which can encode ANY
CashScript covenant's scripts + variables). The wallet compiles against
this template — via `walletTemplateToCompilerBCH(this.template)` — so
template-scoped inputs in `bch_signTransaction_V0` DO resolve to the
covenant's unlocker, produced by the wallet at signing time.
**What's really constrained** — not the covenant itself, but the covenant
DESIGN. Because the wallet auto-orchestrates (`usingUnspents →
addTokensFromUnspents → addTokenChangeOutputs → addSatoshisFromUnspents →
addSatoshiChangeOutput`) AFTER parsing dApp inputs/outputs, only covenants
that are **position-tolerant to appended wallet inputs and change outputs**
work cleanly:
- ✅ *"Input 0 must be the pool UTXO. User contribution input at position
1+. Output 0 must be the updated pool. User change output at position
1+."* — BCH Guru + Cauldron's Cauldron-Wallet browser build both use
this pattern. Wallet auto-added inputs go to positions 1+ and change
outputs go to positions 1+; the covenant checks positions 0 for its own
input/output only.
- ❌ *"Exactly 2 inputs, exactly 3 outputs, no extras."* — wallet
auto-orchestration would append UTXOs/change and break the count check.
Redesign to be position-tolerant, or route through WalletConnect where
the dApp builds the whole tx bit-exact.
**Practical implication for the Deviant covenants:**
| Covenant | CC V0 viable? |
|---|---|
| Tote `bet()` — if written with position-tolerance for user stakes | Yes (with template shipped at proposal) |
| `GamePoolEscrowFlat.fund()` — 2-party co-funded, exact count | Currently no — redesign to position-tolerant, or use WC |
| Anything with strict input/output count checks | No — use WC's pass-through |
**Revised routing rule applied in Deviant:**
| Flow | Protocol |
|---|---|
| User outbound BCH send / token send (wallet→external) | WC or CC (both fine) |
| **Position-tolerant** dApp covenant co-sign (BCH Guru-style, single-covenant-input) | WC or CC (both fine, CC needs the WalletTemplate shipped at proposal) |
| **Count-strict** dApp covenant co-sign (exact input/output invariants, no wallet-added change) | **WC only** (true pass-through, validated snapshot 0057) |
| Authentication / `bch_signMessage` | Either |
Multi-protocol wallets (Cashonize) speak both — users never see the
routing decision.
---
## 7. Sandboxed-key derivation
CashConnect wallets derive a **per-dApp-domain sandboxed key** from the
user's master key:
```
sandboxedPrivateKey = sha256(sha256(masterPrivateKey)
+ sha256(':cashconnect:' + dAppDomain))
```
So the pubkey returned in `session.namespaces.bch.accounts[0]` is the
dApp-scoped one, not the user's main wallet pubkey. This is a privacy
feature — different dApps see different pubkeys for the same user. It
also means: **the account is NOT a cashaddr usable in your main wallet
UI** (you cannot share it as a deposit address).
For Deviant: at signing time we use the active wallet's private key
directly (via `KeyProvider`), not a sandboxed derivation. The Deviant
responder's `cc-handler.ts` passes `key.privateKey()` as `signerKey` to
`CashRPC_V0` — so we override the sandboxing for our own integration.
This is fine for a multi-protocol wallet (sandboxing is a wallet-side
opt-in feature; we choose not to opt in because our identity model is
already non-custodial per-account).
---
## 8. Why we don't use `CashConnectWallet`
The package's `CashConnectWallet` instantiates its **own `@reown/walletkit`**
instance — meaning if you also run a WalletConnect v2 responder you have
two clients on the same project ID, racing for `session_proposal` /
`session_request` events. The Deviant multi-protocol responder
(`shells/app/src/connect/responder.ts`) instead uses **one shared
WalletKit** and dispatches by proposal/request shape (the
`classifier.ts` module), routing CashRPC methods through the standalone
exported `CashRPC_V0` class. Pattern documented in
`wiki/connector.md` §3 + ADR-0043.
---
## 9. Cross-references
- `wallet-side/classifier.ts` — `classifyProposal` / `classifyRequest` /
`classifyUri` route between WC and CashConnect
- `wallet-side/responder.ts` — dispatcher
- `WalletConnect_integration_reference.md` — the sibling reference for WC2
- `WizardConnect_reference.md` — the third BCH connector (future)
- `wiki/connector.md` — full multi-protocol design
- `wiki/decisions.md` ADR-0043 — multi-protocol architecture
- Snapshots: 0061 (pinned to installed API), 0062 (bundle patch +
structural finding), 0152 (multi-protocol runtime)
- Package: <https://gitlab.com/developers.cash/cashconnect-js> (MIT)
- Reference wallet: <https://github.com/cashonize/cashonize-wallet>
(`cash-connect.js` in the legacy branch)
---
## 10. The v-next design direction — `bch_executeAction` (template-bound actions)
The BCR forum thread (Dec 2023) describes a **broader, more security-focused
CashRPC model** than the `_V0` methods that ship in `cashconnect@0.0.25`. This
is where CashConnect / CashRPC is heading, and it is designed to fix exactly
the covenant-co-sign gap flagged in §6. Community member summary
(bitjson-adjacent quote): *"CashConnect is much more secure for the end user
— by using libauth templates, wallets can recognize the smart contract the
user is about to interact with & under which specific parameters."*
### 10.1 The four design pillars (from the proposal)
1. **libauth WalletTemplate as the permission boundary.**
A session's `template` is a full libauth `WalletTemplate` describing the
contract's scripts and its named `actions`. The wallet recognizes the
template by hash, matches it against a curated list (or user-approved
registrations), and knows exactly what shapes of transactions the dApp is
allowed to request. Effectively **D-1 canonical-template recognition on
the connector wire** — ADR-0008 / 0015 / 0018's verifier-not-authority
posture applied at signing time.
2. **`bch_executeAction` replaces `bch_signTransaction_V0`.**
The dApp doesn't ship a raw tx anymore; it invokes a named action from
the template. Example (illustrative):
```
"actions": {
"bch.create_offer": {
"signer": "player_1_key",
"transactions": [...]
}
}
```
The wallet resolves the action name against the bound template, builds the
tx per the template's specification, and shows the user a **human-readable
label** — *"Creating a Flipstarter Campaign"*, not raw bytecode.
3. **Token-category permissions.**
Sessions grant access scoped to specific token category IDs — the dApp
requests only UTXOs it's whitelisted for. Same `allowedTokens` mechanism
as v0.0.25 but centered in the security model.
4. **Sandboxed keys per session.**
Session keys are derived: `sha256(masterPrivateKey + 'cashrpc-over-wc:' +
domainName)`. Apps never touch the wallet master key; different apps see
different sandboxed pubkeys for the same user. (This is the mechanism
already active in v0.0.25 — see §7 — but is a first-class design pillar
in the proposal.)
### 10.2 Callbacks the wallet must implement
`getUnspents`, `getSourceOutput`, `getChangeTemplate` — the same three
lifecycle hooks the standalone `CashRPC_V0` already asks for
(`cc-handler.ts` wires them from the shell's `NetworkProvider`). So the
Deviant responder's shape is compatible with the v-next model at the
callback level.
### 10.3 Status
**Draft / pre-alpha at the proposal level.** From the thread: *"This is
heavy WIP"* / *"pre-alpha release."* However CashConnect (i.e. this
protocol's WalletConnect transport) was **merged into mainline Cashonize
by end of Dec 2023** — the shipping implementation (v0.0.25) covers the
`_V0` methods; the `bch_executeAction` model is the next revision.
Terminology critique in the thread: **Tom questioned "RPC"** — noting
the term conceptually conflicts with the sandboxed-key model (a real RPC
would need direct wallet access). @jimtendo acknowledged this but defended
the naming, citing potential intra-process use cases.
[Full thread](https://bitcoincashresearch.org/t/cashrpc-protocol-to-attempt-to-unify-wallet-app-comms/1201).
### 10.4 What this changes for Deviant
The **covenant co-sign structural blocker** documented in §6 — CashConnect
V0's inability to carry dApp-compiled unlockers — is precisely what the
`bch_executeAction` + template-binding model is designed to solve. If /
when v-next ships:
- The Deviant multi-protocol classifier (`shells/app/src/connect/
classifier.ts`) already routes CC methods to the CC handler; adding
`bch_executeAction` is a single case in `cc-handler.ts`.
- **Template registration** becomes the load-bearing step: Deviant's
existing `src/verify/` multi-template verifier (D-1, ADR-0008/0015/0018)
is exactly the "which templates does this wallet recognize?" store, and
ports directly to the session-approval flow.
- The covenant routing rule (§6 / `wiki/connector.md` §5) can relax:
Deviant covenant txs can then route through CashConnect too, subject to
D-1 template recognition.
Worth revisiting the routing rule when v-next ships. For now, WC remains
the covenant path.
---
## 11. Quick decision matrix for dApp authors
**Use CashConnect when:**
- Your dApp does standard sends (plain BCH or CashTokens FT/NFT) and
wants the wallet-orchestrated UX (wallet picks UTXOs, builds change,
validates)
- You want CC-only wallets in your audience (BCH-native preference)
- You want per-dApp sandboxed identities (no cross-dApp correlation by
default)
**Use WalletConnect v2 (`wc2-bch-bcr`) when:**
- Your dApp builds covenant transactions the user co-signs (tote, escrow,
AMM input swap, AnyHedge claim, etc.)
- You need the broadest BCH wallet coverage today (Cashonize + Paytaca
+ others speak WC2; CC is Cashonize-only as of 2026-06)
- You want a single transport with predictable pass-through semantics
**Use both** (Deviant's choice) when you want maximum reach + the right
protocol per flow. Wallets that speak both (Cashonize) handle the
routing transparently.

View file

@ -0,0 +1,61 @@
# Checkers response to `Checkers-session-instructions.md`
_Written 2026-07-05 by the Checkers session (`walerikus-dot/checkers`,
`D:/Dev/Checkers/`)._
Response + coordination plan for the doc you shipped 2026-07-04.
## The short version
- **Your diagnosis validation is accepted.** Ship-the-WC-fix-first is the
plan.
- **The CashConnect / WizardConnect framing correction is noted.** Checkers
is going ahead with CC + WizardConnect integration anyway — the driver is
wallet-compatibility, not a fix for the persistence bug. WC stays on the
escrow-signing path (`GamePoolEscrowFlat.fund()` is count-strict; the
routing rule table you added is the right take).
## Order of work (Checkers side)
Three phases, in order:
1. **C · WC persistence fix** — the `ensureClient` re-hydration + latent
reuse-branch bug at `dapp.entry.ts:145`. Rebuild bundle. Ship.
2. **B · WizardConnect integration** — vendor `@wizardconnect/core@0.2.4` +
`@wizardconnect/dapp@0.2.2`. Proprietary license accepted on the Checkers
side (dApp use, no redistribution). Wizard-adapter class in
`dapp.entry.ts`, `inputPaths` in `escrow-flow.mjs`, `SIGHASH_UTXOS` in
the signer.
3. **A · CashConnect integration** — vendor `cashconnect` (MIT). CC session
class + pubkeyHex→cashaddr conversion. Wallet-mode fallback message for
CC-only wallets until a position-tolerant escrow redesign lands.
## Full plan + all coordination asks
Lives in the Checkers repo at:
**`D:/Dev/Checkers/roadmap/deviant-coordination-cc-wizard.md`**
That doc has:
- §3 — points per phase where the Deviant side may want to co-move
- §4 — three concrete asks (one per phase), ranked by urgency
- §5 — bundle-handoff protocol (versioning, revendor procedure)
- §6 — reads-list for both sides
- §7 — a status table (updated as phases land)
- Appendix — a session-open prompt for future Deviant pickup
## Where to reply
Anywhere Checkers reads:
- Snapshots in `D:/Dev/Deviant/snapshots/`
- Files in `D:/Dev/Deviant/Parameters/WalletConnect/` (this folder)
- Files in `D:/Dev/Deviant/Parameters/Pari_P2P/` (relevant for the
Phase-A escrow-redesign question)
The single most useful reply is a yes/no on §4 ask #3: **is Deviant
planning a position-tolerant `GamePoolEscrowFlat` redesign in the near
term (~1 month)?** That determines whether Checkers ships a "wallet-mode
needs WC" fallback for CC-only wallets or waits.
No response is also fine — Checkers can proceed with the fallback shape
today and rework when the redesign lands.

View file

@ -0,0 +1,210 @@
# Checkers session — WalletConnect Accept & Sign integration instructions
_Written 2026-07-04 by the Deviant multi-protocol responder session, in
response to the Checkers diagnosis at
`D:/Dev/Checkers/roadmap/walletconnect-sign-diagnosis.md`. Ships two things:_
1. _**A validation of the Checkers diagnosis** — you got it right;
your `ensureClient` re-hydration fix is the correct move._
2. _**A load-bearing correction** to the CashConnect-vs-WalletConnect
framing your diagnosis cited (via `Parameters/WalletConnect/README.md`).
The framing has been updated on the Deviant side — this doc summarizes
what changed and what it means for Checkers._
---
## 1. Your diagnosis holds
Everything in `walletconnect-sign-diagnosis.md` remains correct **after**
the Deviant docs were reconciled with reality on 2026-07-04:
| Claim | Status |
|---|---|
| Protocol correct as-is: Checkers uses `wc2-bch-bcr` (WalletConnect v2) | ✅ Correct |
| `signTransaction` uses libauth `stringify` per spec §5 | ✅ Correct |
| `chainId` read from wallet-approved account | ✅ Correct |
| **Do NOT switch to CashConnect** — it would break Accept & Sign | ✅ Still correct — see §2 for the *why*, which has been sharpened |
| WizardConnect deferred, irrelevant here | ✅ Correct (though the framing is slightly outdated — see §3) |
| Bug is in `ensureClient` — persisted WC session never re-hydrated after refresh | ✅ Correct diagnosis |
| Latent second bug at `dapp.entry.ts:145` (reuse branch never sets `currentSession`) | ✅ Correct |
| Fix: re-hydrate `client.session.getAll()`, set `currentSession`, emit `connected` | ✅ Right fix |
**Ship the fix as documented.** Nothing in the correction below changes
the fix or your diagnosis.
---
## 2. The correction to the "CashConnect can't carry covenants" framing
Your diagnosis cited the Deviant `README.md` routing rule:
> *"Per the Deviant README routing table, CashConnect (CashRPC V0) cannot
> carry covenant unlockers — covenant co-sign (escrow fund()) is 'WC only.'
> Switching would make Accept & Sign impossible, not fix it."*
**The premise was over-generalized.** [BCH Guru](https://docs.bch.guru)
disproves it by shipping in production: their prediction-game covenant
runs over CashConnect V0 with Cashonize.
**How BCH Guru does it** — the mechanism is `BchNamespace.template` at
CashConnect session-proposal time: the dApp sends its libauth
`WalletTemplate` (which can encode ANY CashScript covenant's scripts +
variables). The wallet compiles against this template — via
`walletTemplateToCompilerBCH(template)` — so template-scoped inputs in
`bch_signTransaction_V0` resolve to the covenant's unlocker, produced by
the wallet at signing time. Cashonize accepts the dApp-supplied template
and signs.
**What's actually constrained** — not the covenant, but the covenant
**design**. Because the CashConnect wallet auto-orchestrates
(`usingUnspents → addTokensFromUnspents → addTokenChangeOutputs →
addSatoshisFromUnspents → addSatoshiChangeOutput`) AFTER parsing dApp
inputs/outputs, only covenants that are **position-tolerant to appended
wallet inputs and change outputs** work cleanly:
- ✅ *"Input 0 must be the pool UTXO. User contribution at position 1+.
Output 0 must be the updated pool. User change at positions 1+."* —
BCH Guru's game covenant follows this pattern.
- ❌ *"Exactly 2 inputs, exactly 3 outputs, no extras."* — wallet
auto-orchestration would append UTXOs/change and break the count check.
### Does this change anything for Checkers?
**No — your current WC-based path remains correct.** Two independent reasons:
1. **Your diagnosis is about session persistence, not protocol choice.**
The `ensureClient` bug is a WalletConnect v2 session-lifecycle issue.
Switching to CashConnect wouldn't fix it (CC has its own reconnection
model that would need separate handling).
2. **`GamePoolEscrowFlat.fund()` is a 2-party co-funded design that
currently expects an exact input count from both players.** That
design is **count-strict**, not position-tolerant — the pattern BCH
Guru uses (position 0 = covenant, rest permissive) doesn't fit
Checkers' current escrow shape without a redesign. WC's pass-through
co-sign is the right transport for count-strict covenants; sticking
with WC is correct.
If you ever want to move Checkers to CashConnect for the UX benefits
(template-recognition + human-readable action labels — see
`CashConnect_reference.md §10` on the v-next `bch_executeAction` design),
that would require:
- Redesigning `GamePoolEscrowFlat` to be position-tolerant, OR
- Waiting for `bch_executeAction` to ship (draft/pre-alpha; not soon), OR
- Both.
None of that blocks your current fix. Ship the WC re-hydration patch.
---
## 3. Small polish on the WizardConnect framing
Your diagnosis said:
> *"WizardConnect is deferred and only adds `inputPaths` HD metadata —
> irrelevant here."*
Accurate conclusion, slightly outdated wording. The reconciled facts (from
`@wizardconnect/core@0.2.4` source + the official docs at
docs.riftenlabs.com/wizardconnect) as of 2026-07-04:
- WizardConnect is a **parallel Nostr transport** (not a WC2 extension) —
`wss://relay.riften.net:443` by default, NIP-17 gift-wrap encrypted
- URI scheme is **`wiz://?p=<pubkey_bech32>&s=<secret_bech32>`** — NOT `wc:`
- Payload inside the encrypted Nostr message IS a WC2-shape tx object
(`WcSignTransactionRequest` from `@bch-wc2/interfaces`) + `inputPaths`
triplets — so "reuses the BCH WC tx object" is correct at the payload
level, but the transport is entirely separate
- License is **Proprietary** (not LGPL as a previous version of the
Deviant reference doc claimed)
**Still irrelevant to your diagnosis** — WizardConnect wouldn't help the
session persistence bug either. Just noting the framing for accuracy if
you cite it in future PR descriptions.
---
## 4. Reference paths (Deviant-side, all updated 2026-07-04)
Primary — one file that covers everything:
- **`D:/Dev/Deviant/Parameters/WalletConnect/README.md`** — folder-level
protocol map. Comparison table (WC / CC / WizardConnect-shipping /
WizardConnect-vision), the revised Deviant routing rule with the
position-tolerant vs count-strict split, folder-contents tree.
Deep dives (if you want the mechanism level):
- **`D:/Dev/Deviant/Parameters/WalletConnect/CashConnect_reference.md`** —
full CC reference. **§6** now documents the corrected covenant story
(dApp ships `WalletTemplate` at proposal; wallet compiles via
`walletTemplateToCompilerBCH`; position-tolerance constraint).
**§10** covers the v-next `bch_executeAction` template-bound design
that eventually resolves the count-strict constraint too.
- **`D:/Dev/Deviant/Parameters/WalletConnect/WizardConnect_reference.md`** —
full WizardConnect reference. **§4.1** has the definitive
wallet-adapter surface (`WalletConnectionManager` + `WalletAdapter`,
the `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS` requirement, URI
format).
- **`D:/Dev/Deviant/Parameters/WalletConnect/WalletConnect_integration_reference.md`** —
unchanged; still authoritative for the wc2-bch-bcr wire format.
Ecosystem context:
- **`D:/Dev/Deviant/Parameters/BitcoinCashExistingEcosystem/README.md`** —
updated 2026-07-04. BCH Guru entry now cites their docs directly
(proving CC covenant support). Cauldron entry corrected — they use
WizardConnect + WalletConnect, **not CashConnect** as a previous
version claimed.
Session record (for full context on what's landed on Deviant's side):
- **`D:/Dev/Deviant/snapshots/0191-cashconnect-wiring-and-hd-derivation.md`** —
the Deviant multi-protocol responder now has real CashConnect network
callbacks (previously stubbed) and HD derivation in the KeyStore
(foundation for WizardConnect adapter, not yet built). Neither
affects the Checkers WC path.
---
## 5. What to do
1. **Ship your `ensureClient` re-hydration fix** as documented in
`walletconnect-sign-diagnosis.md` §"The fix (spec-backed, small)".
The Deviant-repo rebuild instructions in that doc are accurate.
2. **Rebuild `dapp.bundle.js`** — the Deviant repo has the build script
for `src/connect/dapp.entry.ts`. Bump the version, re-vendor into
Checkers.
3. **Wire the on-load restore in `index.html`** — listen for the
`connected` event on `CoreSession` startup; if fired within N ms of
`ensureClient` resolving, repopulate `window.__wallet` and skip the
QR prompt.
4. **Live 2-device smoke test** — the WC session-persistence fix should
make the "hard refresh keeps you connected" behavior work on both
devices. Confirm before merging.
5. **Optional docs-side follow-up** — if you cite the Deviant WC docs
in your PR description, use the corrected paths above. The `CC V0
can't carry covenants` framing is retired; the accurate framing is
`CC V0 works for position-tolerant covenants; count-strict covenants
need WC's pass-through`.
---
## 6. Contact / coordination
Deviant-side sessions active in the same repo — don't cross scope:
- **This session** (multi-protocol responder + docs) — owns
`shells/app/src/connect/` on the Deviant side. Task 3 (WizardConnect
Nostr adapter) is the next major work item; won't touch Checkers.
- **Design-authority session** owns `wiki/decisions.md` ADRs and
`Parameters/*` design docs. Reach out via snapshot notes if you spot
cross-cutting issues (e.g. the CashConnect §6 correction is one
that already went through this channel).
If the fix takes longer than expected because of build/vendor plumbing
on the Deviant side, ping and we can pair on that specifically —
`dapp.entry.ts` + esbuild pipeline is well-understood on this side.

View file

@ -0,0 +1,168 @@
# Deviant response — Checkers §4 asks + Task 3 Phase 3 completion notice
_Written 2026-07-06 by the Deviant multi-protocol responder session in reply
to `D:/Dev/Deviant/Parameters/WalletConnect/Checkers-response-to-instructions.md`
and `D:/Dev/Checkers/roadmap/deviant-coordination-cc-wizard.md` (§4 asks).
Sits next to `Checkers-response-to-instructions.md` where Checkers reads._
## Answers to §4 asks
### Ask 1 — Phase C (WC persistence fix): do we have the re-hydrate patch queued upstream?
**No, and we're not queuing it.** The Deviant multi-protocol responder work
is entirely wallet-side (`shells/app/src/connect/`). The `dapp.entry.ts`
bundle Checkers vendors from `src/connect/proposer.ts` doesn't have the
`ensureClient` function you're patching — that's Checkers-side glue you added
around our proposer primitives. Ship your fix; nothing upstream conflicts.
If it makes sense to eventually pull the re-hydrate pattern into the
proposer library (so other dApps get it for free), file a proposal and we'll
consider it — but for now it lives cleanly in your bundle.
### Ask 2 — Phase B (WizardConnect license): is `@wizardconnect/core@0.2.4` acceptable for the Checkers dApp use case?
**Short answer: yes for chipnet-beta ship, cautious yes for prod ship.**
Our license investigation (documented at
`WizardConnect_reference.md` §3.3 and `wz-adapter.ts` header) found:
- No LICENSE file in either published tarball
- No `license` field in `package.json` for either package
- No LICENSE at the gitlab repository (`gitlab.com/riftenlabs/lib/wizardconnect`)
- `npm view` returns empty for the license field
**Community precedent supports use in a shipping product** — Cauldron,
Paytaca, and Selene all ship WizardConnect integrations in production
consumer wallets/dApps. If Riften Labs objected to that use, they'd have
either added a restrictive LICENSE or reached out to those projects by now.
**But the technical-legal reality is: absent an explicit grant, code is
copyright-restricted by default.** For chipnet-beta / development ship this
is defensible risk. For a production commercial product I'd want an explicit
"yes, use this in Checkers" from Dagur or H4lvor. The bar is one message,
not a legal review — just get it on record before mainnet ship or public
promotion.
**Deviant is going ahead with the same reasoning** for the wallet-side
integration (Task 3 Phases 1–3, snapshots 0191, 0193, 0194). Same license
callouts, same wait-on-explicit-permission-before-mainnet posture.
**On the `SIGHASH_UTXOS` shared helper you asked about** — this is now
implemented on the Deviant side in `shells/app/src/connect/signer.ts`:
- Exported constant: `WIZARDCONNECT_SIGHASH = 0x61`
(`SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`). Import from
`signer.ts` and use as the sighash byte on the WizardConnect path.
- Exported function: `coSignTransaction(input)` — the `input.sighashType`
field takes any of `[0x41, 0x61]`; anything else is rejected with a
clear error. `input.inputPaths` on the request routes per-input HD
child key derivation via `input.deriveForInput(pathName, addressIndex)`.
Full mechanism in `signer.ts:117-…`.
You can either:
- **(a) Vendor the Deviant proposer library and reuse `signer.ts` directly**
— the shared helper is already there. This is the recommended path if
you want to stay in lockstep with the Deviant responder's sighash rules.
- **(b) Implement it locally in `escrow-flow.mjs`** — the primitive is
literally `SIGHASH_ALL(0x01) | SIGHASH_FORKID(0x40) | SIGHASH_UTXOS(0x20)
= 0x61`. libauth's `SigningSerializationFlag` enum has all three.
Copy-paste is fine.
Either way works. We won't move the `WIZARDCONNECT_SIGHASH` constant or the
sighash-validation logic without notice.
### Ask 3 — Phase A (position-tolerant `GamePoolEscrowFlat` redesign): near-term roadmap?
**Honestly: I don't know for certain, and there's no session I'm aware of
that's actively working on it.** The Deviant contract track and the
`Parameters/Pari_P2P/` design track are owned by different sessions than
mine (multi-protocol responder). What I can share from the reference docs
that are current on the Deviant side:
- The **BCH Guru pattern** (covenant at position 0, wallet inputs/change
at 1+) is documented in `CashConnect_reference.md §6` as the design
shape that makes CC V0 covenant co-sign viable. If a redesign happens,
that's the target pattern.
- The **`bch_executeAction` v-next model** (CashConnect_reference.md §10)
is the other resolution — dApp registers its libauth `WalletTemplate`
at proposal time, wallet recognizes it, wallet builds the tx per named
action. When it ships, position-strict covenants become viable over CC
too. Status is "draft / pre-alpha" per the BCR forum thread; no
observable shipping activity in the last 6 months.
**Recommendation for Checkers Phase A:** ship the fallback message today
("Wallet-mode games need WalletConnect transport — please use Cashonize
or Paytaca via WC"). Don't wait. Two reasons:
1. **We can't promise timing.** No session has claimed the escrow redesign
in the Deviant snapshot log through 0194. It could land next week or
next quarter.
2. **The fallback is graceful.** A CC-only wallet user gets a clear
explanation instead of a failed transaction. When the redesign lands,
removing the fallback is a one-line change.
If the redesign priority shifts on the Deviant side, we'll surface it in a
snapshot titled clearly (`gamepoolescrow-position-tolerant-*`). Watch
`snapshots/` — that's the canonical source for scope changes.
---
## What Deviant shipped between your response and this one
Task 3 Phase 3 is complete on the Deviant wallet side. The multi-protocol
responder now handles **all three transports in one instance**:
- **WalletConnect** (wc2-bch-bcr) — validated end-to-end (snapshot 0057)
- **CashConnect** (CashRPC over WC2) — network callbacks wired (0191),
handshake + standard sends work; covenant flows depend on
position-tolerant covenant design (see Ask 3 above)
- **WizardConnect** (Nostr NIP-17 transport) — full adapter + handler +
responder integration (0193 Phases 1+2, 0194 Phase 3). URI routing,
xpub handshake, `inputPaths` HD key mapping, `SIGHASH_UTXOS` all
implemented. On-device pairing validation still needed with Cauldron.
**Compatibility verified:**
- `pair('wc:…')` → WalletKit; `pair('cc:…')` → WalletKit (CC rides
WC2 relay); `pair('wiz:…')` → WizardConnect Nostr transport
- WizardConnect `pendingSignRequest` events surface as
`SignRequestPreview` alongside WC/CC requests — same UI approval flow
- `approveSignRequest` routes signed hex to the correct transport by
synthetic request id
- 12 dedicated compatibility tests (`connect-responder-compat.test.ts`,
all 138 shell tests pass)
## Handoff protocol clarification
Per your §5, `dapp.entry.ts` versioning: my current session hasn't
touched that file (it's the proposer side; my work is the responder
side). If a follow-up Deviant session adds a re-hydrate pattern to the
proposer (which would be a useful upstream port of your fix), it'll bump
the version — you'll pick it up on the next revendor.
## Status table update proposal
For your §7 table, when C ships, add my status too so Checkers has one
place to see both sides' phase state:
| Phase | Checkers | Deviant responder |
|---|---|---|
| C · WC persistence fix | (you) | n/a (proposer-side, not touched) |
| B · WizardConnect | (you — dApp side) | ✅ shipped (0191/0193/0194) |
| A · CashConnect | (you — dApp side) | ✅ shipped (0191) |
Cross-references:
- `snapshots/0191-cashconnect-wiring-and-hd-derivation.md`
- `snapshots/0193-wizardconnect-adapter-and-signer-inputPaths.md`
- `snapshots/0194-wizardconnect-responder-integration.md` (the current session)
- `Parameters/WalletConnect/WizardConnect_reference.md` §4.1 (adapter API)
- `Parameters/WalletConnect/CashConnect_reference.md` §6 (position-tolerance rule)
## Where I'll watch
Ping me by writing anywhere Deviant reads:
- `snapshots/` — I watch the latest few numerically
- `Parameters/WalletConnect/*` — this folder is my canonical inbox
- `Parameters/Pari_P2P/*` — I check when position-tolerant redesign
comes up
Or if it's an urgent cross-session issue, drop a note in
`Checkers-response-to-instructions.md` — I'll see it on next pass.

View file

@ -0,0 +1,101 @@
# BCH Connector Protocols — folder map
This folder is the project's reference material for **all three** BCH dApp↔wallet
connector protocols, even though it's named "WalletConnect" for historical
reasons (the first one we integrated). Each has its own deep-dive file:
| Doc | Status in Deviant | One-line |
|---|---|---|
| [`WalletConnect_integration_reference.md`](./WalletConnect_integration_reference.md) | ✅ **Implemented + on-device validated** (snapshot 0057) | wc2-bch-bcr over WC2 relay — pass-through co-sign, the broadest BCH wallet coverage |
| [`CashConnect_reference.md`](./CashConnect_reference.md) | ✅ **Implemented (handshake, multi-protocol dispatch)** (snapshot 0152). Covenant co-sign blocked by V0 — routes through WC. **v-next `bch_executeAction` model is designed to fix this** (see doc §10). | **CashRPC** = the underlying protocol (transport-agnostic); **CashConnect** = CashRPC + WalletConnect v2. BCH community, @jimtendo proposal (Dec 2023 BCR forum), merged into Cashonize |
| [`WizardConnect_reference.md`](./WizardConnect_reference.md) | ⏳ **Deferred** until a wallet-required dApp uses `inputPaths` (Path A) or the Nostr vision layer ships (Path B) | Two-layer situation: **shipping** = WC2 extension adding `inputPaths` HD-key metadata (Paytaca); **vision** = Nostr NIP-17 E2E stack. Both from Riften Labs |
The runtime that uses all of the above lives at
`shells/app/src/connect/` (the wallet-side responder, ADR-0043 + snapshot 0152)
and `src/connect/proposer.ts` (the dApp-side proposer, snapshot 0057). The
files in this folder are **reference copies + dApp-developer documentation**.
## At-a-glance protocol comparison
| | WalletConnect (wc2-bch-bcr) | CashConnect (CashRPC over WC2) | WizardConnect — shipping | WizardConnect — vision |
|---|---|---|---|---|
| **Relay** | WC2 (`relay.walletconnect.com`) | WC2 (same) | WC2 (same) | Nostr |
| **Encryption to relay** | TLS only | TLS only | TLS only (same as WC2) | **E2E NIP-17 gift-wrap** |
| **URI scheme** | `wc:` | `cc:` (rewritten from `wc:`) | `wc:` (WC2 pair) | TBD |
| **Namespace** | `bch` | `bch` (+ `protocol: 'CashRPC:0'`, `template`, `allowedTokens`) | `bch` (WC2 dialect) | N/A — different transport |
| **Methods (shipping)** | `bch_getAddresses` / `bch_signMessage` / `bch_signTransaction` | `bch_*_V0`-suffixed + `wc_authRequest` | Same as WC2 + `inputPaths` extension on the tx object | xpub-driven |
| **Methods (v-next)** | — | **`bch_executeAction`** (template-bound named actions) | — | — |
| **Account format** | `bch:<chain>:<cashaddr>` | `bch:<chain>:<pubkeyHex>` (sandboxed per dApp domain) | Same as WC2 + inputPaths metadata | xpub (dApp derives) |
| **signTransaction model** | **Pass-through** (dApp builds, wallet co-signs placeholder inputs) | **Wallet-orchestrated** (V0). **v-next**: template-bound `bch_executeAction` (wallet recognizes contract, shows human-readable action label) | **HD-aware pass-through** — dApp attaches `[inputIndex, pathName, addressIndex]` triplets; wallet derives child key and signs | TBD |
| **Wire encoding** | libauth `stringify`, JSON tree of EJSON markers | libauth `stringify`, single EJSON-string blob | Same as WC2 + `inputPaths` array | Nostr event |
| **Covenant co-sign?** | ✅ Yes (validated 0057) | ❌ No at V0 (structural — see CC §6). **v-next: yes** via template-bound actions | Should work — pass-through inherits WC2 covenant support | TBD |
| **Standard sends?** | ✅ | ✅ | ✅ | TBD |
| **Wallets** | Cashonize, Paytaca, others | Cashonize | **Paytaca** | (future) |
| **dApps** | Cauldron, BCH Bull, Tote/Checkers, Flipstarter, … | (CC's audience overlaps with WC) | Anything attaching `inputPaths` to WC2 tx objects | **ParyonUSD** |
| **License (SDK)** | MIT | MIT | LGPL-3.0-or-later + CLA | Same |
| **Reown project ID needed?** | Yes | Yes (same one) | Yes (same one — extends WC2) | No |
| **Author / origin** | Reown (formerly WalletConnect Inc.) | BCH community, @jimtendo (2023 BCR proposal) | Riften Labs | Riften Labs |
## The Deviant routing rule (what users never see) — revised 2026-07-04
Multi-protocol wallets (Cashonize, the Deviant wallet) speak both WC + CC.
Users just paste a URI and approve; the wallet routes by proposal/request
shape (`shells/app/src/connect/classifier.ts`):
| Flow | Protocol the dApp should choose |
|---|---|
| User outbound BCH send / token send (wallet → external) | WC or CC — both fine |
| **Position-tolerant** dApp covenant (covenant at fixed positions, wallet-added inputs at positions 1+, wallet-added change at positions 1+) — e.g. BCH Guru, position-tolerant Cauldron pools | WC or CC — both fine. CC requires shipping the libauth `WalletTemplate` at proposal time |
| **Count-strict** dApp covenant (exact input/output count invariants that break under wallet auto-orchestration) | **WC only** — true pass-through per snapshot 0057 |
| Authentication / message-sign | either |
| Multiple concurrent sessions of either protocol | supported natively (isolated by topic) |
**Correction to a previous version of this table (2026-07-04):** the
sweeping "CC V0 can't carry covenant unlockers → WC only for all covenants"
rule was wrong. BCH Guru is a live counter-example — their prediction-game
covenant ships over CashConnect V0 with Cashonize. See
`CashConnect_reference.md §6` for the corrected mechanism (dApp ships
libauth `WalletTemplate` at proposal time; wallet compiles via
`walletTemplateToCompilerBCH`; covenant must be position-tolerant).
## Folder contents at a glance
```
Parameters/WalletConnect/
├── README.md ← this map
│
├── WalletConnect_integration_reference.md ← WC2 (wc2-bch-bcr) full reference
├── CashConnect_reference.md ← CashConnect (CashRPC v0) full reference
├── WizardConnect_reference.md ← WizardConnect research + future-impl notes
│
├── dapp-side/ ← shared proposer-side primitives
│ ├── proposer.ts (validated cashscript co-sign helpers)
│ └── index.ts
│
├── wallet-side/ ← reference responder (multi-protocol)
│ ├── responder.ts (live WalletKit listener + dispatcher)
│ ├── classifier.ts (WC vs CC routing — pure)
│ ├── signer.ts (libauth co-signer w/ isOwnedInput guard)
│ ├── wc-handler.ts (bch_getAddresses / signMessage / signTransaction)
│ ├── bch-namespace.ts (constants — chains, methods, account formats)
│ └── index.ts
│
└── examples/ ← runnable dApp test scripts
├── dapp-connect-example.mjs (pair + getAddresses + signMessage)
├── dapp-signtx-example.mjs (bch_signTransaction co-sign)
├── spike-readme.md (spike directory README)
└── spike-testing.md (testing notes from real device runs)
```
A `cc-handler.ts` should be added to `wallet-side/` once the reference snapshot
catches up to the current implementation — left as a TODO so the folder stays
in sync with `shells/app/src/connect/`.
## Cross-references
- Runtime: `shells/app/src/connect/` (multi-protocol responder, ADR-0043)
- dApp side: `src/connect/proposer.ts` (validated against Cashonize, snapshot 0057)
- Design: `wiki/connector.md` (full multi-protocol design)
- Decisions: `wiki/decisions.md` ADR-0041 (WC origin), ADR-0043 (multi-protocol)
- Snapshots: 0055 (WC handshake) → 0057 (WC co-sign) → 0061 (CC API pinned) → 0062 (bundle patch + structural finding) → 0152 (multi-protocol runtime) → 0153 (QR scanner + WizardConnect research)
- Ecosystem context: `../BitcoinCashExistingEcosystem/` (wallets + dApps that speak any of the above)

View file

@ -0,0 +1,652 @@
# WalletConnect v2 + BCH — dApp integration reference
_For dApp developers integrating WalletConnect against the Deviant wallet (and
any other `wc2-bch-bcr`-compliant BCH wallet — Cashonize, Paytaca, etc.). This
is a how-to with copy-paste recipes, gotchas we hit in real integrations, and
all referenced source files co-located in this folder. If you build a BCH dApp
that needs to ask a user's wallet to sign something, this is the spec to follow._
---
## Folder contents — everything you need is here
```
Parameters/WalletConnect/
├── WalletConnect_integration_reference.md ← this file
│
├── dapp-side/ ← the proposer (dApp) primitives
│ ├── proposer.ts (validated cashscript co-sign helpers)
│ └── index.ts (module barrel)
│
├── wallet-side/ ← the responder (wallet) reference
│ ├── responder.ts (live WalletKit listener + dispatcher)
│ ├── wc-handler.ts (bch_getAddresses / signMessage / signTransaction)
│ ├── signer.ts (libauth co-signer with isOwnedInput guard)
│ ├── bch-namespace.ts (chains, methods, account formats)
│ ├── classifier.ts (WC vs CashConnect proposal routing)
│ └── index.ts (type surface)
│
└── examples/ ← runnable dApp test scripts
├── dapp-connect-example.mjs (pair + getAddresses + signMessage)
├── dapp-signtx-example.mjs (bch_signTransaction co-sign)
├── spike-readme.md (the spike directory's README)
└── spike-testing.md (testing notes from real device runs)
```
The `dapp-side/` files are what your dApp imports. The `wallet-side/` files are
**reference copies** of how the Deviant wallet implements the responder — useful
to understand what your `bch_signTransaction` call does on the receiving end,
particularly the signing discipline guarantees.
---
## 0. TL;DR — minimum viable dApp integration
```js
import { SignClient } from '@walletconnect/sign-client';
import { stringify } from '@bitauth/libauth';
import { TransactionBuilder, placeholderP2PKHUnlocker } from 'cashscript';
// 1. Get a free Reown project ID at https://cloud.reown.com (one per dApp).
const client = await SignClient.init({
projectId: 'YOUR_REOWN_PROJECT_ID',
metadata: {
name: 'Your dApp',
description: 'What it does',
url: 'https://yourapp.example',
icons: ['https://yourapp.example/icon.png'],
},
});
// 2. Pair — request BOTH chains so chipnet AND mainnet wallets can join.
const { uri, approval } = await client.connect({
optionalNamespaces: {
bch: {
chains: ['bch:bitcoincash', 'bch:bchtest'],
methods: ['bch_getAddresses', 'bch_signMessage', 'bch_signTransaction'],
events: [],
},
},
});
// 3. Show `uri` as a QR or deep-link. User scans in their wallet.
// 4. Await approval → read which chain the wallet picked from accounts[0].
const session = await approval();
const acc = session.namespaces.bch.accounts[0]; // 'bch:<chain>:<bare-cashaddr>'
const parts = acc.split(':');
const chainId = `${parts[0]}:${parts[1]}`; // 'bch:bchtest' or 'bch:bitcoincash'
// 5. Request a signed tx (the load-bearing part).
const tb = new TransactionBuilder({ provider });
tb.addInput(userUtxo, placeholderP2PKHUnlocker(userCashaddr));
tb.addOutput({ to: recipient, amount: 1000n });
const wcObj = tb.generateWcTransactionObject({
broadcast: true, // wallet broadcasts after signing
userPrompt: 'Send 0.00001 BCH to alice', // human-readable; wallet shows verbatim
});
// The wire format: libauth `stringify`, NOT plain JSON. Skip this step and
// most BCH wallets reject the request with a zod schema error.
const params = JSON.parse(stringify(wcObj));
const result = await client.request({
topic: session.topic,
chainId,
request: { method: 'bch_signTransaction', params },
});
// → { signedTransaction: '0200...hex', signedTransactionHash: '...' }
```
That's the whole flow. The rest of this doc explains each step, the wire
format, the co-sign mechanics for multi-party txs, and the pitfalls.
---
## 1. Architecture
WalletConnect v2 is a relay protocol. Three components:
- **Proposer (dApp)** — initiates the session, sends method requests. Uses
**`@walletconnect/sign-client`** in a browser or Node.
- **Responder (wallet)** — receives proposals and method requests, signs locally,
returns results. Uses **`@reown/walletkit`** (formerly `@walletconnect/web3wallet`).
- **Relay** — Reown's WebSocket relay network. Both ends authenticate with a
Reown Cloud project ID. **The dApp and the wallet each need their own project
ID** — don't reuse one for both.
```
┌─────────────┐ bch_signTransaction ┌─────────────┐
│ Your dApp │────────────────────────────│ Wallet │
│ │ (via Reown relay) │ │
│ SignClient │◄───── signed tx hex ───────│ WalletKit │
└─────────────┘ └─────────────┘
▲ ▲
│ projectId: cloud.reown.com/{app} │ projectId: cloud.reown.com/{wallet}
│ │
```
The session, once approved, persists across page reloads (until the wallet
disconnects or the session TTL expires). Each session has a `topic` (a 64-char
hex string) used to address requests.
---
## 2. Project setup (dApp side)
### 2.1 Reown project ID
Sign in at `https://cloud.reown.com`, create a project, copy its ID. **The name
+ icon you set on the dashboard are what the wallet will display in its
pairing prompt** — wallets prefer the Cloud-registered metadata over the
runtime metadata you pass to `SignClient.init`. Set both consistently.
### 2.2 Dependencies
```bash
npm install @walletconnect/sign-client@^2.17.0 cashscript@^0.12 @bitauth/libauth@^3.1
```
`@walletconnect/sign-client@2.17.0` is the version pinned in our reference
integration (see `dapp-side/proposer.ts` header comment). Newer versions should
work but only 2.17.0 is verified.
For a Node-CLI test, also install `qrcode-terminal` to print the pair URI as a
scannable terminal QR (see `examples/dapp-connect-example.mjs`).
### 2.3 Metadata
```js
const metadata = {
name: 'Your dApp',
description: 'One-line product blurb',
url: 'https://yourapp.example',
icons: ['https://yourapp.example/icon.png'],
};
```
The `icons` must be **publicly accessible HTTPS URLs**. Mobile wallets fetch
them at pair time; broken or non-https URLs degrade to a generic placeholder.
---
## 3. The pairing flow
### 3.1 Initialize the client
```js
const client = await SignClient.init({ projectId, metadata });
```
### 3.2 Connect — request both chains
```js
const { uri, approval } = await client.connect({
optionalNamespaces: {
bch: {
chains: ['bch:bitcoincash', 'bch:bchtest'],
methods: ['bch_getAddresses', 'bch_signMessage', 'bch_signTransaction'],
events: [],
},
},
});
```
**Use `optionalNamespaces`, not `requiredNamespaces`.** Wallets are stricter
about required ones; if a chipnet-only wallet sees `bch:bitcoincash` in
`requiredNamespaces` it'll reject the proposal outright. With optional, the
wallet picks whichever chain it supports.
**Request BOTH chains** — one dApp build then pairs with mainnet wallets
(Cashonize, Paytaca) AND chipnet wallets (Deviant chipnet beta) without
separate builds. The pitfalls section (§9) explains the chain-mismatch
symptom we hit in the field.
### 3.3 Show the URI
```js
console.log(uri); // 'wc:abc123...@2?relay-protocol=irn&symKey=...'
qrcode.generate(uri, { small: true }); // optional: terminal QR
```
In a browser dApp, render it as a `<canvas>` QR (e.g., `qrcode` npm package)
and also expose a "Copy URI" button + a "Open in wallet" button that follows
`window.location.href = uri` (mobile wallets register a `wc:` URI handler).
### 3.4 Await approval
```js
const session = await approval();
// session.topic — use this for all subsequent requests
// session.namespaces.bch.accounts — the wallet's account string(s)
// session.peer.metadata — the wallet's identity
```
**Read which chain the wallet actually picked from the account string** — don't
assume the dApp's preferred chain. The CAIP-10 account format is strictly
**three colon-separated segments**:
```
bch:<chainRef>:<bare-cashaddr>
bch:bitcoincash:qpx7lm... ← mainnet
bch:bchtest:qzj9dn... ← chipnet
```
`<chainRef>` is `bitcoincash` or `bchtest`. `<bare-cashaddr>` is the address
**without** its `bitcoincash:` / `bchtest:` prefix. Code to recover the full
prefixed cashaddr if your BCH SDK needs it:
```js
const parts = acc.split(':'); // ['bch', 'bchtest', 'qzj9dn...']
const chainId = `${parts[0]}:${parts[1]}`; // 'bch:bchtest'
const cashaddr = `${parts[1]}:${parts.slice(2).join(':')}`; // 'bchtest:qzj9dn...'
```
### 3.5 Session persistence
WalletConnect persists sessions in `localStorage` (browser) or memory (Node).
On page reload, `SignClient.init` will re-hydrate any existing sessions —
call `client.session.getAll()` to enumerate them. To disconnect cleanly:
```js
await client.disconnect({
topic: session.topic,
reason: { code: 6000, message: 'User disconnected' },
});
```
---
## 4. The `wc2-bch-bcr` namespace
This is the BCH-specific WalletConnect v2 method set, designed alongside
CashScript and validated by Cashonize / Paytaca / Deviant. The community spec
lives at `bitcoincashresearch.org`.
### 4.1 Identifiers
| Field | Value |
|---|---|
| Namespace | `bch` |
| Chains | `bch:bitcoincash` (mainnet), `bch:bchtest` (chipnet) |
| Account format (CAIP-10) | `bch:<chainRef>:<bare-cashaddr>` |
| Methods | `bch_getAddresses`, `bch_signMessage`, `bch_signTransaction` |
| Events | (none currently) |
### 4.2 Methods
#### `bch_getAddresses`
```
params: {} (or unspecified)
returns: string[] — array of the wallet's prefixed cashaddrs
e.g. ['bchtest:qzj9dn...']
```
Always returns the **prefixed** form (with `bchtest:` / `bitcoincash:`). Most
BCH SDKs (mainnet-js, cashscript) expect this format. Use it for balance
queries.
#### `bch_signMessage`
```
params: { message: <utf-8 string>, userPrompt?: string }
returns: { signedMessage: <utf-8 echo>, signature: <hex> }
```
Useful for proving wallet ownership (login challenges). The exact signature
algorithm (classical BIP-322-like vs simple `ecdsa(sha256(msg))`) varies by
wallet — verify with your target wallet's docs. Deviant currently signs
`ECDSA-DER(sha256(message))` (see `wallet-side/wc-handler.ts:handleSignMessage`).
#### `bch_signTransaction`
The load-bearing method. Covered in §6.
---
## 5. The wire format (libauth `stringify` / `parseExtendedJson`)
This is the one detail you can't ignore. **WalletConnect's JSON-RPC params for
`bch_signTransaction` must be encoded with libauth's `stringify`**, NOT plain
`JSON.stringify`. Two transformations:
```
Uint8Array → "<Uint8Array: 0x..>"
bigint → "<bigint: ..n>"
```
Wallets validate these patterns strictly (Cashonize's zod schema fails fast
with `invalid_type` then `invalid_format` if you pass plain hex or arrays).
We learned this the hard way — see snapshot 0057.
```js
import { stringify } from '@bitauth/libauth';
const wcObj = tb.generateWcTransactionObject({ broadcast: false, userPrompt: '...' });
// CORRECT: round-trip via libauth stringify
const params = JSON.parse(stringify(wcObj));
// WRONG: plain JSON loses Uint8Array / bigint
const wrongParams = JSON.parse(JSON.stringify(wcObj));
await client.request({ topic, chainId, request: { method: 'bch_signTransaction', params } });
```
The response is also libauth-`stringify`'d on the wallet side, but JSON-RPC
delivers it as a parsed object — `signedTransaction` and `signedTransactionHash`
arrive as plain hex strings, no decoding needed.
---
## 6. `bch_signTransaction` recipes
### 6.1 Single-input single-sig — simple send
```js
import { TransactionBuilder, placeholderP2PKHUnlocker } from 'cashscript';
import { stringify } from '@bitauth/libauth';
const userUtxo = await provider.getUtxos(userCashaddr).then(u => u[0]);
const tb = new TransactionBuilder({ provider });
tb.addInput(userUtxo, placeholderP2PKHUnlocker(userCashaddr));
tb.addOutput({ to: recipientCashaddr, amount: 100_000n }); // 0.001 BCH
tb.addOutput({ to: userCashaddr, amount: BigInt(userUtxo.satoshis) - 100_000n - 1_000n }); // change
const wcObj = tb.generateWcTransactionObject({
broadcast: true, // wallet broadcasts after signing
userPrompt: 'Send 0.001 BCH to alice@example', // human prompt
});
const result = await client.request({
topic: session.topic,
chainId,
request: { method: 'bch_signTransaction', params: JSON.parse(stringify(wcObj)) },
});
// → { signedTransaction: '0200...', signedTransactionHash: 'abc...' }
```
### 6.2 Multi-input co-sign — both users sign one tx
For 2-party flows (escrows, atomic swaps, multi-payer collections): build ONE
tx with placeholder unlockers for each party's input. Send the SAME
`WcTransactionObject` to both wallets via their respective sessions. Each wallet
fills in only its own placeholder.
```js
const tb = new TransactionBuilder({ provider });
tb.addInput(aliceUtxo, placeholderP2PKHUnlocker(aliceCashaddr));
tb.addInput(bobUtxo, placeholderP2PKHUnlocker(bobCashaddr));
tb.addInput(operatorBchUtxo, operatorSigTemplate.unlockP2PKH()); // operator signs immediately
tb.addOutput({ to: escrowAddress, amount: poolSats, token: { amount: poolFt, category } });
// + change
const wcObj = tb.generateWcTransactionObject({
broadcast: false, // server merges + broadcasts after
userPrompt: 'Stake 1 CHE in match #1234',
});
const params = JSON.parse(stringify(wcObj));
const [aResult, bResult] = await Promise.all([
client.request({ topic: aliceSession.topic, chainId, request: { method: 'bch_signTransaction', params } }),
client.request({ topic: bobSession.topic, chainId, request: { method: 'bch_signTransaction', params } }),
]);
// Each result is the full tx hex with ONLY that party's input filled
// (the other party's input is still a placeholder unlocker).
```
### 6.3 Merging two signed copies
```js
import { decodeTransaction, encodeTransaction, hexToBin, binToHex } from '@bitauth/libauth';
const aTx = decodeTransaction(hexToBin(aResult.signedTransaction));
const bTx = decodeTransaction(hexToBin(bResult.signedTransaction));
// Build a final tx that takes each input's unlockingBytecode from the wallet
// that owns it. Other inputs (operator's BCH input) keep the operator's
// signature from the original build.
const merged = {
...aTx,
inputs: aTx.inputs.map((aInput, i) => {
if (isOwnedBy(i, aliceCashaddr)) return aInput; // alice's signed input
if (isOwnedBy(i, bobCashaddr)) return bTx.inputs[i];// bob's signed input
return aInput; // operator's input (already signed)
}),
};
const finalHex = binToHex(encodeTransaction(merged));
await provider.sendRawTransaction(finalHex);
```
### 6.4 Signing discipline — the structural guarantee
**The wallet signs ONLY inputs whose `sourceOutputs[i].lockingBytecode == p2pkh(walletPubkeyHash)`.**
This is enforced at the wallet's signer (see `wallet-side/signer.ts` —
`isOwnedInput`). The wallet:
- **Will sign** an input whose source-output is the wallet's own P2PKH.
- **Will NOT sign** any other input — even if your `userPrompt` asks for it.
This means a malicious dApp **cannot trick wallet A into signing wallet B's
input**, even if the dApp ships a Wc tx with B's source output. The wallet
ignores it. Rely on this property: it's structural, not advisory.
---
## 7. Error handling
### 7.1 Common error codes
| Code | Meaning | What to do |
|---|---|---|
| **5000** | Generic "user rejected" | Show "Cancelled" UI; let user retry |
| **5100** | "No supported chain in proposal" | The wallet doesn't support any of the chains you asked for. Add the wallet's chain to your `optionalNamespaces.bch.chains` |
| **6000** | "User disconnected" | Session torn down; reconnect required |
| **USER_REJECTED** (no numeric code) | Standard rejection | Same as 5000 |
### 7.2 Expired proposals / sessions
WalletConnect session proposals have a **5-minute TTL** by default. If the
user takes too long between scanning the QR and approving in the wallet, the
proposal expires. Your `await approval()` Promise rejects with a relay timeout
error. Mitigation: surface the QR with a visible countdown; if the user
hesitates, allow re-generating.
### 7.3 Malformed requests
If you send raw JSON (without libauth `stringify`), most wallets respond with
a JSON-RPC error. The error body usually contains the validation failure
(zod-style "invalid_type" or "invalid_format" complaints). Always go through
`JSON.parse(stringify(wcObj))`.
---
## 8. Folder breakdown — what each reference file is
### dapp-side/
- **`proposer.ts`** — `buildCoSignRequest`, `proposeCoSign`, `toWcParams`,
`placeholderP2PKHUnlocker`. Wraps the cashscript primitives for clean re-use
in any dApp. The `WalletSession` interface seam lets you plug in a
WalletConnect adapter, a CashConnect adapter, or a test mock.
- **`index.ts`** — module barrel; what you import from your dApp.
### wallet-side/ (reference for understanding what the wallet does)
- **`responder.ts`** — `createResponder({walletKit, key, network, events})`.
Listens to `session_proposal`, `session_request`, `session_delete`; classifies
each by shape; dispatches WC requests to `wc-handler.ts`.
- **`wc-handler.ts`** — implementations of `bch_getAddresses`,
`bch_signMessage`, `bch_signTransaction`. The signTransaction handler
delegates to `signer.ts`.
- **`signer.ts`** — libauth-based per-input signer. `coSignTransaction` walks
the tx's inputs, signs only those whose source-output P2PKH matches the
wallet's own pubkey hash. The `isOwnedInput` predicate is the structural
safety guarantee.
- **`bch-namespace.ts`** — wire constants: `SUPPORTED_CHAINS`, `WC_METHODS`,
`CC_METHODS`, `CC_PROTOCOL`, account format helpers.
- **`classifier.ts`** — pure routing functions: `classifyProposal`,
`classifyRequest`, `classifyUri`. Decide WC vs CashConnect by shape.
- **`index.ts`** — type surface: `BchChain`, `BchSignTxRequest`,
`SignedTxResponse`, `Responder` interface, etc.
### examples/
- **`dapp-connect-example.mjs`** — runnable Node script: pair + getAddresses +
signMessage against any `wc2-bch-bcr` wallet. Includes the chipnet+mainnet
co-pair pattern. Run with `WC_PROJECT_ID=<id> node dapp-connect-example.mjs`.
- **`dapp-signtx-example.mjs`** — runnable `bch_signTransaction` demo. Mainnet
by default; override with `WC_CHAIN=bch:bchtest`. Sets up a self-send with a
placeholder unlocker; the wallet returns the signed tx with `broadcast:false`.
- **`spike-readme.md`** + **`spike-testing.md`** — the spike directory's own
README + testing notes covering on-device validation against Cashonize.
---
## 9. Pitfalls we hit in real integrations (READ THIS)
### 9.1 Don't send 4-segment account strings
CAIP-10 requires **`namespace:chainId:address`** — three segments. If the
wallet returns the canonical cashaddr (`bchtest:qpx...`) and your code blindly
constructs `bch:<chain>:<cashaddr>` = `bch:bchtest:bchtest:qpx...`, that's 4
segments and WalletKit's validator rejects with:
> *"Unsupported accounts. approve() namespace account ... should be a string
> and conform to namespace:chainId:... format"*
**Fix:** strip the network prefix from the cashaddr before concatenating.
`bare = cashaddr.includes(':') ? cashaddr.slice(cashaddr.indexOf(':') + 1) : cashaddr`.
### 9.2 Request BOTH chains in `optionalNamespaces`
If your dApp build hardcodes `chains: ['bch:bitcoincash']` (mainnet only),
chipnet wallets reject the proposal silently with code 5100 and your
`approval()` never resolves. Symptom: the wallet shows "Pairing…" forever (or
a generic error); the dApp shows "WalletConnect cancelled or failed."
**Fix:** `chains: ['bch:bitcoincash', 'bch:bchtest']` and read back the
wallet's chosen chain from `session.namespaces.bch.accounts[0]`. Test scripts
in `examples/` already do this.
### 9.3 Use libauth `stringify`, not `JSON.stringify`
Repeat from §5 because it bites everyone: the wire format encodes
`Uint8Array` and `bigint` with libauth's special markers. Plain JSON drops
them. Always: `JSON.parse(stringify(wcObj))`.
### 9.4 Use the wallet's chosen chain in `request.chainId`, not your preferred
```js
// WRONG: assumes the chain you requested
chainId: 'bch:bitcoincash'
// RIGHT: read from the session
const acc = session.namespaces.bch.accounts[0];
const chainId = `${acc.split(':')[0]}:${acc.split(':')[1]}`;
client.request({ topic, chainId, request: {...} });
```
If you ask the wallet to sign on `bch:bitcoincash` but it's a chipnet wallet,
you'll get a chain-mismatch error.
### 9.5 Separate project IDs for the dApp and the wallet
When designing your dApp's product setup: **never reuse the wallet's project ID
for the dApp's**, or vice versa. Wallets and dApps each present themselves to
peers via their own Reown Cloud project metadata. Reusing IDs means peers
display the wrong name (e.g. wallet shows "Cauldron connected" instead of
"Your dApp connected" because it pulled the metadata from the wrong project).
### 9.6 Don't generate a new pair URI per attempt without disconnecting old ones
Each `client.connect()` creates a new pairing topic. If the user keeps tapping
"connect" without the wallet completing pairing, you accumulate dead topics on
the relay. Clean up: `await client.pairing.disconnect({ topic: oldTopic })`
when generating a new URI.
### 9.7 `broadcast: true` means **the wallet broadcasts** — not the dApp
`broadcast: true` in `WcTransactionObject` tells the responder to broadcast
the signed tx itself after signing. The wallet uses its own network provider
to do so. If you want to broadcast yourself (e.g., to merge with other
signatures first), use `broadcast: false` and broadcast from your own
provider after receiving `signedTransaction`.
---
## 10. CashConnect — a different protocol on the same relay
The BCH ecosystem also has **CashConnect** (CashRPC), a richer protocol that
runs over the same WC2 relay but has different methods, wire shape, and URI
scheme. **It is NOT a drop-in for `wc2-bch-bcr`.** Key differences (see
`wallet-side/classifier.ts` for the routing):
| Aspect | `wc2-bch-bcr` (this doc) | CashConnect (CashRPC v0.0.25) |
|---|---|---|
| URI scheme | `wc:` | `cc:` (rewritten by the cashconnect bundle) |
| Methods | `bch_getAddresses`, `bch_signMessage`, `bch_signTransaction` | `_V0`-suffixed: `bch_getBalance_V0`, `bch_signMessage_V0`, `bch_signTransaction_V0`, ...; plus `wc_authRequest` |
| Proposal marker | (none) | `bch.protocol = 'CashRPC:0'` in `requiredNamespaces` |
| Account format | `bch:<chain>:<cashaddr>` | `bch:<chain>:<pubkeyHex>` |
| Co-sign model | **dApp-built tx pass-through** (you build the tx, wallet just signs your inputs) | **Wallet-orchestrated** (you give wallet a template + amount; wallet builds, VM-verifies, signs) |
| Carries dApp covenant unlocker? | **Yes** — full `WcTransactionObject` with unlockingBytecode | **No** — wallet-template-scoped only |
**For dApps that build their own txs with CashScript covenants (escrows,
swaps, prediction markets, etc.), use `wc2-bch-bcr`** — that's this doc.
CashConnect is better suited for standard-send dApps (payment apps, faucets)
where the wallet builds the tx. If you want both, you need two adapters; the
Deviant wallet ships both behind one relay socket (see
`wallet-side/responder.ts` for the dispatch pattern).
---
## 11. End-to-end test it on chipnet (before mainnet)
The Deviant chipnet beta is a `wc2-bch-bcr` responder live at
`https://testchipnet.duckdns.org/`. To test:
1. Get your Reown project ID + set `WC_PROJECT_ID` env var.
2. From `examples/`:
```bash
WC_PROJECT_ID=<your_id> WC_CHAIN=bch:bchtest node dapp-connect-example.mjs
```
3. Scan the printed QR in the Deviant chipnet beta wallet (Apps → Connect →
paste/scan URI).
4. Approve. The script prints `bch_getAddresses` and `bch_signMessage` results.
5. Then `node dapp-signtx-example.mjs` to test signTransaction with `broadcast: false`.
Once those work, you've validated your dApp end-to-end on chipnet. Promote to
mainnet by switching `WC_CHAIN=bch:bitcoincash` (or by requesting both chains
and letting the wallet pick).
---
## 12. References
### Canonical sources (in this repo)
- **dApp proposer:** `src/connect/proposer.ts`, `src/connect/index.ts`
- **Wallet responder (live):** `shells/app/src/connect/*.ts`
- **Working dApp test scripts:** `spike/wc-connect/connect.mjs`,
`spike/wc-connect/signtx.mjs`
- **The connector design doc:** `wiki/connector.md`
- **ADRs:** 0016 (WC routing), 0041 (responder), 0043 (multi-protocol responder)
- **Validation snapshots:** 0055 (handshake), 0057 (co-sign), 0061 (CashConnect)
### External
- **Reown docs:** `https://docs.reown.com/`
- **WalletConnect v2 spec:** `https://specs.walletconnect.com/`
- **wc2-bch-bcr discussion:** `https://bitcoincashresearch.org/t/wallet-connect-v2-support-for-bitcoincash/1100`
- **CashScript:** `https://cashscript.org/docs/guides/walletconnect`
- **Reown Cloud (project IDs):** `https://cloud.reown.com/`
---
_If you find a divergence between this doc and the runtime behaviour (the WC SDK
moves, wallets ship new versions, the BCH spec evolves), trust the runtime and
annotate the diff back here. This is a living reference._

View file

@ -0,0 +1,351 @@
# WizardConnect — design reference + future-implementation notes
_BCH connector by **Riften Labs** (devs Dagur & H4lvor). **Two source pages
describe it two different ways** — reconciliation below is the load-bearing
part of this doc. Reference for evaluating it as a third Apps-tab protocol in
the Deviant wallet alongside WalletConnect (`WalletConnect_integration_reference.md`)
and CashConnect (`CashConnect_reference.md`). **Not yet implemented in Deviant.**_
**Sources (both partially describe the same thing — pinned against the
installed source 2026-06-29):**
- <https://docs.riftenlabs.com/wizardconnect/> — describes the **transport
and encryption model** (Nostr NIP-17 gift-wrap, xpub handshake,
auto-reconnect).
- <https://cashscript.org/docs/guides/walletconnect#wizardconnect> —
describes the **transaction payload format** (reuses the WC2
`WcTransactionObject` shape + adds `inputPaths` triplets).
## 0a. Reconciled — what actually ships in `@wizardconnect/core@0.2.4`
Read the tarball; the two source pages describe **the same protocol at
different layers**:
- **Transport = Nostr WebSocket relay** at `wss://relay.riften.net:443`
(per `key-exchange.js:DEFAULT_RELAY_URLS`). Uses `nostr-tools/pool`
under the hood. NIP-17 gift-wrap encryption for E2E confidentiality.
- **URI scheme = `wiz://?p=<publicKey>&s=<secret>`** — plus a
QR-alphanumeric-safe variant `WIZ://%3FP%3D...%26S%3D...`. Both are
encoded/decoded by `encodeKeyExchangeURI` / `decodeKeyExchangeURI`.
**NOT `wc:` and NOT a WC2 URI extension.**
- **Handshake:** wallet sends `PathXpub[]` (array of `{name, xpub}` per
named HD path) so the dApp derives addresses locally. Session state is
`Hdwalletv1Session { paths: PathXpub[], extensions? }`.
- **Sign-transaction payload wraps a WC2-compatible tx object** —
`SignTransactionRequest.transaction: WcSignTransactionRequest` (imported
from `@bch-wc2/interfaces`). Plus `inputPaths: [inputIndex, pathName,
addressIndex][]`. So the CashScript-guide's "reuses the BCH WalletConnect
transaction object" is correct — but only about the tx payload shape
*inside* the Nostr message. The transport itself is Nostr, not WC2.
- **Protocol name:** `hdwalletv1` (per `PROTOCOL_NAME` export).
**Correction to a previous version of this doc (2026-06-29):** I had
described "Path A = WC2 extension" and "Path B = Nostr stack" as two
possibilities. **There is only one path today** — the Nostr stack with a
WC2-shape tx payload. Any Deviant adapter needs to run a Nostr relay
client, not extend the existing WC handler with `inputPaths` support.
---
## 0. TL;DR (pinned against `@wizardconnect/core@0.2.4` source)
| Question | Answer |
|---|---|
| Protocol name | `hdwalletv1` (`PROTOCOL_NAME` in `protocols/hdwalletv1.js`) |
| Transport | Nostr WebSocket relay (default `wss://relay.riften.net:443`) |
| Encryption | NIP-17 gift-wrap end-to-end (relay sees only ciphertext) |
| URI scheme | **`wiz://?p=<publicKey>&s=<secret>`** (also QR-safe `WIZ://%3F...`) |
| Account model | Wallet sends `PathXpub[]` at handshake; dApp derives addresses locally |
| Path names | `receive`, `change`, `defi` — plus extension names (opaque to protocol) |
| BIP44 branch indices | `receive → 0`, `change → 1`, `defi → 7` (via `childIndexOfPathName`); extensions have no fixed index |
| Full BIP44 path | `m/44'/145'/<account>'/<branchFromMap>/<addressIndex>` (BCH coin type 145) |
| Sign-tx payload | `SignTransactionRequest.transaction: WcSignTransactionRequest` (imported from `@bch-wc2/interfaces`) + `inputPaths: [inputIndex, pathName, addressIndex][]` |
| Message actions | `dapp_ready`, `wallet_ready`, `sign_transaction_request`, `sign_transaction_response`, `sign_cancel`, `disconnect`, `chunk`, `ping`, `pong` |
| Reconnection | Auto — both sides reconnect independently (per Riften Labs docs) |
| Wallet support | **Paytaca** (reference wallet) |
| dApp support | **Cauldron** (per docs.riftenlabs.com/cauldron/), **ParyonUSD** |
| SDK packages | `@wizardconnect/core@0.2.4`, `@wizardconnect/wallet@0.2.2`, `@wizardconnect/dapp@0.2.2`, `@wizardconnect/react@?` |
| Dependencies of core | `@bch-wc2/interfaces`, `nostr-tools`, `@bitauth/libauth`, `eventemitter3`, `isomorphic-ws`, `lossless-json`, `ws` |
| License | **Proprietary** (per `npm view @wizardconnect/core`) — NOT LGPL as previous version of this doc claimed |
| Status in Deviant | **Not yet implemented.** Adapter design in §4 below. |
## 0b. What CashScript itself says (verbatim summary)
- *"WizardConnect is an HD-wallet-aware signing protocol."*
- *"It reuses the BCH WalletConnect transaction object but adds HD path
metadata called `inputPaths` to specify which HD key should sign each
input."* → **Correct about the tx-object shape**, but only inside the
Nostr-encrypted wrapper.
- *"CashScript does not add a separate WizardConnect abstraction."*
Developers build transactions using the existing
`generateWcTransactionObject()` method and attach `inputPaths` metadata
themselves before sending requests to the wallet.
- Wallet-side entry point (per the SDK): `dappConnectionManager.signTransaction()`
on the dApp side; `initiateWalletRelay()` on the wallet side.
---
## 1. Why it's distinctive
Three genuine advantages over WC2 / CashConnect:
### 1.1 End-to-end encryption (privacy ceiling raised)
The wallet and dApp exchange **NIP-17 gift-wrap encrypted** messages over a
Nostr relay. The relay sees only ciphertext. WC2's TLS-only relay can see
session-pairing metadata (peer names, topics, request counts); WizardConnect
removes that observability.
### 1.2 xpub handshake (no per-address round-trips)
> "The wallet sends BIP32 xpubs in the handshake; the dapp derives all
> addresses locally with no further round-trips."
For dApps that need multiple addresses from the same wallet (DEXes,
prediction markets, multisig flows), this avoids the chatty WC2 pattern
of `bch_getAddresses` per derivation. The dApp computes any address
from the xpub locally.
### 1.3 Automatic reconnection
> "Both sides can disconnect and reconnect independently … and converge
> back to a live session without user action."
WC2 sessions need explicit resurrection or re-pairing when either side
goes offline.
### 1.4 Semantic derivation paths
`receive`, `change`, `defi` rather than numeric BIP32 indices. Easier for
dApps to reason about purpose-of-address.
---
## 2. SDK packages
Four packages (**Proprietary license** — corrected 2026-06-29):
| Package | Role |
|---|---|
| `@wizardconnect/core` | Transport + encryption + protocol types |
| `@wizardconnect/wallet` | `WalletConnectionManager` — wallet-side multi-connection management |
| `@wizardconnect/dapp` | `DappConnectionManager` — dApp-side xpub state |
| `@wizardconnect/react` | Components, hooks, QR dialog |
---
## 3. Architecture notes for a future Deviant adapter
It does NOT fit the existing Deviant multi-protocol classifier
(`shells/app/src/connect/classifier.ts`). The classifier routes by WC2
proposal shape and assumes the relay is WC2; WizardConnect needs its own
Nostr-relay client and its own session lifecycle.
```
KeyStore (one)
│
▼
WalletKit (WC2 relay) ──┬── WC handler (bch_*)
│ └── CashRPC_V0 (bch_*_V0)
│
WizardManager (Nostr relay) ──── WZ handler ← THIS is what would be added
```
### 3.1 Sketch of the integration
- Add `shells/app/src/connect/wz-handler.ts` wrapping
`@wizardconnect/wallet`'s `WalletConnectionManager`.
- `responder.ts` carries **two** clients now (WalletKit + Wizard manager).
They don't conflict — they're isolated by transport — but the UI state
surface (sessions list, pending proposal, sign request) needs to union
both event streams.
- Extend `classifyUri` with a fourth branch for the WizardConnect URI
scheme (TBD — capture from SDK on adoption).
- Extend the `bch-namespace.ts` constants with WizardConnect's method
surface once we read its `protocol-types.ts`.
### 3.2 KeyStore implications
The xpub handshake means we need to expose an xpub from the active
KeyStore. The current `KeyStore` interface (`src/wallet/keystore.ts`)
returns an `AccountIdentity` with a single 33-byte compressed publicKey
+ a derivation path. Either:
- Extend `KeyStore` with `exportXpub(): Promise<string>` (clean), or
- Compute the xpub from the seed at WizardConnect-init time (separate
derivation pass)
Pick the first — the KeyStore is the right authority for what derived
material a session may see.
### 3.3 License implications
**Proprietary.** Consuming the packages as a dependency is legally standard
provided the terms allow (verify with npm-view of the LICENSE field before
adopting — proprietary bundles sometimes have restrictions on production
use, redistribution, or embedded shipping). The Deviant wallet is
open-source; check the SDK's actual LICENSE file if adopting.
---
## 4. Implementation plan (single path — the two-path framing was wrong)
**Corrected 2026-06-29 after reading `@wizardconnect/core@0.2.4` source.**
Previous versions of this doc alternated between "distinct proprietary
protocol" (which was too strong at the payload-format layer) and
"WC2 extension" (which was wrong at the transport layer). Both were partial
views. The actual shipping protocol is:
- **Nostr transport** — WebSocket to `wss://relay.riften.net:443` via
`nostr-tools`, NIP-17 gift-wrap encryption. NOT the WalletConnect v2 relay.
- **`wiz://` URI scheme** at pairing time, NOT `wc:`.
- **WC2-shape transaction payload inside the encrypted Nostr message** —
the `SignTransactionRequest.transaction` field is a `WcSignTransactionRequest`
from `@bch-wc2/interfaces` (the same tx-object shape as WC2). Plus the
`inputPaths` triplets.
So there is **one implementation path**, not two. It IS the "parallel
Nostr adapter" from an earlier draft of this doc — the "WC handler
extension" hypothesis is architecturally wrong at the transport level.
### 4.1 The plan (revised 2026-07-04 after reading official docs)
The wallet-side integration surface is **`WalletConnectionManager` +
`WalletAdapter` interface** — not the raw `initiateWalletRelay` primitive
from a previous version of this doc. Higher-level, less code to write.
1. **Add deps:** `@wizardconnect/core@0.2.4`, `@wizardconnect/wallet@0.2.2`.
Both proprietary — verify LICENSE before merging.
2. **Extend `KeyStore`** (`src/wallet/keystore.ts`) — Task 2 already
shipped `deriveChildKey` + `supportedPathNames` + BIP44 branch map
(`receive → 0`, `change → 1`, `defi → 7`) matching WizardConnect's
`hdwalletv1.js:childIndexOfPathName`. Still needed for Task 3:
- `exportXpub(pathName: string): Promise<string>` — returns the
BIP32 base58-encoded xpub at `m/44'/145'/0'/<branch>` (branch level,
one step above address indices). The `defi` path is officially
aliased "Cauldron" in the docs.
3. **New `shells/app/src/connect/wz-adapter.ts`** — implements
`WalletAdapter` per the docs:
```ts
interface WalletAdapter {
walletName: string;
walletIcon: string;
getRelayPrivateKey(): Uint8Array; // 32-byte, ephemeral or HD
getPublicKey(path: string, index: number): Uint8Array; // 33-byte compressed secp256k1
getXpub(path: string): string; // base58 BIP32 xpub
signTransaction(request): Promise<{ signedTransactionHex: string }>;
getAdditionalPaths?(): string[]; // optional extension paths
getExtensions?(): Record<string, unknown>; // optional protocol extensions
}
```
`getRelayPrivateKey` returns a fresh 32-byte relay-encryption key per
session (the docs say either ephemeral or HD-derived — we use
`crypto.getRandomValues(new Uint8Array(32))` for ephemeral).
4. **New `shells/app/src/connect/wz-handler.ts`** — instantiates
`WalletConnectionManager(adapter)` and wires it into the responder's
event streams. Emits `pendingSignRequest` → responder surfaces to UI.
On approve → `manager.sendSignResponse(connectionId, sequence,
signedTxHex)`.
5. **Extend `classifier.ts:classifyUri`** — recognize `wiz://` /
`WIZ://` prefixes (URI format: `wiz://?p=<pubkey_bech32>&s=<secret_bech32>`
default, or `wiz://<hostname>:<port>?p=...&s=...&pr=<protocol>`
custom-relay). Return a new `'wz'` protocol tag.
6. **Extend `responder.ts`** — hold two transport clients (the shared
WalletKit + the WizardConnect `WalletConnectionManager`). Both feed
the same event streams (session proposal, sign request) — union the
state in the UI.
7. **Extend `signer.ts:coSignTransaction`** — honour `inputPaths` triplets.
For each `[inputIndex, pathName, addressIndex]`:
- Call `KeyProvider.deriveForInput(pathName, addressIndex)` — wires
to Task 2's `KeyStore.deriveChildKey`.
- Derive the child address, confirm it matches the source-output's
`lockingBytecode` — structural non-custody, ADR-0041 §5.
- Sign the mapped input.
- **SIGHASH REQUIREMENT (from docs, non-negotiable):** wallet MUST
sign with `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. Other
sighash flags MUST be rejected. This is stronger than our WC path
which uses `SIGHASH_ALL | SIGHASH_FORKID = 0x41`; WizardConnect
REQUIRES the additional `SIGHASH_UTXOS` flag (see libauth
`SigningSerializationFlag.utxos`).
8. **URI routing in `qr-uri.ts`** — add `wiz:` / `WIZ:` (case-insensitive)
prefixes to the `connect` classification. QR encoding uses alphanumeric
mode with `?`/`=`/`&` percent-encoded as `%3F`/`%3D`/`%26` — the
`decodeKeyExchangeURI` helper in `@wizardconnect/core` accepts both
standard and QR-encoded formats, so we just pass the raw scan through.
### 4.2 Effort estimate
- KeyStore extension + impls + tests: ~1 day
- `wz-handler.ts` + responder wiring + classifier/qr-uri updates: ~1 day
- On-device validation with Cauldron: ~half day (device work is user's)
- Total: ~2.5 days of focused work
### 4.3 The named-precedent dApps
**Cauldron** (per docs.riftenlabs.com/cauldron/ — has a dedicated
WizardConnect docs section) and **ParyonUSD** (paryonusd.com/app) both
use WizardConnect. Cauldron is the highest-signal test target because
its swap + LP flows genuinely need HD-key-per-input mapping.
---
## 5. Why it's still interesting to track
- The **xpub-handshake-then-derive-locally** model is genuinely better
than WC2's per-address pattern. If the BCH dApp ecosystem migrates
toward this, we want to know.
- The **E2E encryption** raises the privacy ceiling. For a privacy-
oriented wallet like Deviant, this aligns with the project's posture.
- **Nostr as a transport** decouples from WalletConnect, Inc. and
Reown's product cycle. WC2's relay is run by a company; Nostr relays
are pluralistic. Politically + operationally desirable.
---
## 6. Pinned facts (2026-06-29 tarball inspection) + remaining open items
**Pinned from `@wizardconnect/core@0.2.4`:**
- URI scheme: **`wiz://`** (QR-safe form `WIZ://%3F...`)
- Default relay: `wss://relay.riften.net:443`
- Protocol name: **`hdwalletv1`**
- Path-name → BIP44 branch: `receive→0`, `change→1`, `defi→7`, extensions
are opaque (no fixed branch index — dApp discovers via xpub)
- Sign-tx payload: `SignTransactionRequest.transaction` is a
`WcSignTransactionRequest` (WC2-shape tx object) + `inputPaths` triplets
- **Sign-transaction semantics:** dApp-built pass-through (same as WC2 —
the tx object is a WC2 object). So the covenant-routing rule from
ADR-0043 §5 applies: **WizardConnect CAN co-sign covenants** (unlike
CashConnect V0).
**Still open:**
- Whether `initiateWalletRelay` accepts a caller-managed nostr-tools
relay pool, or insists on owning the connection. Determines whether
Deviant can share one Nostr relay client across multiple sessions or
needs one per session.
- Sign-message format (`hdwalletv1` protocol only defines
`sign_transaction_request` — no message-sign action). Per the
CashScript guide: *"WizardConnect covers transaction signing only. For
arbitrary message signing, use BCH WalletConnect."* So message-sign
falls through to WC2 in a dual-protocol dApp.
- Whether the `extensions` field on `Hdwalletv1Session` is used by any
shipping dApp today (e.g., `stealth_scan`).
---
## 7. Cross-references
- `WalletConnect_integration_reference.md` — WC2 reference (this folder)
- `CashConnect_reference.md` — CC reference (this folder)
- `wiki/connector.md` §3a — multi-protocol routing rule
- `wiki/decisions.md` ADR-0043 — multi-protocol responder architecture
- Snapshots: 0153 (WizardConnect research recorded), 0062 (the
"is it WC2 underneath?" probe resolved)
- Memory: `wizardconnect-bch-connector.md`
- Upstream docs: <https://docs.riftenlabs.com/wizardconnect/>
- Reference dApp: <https://paryonusd.com/app>

View file

@ -0,0 +1,20 @@
/**
* Connect module — the generic dApp-side ("proposer") wallet-connection core,
* shared by every Deviant dApp (tote, checkers, event-prediction) and both
* wallet-connection protocols (WalletConnect wc2-bch-bcr + CashConnect).
*
* A dApp builds its tx with CashScript (covenant inputs carry their unlockers,
* each user input via `placeholderP2PKHUnlocker`), turns it into a co-sign
* request, and hands it to any `WalletSession` adapter. See proposer.ts +
* spike/wc-connect/ for the protocol adapters (device-side).
*/
export {
buildCoSignRequest,
proposeCoSign,
toWcParams,
placeholderP2PKHUnlocker,
type WalletSession,
type SignedTxResult,
type WcTransactionObject,
type WcTransactionOptions,
} from './proposer.js';

View file

@ -0,0 +1,110 @@
/**
* src/connect/proposer.ts — the generic dApp-side ("proposer") connector core,
* shared by every Deviant dApp (tote, checkers, event-prediction) and by both
* wallet-connection protocols (WalletConnect wc2-bch-bcr + CashConnect).
*
* Roles (see spike/wc-connect/README.md):
* - dApp = PROPOSER: opens a session, builds an unsigned tx leaving the USER's
* own inputs as placeholders, and requests the wallet co-sign them.
* - wallet = RESPONDER: connects, and signs only its own inputs.
*
* This module is protocol-agnostic: a dApp builds its transaction with CashScript
* (covenant inputs carry their real unlockers; each user input is added with
* `placeholderP2PKHUnlocker(userAddress)`), turns it into a `WcTransactionObject`
* via `buildCoSignRequest`, and hands it to a `WalletSession` (a WalletConnect or
* CashConnect adapter). Validated handshake: snapshot 0055.
*
* What is shared across protocols is the TX-BUILDING (the `TransactionBuilder` with
* placeholder user inputs). The WIRE REQUEST is NOT identical across protocols:
* - WalletConnect (wc2-bch-bcr): `bch_signTransaction` takes the `WcTransactionObject`
* verbatim, serialized via `toWcParams` (libauth `stringify`). This is the
* device-validated path (snapshot 0057).
* - CashConnect (CashRPC, v0.0.25): `bch_signTransaction_V0` is **wallet-
* orchestrated**, not a dApp-built pass-through. Template-scoped inputs use the
* BchNamespace `template` (a libauth `WalletTemplate` the dApp CAN supply at
* session establishment), but the wallet then auto-runs
* `usingUnspents → addTokensFromUnspents → addTokenChangeOutputs →
* addSatoshisFromUnspents → addSatoshiChangeOutput` and VM-`verify`s before
* signing. Net: CashConnect V0 is the right fit for **standard wallet-side flows**
* (plain BCH sends, CashTokens FT/NFT sends) — the wallet builds and signs. For
* Deviant's **dApp-built covenant txs** (tote `bet()`, `GamePoolEscrowFlat.fund()`),
* route through WalletConnect: the dApp-supplied-template path is technically
* open but blocked in practice by the auto-orchestration breaking covenant
* structure, wallets' refusal to run arbitrary dApp templates, and the VM-verify
* gate. `toWcParams` below remains WalletConnect-only.
* Multi-protocol wallets (e.g. Cashonize) speak both, so users never see the routing
* decision. See spike/wc-connect/cashconnect.mjs + TESTING.md §2 for the CashConnect
* handshake test.
*/
import { TransactionBuilder } from 'cashscript';
import {
placeholderP2PKHUnlocker,
type WcTransactionObject,
type WcTransactionOptions,
} from 'cashscript';
import { stringify } from '@bitauth/libauth';
export { placeholderP2PKHUnlocker };
export type { WcTransactionObject, WcTransactionOptions };
/** What the wallet returns from `bch_signTransaction`. */
export interface SignedTxResult {
/** Fully-signed transaction hex. */
readonly signedTransaction: string;
/** Its txid (hash). */
readonly signedTransactionHash: string;
}
/**
* A connected wallet session — the seam every adapter implements, so a Deviant
* dApp is written ONCE against this interface and works over WalletConnect,
* CashConnect, or (later) WizardConnect. Concrete adapters wrap the relay/SDK
* (device-side; see spike/wc-connect/{connect,cashconnect}.mjs).
*/
export interface WalletSession {
/** The bch account(s) the wallet exposed at pairing (cashaddr). */
getAddresses(): Promise<string[]>;
/** Ask the wallet to co-sign the placeholder inputs of a proposed tx. */
signTransaction(request: WcTransactionObject): Promise<SignedTxResult>;
}
/**
* Turn a fully-built (but user-inputs-unsigned) transaction into a co-sign
* request. Thin wrapper over CashScript's `generateWcTransactionObject` so every
* dApp has one named entry point (and a place to normalise across protocols if
* they ever diverge). The builder must already have:
* - covenant inputs added with their real `contract.unlock.*(...)` unlockers,
* - each user input added with `placeholderP2PKHUnlocker(userAddress)`,
* - all outputs added.
*/
export function buildCoSignRequest(
tb: TransactionBuilder,
opts: WcTransactionOptions = {},
): WcTransactionObject {
return tb.generateWcTransactionObject(opts);
}
/**
* Serialize a `WcTransactionObject` into the JSON-RPC `params` a BCH wallet
* expects for `bch_signTransaction` (wc2-bch-bcr). The wire format is **libauth's
* `stringify`**: `Uint8Array → "<Uint8Array: 0x..>"`, `bigint → "<bigint: ..n>"`.
* Wallets (e.g. Cashonize) validate these exact patterns and reject plain
* hex/decimal (or raw arrays/numbers) — so an adapter MUST pass `toWcParams(req)`,
* never the raw object, to `client.request`.
*/
export function toWcParams(req: WcTransactionObject): unknown {
return JSON.parse(stringify(req));
}
/**
* Build the co-sign request and send it to the wallet in one step. The dApp signs
* its covenant inputs; the wallet fills the placeholder (user) inputs and — when
* `broadcast` is set — broadcasts. Returns the signed tx.
*/
export async function proposeCoSign(
session: WalletSession,
tb: TransactionBuilder,
opts: WcTransactionOptions = {},
): Promise<SignedTxResult> {
return session.signTransaction(buildCoSignRequest(tb, opts));
}

View file

@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* connect.mjs — minimal WalletConnect v2 "dApp" that pairs with a BCH wallet
* (Cashonize) and proves the handshake + signing works with a CHIPNET address,
* per the wc2-bch-bcr spec (namespace `bch`, chain `bch:bchtest`).
*
* This is the FIRST milestone for the Deviant tote connector: confirm a bettor's
* own wallet can connect and sign. The tote bet co-sign (bch_signTransaction with
* an unsigned tote tx) is the next milestone, added once this handshake works on
* your device.
*
* Steps:
* 1. Get a free WalletConnect/Reown projectId at https://cloud.reown.com
* 2. In Cashonize, import a chipnet WIF (e.g. wallet D) and switch to chipnet.
* 3. WC_PROJECT_ID=<id> npm run connect
* 4. Paste the printed URI (or scan the QR) into Cashonize → WalletConnect.
* 5. Approve the session, then the getAddresses + signMessage prompts.
*
* Network note: the tote runs on chipnet (`bch:bchtest`). If your Cashonize build
* exposes only mainnet for WC, the handshake + signMessage still prove the wiring
* (no funds needed); chipnet tx-signing is handled at the next milestone.
*
* Co-pairing pattern (recommended for any dApp — added 2026-06-15 per the
* Checkers chipnet-co-support issue, where a mainnet-only dApp builds couldn't
* pair with chipnet wallets like the Deviant chipnet beta): request BOTH chains
* as `optionalNamespaces.bch.chains` and read the chain the wallet actually
* picked from the returned session's `namespaces.bch.accounts[0]` (account
* string `bch:<chain>:<cashaddr>`). One dApp build then pairs with mainnet AND
* chipnet wallets — no separate builds, no Cloud-project switching. Set
* `WC_CHAIN=bch:bchtest` (or `bch:bitcoincash`) to force a single chain for
* chain-locked test flows.
*/
import { SignClient } from '@walletconnect/sign-client';
import qrcode from 'qrcode-terminal';
const FORCED_CHAIN = process.env.WC_CHAIN; // optional override, e.g. 'bch:bchtest'
const CHAINS = FORCED_CHAIN ? [FORCED_CHAIN] : ['bch:bitcoincash', 'bch:bchtest'];
const projectId = process.env.WC_PROJECT_ID;
if (!projectId) {
console.error('Set WC_PROJECT_ID — get a free one at https://cloud.reown.com');
process.exit(1);
}
/** Read the chain the wallet picked at approval time from a session's
* `namespaces.bch.accounts[0]`. The wc2-bch-bcr account format is
* `bch:<chain>:<cashaddr>` — segments 0..1 are the chain id. */
function chainFromSession(session) {
const acc = session?.namespaces?.bch?.accounts?.[0] ?? '';
const parts = acc.split(':');
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : CHAINS[0];
}
function divider() { console.log('='.repeat(72)); }
async function main() {
const client = await SignClient.init({
projectId,
metadata: {
name: 'Deviant Tote (chipnet test)',
description: 'Non-custodial pari-mutuel tote — WalletConnect co-sign test',
url: 'https://deviant.cash',
icons: ['https://deviant.cash/icon.png'],
},
});
const { uri, approval } = await client.connect({
optionalNamespaces: {
bch: {
chains: CHAINS, // request all supported chains; wallet picks one at approval
methods: ['bch_getAddresses', 'bch_signMessage', 'bch_signTransaction'],
events: [],
},
},
});
if (uri) {
divider();
console.log(`Pair this in Cashonize → WalletConnect (chains: ${CHAINS.join(', ')}):`);
divider();
console.log(uri);
console.log('');
qrcode.generate(uri, { small: true });
console.log('Waiting for the wallet to approve the session…');
}
const session = await approval();
// Read which chain the wallet actually picked from the session — don't assume
// CHAINS[0]. A chipnet wallet pairing against this dApp will return a
// `bch:bchtest:…` account; a mainnet wallet returns `bch:bitcoincash:…`.
const pickedChain = chainFromSession(session);
divider();
console.log(`Paired with: ${session.peer.metadata.name}`);
console.log(`Chain: ${pickedChain}`);
const accounts = session.namespaces.bch?.accounts ?? [];
console.log(`Accounts: ${accounts.join(', ')}`);
divider();
// 1) getAddresses — confirm which address the wallet exposes.
const addresses = await client.request({
topic: session.topic,
chainId: pickedChain,
request: { method: 'bch_getAddresses', params: {} },
});
console.log('bch_getAddresses →', addresses);
// 2) signMessage — confirm the wallet can sign with that key.
const message = `Deviant tote WalletConnect test ${new Date().toISOString()}`;
const signed = await client.request({
topic: session.topic,
chainId: pickedChain,
request: { method: 'bch_signMessage', params: { message, userPrompt: 'Sign to prove your Deviant tote connection' } },
});
console.log('bch_signMessage →', signed);
divider();
console.log('HANDSHAKE OK — the wallet connected and signed. Next milestone: bch_signTransaction co-sign of a tote bet.');
divider();
process.exit(0);
}
main().catch((e) => { console.error('FAILED:', e?.message ?? e); process.exit(1); });

View file

@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* signtx.mjs — WalletConnect `bch_signTransaction` CO-SIGN test (milestone 4
* mechanism), beyond the handshake in connect.mjs. It proves the wallet can
* co-sign a transaction the dApp built — the exact path the tote/checkers use.
*
* SAFE: it builds a SELF-SEND of one of YOUR OWN UTXOs back to your own address,
* marks that input as a placeholder for the wallet to sign, and requests signing
* with **broadcast: false** — so the wallet signs but NOTHING is broadcast and no
* funds move. You just see the signed transaction hex come back.
*
* Flow: pair (as connect.mjs) → bch_getAddresses → fetch your UTXOs → build a
* self-send with a placeholder input → bch_signTransaction(broadcast:false).
*
* Setup:
* 1. cd spike/wc-connect && npm install (installs cashscript too)
* 2. WC_PROJECT_ID=<id> npm run connect:signtx
* 3. Paste the URI into your wallet (Cashonize) on the SAME network as WC_CHAIN
* and approve the session + the sign request.
*
* Network: defaults to mainnet (bch:bitcoincash) — the wallet that paired earlier.
* Override WC_CHAIN=bch:bchtest for chipnet (wallet must be on chipnet). Needs the
* address to hold >= 1 UTXO (a tiny balance); no funds are spent (broadcast:false).
*/
import { SignClient } from '@walletconnect/sign-client';
import qrcode from 'qrcode-terminal';
import { TransactionBuilder, ElectrumNetworkProvider, placeholderP2PKHUnlocker } from 'cashscript';
const CHAIN = process.env.WC_CHAIN || 'bch:bitcoincash';
const NETWORK = CHAIN === 'bch:bchtest' ? 'chipnet' : 'mainnet';
const projectId = process.env.WC_PROJECT_ID;
if (!projectId) { console.error('Set WC_PROJECT_ID — https://cloud.reown.com'); process.exit(1); }
function divider() { console.log('='.repeat(72)); }
// wc2-bch-bcr serialization == libauth's `stringify` format, which wallets
// (Cashonize) validate strictly: Uint8Array -> "<Uint8Array: 0x..>", bigint ->
// "<bigint: ..n>". (Plain hex/decimal strings are rejected.)
function bin2hex(b){let s='';for(const x of b)s+=x.toString(16).padStart(2,'0');return s;}
function wcStringify(o){return JSON.stringify(o,(k,v)=>typeof v==='bigint'?`<bigint: ${v}n>`:(v instanceof Uint8Array?`<Uint8Array: 0x${bin2hex(v)}>`:v));}
async function main() {
const client = await SignClient.init({
projectId,
metadata: { name: 'Deviant co-sign test', description: 'bch_signTransaction self-send (broadcast:false)', url: 'https://deviant.cash', icons: ['https://deviant.cash/icon.png'] },
});
const { uri, approval } = await client.connect({
optionalNamespaces: { bch: { chains: [CHAIN], methods: ['bch_getAddresses', 'bch_signTransaction'], events: [] } },
});
if (uri) { divider(); console.log(`Pair in your wallet (chain ${CHAIN}):`); divider(); console.log(uri); qrcode.generate(uri, { small: true }); console.log('Waiting for approval…'); }
const session = await approval();
divider(); console.log(`Paired with ${session.peer.metadata.name}`); divider();
// 1) address
const addresses = await client.request({ topic: session.topic, chainId: CHAIN, request: { method: 'bch_getAddresses', params: {} } });
const address = Array.isArray(addresses) ? addresses[0] : addresses;
console.log(`Address: ${address}`);
// 2) fetch the user's UTXOs and pick one plain-BCH UTXO for the self-send
const provider = new ElectrumNetworkProvider(NETWORK);
const utxos = (await provider.getUtxos(address)).filter((u) => !u.token);
if (!utxos.length) { console.error(`No plain-BCH UTXOs at ${address} on ${NETWORK} — fund it with a tiny amount and retry (nothing is spent; broadcast:false).`); process.exit(2); }
const u = utxos.sort((a, b) => Number(b.satoshis) - Number(a.satoshis))[0];
console.log(`Using UTXO ${u.txid}:${u.vout} (${u.satoshis} sats)`);
// 3) build a self-send with the user input as a PLACEHOLDER (wallet co-signs it)
const fee = 500n;
const tb = new TransactionBuilder({ provider })
.addInput(u, placeholderP2PKHUnlocker(address))
.addOutput({ to: address, amount: BigInt(u.satoshis) - fee });
const wcTxObj = tb.generateWcTransactionObject({ broadcast: false, userPrompt: 'Deviant co-sign test (self-send, NOT broadcast)' });
// 4) request the wallet co-sign (broadcast:false → nothing is sent)
const signed = await client.request({
topic: session.topic, chainId: CHAIN,
request: { method: 'bch_signTransaction', params: JSON.parse(wcStringify(wcTxObj)) },
});
divider();
console.log('bch_signTransaction →', typeof signed === 'object' ? JSON.stringify(signed).slice(0, 200) : signed);
console.log('CO-SIGN OK — the wallet signed a dApp-built tx (not broadcast). This is the tote/checkers stake path.');
divider();
process.exit(0);
}
main().catch((e) => { console.error('FAILED:', e?.message ?? e); process.exit(1); });

View file

@ -0,0 +1,128 @@
# Deviant tote — WalletConnect connector (chipnet test)
Standalone WalletConnect v2 "dApp" that pairs with a BCH wallet (Cashonize) and
proves a **chipnet address can connect and sign** — the first milestone toward
non-custodial tote staking (`wc2-bch-bcr` spec: namespace `bch`, chain
`bch:bchtest`, methods `bch_getAddresses` / `bch_signMessage` / `bch_signTransaction`).
It is **separate from the main project** (its own `node_modules`) so it never
touches Deviant's pinned dependencies.
## What it does (milestone 1)
1. Opens a WalletConnect session and prints a pairing **URI + QR**.
2. After you approve in the wallet: `bch_getAddresses` (shows the connected
address) and `bch_signMessage` (proves the wallet can sign with that key).
The tote **bet co-sign** (`bch_signTransaction` of an unsigned tote tx the wallet
signs its own stake input on) is **milestone 2** — added once this handshake works
on your device.
## Steps
1. **Get a free WalletConnect/Reown projectId** at <https://cloud.reown.com>
(create a project → copy the Project ID).
2. **Put a chipnet address in Cashonize.** Import a chipnet WIF — e.g. wallet D
from `../../.chipnet-keys-d.json` (the `wif` field) — into Cashonize, and switch
Cashonize to **chipnet** if it exposes a network toggle.
- If your Cashonize build only does mainnet for WalletConnect, the handshake +
`signMessage` still validate the wiring (they need no funds). Set
`WC_CHAIN=bch:bitcoincash` in that case and we handle chipnet tx-signing at
milestone 2.
3. **Run the connector** (from this folder, or with the prefix from the repo root):
```
WC_PROJECT_ID=<your-project-id> npm run connect
# optional: WC_CHAIN=bch:bitcoincash WC_PROJECT_ID=... npm run connect
```
4. **Pair**: copy the printed `wc:` URI (or scan the QR) into Cashonize →
WalletConnect → approve the session.
5. **Approve** the `getAddresses` and `signMessage` prompts. The connector prints
the address + signature and exits with `HANDSHAKE OK`.
## Verified so far
- The connector loads, initializes `SignClient`, and reaches the WalletConnect
relay (it errors only on a missing/invalid projectId — confirming the dApp side
is wired). The pairing + signing half runs on **your device** and is what these
steps drive.
## Multi-protocol requirement (WalletConnect + CashConnect + WizardConnect)
BCH has multiple wallet-connection standards, and the tote must support them so
bettors on any wallet ecosystem can stake non-custodially:
- **WalletConnect (wc2-bch-bcr)** — WalletConnect v2 + the `bch` namespace.
`connect.mjs` / `npm run connect:wc`. Cashonize speaks it.
- **CashConnect** — BCH-native protocol by developers.cash (@jimtendo) built on
**CashRPC** (WC2 is its transport today; HTTP/LibP2P planned). Merged into mainline
Cashonize. Package `cashconnect@0.0.25` (dApp `CashConnectDapp`, wallet
`CashConnectWallet`); reference impl `developers-cash/cashonize` cash-connect.js.
`cashconnect.mjs` / `npm run connect:cc` — **rewritten against the installed dApp
API** (CONFIRM markers resolved from `dist/index.d.ts`); handshake (getBalance +
signMessage) ready for on-device pairing. **The CashConnect wire API is NOT the same
as WalletConnect's** — see the diagram note below.
- **WizardConnect** — a third BCH connector (Riften Labs / NOSTR-based), used by the
**ParionUSD** stablecoin. To add as a third adapter once its SDK/flow is confirmed.
**Two sides, both needed.** A dApp side (the tote *proposes* txs — these scripts) and
a **wallet side** (the **Deviant wallet**, under construction, will *respond* — host
`CashConnectWallet` / a WC2 wallet responder so Deviant users can connect to the tote
and to other dApps like ParionUSD). The scripts here are the dApp side; the wallet-side
integration lands in the Deviant wallet.
**Reusable proposer core (`src/connect/proposer.ts`, unit-tested).** The dApp side is
generalised into a protocol-agnostic module: a `WalletSession` interface (the seam
every adapter implements) + `buildCoSignRequest(tb, opts)` (wraps CashScript
`generateWcTransactionObject`) + `proposeCoSign(session, tb, opts)`. Any Deviant dApp —
tote, **checkers**, event-prediction — is written ONCE against `WalletSession`: build
the tx (covenant inputs unlocked by the contract; each user input via
`placeholderP2PKHUnlocker`), `proposeCoSign`, done. The WC / CashConnect / WizardConnect
adapters (these scripts) are interchangeable behind it; the same request object works
over either protocol, only the transport differs.
**Architecture: shared tx-building, pluggable connection adapters.** The unsigned
tote transaction is built ONCE — CashScript's
`TransactionBuilder.generateWcTransactionObject({ broadcast, userPrompt })` produces
the `{ transaction, sourceOutputs, broadcast, userPrompt }` object, with the bettor's
own stake input marked unsigned via `placeholderP2PKHUnlocker(bettorAddress)`. Only
the **connect / session / sign-request** layer differs per protocol:
```
buildToteBetTx() ──> TransactionBuilder ──┬─► WC adapter: WcTransactionObject ──► bch_signTransaction (verbatim, toWcParams)
└─► CC adapter: map → SignTransactionV0Params[] ──► bch_signTransaction_V0
```
What is shared is the **tx-building** (the `TransactionBuilder` with the bettor's
stake input marked unsigned via `placeholderP2PKHUnlocker`). The **wire request is
not**: WalletConnect's `bch_signTransaction` consumes the `WcTransactionObject`
verbatim, but CashConnect's `bch_signTransaction_V0` takes
`Array<SignTransactionV0Params>` (template-/wallet-scoped inputs+outputs) — a
different shape that a CashConnect adapter must **map** the built tx into. (The
`cashconnect` SDK applies its own libauth `stringify`, so a CC payload must NOT be
pre-`toWcParams`-ed.) Both still return a signed-tx hex; the covenant inputs are
signed by the dApp (CashScript), the wallet signs only the bettor's P2PKH input.
This WcTransactionObject→CashRPC mapping is the open CashConnect co-sign milestone.
## Milestones
1. **WC handshake** — `connect.mjs` (`bch_getAddresses` + `bch_signMessage`).
✅ **device-validated** (2026-05-25, Cashonize on `bch:bitcoincash`): paired,
`bch_getAddresses` returned the wallet address and `bch_signMessage` returned a
valid signature. (chipnet `bch:bchtest` needs Cashonize switched to chipnet; the
mainnet handshake proves the wiring with no funds.)
2. **CashConnect handshake** — `cashconnect.mjs`. 🔧 rewritten against the installed
`cashconnect@0.0.25` dApp API (`CashConnectDapp(options, chain, dialogs)` →
`start()` → `connect(requiredNamespaces)`; CashRPC `bch_getBalance_V0` +
`bch_signMessage_V0`). Imports + exports verified to resolve; pairing runs
on-device. Co-sign (`bch_signTransaction_V0`) needs the WcTransactionObject→CashRPC
mapping (above) and is the next milestone.
3. **WizardConnect handshake** — third adapter (ParionUSD's connector). ⏳ when its SDK/flow is confirmed.
4. **Co-sign (`bch_signTransaction`)** — `signtx.mjs` (`npm run connect:signtx`):
builds a self-send with the user input as a placeholder, requests the wallet
co-sign. ✅ **device-validated** (2026-05-25, Cashonize/mainnet, `broadcast:false`):
returned signed-tx hash `1bfda2ccafa7ae071cf16f04a04e83491aa4af238ff978690bb7e797acdc3bb9`.
This is the exact tote `bet()` / checkers `GamePoolEscrow.fund()` staking path,
shared across all adapters (built once via CashScript). **Serialization gotcha:**
WC params must use libauth's `stringify` format (`<Uint8Array: 0x..>` / `<bigint:
..n>`), not plain hex/JSON — captured in `src/connect/proposer.ts:toWcParams`.

View file

@ -0,0 +1,126 @@
# Testing the wallet-connector adapters (WalletConnect · CashConnect · WizardConnect)
How to test each adapter **on a device with a real wallet** (the relay + signing
can't run from a headless box). Covers prerequisites, run commands, expected
results, the gotchas we already hit live, and — for the two protocols whose dApp
API isn't yet pinned — exactly what to confirm against the installed SDK.
## 0. Prerequisites (once)
```
cd spike/wc-connect
npm install
```
- **Project ID** (WalletConnect/CashConnect, both ride the WC2 relay): free at
https://cloud.reown.com → create a project → copy the 32-char id. Export it:
`export WC_PROJECT_ID=<id>` (it's a public client id, not a secret; don't commit it).
- A wallet that speaks the protocol (Cashonize for WC + CashConnect; Paytaca for
WizardConnect). Import a key (e.g. a chipnet WIF) if you want chipnet.
- **Network:** the dApp advertises `WC_CHAIN` (`bch:bitcoincash` mainnet [default] or
`bch:bchtest` chipnet). **The wallet must be on the same network.**
## 1. WalletConnect (wc2-bch-bcr) — VALIDATED
Two tests, both `connect.mjs`-family:
**Handshake** (✅ device-validated, snapshot 0055):
```
WC_PROJECT_ID=$WC_PROJECT_ID npm run connect:wc
```
Paste the printed `wc:…` URI into Cashonize → WalletConnect, approve. Expect:
`Paired with: Cashonize`, then `bch_getAddresses → [address]` and a `bch_signMessage`
signature. (Earlier success: address `bitcoincash:qqlqsl7s2…`, a valid base64 sig.)
**Co-sign** (`bch_signTransaction`, the tote/checkers stake path — SAFE, nothing
broadcast):
```
WC_PROJECT_ID=$WC_PROJECT_ID npm run connect:signtx
```
It builds a **self-send of one of your own UTXOs**, marks that input as a placeholder
for the wallet, and requests signing with **`broadcast: false`** → the wallet signs,
no funds move, you see the signed tx hex. (Needs the address to hold ≥1 UTXO; on
mainnet a few thousand sats, on chipnet your faucet funds. Set `WC_CHAIN=bch:bchtest`
+ put the wallet on chipnet to test the tote's real network.)
## 2. CashConnect — API pinned to `cashconnect@0.0.25`; pair to test handshake
```
WC_PROJECT_ID=$WC_PROJECT_ID npm run connect:cc
```
`cashconnect.mjs` is **rewritten against the installed dApp API** (resolved from
`node_modules/cashconnect/dist/index.d.ts` + compiled source) — no more `CONFIRM`
guesses. Confirmed facts, and how CashConnect **differs from WalletConnect**:
- **Construction:** `new CashConnectDapp({ projectId, metadata }, chain, dialogs)`
then `await dapp.start()`. `dialogs` = `{ newSession, reconnectSession, request }`
classes; the pairing URI is surfaced by instantiating `dialogs.newSession(url, …)`
(CashConnect rewrites the `wc:` scheme to **`cc:`**). Paste the `cc:` URI into
Cashonize → CashConnect.
- **Connect:** `await dapp.connect(requiredNamespaces)` returns a **boolean** (no
`{ uri, approval }`). The account is read in `onConnected` from
`session.namespaces.bch.accounts[0]`, formatted `bch:<network>:<pubkeyHex>` — a
**pubkey, not a cashaddr**; there is **no `bch_getAddresses`**.
- **Methods (CashRPC, `_V0`-suffixed):** `bch_getBalance_V0`,
`bch_getChangeLockingBytecode_V0`, `bch_getTokens_V0`, `bch_signMessage_V0`,
`bch_signTransaction_V0`, `wc_authRequest`. The handshake test calls
`bch_getBalance_V0` + `bch_signMessage_V0` (params `{ message: Uint8Array }`).
- **Serialization:** `dapp.request(method, params)` applies libauth `stringify` to
`params` internally and `parseExtendedJson`es the response — pass **raw** objects
(Uint8Array/bigint); do **not** pre-apply `toWcParams` (that is WalletConnect-only).
- **Co-sign is wallet-orchestrated, not dApp-pass-through.** Reading the wallet's
`CashRPC_V0.signTransaction` handler (lines 995–1112 of the installed dist):
1. Inputs/outputs are either template-scoped (uses the BchNamespace `template`
— a libauth `WalletTemplate` the **dApp can provide** at session establishment,
so a CashScript covenant CAN be exported to a template and referenced) or
wallet-scoped (wallet looks up its own UTXO).
2. After parsing dApp-provided inputs/outputs the wallet auto-runs
`usingUnspents → addTokensFromUnspents → addTokenChangeOutputs →
addSatoshisFromUnspents → addSatoshiChangeOutput` — it adds its own UTXOs +
change, taking control of input/output composition.
3. The wallet runs a libauth VM `verify` on the assembled tx and rejects on fail.
Net: CashConnect V0 is designed for **wallet-orchestrated standard flows** — plain
BCH sends and CashTokens FT/NFT sends — which it does cleanly. For **dApp-built
covenant txs** (tote `bet()`, `GamePoolEscrowFlat.fund()`), the dApp-supplied-
template path is technically open but blocked in practice by the auto-orchestration
breaking covenant input/output structure, wallets' reasonable refusal to run
arbitrary dApp templates, and the VM verify gate. WalletConnect's `bch_signTransaction`
(validated 0057, true pass-through) remains the path for those flows. Cashonize
speaks both, so users never see the routing decision.
- **Bundle patch (cashconnect@0.0.25):** the bundle does
`import Client from '@walletconnect/sign-client'`; sign-client's ESM build sets
`default` to the package namespace (object), not the `SignClient` class — so under
ESM `Client.init` is undefined. `patches/apply-cashconnect-fix.mjs` rewrites the
bundle to `import { SignClient as Client } …`. Idempotent; wired as `postinstall`.
- **Project ID (`WC_PROJECT_ID=c4ac…3de0`) validated against the WC2 relay** —
dApp initializes, `dapp.start()` succeeds, `connect()` produces a `cc:` pairing
URI. Pair it in Cashonize → CashConnect to complete the handshake on-device.
## 3. WizardConnect — confirm protocol/SDK, then adapter
Not yet scaffolded — pin the protocol first:
- **Sources:** Riften Labs docs https://docs.riftenlabs.com/ (Cauldron / wallets);
it ships in **Paytaca** (paytaca/paytaca-app releases mention WizardConnect:
parallel broadcast via watchtower, duplicate-request suppression). ParionUSD uses it.
- **Confirm:** is WizardConnect (a) a distinct protocol with its own SDK, or (b)
Paytaca's branding over WalletConnect v2? If (b), the existing `connect.mjs` may pair
with Paytaca directly (try `npm run connect:wc` against Paytaca). If (a), get its
dApp SDK + the connect/sign API, then add an adapter mirroring `cashconnect.mjs`.
- The `WalletSession` interface (`src/connect/proposer.ts`) is the seam the adapter
implements either way.
## 4. Troubleshooting (from live runs)
- **`Proposal expired`** — pairing URIs last ~5 min. Generate, then paste/scan
*immediately*; don't switch networks mid-pairing. If it expires, just re-run.
- **"Dapp requires wallet to be on chipnet"** — the dApp advertised `bch:bchtest` but
the wallet is on mainnet. Switch the wallet's network, OR (for a wiring test) run the
dApp on mainnet: `WC_CHAIN=bch:bitcoincash`. The handshake/co-sign prove the adapter
regardless of network; only a real broadcast needs matching networks.
- **No QR / can't scan** — copy the `wc:…` URI text and use the wallet's "paste URI".
- **`No plain-BCH UTXOs`** (signtx) — the connected address is empty; fund it with a
tiny amount (nothing is spent — `broadcast:false`).
- **Relay/init hangs** — check outbound access to the WC2 relay; retry.
## 5. What "validated" means per adapter
| Adapter | Handshake | Co-sign (`signTransaction`) |
|---|---|---|
| WalletConnect | ✅ on-device (0055) | ✅ on-device (0057), `connect:signtx` |
| CashConnect | API pinned to v0.0.25; project ID + relay validated; pair to validate on-device | ✅ for standard BCH / token sends; **dApp covenant txs** route through WC (auto-orchestration blocks them in practice) |
| WizardConnect | confirm protocol first | via the same `WalletSession` seam |
The dApp-side **co-sign core is shared + unit-tested** (`src/connect/proposer.ts`,
snapshot 0056) — each adapter only supplies connect + the `signTransaction` transport.

View file

@ -0,0 +1,71 @@
/**
* bch-namespace.ts — wire constants shared by the WalletConnect (wc2-bch-bcr) and
* CashConnect (CashRPC) responder handlers.
*
* Both protocols ride the SAME WC2 relay and use the SAME namespace name (`bch`);
* they differ in proposal shape (CashConnect adds `protocol: 'CashRPC:0'` +
* `template` + `allowedTokens`), method names (`_V0`-suffixed for CC), and wire
* encoding. See ADR-0043 + wiki/connector.md for the full design rationale.
*/
import type { BchChain } from './index';
/** Account string layout for wc2-bch-bcr: `bch:<network>:<cashaddr>`. */
export type WcAccount = `${BchChain}:${string}`;
/** Account string layout for CashConnect: `bch:<network>:<pubkeyHex>`. The wallet
* exposes the **public key** (33-byte compressed, hex), NOT a cashaddr — confirmed
* in cashconnect@0.0.25 (`CashConnectWallet.onSessionProposal`, lines ~1483 of the
* installed dist: `accounts: ['${chain}:${network}:${binToHex(sandboxedPublicKey)}']`).
* Snapshot 0062 finding 1. */
export type CcAccount = `${BchChain}:${string}`;
// ─── WalletConnect (wc2-bch-bcr) ──────────────────────────────────────────
/** WalletConnect methods the responder MUST handle. Mirrors the proposer
* (src/connect/proposer.ts) and snapshot 0057's validated co-sign. */
export const WC_METHODS = [
'bch_getAddresses',
'bch_signMessage',
'bch_signTransaction',
] as const;
export type WcMethod = (typeof WC_METHODS)[number];
// ─── CashConnect (CashRPC) ────────────────────────────────────────────────
/** CashConnect methods the responder MAY handle. CashConnect V0 is
* wallet-orchestrated (it builds the tx from instructions, runs a VM verify, and
* signs) — so the responder routes signTransaction to the cashconnect package's
* exported `CashRPC_V0` class. CC cannot co-sign Deviant covenant txs at V0; those
* flows route through WC (ADR-0043 §2.3 + snapshot 0062 Finding 3). */
export const CC_METHODS = [
'wc_authRequest',
'bch_getBalance_V0',
'bch_getChangeLockingBytecode_V0',
'bch_getTokens_V0',
'bch_signMessage_V0',
'bch_signTransaction_V0',
] as const;
export type CcMethod = (typeof CC_METHODS)[number];
/** CashConnect's protocol marker on the `bch` namespace at session proposal. Used
* by `classifier.classifyProposal` to distinguish CC sessions from plain WC ones. */
export const CC_PROTOCOL = 'CashRPC:0' as const;
/** CashConnect emits `balancesChanged`; plain WC emits nothing. */
export const CC_EVENTS = ['balancesChanged'] as const;
/** Chains supported by both protocols (per cashconnect's `SupportedChains` and the
* proposer's namespace). The shell's `ALLOWED_NETWORKS` may further restrict this
* at runtime (chipnet-only beta builds, etc.). */
export const SUPPORTED_CHAINS: readonly BchChain[] = ['bch:bchtest', 'bch:bitcoincash'];
/** Map a `BchChain` to the libauth network token used by the noble crypto shim and
* cashscript's `ElectrumNetworkProvider`. `bch:bchreg` is not exposed in the shell. */
export function chainToNetwork(chain: BchChain): 'chipnet' | 'mainnet' {
return chain === 'bch:bchtest' ? 'chipnet' : 'mainnet';
}
/** Inverse: a network → its chain id. */
export function networkToChain(network: 'chipnet' | 'mainnet' | 'testnet4'): BchChain {
// testnet4 has no canonical BCH WC chain id; surface as chipnet for transport
// purposes (no live dApps speak testnet4 anyway). Document this in connector.md.
return network === 'mainnet' ? 'bch:bitcoincash' : 'bch:bchtest';
}

View file

@ -0,0 +1,71 @@
/**
* classifier.ts — pure protocol routing for the multi-protocol responder.
*
* Both WalletConnect (wc2-bch-bcr) and CashConnect (CashRPC) ride one WC2 relay
* via one `WalletKit` instance (ADR-0043 §2 "Option B"). This module is the seam
* that decides which handler a session/request goes to. Pure — no I/O, no SDK
* imports — so the dispatcher in responder.ts is trivially correct and the
* decision is unit-testable.
*
* Rules (verified against the installed `cashconnect@0.0.25` source, snapshot
* 0062 Finding 3):
*
* - **Proposal classification:** if the `bch` namespace carries `protocol:
* 'CashRPC:0'`, it's CashConnect; otherwise it's WalletConnect. CashConnect's
* `BchNamespace` also carries `template` + `allowedTokens` but we key off the
* protocol field because (a) it's the explicit version tag and (b) `template`
* is technically optional in CC v0.0.25's wallet handler.
*
* - **Request classification:** CashRPC methods are `_V0`-suffixed
* (`bch_signMessage_V0`, `bch_signTransaction_V0`, etc.) plus the bare
* `wc_authRequest`. Everything else is plain WC.
*
* - **URI classification:** CashConnect rewrites `wc:` → `cc:` at proposal time
* to distinguish itself in wallet UIs (cashconnect dist line ~197). The shell
* accepts either prefix and falls back to inspecting the proposal shape.
*
* On ambiguous proposals (no `bch` namespace at all, malformed shapes, unknown
* protocol versions), we throw — better to surface "unsupported" to the user than
* silently route to the wrong handler.
*/
import { CC_PROTOCOL } from './bch-namespace';
export type Protocol = 'wc' | 'cc';
/** Subset of a WC v2 session proposal that's enough to classify. We accept either
* the full proposal event from `@reown/walletkit` or just its
* `proposal.params.requiredNamespaces`/`optionalNamespaces` payload. */
export interface ClassifiableProposal {
readonly requiredNamespaces?: Record<string, { readonly protocol?: string } | undefined>;
readonly optionalNamespaces?: Record<string, { readonly protocol?: string } | undefined>;
}
/** Classify a session proposal. Throws if there's no `bch` namespace at all. */
export function classifyProposal(proposal: ClassifiableProposal): Protocol {
const bch = proposal.requiredNamespaces?.bch ?? proposal.optionalNamespaces?.bch;
if (!bch) {
throw new Error('Proposal does not request the `bch` namespace; cannot route.');
}
return bch.protocol === CC_PROTOCOL ? 'cc' : 'wc';
}
/** Classify a sign-request method. Pure string check — no SDK types needed. */
export function classifyRequest(method: string): Protocol {
// CashRPC methods carry the `_V0` (or future `_Vn`) version suffix.
// `wc_authRequest` is CashConnect's auth flow (it's the only `wc_*` method in
// CashRPC; plain WC v2 doesn't surface auth requests through this channel).
if (/_V\d+$/.test(method) || method === 'wc_authRequest') return 'cc';
return 'wc';
}
/** Classify a pairing URI by its scheme prefix. Returns `null` on unknown shapes,
* signalling that the caller should fall back to proposal-shape classification
* once a session_proposal arrives. */
export function classifyUri(uri: string): Protocol | null {
const t = uri.trim();
if (t.startsWith('cc:') || t.startsWith('cashconnect:') || t.startsWith('bchconnect:')) {
return 'cc';
}
if (t.startsWith('wc:')) return 'wc';
return null;
}

View file

@ -0,0 +1,172 @@
/**
* shells/app/src/connect — wallet-side ("responder") multi-protocol connector.
*
* The dApp/proposer side lives in `src/connect/proposer.ts` (validated end-to-
* end against Cashonize, snapshot 0057). This is its mirror image: the wallet
* pairs with external dApps over the shared WC2 relay and serves BOTH the
* **WalletConnect** (wc2-bch-bcr) and **CashConnect** (CashRPC) protocols from
* a single relay socket — one pair, one session per dApp, dispatched by shape.
*
* Design + decision record: `wiki/connector.md`, ADR-0041 (WC-only origin) +
* ADR-0043 (multi-protocol extension).
*
* Runtime layout:
*
* - `bch-namespace.ts` — methods, chains, protocol marker, account formats
* - `classifier.ts` — pure routing: proposal/request/URI → 'wc' | 'cc'
* - `signer.ts` — libauth pass-through co-signer (WC path)
* - `wc-handler.ts` — bch_signMessage / bch_signTransaction / bch_getAddresses
* - `cc-handler.ts` — wraps the cashconnect package's CashRPC_V0
* - `responder.ts` — WalletKit listener + dispatcher (the orchestrator)
*
* The runtime is dependency-injection-friendly: the shell provides the
* `WalletKit` instance + `KeyProvider` (sealed access to the active KeyStore) +
* `NetworkProvider` (Electrum), and the responder wires them. Headless-testable
* pieces (`classifier`, `signer`'s p2pkh detection) ship unit tests; the WalletKit
* loop is exercised on-device against Cashonize, BCH Guru, and the tote.
*/
// ── Wire types (mirror the proposer + the wc2-bch-bcr / CashRPC specs) ───────
/** Supported chains. `bch:bchtest` = chipnet; `bch:bitcoincash` = mainnet. */
export type BchChain = 'bch:bchtest' | 'bch:bitcoincash';
/** Methods the responder MAY surface to the dApp. The actual set offered per
* session depends on which protocol the proposal selected (see
* `classifier.classifyProposal`). */
export type BchMethod =
// WalletConnect (wc2-bch-bcr) — snapshot 0057 validated:
| 'bch_getAddresses' | 'bch_signMessage' | 'bch_signTransaction'
// CashConnect (CashRPC v0.0.25) — snapshot 0062 pinned:
| 'wc_authRequest'
| 'bch_getBalance_V0' | 'bch_getChangeLockingBytecode_V0' | 'bch_getTokens_V0'
| 'bch_signMessage_V0' | 'bch_signTransaction_V0';
/** Account string format. For wc2-bch-bcr the third segment is a cashaddr; for
* CashConnect it is a 33-byte compressed pubkey, hex-encoded. */
export type BchAccount = `${BchChain}:${string}`;
/** Decoded form of an incoming `bch_signTransaction` after libauth
* `parseExtendedJson`. Mirror of cashscript's `WcTransactionObject`. */
export interface BchSignTxRequest {
/** Decoded transaction (libauth `TransactionCommon`). Re-encode with
* `encodeTransaction` after signing the user's inputs. */
readonly transaction: unknown;
/** Per-input source outputs (decoded). `lockingBytecode` is `Uint8Array`. */
readonly sourceOutputs: ReadonlyArray<unknown>;
/** If true, the responder should also broadcast after signing. */
readonly broadcast?: boolean;
/** Human-readable prompt the wallet must render verbatim. */
readonly userPrompt?: string;
}
/** Decoded form of `bch_signMessage`. */
export interface BchSignMessageRequest {
/** Message bytes (decoded `Uint8Array`). UTF-8 decode for display when printable. */
readonly message: Uint8Array;
/** The cashaddr that should sign (WC) or the chain-namespace account (CC). */
readonly address: string;
}
/** The wallet's response shape for `bch_signTransaction`. Libauth-`stringify`
* it on the way out. */
export interface SignedTxResponse {
/** Fully-signed transaction hex. */
readonly signedTransaction: string;
/** Its txid (hex, big-endian display order). */
readonly signedTransactionHash: string;
}
/** The wallet's response shape for `bch_signMessage`. */
export interface SignedMessageResponse {
readonly signedMessage: string;
readonly signature: string;
}
// ── Account identity (re-exported so the responder doesn't reach into `src/`) ──
/** Mirrors `src/wallet/keystore.ts AccountIdentity` — duplicated locally so the
* shell module compiles standalone. The shell wires real `KeyStore.unlock()` →
* `AccountIdentity` at runtime; types match by shape. */
export interface AccountIdentity {
readonly cashaddr: string;
readonly tokenAddress: string;
readonly ownedAddresses: readonly string[];
readonly publicKey: Uint8Array;
readonly network: 'chipnet' | 'testnet4' | 'mainnet';
readonly derivationPath: string;
}
// ── Responder runtime surface (implemented in responder.ts) ──────────────────
/** Session metadata surfaced to the UI from a `session_proposal` event. */
export interface SessionProposal {
readonly id: number;
readonly dApp: { readonly name: string; readonly url: string; readonly icons: readonly string[] };
readonly chains: readonly BchChain[];
readonly methods: readonly BchMethod[];
}
/** A sign request, surfaced to the UI for approval. */
export type SignRequestPreview =
| { readonly kind: 'sign-message'; readonly id: number; readonly sessionId: string; readonly appName: string; readonly request: BchSignMessageRequest }
| { readonly kind: 'sign-tx'; readonly id: number; readonly sessionId: string; readonly appName: string; readonly request: BchSignTxRequest };
/** Events the responder emits to the AppsScreen UI. */
export interface ResponderEvents {
onSessionProposal: (proposal: SessionProposal) => void;
onSignRequest: (request: SignRequestPreview) => void;
onSessionDelete: (sessionId: string) => void;
onError: (error: Error) => void;
}
/** Public API the AppsScreen uses. */
export interface Responder {
/** Pair with a dApp by pasting/scanning its `wc:` or `cc:` URI. */
pair(uri: string): Promise<void>;
/** Approve a pending session proposal. `accounts` is WC-format; for CC
* proposals the responder rewrites accounts to the `bch:<net>:<pubkeyHex>`
* form automatically (`methods` is ignored for CC — the full V0 method set
* is offered). */
approveSession(proposalId: number, accounts: readonly BchAccount[], methods: readonly BchMethod[]): Promise<void>;
/** Reject a pending session proposal. */
rejectSession(proposalId: number, reason?: string): Promise<void>;
/** Approve and respond to a pending sign request. */
approveSignRequest(
requestId: number,
response: SignedTxResponse | SignedMessageResponse | { readonly addresses: readonly string[] },
): Promise<void>;
/** Reject a pending sign request. */
rejectSignRequest(requestId: number, reason?: string): Promise<void>;
/** List currently-active sessions. */
listSessions(): Promise<ReadonlyArray<{ readonly topic: string; readonly peer: SessionProposal['dApp'] }>>;
/** Tear down a session. */
disconnect(topic: string, reason?: string): Promise<void>;
}
// ── Signer surface (implemented in signer.ts) ────────────────────────────────
/** What the libauth-based co-signer needs to sign one tx. */
export interface CoSignInput {
readonly request: BchSignTxRequest;
readonly ownPubkeyHash: Uint8Array;
readonly ownPublicKey: Uint8Array;
readonly privateKey: Uint8Array;
readonly network: 'chipnet' | 'testnet4' | 'mainnet';
}
/** The signing function shape. The runtime implementation (`signer.ts`
* `coSignTransaction`) only signs inputs whose `sourceOutputs[i].lockingBytecode`
* equals our own P2PKH — never any others, even if asked. */
export type CoSign = (input: CoSignInput) => Promise<SignedTxResponse>;
// ── Runtime re-exports (so AppsScreen.tsx imports stay tidy) ─────────────────
export { createResponder } from './responder';
export type { ResponderConfig, KeyProvider, NetworkProvider } from './responder';
export { classifyProposal, classifyRequest, classifyUri } from './classifier';
export type { Protocol } from './classifier';
export { coSignTransaction, isOwnedInput, p2pkhLockingBytecode, p2pkhUnlockingBytecode, BCH_SIGHASH_ALL_FORKID } from './signer';
export {
CC_PROTOCOL, CC_METHODS, CC_EVENTS, WC_METHODS,
SUPPORTED_CHAINS, chainToNetwork, networkToChain,
} from './bch-namespace';

View file

@ -0,0 +1,440 @@
/**
* responder.ts — the multi-protocol wallet-side responder (ADR-0043).
*
* Runs **one** `@reown/walletkit` instance over **one** WC2 relay socket, and
* dispatches each session proposal + session request to the right protocol
* handler:
*
* classifyProposal(proposal) → 'wc' → approve with wc2-bch-bcr namespace
* → 'cc' → approve with CashRPC namespace
* classifyRequest(method) → 'wc' → wc-handler.ts
* → 'cc' → per-session CashRPC_V0 (cc-handler.ts)
*
* The user pairs once per dApp; the protocol is decided by the proposal shape,
* not by the user. Multiple sessions of either protocol coexist; the dispatcher
* routes per topic.
*
* Wire encoding (libauth Extended JSON, the same wire format the proposer
* validated in snapshot 0057):
*
* - **WC**: the proposer sends params as a JSON tree with `<Uint8Array: 0x..>` /
* `<bigint: ..n>` markers; we round-trip via `parseExtendedJson(JSON.stringify(.))`
* to recover real `Uint8Array` + `bigint` before handing to the handler.
* - **CC**: the cashconnect package's `request()` puts params as a single string
* blob in `request.params: string` (per its `RequestEvent` type); we call
* `parseExtendedJson(string)` directly.
*
* Non-custody (ADR-0041 §5): the signer never signs an input it doesn't own;
* the responder never persists secrets; the `KeyProvider` mediates all key access
* so the active KeyStore (Capacitor secure-storage / WebKeyStore) stays in charge.
*/
import { WalletKit, type WalletKitTypes } from '@reown/walletkit';
import { stringify } from '@bitauth/libauth';
import {
CC_EVENTS,
CC_METHODS,
SUPPORTED_CHAINS,
WC_METHODS,
} from './bch-namespace';
import { classifyProposal, classifyRequest, type Protocol } from './classifier';
import { handleGetAddresses } from './wc-handler';
import { buildCcSession, type CcSession } from './cc-handler';
import type {
AccountIdentity,
BchChain,
BchSignMessageRequest,
BchSignTxRequest,
ResponderEvents,
Responder,
SessionProposal,
SignRequestPreview,
} from './index';
/** A live `WalletKit` instance (the relay-connected wallet-side WC2 client). The
* class export is also used as its instance type — `WalletKit` works as both. */
export type WalletKitInstance = InstanceType<typeof WalletKit>;
/** Sealed access to the active KeyStore account. Implementations are produced by
* `shells/app/src/core/wallet.ts` after `KeyStore.unlock(...)`; the responder
* never touches the KeyStore API directly so its lifecycle stays clean. */
export interface KeyProvider {
/** Public identity (addresses, pubkey, network, etc.). Safe to call freely. */
identity(): AccountIdentity;
/** The 32-byte private key for signing. Implementations may re-prompt for
* biometric auth here; callers must NOT cache the result. */
privateKey(): Uint8Array;
/** The 20-byte RIPEMD160(SHA256(pubkey)). Cached per session by the
* responder; provided by the KeyStore so we don't recompute. */
pubkeyHash(): Uint8Array;
}
/** Network-level callbacks CashConnect's `CashRPC_V0` needs to resolve source
* outputs and (when its standard-send path is exercised) the user's UTXOs. The
* shell wires this against its existing `ElectrumNetworkProvider`. */
export interface NetworkProvider {
/** Resolve a source output by `(txHash, outpointIndex)`. The libauth-shape
* return is what `CashRPC_V0.getSourceOutput` expects. */
getSourceOutput(chain: BchChain, txHash: Uint8Array, outpointIndex: number): Promise<unknown>;
/** Optionally return the wallet's spendable UTXOs (for CashRPC standard-send
* flows). The shell may stub this `() => []` while the standard-send adapter
* is still being wired. */
getUnspents?(chain: BchChain): Promise<readonly unknown[]>;
}
/** Per-topic state the dispatcher tracks. */
interface ActiveSession {
readonly topic: string;
readonly protocol: Protocol;
readonly chain: BchChain;
readonly peer: SessionProposal['dApp'];
/** Present iff `protocol === 'cc'`. */
readonly cc?: CcSession;
}
/** Init configuration. The shell provides the `WalletKit` instance (already
* initialised against the relay) so the responder doesn't own the projectId or
* the WC2 socket lifecycle. */
export interface ResponderConfig {
readonly walletKit: WalletKitInstance;
readonly key: KeyProvider;
readonly network: NetworkProvider;
readonly events: ResponderEvents;
/** If set, restrict approvals to these chains (e.g. chipnet-only beta builds). */
readonly allowedChains?: readonly BchChain[];
}
/** Construct the responder. The returned object is the public surface the UI
* (`AppsScreen.tsx`) calls; the WalletKit listeners are wired internally. */
export function createResponder(config: ResponderConfig): Responder {
const { walletKit, key, network, events } = config;
const allowedChains = config.allowedChains ?? SUPPORTED_CHAINS;
const sessions = new Map<string, ActiveSession>();
/** Pending sign requests keyed by request id so `approveSignRequest` /
* `rejectSignRequest` can resolve them. */
const pending = new Map<number, { topic: string; resolve: (v: unknown) => void; reject: (e: Error) => void }>();
/** Pending session proposals keyed by proposal id, awaiting user approval. */
const proposals = new Map<number, { protocol: Protocol; chain: BchChain; raw: WalletKitTypes.SessionProposal }>();
// ─── session_proposal ──────────────────────────────────────────────────
walletKit.on('session_proposal', (proposal: WalletKitTypes.SessionProposal) => {
try {
const protocol = classifyProposal({
requiredNamespaces: proposal.params.requiredNamespaces as never,
optionalNamespaces: proposal.params.optionalNamespaces as never,
});
const ns = (proposal.params.requiredNamespaces?.bch ?? proposal.params.optionalNamespaces?.bch) as { chains?: readonly string[] } | undefined;
const requestedChains = (ns?.chains ?? []) as readonly BchChain[];
const chain = requestedChains.find((c) => allowedChains.includes(c));
if (!chain) {
// Reject explicitly — better than a silent timeout for the dApp.
const peerName = proposal.params.proposer.metadata.name || 'The dApp';
const reqMsg = requestedChains.length ? requestedChains.join(', ') : '(none)';
const allowedMsg = allowedChains.join(', ');
const reason = `${peerName} only supports ${reqMsg}; this wallet supports ${allowedMsg}. Ask the dApp to add ${allowedMsg.replace('bch:bchtest', 'chipnet').replace('bch:bitcoincash', 'mainnet')} support, or switch the wallet's network if available.`;
walletKit.rejectSession({ id: proposal.id, reason: { code: 5100, message: reason } }).catch(() => {});
// Also surface to the UI so the user sees what happened instead of an
// indefinite "Waiting for the dApp…" — the proposal handler is the only
// place this mismatch can be detected.
events.onError(new Error(`No compatible chain — ${reason}`));
return;
}
proposals.set(proposal.id, { protocol, chain, raw: proposal });
const preview: SessionProposal = {
id: proposal.id,
dApp: {
name: proposal.params.proposer.metadata.name,
url: proposal.params.proposer.metadata.url,
icons: proposal.params.proposer.metadata.icons,
},
chains: [chain],
methods: (protocol === 'wc' ? [...WC_METHODS] : [...CC_METHODS]) as never,
};
events.onSessionProposal(preview);
} catch (err) {
events.onError(toError(err));
}
});
// ─── session_request ───────────────────────────────────────────────────
walletKit.on('session_request', async (event: WalletKitTypes.SessionRequest) => {
const { topic, id, params } = event;
const method = params.request.method;
const session = sessions.get(topic);
if (!session) {
await respondError(walletKit, topic, id, 5000, `Unknown session topic ${topic}`);
return;
}
try {
const protocol = classifyRequest(method);
if (protocol !== session.protocol) {
// A WC session can't receive CC methods or vice versa — reject so the
// dApp gets a clear error rather than a silent timeout.
throw new Error(`Method ${method} is not valid for ${session.protocol.toUpperCase()} session ${topic}`);
}
// Decode the params from libauth Extended JSON.
const decoded = decodeParams(protocol, params.request.params);
// Build a UI preview + register a continuation. We resolve the
// continuation when the UI calls approveSignRequest / rejectSignRequest.
const continuation = new Promise<unknown>((resolve, reject) => {
pending.set(id, { topic, resolve, reject });
});
const preview = buildPreview(protocol, method, decoded, id, session);
if (preview) events.onSignRequest(preview);
const result = await continuation;
// ── Approval path ──
// If the UI returned a "decoded" response (e.g. a SignedTxResponse object),
// re-encode via libauth `stringify` and ship.
const wire = protocol === 'cc'
// CC expects params as Extended-JSON-string; the cashconnect SDK applies
// parseExtendedJson on the dApp side. For responses, the CC standard
// uses the same EJSON encoding (see `respondSessionRequest({result: stringifyExtendedJson(response)})`).
? stringify(result)
: roundTripEjson(result);
await walletKit.respondSessionRequest({
topic,
response: { id, jsonrpc: '2.0', result: wire },
});
} catch (err) {
const e = toError(err);
pending.delete(id);
events.onError(e);
await respondError(walletKit, topic, id, 5001, e.message);
}
});
// ─── session_delete ────────────────────────────────────────────────────
walletKit.on('session_delete', (event: WalletKitTypes.SessionDelete) => {
const topic = (event as { topic?: string }).topic ?? '';
sessions.delete(topic);
events.onSessionDelete(topic);
});
// ─── Helpers for the dispatcher ────────────────────────────────────────
function buildPreview(protocol: Protocol, method: string, decoded: unknown, id: number, session: ActiveSession): SignRequestPreview | null {
// bch_getAddresses doesn't need user approval; auto-resolve.
if (protocol === 'wc' && method === 'bch_getAddresses') {
const addresses = handleGetAddresses(key);
// Resolve the pending continuation synchronously next microtask.
queueMicrotask(() => {
const p = pending.get(id);
if (!p) return;
pending.delete(id);
p.resolve({ addresses });
});
return null;
}
// Only message-sign and tx-sign need a UI prompt; other CC methods are
// wallet-orchestrated and auto-resolve as well.
if (protocol === 'wc' && method === 'bch_signMessage') {
return {
kind: 'sign-message',
id, sessionId: session.topic, appName: session.peer.name,
request: decoded as BchSignMessageRequest,
};
}
if (protocol === 'wc' && method === 'bch_signTransaction') {
return {
kind: 'sign-tx',
id, sessionId: session.topic, appName: session.peer.name,
request: decoded as BchSignTxRequest,
};
}
if (protocol === 'cc') {
// CashRPC: route through the per-session CashRPC_V0 instance. Some
// methods (signMessage/signTransaction) the UI should still prompt for;
// others (getBalance/getTokens) can pass through silently. For v1 we
// auto-resolve everything by dispatching directly — the UI gets to
// approve/reject via the standard SessionRequest preview before the
// method runs, in a follow-up iteration.
if (!session.cc) {
throw new Error(`CashConnect session ${session.topic} has no CashRPC_V0 instance`);
}
queueMicrotask(async () => {
const p = pending.get(id);
if (!p) return;
pending.delete(id);
try {
const result = await session.cc!.dispatch(method, decoded);
p.resolve(result);
} catch (err) {
p.reject(toError(err));
}
});
return null;
}
return null;
}
// ─── Public API ─────────────────────────────────────────────────────────
return {
async pair(uri) {
await walletKit.pair({ uri });
},
async approveSession(proposalId, accounts, methods) {
const pending = proposals.get(proposalId);
if (!pending) throw new Error(`No pending proposal ${proposalId}`);
proposals.delete(proposalId);
const id = key.identity();
const accountsForNamespace =
pending.protocol === 'wc'
? accounts // wc cashaddr-style: `bch:<chain>:<cashaddr>`
: [`${pending.chain}:${binToHex(id.publicKey)}` as const]; // CC pubkey-style
const namespaces: Record<string, unknown> =
pending.protocol === 'wc'
? {
bch: {
chains: [pending.chain],
methods: [...methods],
events: [],
accounts: accountsForNamespace,
},
}
: {
bch: {
chains: SUPPORTED_CHAINS.slice(),
methods: [...CC_METHODS],
events: [...CC_EVENTS],
accounts: accountsForNamespace,
},
};
const session = await walletKit.approveSession({
id: proposalId,
namespaces: namespaces as never,
}) as { topic: string };
const peerMeta = pending.raw.params.proposer.metadata;
const active: ActiveSession = {
topic: session.topic,
protocol: pending.protocol,
chain: pending.chain,
peer: { name: peerMeta.name, url: peerMeta.url, icons: peerMeta.icons },
};
if (pending.protocol === 'cc') {
const cc = await buildCcSession({ chain: pending.chain, key, network });
sessions.set(session.topic, { ...active, cc });
} else {
sessions.set(session.topic, active);
}
},
async rejectSession(proposalId, reason) {
proposals.delete(proposalId);
await walletKit.rejectSession({
id: proposalId,
reason: { code: 5000, message: reason ?? 'User rejected' },
});
},
async approveSignRequest(requestId, response) {
const cont = pending.get(requestId);
if (!cont) throw new Error(`No pending request ${requestId}`);
pending.delete(requestId);
// If the UI handed us only the user's decision (no payload), do the actual
// signing now using the cached pending request data. For the v1 sketch we
// ASSUME the UI already invoked sign-message / sign-transaction via the
// KeyProvider and is handing us the SignedTx/SignedMessage object.
cont.resolve(response);
},
async rejectSignRequest(requestId, reason) {
const cont = pending.get(requestId);
if (!cont) throw new Error(`No pending request ${requestId}`);
pending.delete(requestId);
cont.reject(new Error(reason ?? 'User rejected'));
},
async listSessions() {
return [...sessions.values()].map((s) => ({ topic: s.topic, peer: s.peer }));
},
async disconnect(topic, reason) {
await walletKit.disconnectSession({
topic,
reason: { code: 6000, message: reason ?? 'User disconnected' },
});
sessions.delete(topic);
},
};
}
// ─── Wire encoding helpers ────────────────────────────────────────────────
/** Decode params from libauth Extended JSON.
*
* Both WC and CC use libauth's `stringify` wire format
* (`Uint8Array → "<Uint8Array: 0x..>"`, `bigint → "<bigint: ..n>"`). libauth
* 3.1 doesn't export a corresponding `parseExtendedJson`, so we walk the tree
* ourselves; the patterns are unambiguous (they don't collide with normal
* string content unless an attacker crafts colliding payloads — and the
* responder treats decoded params as untrusted regardless). */
function decodeParams(protocol: Protocol, raw: unknown): unknown {
if (protocol === 'cc') {
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
return reviveExtendedJson(obj);
}
return reviveExtendedJson(raw);
}
const UINT8_PATTERN = /^<Uint8Array: 0x([0-9a-fA-F]*)>$/;
const BIGINT_PATTERN = /^<bigint: (-?\d+)n>$/;
function reviveExtendedJson(value: unknown): unknown {
if (typeof value === 'string') {
const u8 = UINT8_PATTERN.exec(value);
if (u8) {
const hex = u8[1] ?? '';
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
const bi = BIGINT_PATTERN.exec(value);
if (bi) return BigInt(bi[1]!);
return value;
}
if (Array.isArray(value)) return value.map(reviveExtendedJson);
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = reviveExtendedJson(v);
return out;
}
return value;
}
/** Encode WC responses by going through libauth `stringify` then back through
* `JSON.parse` so the response is a clean JSON tree with EJSON markers — the
* shape the proposer's `parseExtendedJson(response)` round-trips correctly. */
function roundTripEjson(value: unknown): unknown {
return JSON.parse(stringify(value));
}
/** Build a `bch:<chain>:<pubkeyHex>` CC account string. */
function binToHex(bin: Uint8Array): string {
let s = '';
for (const b of bin) s += b.toString(16).padStart(2, '0');
return s;
}
async function respondError(walletKit: WalletKitInstance, topic: string, id: number, code: number, message: string) {
try {
await walletKit.respondSessionRequest({
topic,
response: { id, jsonrpc: '2.0', error: { code, message } },
});
} catch {
// Best-effort — the session may already be torn down.
}
}
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}

View file

@ -0,0 +1,182 @@
/**
* signer.ts — libauth-based pass-through co-signer for the WalletConnect path.
*
* The wc2-bch-bcr `bch_signTransaction` model: the dApp builds a libauth-shape
* transaction with covenant inputs already containing their CashScript-compiled
* `unlockingBytecode` and each USER input marked by a `placeholderP2PKHUnlocker`
* (an empty/dummy unlock). The wallet's job is to (a) identify which inputs spend
* UTXOs locked to its own P2PKH and (b) sign just those, leaving every other
* input untouched. This is the "structural non-custody" rule of ADR-0041 §5: we
* NEVER sign an input we don't own, even if asked.
*
* What we do per ownable input:
* 1. Build the BCH signing-serialization (covered = p2pkh script, type = ALL|FORKID).
* 2. Double-sha256 it.
* 3. ECDSA-DER-sign the hash with the user's private key.
* 4. Append the sighash byte.
* 5. Write the standard P2PKH unlocking bytecode `<sigLen> <sig||sighash> <33> <pubkey>`.
*
* Sighash default: `SIGHASH_ALL | SIGHASH_FORKID = 0x41` (BCH replay-protection on,
* commits to all inputs+outputs). Covenants that need a different sighash will
* have to ship that in a future protocol extension — none today (snapshot 0057
* proved this default works against Cashonize).
*
* Pure & headless-testable: takes a decoded transaction + sourceOutputs (the
* responder already `parseExtendedJson`-decoded them off the wire) and returns
* the signed hex + txid hex. No SDK / network / KeyStore imports.
*/
import {
encodeTransactionBCH,
generateSigningSerializationBCH,
hash256,
secp256k1,
binsAreEqual,
binToHex,
SigningSerializationFlag,
// BCH consensus rule we're encoding into the sighash byte:
// ALL = 0x01, FORKID = 0x40 → ALL|FORKID = 0x41
} from '@bitauth/libauth';
import type { CoSignInput, SignedTxResponse } from './index';
/** The sighash byte we commit to. Documented + asserted in the unit test below. */
export const BCH_SIGHASH_ALL_FORKID = SigningSerializationFlag.allOutputs | SigningSerializationFlag.forkId;
/** Libauth-shape transaction input + output (kept loose so this module compiles
* without leaking libauth's internal types up to the index surface). */
interface MutableInput {
outpointTransactionHash: Uint8Array;
outpointIndex: number;
sequenceNumber: number;
unlockingBytecode: Uint8Array;
}
interface MutableOutput {
lockingBytecode: Uint8Array;
valueSatoshis: bigint;
token?: unknown;
}
interface MutableTx {
version: number;
inputs: MutableInput[];
outputs: MutableOutput[];
locktime: number;
}
/** Build the canonical 25-byte P2PKH locking bytecode for a pubkey hash:
* `OP_DUP OP_HASH160 <0x14> <pkh> OP_EQUALVERIFY OP_CHECKSIG`. */
export function p2pkhLockingBytecode(pubkeyHash: Uint8Array): Uint8Array {
if (pubkeyHash.length !== 20) {
throw new Error(`p2pkh pubkeyHash must be 20 bytes, got ${pubkeyHash.length}`);
}
const out = new Uint8Array(25);
out[0] = 0x76; // OP_DUP
out[1] = 0xa9; // OP_HASH160
out[2] = 0x14; // push 20 bytes
out.set(pubkeyHash, 3);
out[23] = 0x88; // OP_EQUALVERIFY
out[24] = 0xac; // OP_CHECKSIG
return out;
}
/** Build the standard P2PKH unlocking bytecode `<sigLen> <sig||sighash> <pkLen> <pk>`. */
export function p2pkhUnlockingBytecode(signatureWithSighash: Uint8Array, publicKey: Uint8Array): Uint8Array {
if (publicKey.length !== 33) {
throw new Error(`compressed pubkey must be 33 bytes, got ${publicKey.length}`);
}
const out = new Uint8Array(1 + signatureWithSighash.length + 1 + publicKey.length);
out[0] = signatureWithSighash.length;
out.set(signatureWithSighash, 1);
out[1 + signatureWithSighash.length] = publicKey.length;
out.set(publicKey, 1 + signatureWithSighash.length + 1);
return out;
}
/** Decide whether input `i` is OURS to sign: source output's `lockingBytecode`
* must equal our P2PKH script. Constant-time-ish (libauth `binsAreEqual`). */
export function isOwnedInput(
sourceOutputLockingBytecode: Uint8Array,
ownPubkeyHash: Uint8Array,
): boolean {
return binsAreEqual(sourceOutputLockingBytecode, p2pkhLockingBytecode(ownPubkeyHash));
}
/** The libauth pass-through co-sign. Mutates a copy of the transaction's inputs
* to fill the unlocking bytecode of every ownable input, then encodes + hashes. */
export async function coSignTransaction(input: CoSignInput): Promise<SignedTxResponse> {
const tx = input.request.transaction as MutableTx;
const sourceOutputs = input.request.sourceOutputs as readonly MutableOutput[];
if (!tx || !Array.isArray(tx.inputs) || !Array.isArray(sourceOutputs)) {
throw new Error('coSignTransaction: malformed request — missing tx.inputs or sourceOutputs');
}
if (tx.inputs.length !== sourceOutputs.length) {
throw new Error(
`coSignTransaction: inputs/sourceOutputs length mismatch (${tx.inputs.length} vs ${sourceOutputs.length})`,
);
}
// Work on a fresh inputs array so we don't mutate the request object.
const signedInputs: MutableInput[] = tx.inputs.map((i) => ({ ...i }));
const ownP2pkhScript = p2pkhLockingBytecode(input.ownPubkeyHash);
let signedCount = 0;
for (let i = 0; i < signedInputs.length; i++) {
const lock = sourceOutputs[i]?.lockingBytecode;
if (!lock || !binsAreEqual(lock, ownP2pkhScript)) continue; // not ours — skip
// Build the BCH-spec signing serialization for this input.
// libauth signature: (CompilationContextBch, { coveredBytecode, signingSerializationType }, sha256?)
// coveredBytecode for a P2PKH unlock is the canonical P2PKH locking script
// itself (per the BCH signing-serialization spec; what would execute on the
// stack after the unlock pushes <sig> <pk>).
const preimage = generateSigningSerializationBCH(
{
inputIndex: i,
sourceOutputs: sourceOutputs as any,
transaction: { ...tx, inputs: signedInputs } as any,
},
{
coveredBytecode: ownP2pkhScript,
signingSerializationType: new Uint8Array([BCH_SIGHASH_ALL_FORKID]),
},
);
const messageHash = hash256(preimage);
const sig = secp256k1.signMessageHashDER(input.privateKey, messageHash);
if (typeof sig === 'string') {
throw new Error(`coSignTransaction: ECDSA sign failed for input ${i}: ${sig}`);
}
// Append the sighash byte to the DER signature.
const sigWithSighash = new Uint8Array(sig.length + 1);
sigWithSighash.set(sig, 0);
sigWithSighash[sig.length] = BCH_SIGHASH_ALL_FORKID;
signedInputs[i].unlockingBytecode = p2pkhUnlockingBytecode(sigWithSighash, input.ownPublicKey);
signedCount++;
}
if (signedCount === 0) {
throw new Error(
'coSignTransaction: no inputs are owned by this wallet — refusing to sign. ' +
'Either the dApp built the tx for a different account, or your pubkey hash is wrong.',
);
}
const signedTx: MutableTx = { ...tx, inputs: signedInputs };
const encoded = encodeTransactionBCH(signedTx as any);
// txid (UI / display order) = reverse-bytes of hash256(encoded). Compute
// directly to avoid the libauth `hashTransactionUiOrder` overload mismatch
// (it demands an injected sha256 implementation under some versions).
const txidLE = reverseBytes(hash256(encoded));
return {
signedTransaction: binToHex(encoded),
signedTransactionHash: binToHex(txidLE),
};
}
function reverseBytes(bin: Uint8Array): Uint8Array {
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin[bin.length - 1 - i]!;
return out;
}

View file

@ -0,0 +1,122 @@
/**
* wc-handler.ts — wc2-bch-bcr method dispatch (the WalletConnect path).
*
* Methods (per ADR-0041 §3 and snapshot 0057 — device-validated against Cashonize):
* - `bch_getAddresses` → returns the wallet's cashaddr(s)
* - `bch_signMessage` → ECDSA-DER over sha256(message) ⚠ format TBD on-device
* - `bch_signTransaction` → libauth pass-through co-sign (delegated to signer.ts)
*
* The signTransaction handler is the load-bearing one for Deviant covenant flows
* (tote `bet()`, `GamePoolEscrowFlat.fund()`, checkers stake) per ADR-0043 §2.3:
* CashConnect V0 cannot carry dApp-compiled covenant unlockers, so all covenant
* co-signs route through this handler.
*
* Signing discipline (ADR-0041 §5): the signer NEVER signs an input it doesn't
* own — even if asked. See signer.ts `isOwnedInput`.
*
* The wire formatting (libauth `stringify` / `parseExtendedJson`) is the
* responder's responsibility. By the time we get here, requests are decoded
* objects with `Uint8Array` bytes and `bigint` values.
*/
import { sha256, ripemd160, secp256k1, binToHex } from '@bitauth/libauth';
import { coSignTransaction } from './signer';
import type {
BchSignMessageRequest,
BchSignTxRequest,
SignedMessageResponse,
SignedTxResponse,
} from './index';
import type { KeyProvider } from './responder';
import { p2pkhLockingBytecode, isOwnedInput } from './signer';
/** Result of `bch_getAddresses`. The wc2-bch-bcr spec returns a string list. */
export type GetAddressesResponse = readonly string[];
/** `bch_getAddresses` — the wallet exposes the cashaddr(s) it controls. We surface
* only the active account's `cashaddr` (singular) at v1; multi-account is a future
* enhancement (the KeyStore is single-account today — `wiki/wallet-ui-design.md`).
*/
export function handleGetAddresses(key: KeyProvider): GetAddressesResponse {
const id = key.identity();
return [id.cashaddr];
}
/** `bch_signMessage` — ECDSA-DER signature over `sha256(message)`.
*
* ⚠ Wire format pending on-device confirmation against Cashonize (ADR-0041 "What
* this ADR does NOT resolve" §2). Two candidates exist on BCH:
* (a) **Bitcoin Cash Signed-Message format** (classical): recoverable ECDSA over
* `hash256("\x18Bitcoin Signed Message:\n" || varint(len(msg)) || msg)`,
* base64-encoded with a 1-byte recovery header. Used by historical wallets.
* (b) **Simple `ecdsa(sha256(msg))` DER** — what most modern WC2 BCH wallets
* appear to do; matches CashConnect's `signMessage` (Schnorr) in shape.
*
* We ship (b) since the proposer in `spike/wc-connect/connect.mjs` issues a plain
* message and Cashonize returned a sig (snapshot 0055) — and the simpler path
* keeps the responder unblocked. On-device validation will confirm; if Cashonize
* actually expects (a), switch this function (NOT the wider responder) and bump
* the wire-format note in ADR-0041.
*
* SECURITY: we always sign with the active account's key — there is no
* per-address routing yet, since the wallet is single-account at v1. */
export function handleSignMessage(
request: BchSignMessageRequest,
key: KeyProvider,
): SignedMessageResponse {
// request.address: the dApp may name an address; we don't currently disambiguate
// between accounts. If/when multi-account lands, look it up against the
// KeyStore's account list and fail closed if the address isn't ours.
const digest = sha256.hash(request.message);
const sig = secp256k1.signMessageHashDER(key.privateKey(), digest);
if (typeof sig === 'string') {
throw new Error(`bch_signMessage failed: ${sig}`);
}
// The "signedMessage" field carries a UTF-8 echo of what was actually signed,
// so the dApp can verify it presented the right text to the user.
const printable = isProbablyUtf8(request.message);
return {
signedMessage: printable ? new TextDecoder('utf-8', { fatal: false }).decode(request.message) : binToHex(request.message),
signature: binToHex(sig),
};
}
/** `bch_signTransaction` — libauth pass-through co-sign. Fills in the unlocking
* bytecode for every input owned by this wallet (sourceOutput.lockingBytecode
* matches our P2PKH); leaves all other inputs untouched.
*
* This is the path snapshot 0057 device-validated; it's also the ONLY path for
* Deviant covenant co-sign (ADR-0043 §2.3). */
export async function handleSignTransaction(
request: BchSignTxRequest,
key: KeyProvider,
): Promise<SignedTxResponse> {
const id = key.identity();
return coSignTransaction({
request,
ownPubkeyHash: hash160OfPubkey(id.publicKey),
ownPublicKey: id.publicKey,
privateKey: key.privateKey(),
network: id.network,
});
}
// ── Helpers ────────────────────────────────────────────────────────────────
function isProbablyUtf8(bin: Uint8Array): boolean {
try {
new TextDecoder('utf-8', { fatal: true }).decode(bin);
return true;
} catch {
return false;
}
}
/** RIPEMD160(SHA256(pubkey)) — the 20-byte pubkey hash used as the P2PKH key.
* Two-primitive form (not the helper alias) to stay portable across libauth
* versions. The responder caches the result per-session via `KeyProvider.pubkeyHash()`. */
function hash160OfPubkey(publicKey: Uint8Array): Uint8Array {
return ripemd160.hash(sha256.hash(publicKey));
}
// Re-export so the signing-discipline check is callable from tests + responder.
export { isOwnedInput, p2pkhLockingBytecode };

View file

@ -0,0 +1,243 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Deviant · chipnet address explorer</title>
<style>
:root { color-scheme: dark; --bg:#0c0e12; --fg:#dfe7ee; --muted:#7d8a98; --acid:#a5f050; --row:#161a22; --row2:#1c2230; --bord:#2a3346; }
body { margin:0; padding:24px; background:var(--bg); color:var(--fg); font:14px/1.4 ui-sans-serif,system-ui,sans-serif; }
h1 { font-size:18px; font-weight:600; margin:0 0 4px; }
.sub { color:var(--muted); font-size:12px; margin-bottom:18px; }
.sub .acid { color:var(--acid); }
.sub .err { color:#ff8079; }
table { border-collapse:collapse; width:100%; max-width:980px; background:var(--row); border:1px solid var(--bord); }
th, td { padding:9px 12px; border-bottom:1px solid var(--bord); text-align:left; }
tr:last-child td { border-bottom:none; }
tr:nth-child(even) td { background:var(--row2); }
th { font-weight:600; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
td.num { font-variant-numeric:tabular-nums; text-align:right; font-family:ui-monospace,monospace; }
td.addr { font-family:ui-monospace,monospace; font-size:12px; color:#bcc4cf; }
.lbl { display:inline-block; padding:2px 8px; border-radius:11px; background:rgba(165,240,80,.12); border:1px solid rgba(165,240,80,.35); color:var(--acid); font-weight:600; font-size:11px; }
.err { color:#ff8079; font-style:italic; }
.total { background:#10141a !important; font-weight:600; }
.footer { color:var(--muted); font-size:11px; margin-top:14px; line-height:1.6; }
.footer code { color:#bcc4cf; }
.dot { display:inline-block; width:7px; height:7px; border-radius:50%; vertical-align:middle; margin-right:6px; background:var(--muted); }
.dot.on { background:var(--acid); box-shadow:0 0 5px var(--acid); }
.dot.err { background:#ff8079; }
/* Token-family switcher: single-select chips above the table */
.filter { display:flex; gap:7px; max-width:980px; margin: 0 0 12px; }
.chip { font: 11px/1 ui-monospace,monospace; letter-spacing:.08em; text-transform:uppercase; padding:8px 14px; border-radius:18px; border:1px solid var(--bord); background:transparent; color:var(--muted); cursor:pointer; transition: color .15s, border-color .15s, background .15s; }
.chip:hover { color:#bcc4cf; }
.chip.on { color:var(--acid); border-color:rgba(165,240,80,.45); background:rgba(165,240,80,.07); text-shadow:0 0 5px rgba(165,240,80,.5); }
/* Show only the active family's columns. data-col="bch"|"mba"|"nba". */
table[data-fam="mba"] [data-col="nba"] { display:none; }
table[data-fam="nba"] [data-col="mba"] { display:none; }
</style>
</head>
<body>
<h1>Deviant · chipnet address explorer</h1>
<div class="sub">
<span class="dot" id="conn-dot"></span><span id="conn-status">connecting…</span>
· queries <span class="acid">chipnet.imaginary.cash</span> directly from your browser
· last update <span id="ts">—</span>
</div>
<div class="filter">
<button class="chip on" data-fam="mba">MBA</button>
<button class="chip" data-fam="nba">NBA</button>
</div>
<table id="tbl" data-fam="mba">
<thead><tr>
<th>Wallet</th><th>Address</th>
<th style="text-align:right">Plain BCH (sats)</th>
<th style="text-align:right">Plain BCH</th>
<th style="text-align:right">BCH UTXOs</th>
<th style="text-align:right" data-col="mba" title="Sum of token_data.amount across MBA-class UTXOs (per-UTXO sats > 1,000 threshold)">MBA units</th>
<th style="text-align:right" data-col="mba" title="Σ sats locked inside MBA UTXOs — the PNV (redeemable BCH floor)">MBA (sats)</th>
<th style="text-align:right" data-col="mba" title="MBA (sats) ÷ 1e8 — the PNV in BCH units">MBA (BCH)</th>
<th style="text-align:right" data-col="nba" title="Sum of token_data.amount across NBA-class UTXOs (dust-only sat values)">NBA units</th>
<th style="text-align:right">Cats</th>
</tr></thead>
<tbody id="rows"><tr><td colspan="10" style="color:#7d8a98;text-align:center">Loading addresses…</td></tr></tbody>
</table>
<div class="footer">
Address list: inlined below. To update, edit the <code>WALLETS</code> constant in this file or regenerate via <code>rtk node scripts/derive-chipnet-addresses.mjs</code> (copies into <code>design/explorer/addresses.json</code> as a backup snapshot).<br>
Electrum: <code>wss://chipnet.imaginary.cash:50004</code> · public chipnet ElectrumX (CashTokens-aware). No private keys in this page.
</div>
<script type="module">
const ENDPOINT = 'wss://chipnet.imaginary.cash:50004';
const POLL_MS = 15000;
const VERSION_CLIENT = 'deviant-explorer/0.1';
const VERSION_PROTOCOL = '1.5.3'; // CashTokens-aware (Fulcrum gates token_data behind ≥1.5)
// --- tiny electrum JSON-RPC over WSS client ----------------------------------
let ws = null;
let nextId = 1;
const pending = new Map();
function connect() {
return new Promise((resolve, reject) => {
ws = new WebSocket(ENDPOINT);
ws.onopen = async () => {
try {
await call('server.version', [VERSION_CLIENT, VERSION_PROTOCOL]);
setConn('on', 'connected');
resolve();
} catch (e) { reject(e); }
};
ws.onmessage = (evt) => {
for (const line of evt.data.split('\n')) {
if (!line.trim()) continue;
let msg;
try { msg = JSON.parse(line); } catch { continue; }
if (msg.id != null && pending.has(msg.id)) {
const { resolve: r, reject: j } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? j(new Error(msg.error?.message || JSON.stringify(msg.error))) : r(msg.result);
}
}
};
ws.onclose = () => { setConn('err', 'disconnected — retrying in 5s'); setTimeout(() => connect().then(tick).catch(() => {}), 5000); };
ws.onerror = () => { setConn('err', 'connection error'); };
});
}
function call(method, params = []) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }) + '\n');
setTimeout(() => { if (pending.has(id)) { pending.delete(id); reject(new Error('timeout: ' + method)); } }, 20000);
});
}
// --- UI helpers --------------------------------------------------------------
const fmtBch = (sats) => (Number(sats) / 1e8).toFixed(8);
const shortAddr = (a) => a ? a.slice(0, 14) + '…' + a.slice(-8) : '';
function setConn(state, text) {
const dot = document.getElementById('conn-dot');
dot.className = 'dot ' + state;
document.getElementById('conn-status').textContent = text;
}
// --- balance fetch -----------------------------------------------------------
// Heuristic split: a token UTXO with per-UTXO sats > MBA_MIN_SATS is treated
// as MBA (has BCH backing inside it — that's its PNV). Token UTXOs at or near
// dust (NBA / NFTs) hold only ~546 sats. Replace with a registry lookup if you
// need strict classification.
const MBA_MIN_SATS = 1000n;
async function balanceFor(cashaddr) {
const utxos = await call('blockchain.address.listunspent', [cashaddr]);
let bchSats = 0n, mbaSats = 0n, mbaUnits = 0n, nbaUnits = 0n, bchUtxos = 0;
const cats = new Set();
for (const u of utxos) {
const v = BigInt(u.value);
if (u.token_data) {
const amt = BigInt(u.token_data.amount || 0);
cats.add(u.token_data.category);
if (v > MBA_MIN_SATS) { mbaSats += v; mbaUnits += amt; }
else { nbaUnits += amt; }
} else {
bchSats += v; bchUtxos++;
}
}
return {
cashaddr,
bchSats: bchSats.toString(),
bchUtxos,
mbaUnits: mbaUnits.toString(),
mbaSats: mbaSats.toString(),
nbaUnits: nbaUnits.toString(),
cats: cats.size,
};
}
// --- inlined wallet list -----------------------------------------------------
// No JSON fetch, no server dependency — the page works from any origin (incl.
// file://). Edit this list directly or regenerate with
// rtk node scripts/derive-chipnet-addresses.mjs
// then paste the result here.
const WALLETS = [
{ label: 'A', cashaddr: 'bchtest:qrd5g74f64gp704n4ggspcwn08e0yalz3u2ltaxuwh' },
{ label: 'B', cashaddr: 'bchtest:qzd2dw4tk2amr5v5pe8vs2ukgzn6kc4cmsv9t3eyc6' },
{ label: 'C', cashaddr: 'bchtest:qzgda5hsk96trkfk4qxa0t808kjwt8gzt5aqnt38dm' },
{ label: 'D', cashaddr: 'bchtest:qzjqnzgjz7w9tdflpz6ufx0rnrnfu4hsps9rznw4dl' },
{ label: 'E', cashaddr: 'bchtest:qpyns8t44guzvcstqgs7wvueq6n6hqgu7cpqzsfhha' },
{ label: 'F', cashaddr: 'bchtest:qr4f5sh8lhs50u7xq5568rcqxwk209dz4ynanfnyhh' },
{ label: 'G', cashaddr: 'bchtest:qp20fvzqy43q7u43erhjxg52w39mqn7cccyqnlkaqu' },
];
async function tick() {
if (ws?.readyState !== WebSocket.OPEN) return;
const rows = [];
for (const w of WALLETS) {
try {
const bal = await balanceFor(w.cashaddr);
rows.push({ label: w.label, ...bal });
} catch (e) {
rows.push({ label: w.label, error: e.message });
}
}
render(rows);
document.getElementById('ts').textContent = new Date().toLocaleString();
}
function render(rows) {
let totalBch = 0n, totalMbaSats = 0n, totalMbaUnits = 0n, totalNbaUnits = 0n, html = '';
for (const row of rows) {
if (row.error) {
html += '<tr><td><span class="lbl">' + row.label + '</span></td><td colspan="9" class="err">' + row.error + '</td></tr>';
continue;
}
totalBch += BigInt(row.bchSats);
totalMbaSats += BigInt(row.mbaSats);
totalMbaUnits += BigInt(row.mbaUnits);
totalNbaUnits += BigInt(row.nbaUnits);
html += '<tr>'
+ '<td><span class="lbl">' + row.label + '</span></td>'
+ '<td class="addr" title="' + row.cashaddr + '">' + shortAddr(row.cashaddr) + '</td>'
+ '<td class="num">' + Number(row.bchSats).toLocaleString() + '</td>'
+ '<td class="num">' + fmtBch(row.bchSats) + '</td>'
+ '<td class="num">' + row.bchUtxos + '</td>'
+ '<td class="num" data-col="mba">' + Number(row.mbaUnits).toLocaleString() + '</td>'
+ '<td class="num" data-col="mba">' + Number(row.mbaSats).toLocaleString() + '</td>'
+ '<td class="num" data-col="mba">' + fmtBch(row.mbaSats) + '</td>'
+ '<td class="num" data-col="nba">' + Number(row.nbaUnits).toLocaleString() + '</td>'
+ '<td class="num">' + row.cats + '</td>'
+ '</tr>';
}
html += '<tr class="total"><td colspan="2">Σ all wallets</td>'
+ '<td class="num">' + Number(totalBch).toLocaleString() + '</td>'
+ '<td class="num">' + fmtBch(totalBch) + '</td>'
+ '<td class="num">—</td>'
+ '<td class="num" data-col="mba">' + Number(totalMbaUnits).toLocaleString() + '</td>'
+ '<td class="num" data-col="mba">' + Number(totalMbaSats).toLocaleString() + '</td>'
+ '<td class="num" data-col="mba">' + fmtBch(totalMbaSats) + '</td>'
+ '<td class="num" data-col="nba">' + Number(totalNbaUnits).toLocaleString() + '</td>'
+ '<td class="num">—</td></tr>';
document.getElementById('rows').innerHTML = html;
}
// Family switcher — single-select MBA / NBA. Default MBA.
document.querySelectorAll('.chip[data-fam]').forEach((chip) => {
chip.addEventListener('click', () => {
document.querySelectorAll('.chip[data-fam]').forEach((c) => c.classList.remove('on'));
chip.classList.add('on');
document.getElementById('tbl').setAttribute('data-fam', chip.getAttribute('data-fam'));
});
});
// --- boot --------------------------------------------------------------------
(async () => {
try { await connect(); }
catch (e) {
document.getElementById('rows').innerHTML = '<tr><td colspan="10" class="err">Could not reach the chipnet electrum server — check connectivity.</td></tr>';
return;
}
await tick();
setInterval(tick, POLL_MS);
})();
</script>
</body>
</html>

View file

@ -58,7 +58,7 @@ founder economics (mint fee accrues to the creator on every claim).
- the invisible-force motif — *Asterae & Planitae*: held by an unseen-but-real force, the mathematical backing.
### 6. Brand world (named surfaces)
Avalanche (create) · Agora (launch/discover/swap) · Orbital (explorer) · Spectrum (asset list) ·
Avalanche (create) · Agora (launch/discover/swap) · Orbital (explorer) · Metroon (asset list) ·
Poseidon (liquidity) · Athenae (voting) · CosmoGenesis (the dark cosmic + acid-green theme).
---

View file

@ -91,8 +91,8 @@ Broader framing (from the chats): the problem isn't only crypto — it's the **s
- **The "Zero Properties"** (marketing): zero counterparty / intermediary / trust / liquidity-ceiling / fungibility-barrier risk, plus zero complexity·maintenance·contribution·dependence·friction. Marketing-only register; tech spec keeps qualifiers.
- **Positioning:** vs conventional tokens (ERC-20, BEP-20, and the like — financially and structurally disconnected from their base layer; MBAs are tied to it, so one asset's success amplifies the whole ecosystem); vs stablecoins (the **bankruptcy-protection floor** — on-chain recoverable value if an issuer fails, where a conventional stablecoin recovers $0); vs DEX (no order matching, no liquidity bootstrap, no counterparty wait). The **volatility paradox** (ADR-0033): the asset marketed as safe fails its advertised property under stress; an MBA promises nothing it cannot keep.
- **Slogan:** *Power Your Ideas.*
- **Naming lexicon (Greek / cosmic):** **CosmoGenesis** = the name of the default visual **theme/style** (dark cosmic — starfield + acid‑green; *cosmo* = world/universe + *genesis* = birth). **NOTE:** previously the wallet‑shell name; the wallet/client is now simply **Deviant**. The alternate style is **Editorial** (Fraunces serif). Toggle label reads "Style · CosmoGenesis / Editorial." · **Agora** = the asset launchpad, discovery, **and swap/exchange marketplace** — the space where users meet and trade (the ancient open marketplace; **absorbs the former "Orbital" swap-space role**) · **Orbital** = the asset **explorer** — market-cap-style data, rankings, and per-asset detail (the "charted heavens"; cf. coinmarketcap / cashtokensmarketcap) · **Spectrum** = the filterable **asset list** (primary filter backed/non-backed; secondary: subcategory e.g. stablecoins, use-case e.g. game currency / tickets / memberships / collectibles; scope: mine / all) · **Avalanche** = the create/mint UI · **Poseidon** = the infinite-liquidity layer (deep as an ocean — the protocol-as-immutable-liquidity-provider, always available, unlimited) · **Athena (Athenae)** = the voting-system protocol (goddess of **wisdom and protection** — apt for capture-resistant collective decision-making) · **Themis (Themidae)** = the Tier-4 Elders (goddess of divine law, justice, and order). Voting docs additionally use **Athenian Democracy** terminology (sortition, citizens, chambers) as synonyms.
- **Olympus** = the **dApp platform layer** — the home where Deviant's first-party dApps reside (gods by domain). **DECIDED MODEL:** **Deviant** = the encompassing environment / project / identity (the whole house); the **CosmoGenesis wallet** = the client / front door; **Olympus** = the dApp platform layer that sits on top; **dApps** = residents of Olympus. **Athenae · Politeia Protocol = dApp #1** (governance, most built-out). **Agora** (ASM market) and **Avalanche** (MBA launch) are **candidate dApps**; future ones too (e.g. a prediction-market dApp ≈ Apollo). **NOT dApps — wallet-native surfaces/views:** Spectrum (asset list), Orbital (explorer), Transition (B→A soft→hard upgrade). So the spec's "five interfaces" splits into **dApps** vs **wallet views**. Strict-pantheon note: Agora (a place) and Avalanche (non-Greek) sit outside the deity logic — kept deliberately for evocativeness (Agora ties to the Athenian thread); the strict forms would be Hermes (commerce) / Hephaestus (the forge). "Olympus, the shining one." The **Olympic tier ladder** (Free → Bronze → Silver → Gold → Laurel) is a separate medal-podium marketing motif, not the platform layer.
- **Naming lexicon (Greek / cosmic):** **CosmoGenesis** = the name of the default visual **theme/style** (dark cosmic — starfield + acid‑green; *cosmo* = world/universe + *genesis* = birth). **NOTE:** previously the wallet‑shell name; the wallet/client is now simply **Deviant**. The alternate style is **Editorial** (Fraunces serif). Toggle label reads "Style · CosmoGenesis / Editorial." · **Agora** = the asset launchpad, discovery, **and swap/exchange marketplace** — the space where users meet and trade (the ancient open marketplace; **absorbs the former "Orbital" swap-space role**) · **Orbital** = the asset **explorer** — market-cap-style data, rankings, and per-asset detail (the "charted heavens"; cf. coinmarketcap / cashtokensmarketcap) · **Metroon** = the filterable **asset list** (primary filter backed/non-backed; secondary: subcategory e.g. stablecoins, use-case e.g. game currency / tickets / memberships / collectibles; scope: mine / all) · **Avalanche** = the create/mint UI · **Poseidon** = the infinite-liquidity layer (deep as an ocean — the protocol-as-immutable-liquidity-provider, always available, unlimited) · **Athena (Athenae)** = the voting-system protocol (goddess of **wisdom and protection** — apt for capture-resistant collective decision-making) · **Themis (Themidae)** = the Tier-4 Elders (goddess of divine law, justice, and order). Voting docs additionally use **Athenian Democracy** terminology (sortition, citizens, chambers) as synonyms.
- **Olympus** = the **dApp platform layer** — the home where Deviant's first-party dApps reside (gods by domain). **DECIDED MODEL:** **Deviant** = the encompassing environment / project / identity (the whole house); the **CosmoGenesis wallet** = the client / front door; **Olympus** = the dApp platform layer that sits on top; **dApps** = residents of Olympus. **Athenae · Politeia Protocol = dApp #1** (governance, most built-out). **Agora** (ASM market) and **Avalanche** (MBA launch) are **candidate dApps**; future ones too (e.g. a prediction-market dApp ≈ Apollo). **NOT dApps — wallet-native surfaces/views:** Metroon (asset list), Orbital (explorer), Transition (B→A soft→hard upgrade). So the spec's "five interfaces" splits into **dApps** vs **wallet views**. Strict-pantheon note: Agora (a place) and Avalanche (non-Greek) sit outside the deity logic — kept deliberately for evocativeness (Agora ties to the Athenian thread); the strict forms would be Hermes (commerce) / Hephaestus (the forge). "Olympus, the shining one." The **Olympic tier ladder** (Free → Bronze → Silver → Gold → Laurel) is a separate medal-podium marketing motif, not the platform layer.
- **Metaphor — Asterae / Planitae (the invisible-force motif):** **Asterae** (stars; lit. "without support") + **Planitae** (planets; lit. "wandering"). The marvel is not disorder — it is that bodies with **no visible support** and **no visible force pushing them** nonetheless hold precise, stable trajectories. The force is real but unseen: gravity. This describes **Deviant's own paradigm**, *not* legacy tokens — an MBA has no visible support (no custodian, issuer, counterparty, or authority) and no visible operator directing it, yet holds its value and moves within exact order, held by an invisible-but-real force: the **mathematical backing / gravitational center** (the base-layer rate) enforced by consensus. Legacy unbacked assets have no such force at all — they simply fall. Use as the elevated narrative/visual motif for how *mathematics, not authority,* holds the system.
- **Resonance:** **DEA ≈ Dias / Zeus** (Δίας, king of the gods; shared root with *dyeus → day / light*, Latin *dies*) — a fortunate resonance for the **flagship** MBA (the "king" asset, seated atop Olympus) and a tie into the *light/shining* thread (Olympus = "the shining one"). *Decided:* Olympus = the **dApp platform layer** (above); the marketing ladder is **Olympic** (Bronze/Silver/Gold medals + **Laurel** crown), distinct from "Olympus."

View file

@ -500,8 +500,8 @@ Genesis event. User sets parameters, locks Backing Standard, deploys Vessel.
**Note on gift claim (V5, snapshot 0017):** The `recipientLockingBytecode` parameter allows the claimer to specify any recipient — not just themselves. Enables third-party-paid onboarding, charitable airdrops, gift cards. Not a vulnerability — a feature.
### SPECTRUM — Asset List
The filterable asset directory. "Spectrum" names the primary axis: assets are listed across the **backed ↔ non-backed** spectrum.
### METROON — Asset List
The filterable asset directory. "Metroon" names the registry of assets — listed across the **backed ↔ non-backed** spectrum.
**Filters:**
- **Primary:** Backed (MBA) / Non-backed (NBA, the "Token" display family)
@ -715,7 +715,7 @@ wiki/
| Term | Definition |
|---|---|
| **Avalanche** | Asset creation interface. Genesis event. Irreversible. |
| **Spectrum** | Asset list / classification view. Filterable directory — primary filter backed (MBA) / non-backed (NBA); secondary filters: subcategory (e.g. stablecoins), use-case (game currency, tickets, memberships, collectibles), tier, verification. Scope toggle: my holdings / all assets. |
| **Metroon** | Asset list / classification view. Filterable directory — primary filter backed (MBA) / non-backed (NBA); secondary filters: subcategory (e.g. stablecoins), use-case (game currency, tickets, memberships, collectibles), tier, verification. Scope toggle: my holdings / all assets. |
| **Orbital** | Asset explorer. Market-cap-style data, rankings, and per-asset detail across the ecosystem — the "charted heavens." |
| **Agora** | Launch, discovery, and ASM swap marketplace (DEA and PLA-until-depletion); the UI for the Autonomous Swap Market (AuSM). |
| **Elevation** | Architecture B → A escalation interface. |
@ -746,7 +746,7 @@ wiki/
---
_Document version: V2.8 · Verified against snapshots 0001–0021 (naming + UI pass; technical validation unchanged from v2.7)_
_Changes from V2.7: dual-register asset names (MBA / DEA); NBA family naming (NBA / Token / classical tokens) with Token retained as wallet display label; UI names restructured — Spectrum = Asset List, Orbital = Explorer, Agora = swap/interact (was Orbital Network)._
_Changes from V2.7: dual-register asset names (MBA / DEA); NBA family naming (NBA / Token / classical tokens) with Token retained as wallet display label; UI names restructured — Metroon = Asset List, Orbital = Explorer, Agora = swap/interact (was Orbital Network)._
_Document version: V2.7 · Compiled 2026-05-19 · Verified against snapshots 0001–0021_
_Changes from V2.6: §2.4 extended from four MBA classes to all eight asset classes the wallet recognizes. The four Token sub-classes (PTU, PTL, MTU, MTL) are now defined formally parallel to the four MBA classes (DEA, PLA, MCA, MTA), with the 2×2 quadrant (mint × backing) as the underlying classification structure. The classification algorithm — how the wallet detects which class a given CashToken UTXO belongs to — is now specified explicitly. §9 Wallet Display Rules table expanded to nine rows (four MBA classes, four Token sub-classes, plus MBA — Unverified for fallback). The wallet renders each of the eight classes with its own label and treatment. §2.4 renamed from "The Four MBA Classes — Canonical Definitions" to "Asset Class Definitions" to reflect the broader scope. §15 Glossary updated: single "Token" entry replaced with five entries (Token category + four sub-classes); MBA entry updated to reference the eight-class structure. The 4-letter naming form (PTUS, PTLS, MTUS, MTLS) is reserved for public taxonomy documents outside the spec where the derivation of the 3-letter form is explained; inside the spec only the 3-letter form is used._

View file

@ -346,7 +346,7 @@
<div class="bbody"><div class="bbody-inner"><ul class="points">
<li><span class="g">Olympus</span> — the home of the whole pantheon; <b>CosmoGenesis</b> — the wallet shell (world + birth).</li>
<li><span class="g">DEA ≈ Zeus</span> (Greek <i>Dias / Deas</i> — root of the word <i>day</i> across many languages; the top-tier asset) · <b>Athena</b> (governance) · <b>Themis</b> (Elders) · <b>Poseidon</b> (liquidity).</li>
<li><b>Agora</b> — launch & swap marketplace · <b>Orbital</b> — the asset explorer (the charted heavens) · <b>Spectrum</b> — the filterable asset list · <b>Avalanche</b> — the create/mint mechanism.</li>
<li><b>Agora</b> — launch & swap marketplace · <b>Orbital</b> — the asset explorer (the charted heavens) · <b>Metroon</b> — the filterable asset list · <b>Avalanche</b> — the create/mint mechanism.</li>
<li>Motif — <span class="gs">Asterae &amp; Planitae</span> (stars "without support," planets "wandering"): bodies held on exact trajectories by an unseen force. <span class="g">Mathematics, not authority, holds the system.</span></li>
</ul></div></div>
</section>

View file

@ -705,7 +705,7 @@
<div class="surfaces">
<div class="surface"><div class="surface-name">Avalanche</div><div class="surface-desc">Create. Genesis in one transaction — core parameters sealed, irreversible.</div></div>
<div class="surface"><div class="surface-name">Spectrum</div><div class="surface-desc">The asset list. Every class, filterable across the backed ↔ non-backed spectrum and by use-case.</div></div>
<div class="surface"><div class="surface-name">Metroon</div><div class="surface-desc">The asset list. Every class, filterable across the backed ↔ non-backed spectrum and by use-case.</div></div>
<div class="surface"><div class="surface-name">Orbital</div><div class="surface-desc">The explorer. Market-cap-style data and rankings across the ecosystem — the charted heavens.</div></div>
<div class="surface"><div class="surface-name">Agora</div><div class="surface-desc">Swap. Counterparty-free, priced by the Universal Exchange Rate — the market that emerges from the mechanism.</div></div>
<div class="surface"><div class="surface-name">Transition</div><div class="surface-desc">Upgrade. Soft layer to hard layer — same asset, enforcement hardened.</div></div>

View file

@ -0,0 +1,91 @@
pragma cashscript ^0.12.0;
// DeviantOracleMBA_DraFee — DeviantOracleMBA sibling with DRA fee redirection.
//
// Same FIATm-family semantics as DeviantOracleMBA (oracle-priced, self-backed
// per-unit lock, magical-redemption property preserved at §4), but every claim
// routes a small additional fee slice to a DRA pool address. The DRA pool
// covenant (DeviantReflectiveDRA) absorbs these inflows into per-DRA NAV via
// its receiveFees() function, lifting the pool's value for all DRA holders.
//
// This is the on-chain fee-revenue redirection for the FIATm-family flagship
// DRA design — Parameters/FIATm/dra_dynamic-rate-asset_FOR-design-record.md §2.
//
// Sibling-not-replacement: existing chipnet-validated USDm/EURm/etc. deployed
// on DeviantOracleMBA continue to operate unchanged. NEW FIATm currencies
// deploy on this variant when they want to feed the DRA pool. Same per-unit
// self-backed property (§4 magical redemption); same oracle stack (§14); just
// adds output [3] for the DRA pool deposit.
//
// Output layout (claim):
// [0] reservoir back to contract (FT reduced, category preserved)
// [1] FT to recipient, self-backed at tokenAmount * price sats
// [2] DRA pool fee deposit (REQUIRED — min dust if computed fee < 546)
// [3] founder fee (only if >= dust; sub-dust waived per existing pattern)
// [4+] BCH change (unconstrained)
//
// The DRA-pool output is at fixed index [2] (NOT [3]) so the layout is
// deterministic regardless of founder-fee dust threshold. This makes it
// easier for the DRA covenant's receiveFees() to identify and absorb the
// inflow in a follow-up consolidation transaction.
contract DeviantOracleMBA_DraFee(
bytes tokenCategory, // bare 32-byte category id (consensus LE order)
pubkey oraclePk, // founder price-oracle public key
int founderFeeBps, // e.g. 5 = 0.05%, 500 = 5%
bytes founderLockingBytecode, // 25-byte P2PKH of fee recipient
int draFeeBps, // NEW — DRA pool fee BPS (e.g. 5 = 0.05% on mint/redeem)
bytes draPoolLockingBytecode // NEW — P2SH32 of DRA pool covenant
) {
function claim(
int tokenAmount,
bytes recipientLockingBytecode,
datasig oracleSig,
bytes oracleMessage
) {
require(tokenAmount > 0);
// --- oracle price attestation (forward swap only, same as DeviantOracleMBA) ---
require(checkDataSig(oracleSig, oracleMessage, oraclePk));
require(oracleMessage.length == 16);
bytes head, bytes priceBytes = oracleMessage.split(12);
require(head.length == 12);
int price = int(priceBytes);
require(price > 0);
int grossSats = tokenAmount * price;
int founderFee = grossSats * founderFeeBps / 10000;
int draFee = grossSats * draFeeBps / 10000;
// DRA fee floors at dust (546 sats) — sub-dust mints still pay 546 to
// DRA pool. For any mint over ~109,200 sats (~$0.11 at BCH=$200 with
// 5 bps fee) the actual fee dominates dust. Sub-dust subsidy is the
// chipnet-stage simplification; mainnet recalibration per §2.
if (draFee < 546) { draFee = 546; }
// [0] reservoir back to this contract, FT reduced by tokenAmount.
require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
require(tx.outputs[0].tokenAmount == tx.inputs[this.activeInputIndex].tokenAmount - tokenAmount);
require(tx.outputs[0].value >= 546);
// [1] FT to recipient, self-backed at the oracle price (redeemable, no oracle).
require(tx.outputs[1].lockingBytecode == recipientLockingBytecode);
require(tx.outputs[1].tokenCategory == tokenCategory);
require(tx.outputs[1].tokenAmount == tokenAmount);
require(tx.outputs[1].value == grossSats);
// [2] DRA pool fee deposit — REQUIRED, at fixed position for deterministic layout.
// The DRA covenant's receiveFees() consolidates these into the pool's main UTXO.
require(tx.outputs[2].lockingBytecode == draPoolLockingBytecode);
require(tx.outputs[2].value == draFee);
require(tx.outputs[2].tokenCategory == 0x);
// [3] founder fee — enforced only when it clears dust; sub-dust waived.
if (founderFee >= 546) {
require(tx.outputs[3].lockingBytecode == founderLockingBytecode);
require(tx.outputs[3].value == founderFee);
require(tx.outputs[3].tokenCategory == 0x);
}
}
}

View file

@ -0,0 +1,214 @@
pragma cashscript ^0.12.0;
// DeviantReflectiveDRA — shared-pool reflective Dynamic Rate Asset.
//
// Sibling of DeviantExpDRA / StepDRA / SuperExpDRA (per-unit bonding-curve
// variants). This DRA variant is structurally distinct:
// - SHARED POOL: all BCH lives in one covenant; DRA tokens are pro-rata
// claims on the pool, NOT self-backed per-unit locks.
// - REFLECTIVE RATE: mint price moves inversely to an external EUR oracle
// rate, asymmetrically dampened by adoption catalysts.
// - FEE RECEIVER: external FIATm covenants route a slice of their tx fees
// into the pool, lifting NAV per DRA for ALL holders monotonically.
//
// Design record: Parameters/FIATm/dra_dynamic-rate-asset_FOR-design-record.md
// Status: chipnet-stage covenant sketch. NOT production-ready. Simulator
// validation in scripts/sim-dra-controller.mjs must confirm the asymmetric
// three-force model converges to its maturity envelope before this is
// promoted to chipnet genesis.
//
// State packed in the NFT commitment (the persistent controller UTXO):
// bytes 8: supply (uint64 — DRA units outstanding)
// bytes 4: catalysts (uint32 — adoption counter)
// bytes 8: dra_rate_0 (uint64 — genesis DRA sats-per-unit, immutable)
// bytes 8: eur_rate_0 (uint64 — genesis EUR sats-per-unit, immutable)
//
// Layout: supply || catalysts || dra_rate_0 || eur_rate_0 = 28 bytes total.
//
// The covenant's `value` field IS the pool BCH balance — no separate accounting.
// NAV per DRA at any moment = covenant.value / supply.
contract DeviantReflectiveDRA(
bytes tokenCategory, // DRA token category
pubkey oraclePubKey, // EUR oracle public key
int catalystTrigger, // cumulative mints per catalyst (210,000 per §12a.2)
int beta_x1000, // β × 1000 (e.g. 100 = 0.100; counter strength per catalyst)
int gamma_x1000 // γ × 1000 (e.g. 50 = 0.050; gain acceleration per catalyst)
) {
// ──────────────────────────────────────────────────────────────────
// mint(oracleMessage, oracleSignature, n_dra, recipient)
//
// Caller provides:
// - oracle message containing current EUR_rate (sats per EURm)
// - oracle signature over that message
// - n_dra: integer DRA units to mint
// - recipient locking bytecode
//
// Caller's BCH input = covenant pool top-up.
// The covenant computes the reflective DRA_rate from the oracle EUR_rate,
// verifies the input BCH equals n_dra × DRA_rate, mints n_dra DRA tokens
// to the recipient, advances supply + catalyst counters.
//
// tx layout:
// inputs[0] = the controller UTXO (this contract)
// inputs[1] = caller funding input (P2PKH)
// outputs[0] = controller UTXO recreated with updated commitment + pool
// outputs[1] = n_dra DRA tokens to recipient
// outputs[2] = caller's change (optional)
// ──────────────────────────────────────────────────────────────────
function mint(
bytes oracleMessage,
datasig oracleSignature,
int n_dra,
bytes recipientLockingBytecode
) {
require(n_dra > 0);
require(checkDataSig(oracleSignature, oracleMessage, oraclePubKey));
// Parse oracle message: assumes format [4-byte timestamp][8-byte eur_rate]
// (Real oracle stack §14 layered checks are layered OUTSIDE this covenant.)
int eur_rate = int(oracleMessage.split(4)[1].split(8)[0]);
require(eur_rate > 0);
// Unpack current state from NFT commitment
bytes state = tx.inputs[this.activeInputIndex].nftCommitment;
int supply = int(state.split(8)[0]);
int catalysts = int(state.split(8)[1].split(4)[0]);
int dra_rate_0 = int(state.split(12)[1].split(8)[0]);
int eur_rate_0 = int(state.split(20)[1].split(8)[0]);
// Compute reflective DRA_rate — doubly asymmetric (counter / gain).
//
// r_x1000 = eur_rate * 1000 / eur_rate_0
// delta = eur_rate - eur_rate_0
//
// if r_x1000 > 1000 (EUR strengthens — negative side, dampened):
// counter_x1000 = r_x1000 * (1000 + catalysts * beta_x1000) / 1000
// dra_rate_raw = dra_rate_0 - (delta * 1000 / counter_x1000)
// else (EUR weakens — positive side, amplified by gain):
// gain_x1000 = 1000 + catalysts * gamma_x1000
// dra_rate_raw = dra_rate_0 - (delta * gain_x1000 / 1000)
int r_x1000 = eur_rate * 1000 / eur_rate_0;
int delta = eur_rate - eur_rate_0;
int dra_rate = dra_rate_0;
if (r_x1000 > 1000) {
int counter_x1000 = r_x1000 * (1000 + catalysts * beta_x1000) / 1000;
dra_rate = dra_rate_0 - (delta * 1000 / counter_x1000);
} else {
int gain_x1000 = 1000 + catalysts * gamma_x1000;
dra_rate = dra_rate_0 - (delta * gain_x1000 / 1000);
}
// Amplitude clamp — widens with adoption (NOT tightens), asymmetric
int lower_x1000 = 500 - catalysts * 15;
if (lower_x1000 < 50) { lower_x1000 = 50; }
int upper_x1000 = 2000 + catalysts * 100;
int min_rate = dra_rate_0 * lower_x1000 / 1000;
int max_rate = dra_rate_0 * upper_x1000 / 1000;
if (dra_rate < min_rate) { dra_rate = min_rate; }
if (dra_rate > max_rate) { dra_rate = max_rate; }
require(dra_rate > 0);
// Mint price = max(reflective DRA_rate, current NAV per DRA).
//
// The max-of-two is structural: new mints cannot enter below NAV (no
// dilution), and during EUR-weakness events post-catalyst the gain-
// amplified reflective rate exceeds NAV → new mints pay a PREMIUM that
// lifts NAV for all holders. This is the structural channel by which
// the gain function accretes value to per-DRA NAV over the adoption arc.
int current_pool = tx.inputs[this.activeInputIndex].value;
int nav_per_dra = dra_rate_0;
if (supply > 0) { nav_per_dra = current_pool / supply; }
int mint_price = nav_per_dra;
if (dra_rate > nav_per_dra) { mint_price = dra_rate; }
require(mint_price > 0);
// Required BCH input from caller
int sats_in = n_dra * mint_price;
require(sats_in > 0);
// New state — catalysts hard-capped at 30 (asymptote, design §6)
int new_supply = supply + n_dra;
int new_catalysts = new_supply / catalystTrigger;
if (new_catalysts > 30) { new_catalysts = 30; }
bytes new_state = bytes8(new_supply) + bytes4(new_catalysts) + bytes8(dra_rate_0) + bytes8(eur_rate_0);
// outputs[0] = controller recreated
require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
require(tx.outputs[0].nftCommitment == new_state);
require(tx.outputs[0].value == tx.inputs[this.activeInputIndex].value + sats_in);
// outputs[1] = n_dra DRA tokens to recipient
require(tx.outputs[1].lockingBytecode == recipientLockingBytecode);
require(tx.outputs[1].tokenCategory == tokenCategory);
require(tx.outputs[1].tokenAmount == n_dra);
require(tx.outputs[1].value >= 546);
}
// ──────────────────────────────────────────────────────────────────
// redeem(n_dra, recipient)
//
// Caller burns n_dra DRA tokens; covenant releases pro-rata pool BCH:
// sats_out = covenant.value × n_dra / supply
//
// tx layout:
// inputs[0] = the controller UTXO
// inputs[1] = caller's DRA tokens to burn
// outputs[0] = controller UTXO recreated (reduced supply + pool)
// outputs[1] = sats_out to recipient (no DRA tokens)
// ──────────────────────────────────────────────────────────────────
function redeem(int n_dra, bytes recipientLockingBytecode) {
require(n_dra > 0);
// Verify input[1] carries the right DRA tokens being burned
require(tx.inputs[1].tokenCategory == tokenCategory);
require(tx.inputs[1].tokenAmount == n_dra);
// Unpack state
bytes state = tx.inputs[this.activeInputIndex].nftCommitment;
int supply = int(state.split(8)[0]);
int catalysts = int(state.split(8)[1].split(4)[0]);
int dra_rate_0 = int(state.split(12)[1].split(8)[0]);
int eur_rate_0 = int(state.split(20)[1].split(8)[0]);
require(supply >= n_dra);
int sats_out = tx.inputs[this.activeInputIndex].value * n_dra / supply;
require(sats_out > 0);
int new_supply = supply - n_dra;
bytes new_state = bytes8(new_supply) + bytes4(catalysts) + bytes8(dra_rate_0) + bytes8(eur_rate_0);
// outputs[0] = controller recreated (catalyst count NEVER decreases — adoption is monotonic)
require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
require(tx.outputs[0].nftCommitment == new_state);
require(tx.outputs[0].value == tx.inputs[this.activeInputIndex].value - sats_out);
// outputs[1] = pure BCH to recipient (DRA was burned by absence in outputs)
require(tx.outputs[1].lockingBytecode == recipientLockingBytecode);
require(tx.outputs[1].value == sats_out);
// tokenCategory absence enforced by CashScript output validation
}
// ──────────────────────────────────────────────────────────────────
// receiveFees() — permissive deposit; no DRA minted, no state change
// except pool growth.
//
// Anyone can call this. The FIATm-family fee-redirector contracts call
// it as part of their mint/redeem/swap operations to route fees here.
//
// tx layout:
// inputs[0] = controller UTXO
// inputs[1+] = fee-paying inputs
// outputs[0] = controller UTXO recreated with pool grown
// ──────────────────────────────────────────────────────────────────
function receiveFees() {
// outputs[0] = controller recreated, same commitment, pool grown
require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
require(tx.outputs[0].nftCommitment == tx.inputs[this.activeInputIndex].nftCommitment);
require(tx.outputs[0].value > tx.inputs[this.activeInputIndex].value);
// Pool MUST grow — refusing zero or negative deposits.
}
}

View file

@ -0,0 +1,166 @@
pragma cashscript ^0.12.0;
// GamePoolEscrowFlatV2 — flat-fee escrow with per-side refund.
//
// Identical to GamePoolEscrowFlat (existing artifact) except for ONE new
// resolution path: `refundParty(int side)`. This enables the Pari_p2p
// **Flow A** (independent deposits) safely — a single-side deposit can be
// reclaimed by its owner after the deadline if the opponent never joined.
// Without this, a Flow A deposit by A with B-never-shows leaves A's funds
// permanently stuck at the escrow address (existing refund() requires a
// combined pool UTXO with poolAmount FTs).
//
// All other paths (resolveArbiter, resolveHashlock, refund) are byte-for-
// byte identical to GamePoolEscrowFlat and validate the same output
// structure. They work transparently with either:
// - one combined pool UTXO (Flow B atomic co-sign), OR
// - two separate deposit UTXOs (Flow A independent deposits)
// because the covenant only constrains outputs, not the input layout.
//
// IMPORTANT: in Flow A, EACH deposit UTXO is its own covenant instance
// spending; resolution txs (resolveArbiter / refund) spend BOTH UTXOs as
// inputs in the same tx and consensus enforces the constraints on each.
//
// Status: CHIPNET-VALIDATED by scripts/chipnet-pari-p2p-test.mjs across four
// scenarios on live chipnet consensus. Latest run (with cancel()) — CHE
// category ebd7c46a…0be36: mint+stake atomic (db444c0d…, 729578e5…),
// resolveArbiter on 2 separate deposits (a73c008e…), refundParty(0) on stuck
// single-side after deadline (ba66a060…), cancel(0, sigA, pubA, sigArbiter)
// immediate user-initiated reclaim with no deadline wait (391fdb18…). Full
// reference: Parameters/Pari_p2p/.
contract GamePoolEscrowFlatV2(
bytes tokenCategory, // the MBA being staked
int poolAmount, // total pooled FTs = 2 × stakeAmount
int poolSats, // total backing = 2 × stakeSats
int stakeAmount, // per-player stake (for refund / refundParty)
int stakeSats, // per-player backing
bytes20 partyA, // player A pkh (refund recipient)
bytes20 partyB, // player B pkh (refund recipient)
pubkey arbiterPk, // arbiter for resolveArbiter (+ fee recipient)
bytes32 secretHash, // sha256(secret) for resolveHashlock
int deadline, // CLTV height/time for refund + refundParty
int feeAmount // flat arbiter fee in FTs; 0..<poolAmount
) {
// ---- Arbiter declares the winner: pool − feeAmount → winner, feeAmount → arbiter ----
function resolveArbiter(sig arbiterSig, bytes winnerLockingBytecode) {
require(checkSig(arbiterSig, arbiterPk));
int rate = poolSats / poolAmount;
require(feeAmount < poolAmount);
int winnerAmount = poolAmount - feeAmount;
require(tx.outputs[0].lockingBytecode == winnerLockingBytecode);
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == winnerAmount);
require(tx.outputs[0].value == winnerAmount * rate);
if (feeAmount > 0) {
require(feeAmount * rate >= 546);
require(tx.outputs[1].lockingBytecode == new LockingBytecodeP2PKH(hash160(arbiterPk)));
require(tx.outputs[1].tokenCategory == tokenCategory);
require(tx.outputs[1].tokenAmount == feeAmount);
require(tx.outputs[1].value == feeAmount * rate);
}
}
// ---- Winner reveals the committed secret: entire pool → revealer ----
function resolveHashlock(bytes secret, bytes winnerLockingBytecode) {
require(sha256(secret) == secretHash);
require(tx.outputs[0].lockingBytecode == winnerLockingBytecode);
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == poolAmount);
require(tx.outputs[0].value == poolSats);
}
// ---- Combined refund: both stakes back to A and B (Flow B or paired Flow A) ----
function refund() {
require(tx.time >= deadline);
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyA));
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == stakeAmount);
require(tx.outputs[0].value == stakeSats);
require(tx.outputs[1].lockingBytecode == new LockingBytecodeP2PKH(partyB));
require(tx.outputs[1].tokenCategory == tokenCategory);
require(tx.outputs[1].tokenAmount == stakeAmount);
require(tx.outputs[1].value == stakeSats);
}
// ---- per-side refund (Pari_p2p Flow A safety net) ----
//
// After the deadline, a single-side deposit can be reclaimed by its
// owner. Permissionless — anyone can trigger it (they just pay the
// miner fee), but the output is structurally forced to the specified
// side's P2PKH. The input must be a SINGLE-STAKE-SIZED UTXO; this
// prevents calling refundParty on a combined pool UTXO that the
// existing refund() should handle.
//
// `side` selects which party's P2PKH receives:
// side = 0 → partyA
// side = 1 → partyB
function refundParty(int side) {
require(tx.time >= deadline);
require(side == 0 || side == 1);
// Constrain the input to a single-stake-sized deposit. This
// closes off a perverse spend where someone tries to refundParty
// a combined pool UTXO (which would otherwise pay only one side
// their stake's worth and "lose" the other side's stake to fees).
require(tx.inputs[this.activeInputIndex].tokenAmount == stakeAmount);
require(tx.inputs[this.activeInputIndex].value == stakeSats);
if (side == 0) {
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyA));
} else {
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyB));
}
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == stakeAmount);
require(tx.outputs[0].value == stakeSats);
}
// ---- USER-SIGNED EARLY CANCEL — both depositor and arbiter must sign ----
//
// Lets a single-side depositor reclaim BEFORE the deadline, without waiting,
// when the arbiter (game server) co-signs an attestation that the match
// was indeed cancelled / no opponent joined.
//
// Why both signatures are required:
// - depositor's sig alone → A could grief mid-game by cancelling after B
// deposited (the covenant cannot see other UTXOs at this address).
// - arbiter's sig alone → server could rug deposits unilaterally.
// - both together → arbiter attests cancellation conditions, depositor
// confirms they want their funds back. Server cooperation is required,
// and refundParty(side) after the deadline remains the no-server fallback.
//
// Caller passes `partyPk` (the depositor's compressed pubkey) so the contract
// can verify checkSig(partySig, partyPk) AND that hash160(partyPk) matches
// the right party's pkh stored at construction.
function cancel(int side, sig partySig, pubkey partyPk, sig arbiterSig) {
require(side == 0 || side == 1);
// Arbiter co-signs the cancellation attestation.
require(checkSig(arbiterSig, arbiterPk));
// Verify partyPk hashes to the right party's stored pkh, AND that this
// party signed.
if (side == 0) {
require(hash160(partyPk) == partyA);
} else {
require(hash160(partyPk) == partyB);
}
require(checkSig(partySig, partyPk));
// Same single-stake-input guard as refundParty.
require(tx.inputs[this.activeInputIndex].tokenAmount == stakeAmount);
require(tx.inputs[this.activeInputIndex].value == stakeSats);
if (side == 0) {
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyA));
} else {
require(tx.outputs[0].lockingBytecode == new LockingBytecodeP2PKH(partyB));
}
require(tx.outputs[0].tokenCategory == tokenCategory);
require(tx.outputs[0].tokenAmount == stakeAmount);
require(tx.outputs[0].value == stakeSats);
}
}

View file

@ -0,0 +1,45 @@
pragma cashscript ^0.12.0;
// PotidaeaEpochState — the global marketplace counter, EPOCH-BATCH variant (ADR-0048).
//
// Same state as PotidaeaOlympiaListing (A|L|notches|lastHeight) but writes happen via a
// once-per-epoch FOLD instead of one tx per listing. Users drop independent
// PotidaeaListingRequest UTXOs (zero contention); a permissionless folder co-spends the
// single state UTXO + a batch of requests and applies them together — so there is exactly ONE
// state-mutating tx per epoch, and the BATCH SIZE is the velocity signal: folding N listings
// climbs M by N notches (many listings in one epoch = hype). No CLTV height trick needed.
//
// This demo ships fixed-size folds (settle2 = a batch of exactly 2); production offers a few
// sizes (settle1/settle2/settle4/…) or an accumulator. Each request self-verifies its own fee
// reached treasury at its mirror output index (input k ↔ output k), so this covenant only
// bumps the counter and checks the treasury outputs carry DEA.
//
// settle2 layout: inputs [state(0), request(1), request(2), funding(3+)]
// outputs [newState(0), treasuryFee(1), treasuryFee(2), change(3+)]
contract PotidaeaEpochState(
bytes deaCategory, // consensus-order DEA category (fee currency)
bytes treasuryLockingBytecode // platform treasury (25 bytes)
) {
// Fold a batch of exactly 2 listing requests. L += 2, notches += 2 (M ×+0.2 this epoch).
function settle2() {
bytes aB, bytes r1 = tx.inputs[this.activeInputIndex].nftCommitment.split(4);
bytes lB, bytes r2 = r1.split(4);
bytes nB, bytes hB = r2.split(4);
// A unchanged (listings, not creations); L and notches climb by the batch size (2).
bytes newCommitment = aB + bytes4(int(lB) + 2) + bytes4(int(nB) + 2) + hB;
// out 0: the state NFT back, same category, folded commitment.
require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
require(tx.outputs[0].nftCommitment == newCommitment);
require(tx.outputs[0].value >= 546);
// out 1, out 2: the two requests' fees to treasury (each request checks its own amount).
require(tx.outputs[1].lockingBytecode == treasuryLockingBytecode);
require(tx.outputs[1].tokenCategory == deaCategory);
require(tx.outputs[2].lockingBytecode == treasuryLockingBytecode);
require(tx.outputs[2].tokenCategory == deaCategory);
}
}

View file

@ -0,0 +1,36 @@
pragma cashscript ^0.12.0;
// PotidaeaListingRequest — a single user's Olympia listing REQUEST (epoch-batch model).
//
// A user who wants to list drops one of these: a UTXO holding their DEA fee. Because each
// request is its OWN independent UTXO, thousands can be created in the same block with ZERO
// contention — nobody touches a shared object. Once per epoch a permissionless FOLD tx
// (PotidaeaEpochState.settle*) consumes a batch of these + the single global state UTXO and
// applies them all at once.
//
// This request is self-policing: `settle()` only lets it be spent if ITS fee reaches the
// treasury at this input's MIRROR output index (input k ↔ output k), so a folder can't
// consume it without paying its fee. If it's never folded, the owner reclaims the DEA after
// a deadline via `refund()`.
contract PotidaeaListingRequest(
bytes ownerPkh, // owner's hash160(pubkey), for the refund path
bytes deaCategory, // consensus-order category id of the flagship DEA (fee currency)
bytes treasuryLockingBytecode, // platform-treasury P2PKH (25 bytes)
int feeDea, // the DEA fee this request commits to pay
int deadline // block height; after this the owner may refund
) {
// Folded into an epoch: this request's fee must land at treasury at the mirror output.
function settle() {
require(tx.outputs[this.activeInputIndex].lockingBytecode == treasuryLockingBytecode);
require(tx.outputs[this.activeInputIndex].tokenCategory == deaCategory);
require(tx.outputs[this.activeInputIndex].tokenAmount == feeDea);
}
// Never folded before `deadline` → owner reclaims the locked DEA.
function refund(pubkey ownerPk, sig ownerSig) {
require(hash160(ownerPk) == ownerPkh);
require(checkSig(ownerSig, ownerPk));
require(tx.time >= deadline);
}
}

View file

@ -0,0 +1,100 @@
pragma cashscript ^0.12.0;
// PotidaeaOlympiaListing — the global Potidaea marketplace state machine (ADR-0048).
//
// A SINGLE self-contained state UTXO (a mutable state-NFT held at this covenant's
// P2SH32 address) tracks the whole marketplace and enforces its fee rules. It does
// not "run on itself" — BCH has no autonomous execution — but every create/list is a
// transaction that SPENDS this UTXO and RECREATES it with updated state, and the
// covenant verifies the transition + the DEA fees. It needs no other asset class to
// operate except DEA (the fee currency, an immutable constructor-bound category).
//
// State commitment (16 bytes, little-endian): A(4) | L(4) | notches(4) | lastHeight(4)
// A = total assets CREATED (drives the listing-fee base, cA·A)
// L = total Olympia LISTINGS (drives cL·L)
// notches = the velocity multiplier as 0.1 steps above ×1.0 (M = 1 + 0.1·notches)
// lastHeight= block height of the last listing (cooldown anchor)
//
// FEES (all in flagship DEA → platform treasury; ADR-0048):
// create() : 10·floor(A/100) DEA — FREE for the first 100, then 10/20/30 per 100-band
// list() : (cA·A + cL·L)·M DEA — M climbs +0.1 per listing (post-bump: 1st ×1.1 …)
//
// COOLDOWN — sequential, dwell ∝ height (the higher M climbed, the longer it lingers):
// removing k notches off the top of height `notches` costs cph·(k·notches − k(k−1)/2).
// The spender passes claimedHeight (bound by CLTV to ≤ the real height) and the
// post-decay `decayedNotches`. Because MORE decay ⇒ LOWER fee, a rational spender
// maximizes decay and therefore claims the TRUE current height — so unlike fee
// *escalation* (DeviantEscFeeBlockHeightV2: not enforceable), the *decay* direction
// IS adversarially enforceable on BCH. The covenant only blocks OVER-decay.
contract PotidaeaOlympiaListing(
bytes deaCategory, // consensus-order category id of flagship DEA (fee currency)
bytes treasuryLockingBytecode, // platform-treasury P2PKH locking bytecode (25 bytes)
int cA, // DEA added per asset created (Olympia: 1)
int cL, // DEA added per existing listing (Olympia: 1)
int cph, // cooldown blocks per height level (e.g. ~1 day)
int creationStep, // creation-fee step DEA per 100-band (10)
int activationThreshold // assets created before Olympia opens (100)
) {
// ---- LIST: add an Olympia listing; pay (cA·A + cL·L)·M DEA; climb + decay M. ----
// Kept lean (few live locals) — deep local stacks made cashc mis-roll a constructor
// arg in the output checks; inlining the math keeps the stack layout unambiguous.
function list(int claimedHeight, int decayedNotches) {
bytes aB, bytes r1 = tx.inputs[this.activeInputIndex].nftCommitment.split(4);
bytes lB, bytes r2 = r1.split(4);
bytes nB, bytes hB = r2.split(4);
int A = int(aB);
int notches = int(nB);
require(A >= activationThreshold); // Olympia is gated to ≥100 created
// Bind claimedHeight to consensus (CLTV → claimedHeight ≤ real current height).
require(tx.time >= claimedHeight);
require(claimedHeight >= int(hB));
require(decayedNotches >= 0);
require(decayedNotches <= notches);
// Sequential decay, dwell ∝ height: removing k = notches−decayedNotches off the top
// costs cph·(k·notches − k(k−1)/2); must have elapsed. Blocks OVER-decay only.
int k = notches - decayedNotches;
require(cph * (k * notches - (k * (k - 1)) / 2) <= claimedHeight - int(hB));
// M (×10): baseline 10 + decayedNotches + 1 (post own bump) = 11 + decayedNotches.
int feeDea = (cA * A + cL * int(lB)) * (11 + decayedNotches) / 10;
// out 0: the state NFT back to this covenant, same category, new commitment.
require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
require(tx.outputs[0].nftCommitment ==
bytes4(A) + bytes4(int(lB) + 1) + bytes4(decayedNotches + 1) + bytes4(claimedHeight));
require(tx.outputs[0].value >= 546);
// out 1: the DEA listing fee → platform treasury.
require(tx.outputs[1].lockingBytecode == treasuryLockingBytecode);
require(tx.outputs[1].tokenCategory == deaCategory);
require(tx.outputs[1].tokenAmount == feeDea);
}
// ---- CREATE: register a new asset; pay 10·floor(A/100) DEA (free first 100). ----
function create() {
bytes aB, bytes r1 = tx.inputs[this.activeInputIndex].nftCommitment.split(4);
bytes lB, bytes r2 = r1.split(4);
bytes nB, bytes hB = r2.split(4);
int A = int(aB);
int feeDea = creationStep * (A / 100); // floor; 0 while A < 100
bytes newCommitment = bytes4(A + 1) + lB + nB + hB; // only A changes
require(tx.outputs[0].lockingBytecode == tx.inputs[this.activeInputIndex].lockingBytecode);
require(tx.outputs[0].tokenCategory == tx.inputs[this.activeInputIndex].tokenCategory);
require(tx.outputs[0].nftCommitment == newCommitment);
require(tx.outputs[0].value >= 546);
if (feeDea > 0) {
require(tx.outputs[1].lockingBytecode == treasuryLockingBytecode);
require(tx.outputs[1].tokenCategory == deaCategory);
require(tx.outputs[1].tokenAmount == feeDea);
}
}
}

View file

@ -58,7 +58,7 @@ capitals (**P**ermissionless **T**oken **U**nlimited → PTU). Taxonomy per ADR-
| `send-flow.html` | Send / Receive / Scan (FAB) — D-4/D-5 raw-BCH co-display, ADR-0013 friction ladder |
| `swap-flow.html` | Orbital swap — builder → quote → result, persistent path indicator (ASM) |
| `settings.html` | Settings — security (friction thresholds), Privacy/CashFusion, network, empty-default registries (ADR-0008) |
| `asset-detail.html` | Spectrum asset pane — 7 regions, PNV + raw-BCH, action row, rate & backing |
| `asset-detail.html` | Metroon asset pane — 7 regions, PNV + raw-BCH, action row, rate & backing |
| `elevation.html` | Elevation B→A — pre-flight integrity panel (T-002 poisoning guard) |
| `activity.html` | Activity — filterable list + underbacked-receipt (T-001 victim) detail |
| `onboarding.html` | Onboarding — Welcome → BCH-fuel concept → seed → network (no tab bar) |

View file

@ -64,7 +64,7 @@
<tbody id="rows"><tr><td colspan="10" style="color:#7d8a98;text-align:center">Loading addresses…</td></tr></tbody>
</table>
<div class="footer">
Address list: <code>design/explorer/addresses.json</code> · regenerate with <code>rtk node scripts/derive-chipnet-addresses.mjs</code> when you add a chipnet wallet.<br>
Address list: inlined below. To update, edit the <code>WALLETS</code> constant in this file or regenerate via <code>rtk node scripts/derive-chipnet-addresses.mjs</code> (copies into <code>design/explorer/addresses.json</code> as a backup snapshot).<br>
Electrum: <code>wss://chipnet.imaginary.cash:50004</code> · public chipnet ElectrumX (CashTokens-aware). No private keys in this page.
</div>
@ -155,13 +155,20 @@ async function balanceFor(cashaddr) {
};
}
// --- render ------------------------------------------------------------------
let WALLETS = [];
async function loadAddresses() {
const r = await fetch('addresses.json', { cache: 'no-store' });
const j = await r.json();
WALLETS = j.wallets;
}
// --- inlined wallet list -----------------------------------------------------
// No JSON fetch, no server dependency — the page works from any origin (incl.
// file://). Edit this list directly or regenerate with
// rtk node scripts/derive-chipnet-addresses.mjs
// then paste the result here.
const WALLETS = [
{ label: 'A', cashaddr: 'bchtest:qrd5g74f64gp704n4ggspcwn08e0yalz3u2ltaxuwh' },
{ label: 'B', cashaddr: 'bchtest:qzd2dw4tk2amr5v5pe8vs2ukgzn6kc4cmsv9t3eyc6' },
{ label: 'C', cashaddr: 'bchtest:qzgda5hsk96trkfk4qxa0t808kjwt8gzt5aqnt38dm' },
{ label: 'D', cashaddr: 'bchtest:qzjqnzgjz7w9tdflpz6ufx0rnrnfu4hsps9rznw4dl' },
{ label: 'E', cashaddr: 'bchtest:qpyns8t44guzvcstqgs7wvueq6n6hqgu7cpqzsfhha' },
{ label: 'F', cashaddr: 'bchtest:qr4f5sh8lhs50u7xq5568rcqxwk209dz4ynanfnyhh' },
{ label: 'G', cashaddr: 'bchtest:qp20fvzqy43q7u43erhjxg52w39mqn7cccyqnlkaqu' },
];
async function tick() {
if (ws?.readyState !== WebSocket.OPEN) return;
const rows = [];
@ -223,12 +230,11 @@ document.querySelectorAll('.chip[data-fam]').forEach((chip) => {
// --- boot --------------------------------------------------------------------
(async () => {
try { await loadAddresses(); }
try { await connect(); }
catch (e) {
document.getElementById('rows').innerHTML = '<tr><td colspan="9" class="err">Failed to load addresses.json — run <code>rtk node scripts/derive-chipnet-addresses.mjs</code> first.</td></tr>';
document.getElementById('rows').innerHTML = '<tr><td colspan="10" class="err">Could not reach the chipnet electrum server — check connectivity.</td></tr>';
return;
}
await connect();
await tick();
setInterval(tick, POLL_MS);
})();

View file

@ -16,13 +16,13 @@
.screen-title { font-family: var(--font-display); font-weight:500; font-size:27px; color: var(--ink-bright); letter-spacing:-.01em; }
.label-mono { font-family: var(--font-mono); font-size:10px; letter-spacing:.18em; text-transform:uppercase; color: var(--muted); }
/* sub-tabs: My Assets | Spectrum */
/* sub-tabs: My Assets | Metroon */
.segmented { display:flex; gap:6px; margin:2px 2px 14px; padding:4px; background: var(--surface-1); border:1px solid var(--border); border-radius:13px; }
.seg { flex:1; text-align:center; cursor:pointer; border:0; background:transparent; font-family: var(--font-mono); font-size:11px; letter-spacing:.08em; text-transform:uppercase; color: var(--muted); padding:10px 8px; border-radius:9px; transition: color .3s, background .3s; }
.seg.active { color: var(--acid); background: var(--surface-2); text-shadow:0 0 8px var(--acid-halo); }
.panel.hidden { display:none; }
/* group-by toggle (Spectrum) */
/* group-by toggle (Metroon) */
.groupbar { display:flex; align-items:center; justify-content:space-between; margin:0 2px 14px; }
.gb-toggle { display:flex; gap:5px; }
.gb { font-family: var(--font-mono); font-size:9px; letter-spacing:.08em; text-transform:uppercase; padding:6px 11px; border-radius:8px; border:1px solid var(--border-strong); background:transparent; color: var(--muted); cursor:pointer; }
@ -116,7 +116,7 @@
<div class="segmented">
<button class="seg active" data-panel="mine">My Assets</button>
<button class="seg" data-panel="spectrum">Spectrum</button>
<button class="seg" data-panel="metroon">Metroon</button>
</div>
<!-- ===== MY ASSETS (holdings) — multi-select MBA · FIATμ · NBA ===== -->
@ -133,16 +133,16 @@
</div>
<!-- ===== SPECTRUM (full directory) — multi-select MBA · FIATμ · NBA ===== -->
<div id="panel-spectrum" class="panel hidden">
<div id="panel-metroon" class="panel hidden">
<div class="groupbar">
<span class="label-mono">All assets</span>
<div class="gb-toggle">
<button class="gb on" data-panel="spectrum" data-f="mba">MBA</button>
<button class="gb on" data-panel="spectrum" data-f="fiatm">FIATμ</button>
<button class="gb on" data-panel="spectrum" data-f="nba">NBA</button>
<button class="gb on" data-panel="metroon" data-f="mba">MBA</button>
<button class="gb on" data-panel="metroon" data-f="fiatm">FIATμ</button>
<button class="gb on" data-panel="metroon" data-f="nba">NBA</button>
</div>
</div>
<div id="spectrum-list"></div>
<div id="metroon-list"></div>
</div>
<!-- ===== adaptive swap sheet — IN-PLACE, replaces the active panel ===== -->
@ -218,14 +218,14 @@
}
// ── sub-tab + toggle ──
document.querySelectorAll('.seg').forEach(function(s){s.addEventListener('click',function(){document.querySelectorAll('.seg').forEach(function(x){x.classList.remove('active');});s.classList.add('active');var p=s.getAttribute('data-panel');document.getElementById('panel-mine').classList.toggle('hidden',p!=='mine');document.getElementById('panel-spectrum').classList.toggle('hidden',p!=='spectrum');});});
document.querySelectorAll('.seg').forEach(function(s){s.addEventListener('click',function(){document.querySelectorAll('.seg').forEach(function(x){x.classList.remove('active');});s.classList.add('active');var p=s.getAttribute('data-panel');document.getElementById('panel-mine').classList.toggle('hidden',p!=='mine');document.getElementById('panel-metroon').classList.toggle('hidden',p!=='metroon');});});
// Multi-select filter: toggle each chip independently. Visible = union of
// active chips. If none active, the list is empty (user disabled all).
function refreshPanel(panel){
var active = new Set();
document.querySelectorAll('.gb[data-panel="'+panel+'"].on').forEach(function(x){active.add(x.getAttribute('data-f'));});
if(panel==='mine') renderList('mine-list', HOLDINGS, active);
else renderList('spectrum-list', ASSETS, active);
else renderList('metroon-list', ASSETS, active);
}
document.querySelectorAll('.gb[data-panel]').forEach(function(b){
b.addEventListener('click',function(){
@ -236,7 +236,7 @@
// ── adaptive swap — in-place expansion (no modal) ──
var sheet=document.getElementById('swapSheet');
var panelMine=document.getElementById('panel-mine'), panelSpec=document.getElementById('panel-spectrum');
var panelMine=document.getElementById('panel-mine'), panelSpec=document.getElementById('panel-metroon');
function closeSwap(){ sheet.classList.remove('open'); panelMine.classList.remove('swap-open'); panelSpec.classList.remove('swap-open'); }
function openSwap(cls,name,glyph,amt){
var path=document.getElementById('ssPath');
@ -286,7 +286,7 @@
})();
// Initial render: both panels start with all three families visible
refreshPanel('mine'); refreshPanel('spectrum');
refreshPanel('mine'); refreshPanel('metroon');
</script>
</body>
</html>

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deviant · Asset detail (Spectrum) · CosmoGenesis mockup</title>
<title>Deviant · Asset detail (Metroon) · CosmoGenesis mockup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">

View file

@ -159,7 +159,7 @@
<!-- ================= ASSETS family (MBA) ================= -->
<div id="family-assets" class="family">
<div class="segmented">
<button class="seg active" data-sub="assets-mine">Spectrum</button>
<button class="seg active" data-sub="assets-mine">Metroon</button>
<button class="seg" data-sub="assets-orbital">Orbital Swap</button>
</div>
@ -228,7 +228,7 @@
</div>
<!-- INLINE swap builder (opens in-place, no separate window) -->
<div id="agora-swap" class="hidden">
<button class="ag-back" id="ag-back">← Spectrum</button>
<button class="ag-back" id="ag-back">← Metroon</button>
<div class="ag-path">✓ ASM · atomic · counterparty-free</div>
<div class="ag-leg">
<div class="ag-lt"><span>From</span><span>balance 340 NOVA</span></div>
@ -247,7 +247,7 @@
<!-- ================= TOKENS family (no peg) ================= -->
<div id="family-tokens" class="family hidden">
<div class="segmented">
<button class="seg active" data-sub="tokens-mine">Spectrum</button>
<button class="seg active" data-sub="tokens-mine">Metroon</button>
<button class="seg" data-sub="tokens-market">Market</button>
</div>

View file

@ -0,0 +1,530 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deviant · Potidaea · CosmoGenesis mockup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="../cosmo-genesis.css" rel="stylesheet">
<style>
body { display:flex; justify-content:center; padding:28px 0; }
.phone { position:relative; z-index:1; width:392px; min-height:860px; background: linear-gradient(180deg, rgba(19,26,42,.55), rgba(10,15,28,.55)); border:1px solid var(--border); border-radius:30px; padding:22px 18px 96px; backdrop-filter: blur(2px); }
/* header */
.screen-head { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:4px; }
.screen-title { font-family: var(--font-display); font-weight:500; font-size:27px; color: var(--ink-bright); letter-spacing:-.01em; }
.screen-title .tri { color: var(--acid); text-shadow:0 0 10px var(--acid-glow); }
.sub-brand { font-family:var(--font-mono); font-size:9px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); margin-bottom:16px; }
.sub-brand b { color:var(--ink-soft); font-weight:500; }
/* top segmented control — the four Potidaea surfaces */
.segmented { display:flex; gap:4px; margin:2px 2px 14px; padding:4px; background: var(--surface-1); border:1px solid var(--border); border-radius:13px; }
.seg { flex:1; text-align:center; cursor:pointer; border:0; background:transparent; font-family: var(--font-mono); font-size:10px; letter-spacing:.04em; text-transform:uppercase; color: var(--muted); padding:9px 4px; border-radius:9px; transition: color .3s, background .3s; }
.seg.active { color: var(--acid); background: var(--surface-2); text-shadow:0 0 8px var(--acid-halo); }
.view { display:none; }
.view.active { display:block; animation: v-in .28s ease-out; }
@keyframes v-in { from { opacity:0; transform:translateY(4px);} to {opacity:1; transform:translateY(0);} }
/* ── Metroon: Stoa | Olympia sub-toggle ── */
.subtoggle { display:flex; gap:6px; margin:0 2px 8px; }
.st { flex:1; font-family:var(--font-mono); font-size:10px; letter-spacing:.08em; text-transform:uppercase; padding:8px; border-radius:9px; border:1px solid var(--border-strong); background:transparent; color:var(--muted); cursor:pointer; }
.st.on { color:var(--acid); border-color:rgba(214,255,61,.45); background:var(--acid-dim); text-shadow:0 0 7px var(--acid-halo); }
.st .free { color:var(--acid); }
.list-note { font-family:var(--font-mono); font-size:9px; color:var(--muted); line-height:1.5; margin:0 2px 12px; }
.list-note b { color:var(--ink-soft); font-weight:500; }
/* rows */
.cat-list { background: var(--surface-1); border:1px solid var(--border); border-radius:16px; margin-bottom:10px; overflow:hidden; }
.row { display:flex; align-items:center; gap:12px; padding:12px 14px; border-bottom:1px solid var(--border); cursor:pointer; transition:background .2s; }
.row:last-child { border-bottom:0; }
.row:hover { background: rgba(214,255,61,.04); }
.rank { width:20px; text-align:center; font-family:var(--font-mono); font-size:12px; color:var(--muted); flex:none; }
.glyph { width:36px; height:36px; flex:none; border-radius:11px; display:grid; place-items:center; background: var(--surface-2); color: var(--ink-soft); font-family: var(--font-mono); font-size:15px; }
.row-main { flex:1; min-width:0; }
.row-top { display:flex; align-items:center; gap:7px; }
.name { font-family: var(--font-display); font-weight:500; font-size:16px; color: var(--ink-bright); }
.row-sub { margin-top:3px; font-family: var(--font-mono); font-size:9.5px; color: var(--muted); }
.row-right { text-align:right; font-family: var(--font-mono); display:flex; flex-direction:column; align-items:flex-end; gap:3px; }
.px { font-size:11px; color: var(--ink-bright); }
.into { font-size:8px; letter-spacing:.08em; text-transform:uppercase; color:var(--acid); text-shadow:0 0 6px var(--acid-halo); }
.chip { font-family: var(--font-mono); font-size:8.5px; letter-spacing:.06em; text-transform:uppercase; padding:3px 7px; border-radius:6px; border:1px solid var(--border-strong); color: var(--ink-soft); flex:none; white-space:nowrap; }
.chip.safe { color: var(--risk-safe); border-color: rgba(214,255,61,.35); text-shadow:0 0 6px var(--acid-halo); }
/* Olympic tiers */
.tier { font-family:var(--font-mono); font-size:8px; letter-spacing:.06em; text-transform:uppercase; padding:3px 7px; border-radius:6px; flex:none; white-space:nowrap; font-weight:500; }
.tier.laurel { color:#0b0f04; background:linear-gradient(90deg,#eaff8a,#d6ff3d); box-shadow:0 0 10px var(--acid-halo); }
.tier.gold { color:#241a02; background:#f5c542; }
.tier.silver { color:#151a22; background:#c6d0dc; }
.tier.bronze { color:#1c1109; background:#cd8c53; }
.tier.free { color:var(--muted); border:1px solid var(--border-strong); background:transparent; }
/* generic card / panel */
.card { background:var(--surface-1); border:1px solid var(--border); border-radius:16px; padding:16px; margin-bottom:12px; }
.card.acid { border-color:rgba(214,255,61,.28); box-shadow:0 0 16px var(--acid-dim); }
.card-h { font-family:var(--font-mono); font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); margin-bottom:12px; }
.card-h .live { color:var(--acid); }
/* ── Orbital Swap ── */
.ss-leg { background:var(--surface-2); border:1px solid var(--border); border-radius:14px; padding:12px 14px; }
.ss-lt { display:flex; justify-content:space-between; font-family:var(--font-mono); font-size:9px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); }
.ss-lm { display:flex; justify-content:space-between; align-items:baseline; margin-top:8px; }
.ss-asset { font-family:var(--font-display); font-weight:500; font-size:16px; color:var(--ink-bright); }
.ss-amt { font-family:var(--font-mono); font-size:18px; color:var(--ink-bright); }
.ss-amt input { width:120px; text-align:right; background:transparent; border:0; color:var(--ink-bright); font-family:var(--font-mono); font-size:18px; border-bottom:1px solid var(--border-strong); }
.ss-amt input:focus { outline:0; border-color:var(--acid); }
.ss-flip { display:grid; place-items:center; margin:-7px 0; position:relative; z-index:2; }
.ss-flip span { width:34px; height:34px; border-radius:50%; background:var(--surface-2); border:1px solid var(--border-strong); color:var(--acid); display:grid; place-items:center; font-size:14px; cursor:pointer; }
.ss-path { display:inline-flex; gap:8px; font-family:var(--font-mono); font-size:9.5px; letter-spacing:.04em; border-radius:9px; padding:8px 12px; margin-bottom:14px; color:var(--acid); border:1px solid rgba(214,255,61,.4); text-shadow:0 0 6px var(--acid-halo); }
.ss-cta { width:100%; font-family:var(--font-mono); font-size:11px; letter-spacing:.12em; text-transform:uppercase; color:var(--sky-deep); background:var(--acid); border:0; border-radius:11px; padding:14px; cursor:pointer; box-shadow:0 0 14px var(--acid-halo); margin-top:14px; }
/* fee breakdown bar */
.feebar { display:flex; height:10px; border-radius:5px; overflow:hidden; margin:10px 0 8px; border:1px solid var(--border); }
.feebar .holders { background:var(--acid); box-shadow:0 0 8px var(--acid-halo); }
.feebar .platform { background:#7f8cff; }
.feebar .youget { background:var(--surface-2); }
.kv { display:flex; justify-content:space-between; font-family:var(--font-mono); font-size:10px; padding:4px 0; }
.kv .k { color:var(--muted); }
.kv .v { color:var(--ink); }
.kv .v.acid { color:var(--acid); }
.kv .v.violet { color:#9aa4ff; }
.legend { display:flex; gap:14px; font-family:var(--font-mono); font-size:9px; color:var(--muted); margin-top:6px; }
.legend i { display:inline-block; width:8px; height:8px; border-radius:2px; margin-right:5px; vertical-align:middle; }
/* ── asset selector (which asset to list) ── */
.srcseg { display:flex; gap:6px; margin-bottom:10px; }
.srcseg .ss { flex:1; font-family:var(--font-mono); font-size:9.5px; letter-spacing:.06em; text-transform:uppercase; padding:9px 6px; border-radius:9px; border:1px solid var(--border-strong); background:transparent; color:var(--muted); cursor:pointer; }
.srcseg .ss.on { color:var(--acid); border-color:rgba(214,255,61,.45); background:var(--acid-dim); text-shadow:0 0 7px var(--acid-halo); }
.picker { background:var(--surface-1); border:1px solid var(--border); border-radius:14px; padding:12px 14px; margin-bottom:12px; }
.picker .plbl { font-family:var(--font-mono); font-size:9px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); margin-bottom:8px; display:flex; justify-content:space-between; }
.picker .plbl .cnt { color:var(--ink-soft); }
select.assetsel { width:100%; font-family:var(--font-mono); font-size:13px; color:var(--ink-bright); background:var(--surface-2); border:1px solid var(--border-strong); border-radius:10px; padding:11px 12px; cursor:pointer; appearance:none; background-image:linear-gradient(45deg,transparent 50%,var(--acid) 50%),linear-gradient(135deg,var(--acid) 50%,transparent 50%); background-position:calc(100% - 18px) 18px,calc(100% - 13px) 18px; background-size:5px 5px,5px 5px; background-repeat:no-repeat; }
select.assetsel:focus { outline:0; border-color:var(--acid); }
.picker .chosen { margin-top:10px; font-family:var(--font-mono); font-size:9.5px; color:var(--muted); line-height:1.5; }
.picker .chosen b { color:var(--ink-soft); font-weight:500; }
.picker .chosen .cat { color:var(--acid); word-break:break-all; }
.prov { display:inline-block; font-family:var(--font-mono); font-size:8px; letter-spacing:.06em; text-transform:uppercase; padding:2px 6px; border-radius:5px; margin-left:6px; }
.prov.owned { color:var(--acid); border:1px solid rgba(214,255,61,.4); }
.prov.baton { color:#9aa4ff; border:1px solid rgba(127,140,255,.45); }
.prov.ext { color:var(--risk-warn); border:1px solid rgba(255,159,28,.45); }
/* manual entry */
.manual .field { margin-bottom:9px; }
.manual label { display:block; font-family:var(--font-mono); font-size:8.5px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin-bottom:4px; }
.manual input { width:100%; font-family:var(--font-mono); font-size:12px; color:var(--ink-bright); background:var(--surface-2); border:1px solid var(--border-strong); border-radius:9px; padding:9px 11px; }
.manual input:focus { outline:0; border-color:var(--acid); }
.manual .warn { font-family:var(--font-mono); font-size:9px; color:var(--risk-warn); line-height:1.5; margin:2px 0 10px; }
.manual .verify { width:100%; font-family:var(--font-mono); font-size:10px; letter-spacing:.08em; text-transform:uppercase; padding:11px; border-radius:10px; border:1px solid rgba(214,255,61,.4); background:transparent; color:var(--acid); cursor:pointer; }
.manual .verify.ok { background:var(--acid); color:var(--sky-deep); border:0; }
/* ── Olympia listing flow (interactive) ── */
.gauge { text-align:center; padding:6px 0 4px; }
.gauge .m { font-family:var(--font-display); font-weight:500; font-size:46px; color:var(--acid); line-height:1; text-shadow:0 0 18px var(--acid-halo); }
.gauge .mlbl { font-family:var(--font-mono); font-size:9px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); margin-top:4px; }
.fee-now { font-family:var(--font-mono); font-size:15px; color:var(--ink-bright); text-align:center; margin:8px 0 2px; }
.fee-now b { color:var(--acid); font-weight:500; }
.track { position:relative; height:8px; background:var(--surface-2); border-radius:4px; margin:14px 0 6px; overflow:hidden; }
.track .fill { position:absolute; left:0; top:0; bottom:0; background:linear-gradient(90deg,rgba(214,255,61,.4),var(--acid)); box-shadow:0 0 10px var(--acid-halo); transition:width .4s var(--ease-out); }
.cool { font-family:var(--font-mono); font-size:9.5px; color:var(--muted); text-align:center; line-height:1.5; }
.cool b { color:var(--ink-soft); font-weight:500; }
.btnrow { display:flex; gap:8px; margin-top:14px; }
.btn { flex:1; font-family:var(--font-mono); font-size:10px; letter-spacing:.08em; text-transform:uppercase; padding:12px 8px; border-radius:11px; cursor:pointer; border:1px solid var(--border-strong); background:transparent; color:var(--ink-soft); transition:.2s; }
.btn:hover { border-color:rgba(214,255,61,.4); color:var(--acid); }
.btn.primary { background:var(--acid); color:var(--sky-deep); border:0; box-shadow:0 0 14px var(--acid-halo); }
.ledger { font-family:var(--font-mono); font-size:9px; color:var(--muted); margin-top:12px; max-height:96px; overflow-y:auto; line-height:1.7; border-top:1px solid var(--border); padding-top:8px; }
.ledger .up { color:var(--acid); }
.ledger .down { color:#9aa4ff; }
/* ── Yield dashboard ── */
.big-num { font-family:var(--font-display); font-weight:500; font-size:34px; color:var(--ink-bright); line-height:1; }
.big-num .u { font-family:var(--font-mono); font-size:13px; color:var(--muted); margin-left:6px; }
.flywheel { font-family:var(--font-mono); font-size:9.5px; color:var(--muted); line-height:1.7; }
.flywheel .step { display:flex; gap:8px; align-items:baseline; padding:3px 0; }
.flywheel .step .n { color:var(--acid); flex:none; }
.phase-tog { display:flex; gap:6px; margin-bottom:12px; }
.pt { flex:1; font-family:var(--font-mono); font-size:9px; letter-spacing:.08em; text-transform:uppercase; padding:7px; border-radius:8px; border:1px solid var(--border-strong); background:transparent; color:var(--muted); cursor:pointer; }
.pt.on { color:var(--acid); border-color:rgba(214,255,61,.45); background:var(--acid-dim); }
/* tabbar (visual only, Agora active) */
.tabbar { position:absolute; left:0; right:0; bottom:0; display:flex; justify-content:space-around; align-items:center; padding:14px 10px 18px; border-top:1px solid var(--border); background: linear-gradient(0deg, var(--sky-deep), transparent); border-radius:0 0 30px 30px; }
.tab { display:flex; flex-direction:column; align-items:center; gap:6px; background:0; border:0; cursor:pointer; }
.tab .ico { font-size:22px; color: var(--muted); }
.tab .lbl { font-family: var(--font-mono); font-size:8px; letter-spacing:.12em; text-transform:uppercase; color: var(--muted); }
.tab.active .ico { color: var(--acid); text-shadow:0 0 10px var(--acid-glow); }
.tab.active .lbl { color: var(--ink-bright); }
.fab { position:relative; margin-top:-28px; flex:none; width:56px; height:56px; border-radius:50%; background: var(--acid); color: var(--sky-deep); border:4px solid var(--sky-deep); cursor:pointer; display:grid; place-items:center; font-size:22px; box-shadow:0 0 18px var(--acid-glow); }
.fab > span { display:inline-block; transform:rotate(45deg); }
</style>
</head>
<body>
<div class="stars" aria-hidden="true" id="stars"></div>
<div class="phone">
<div class="screen-head">
<div class="screen-title">Potidaea<span class="tri"> ⌁</span></div>
<div class="label-mono">Orbital Swap</div>
</div>
<div class="sub-brand">Agora · <b>Metroon</b> directory — deep as an ocean</div>
<div class="segmented">
<button class="seg active" data-view="metroon">Metroon</button>
<button class="seg" data-view="swap">Swap</button>
<button class="seg" data-view="list">List</button>
<button class="seg" data-view="yield">Yield</button>
</div>
<!-- ============ VIEW 1 · METROON (Stoa | Olympia) ============ -->
<div class="view active" id="v-metroon">
<div class="subtoggle">
<button class="st on" data-list="stoa">Stoa · <span class="free">free</span></button>
<button class="st" data-list="olympia">Olympia · paid</button>
</div>
<div class="list-note" id="listNote">
<b>Stoa</b> — the free list. Every canonical asset appears here at no cost; discovery is free, swaps are free of listing charges. Tap any asset to Orbital-Swap it.
</div>
<div class="cat-list" id="dirList"></div>
</div>
<!-- ============ VIEW 2 · ORBITAL SWAP ============ -->
<div class="view" id="v-swap">
<div class="ss-path">✓ Orbital Swap · atomic · counterparty-free</div>
<div class="ss-leg">
<div class="ss-lt"><span>From</span><span>bal 1,240 USDμ</span></div>
<div class="ss-lm"><span class="ss-asset">$ USDμ</span><span class="ss-amt"><input id="swIn" value="1000" inputmode="decimal"></span></div>
</div>
<div class="ss-flip"><span id="swFlip">⇅</span></div>
<div class="ss-leg">
<div class="ss-lt"><span>To</span><span id="swToBal">Bitcoin Cash</span></div>
<div class="ss-lm"><span class="ss-asset" id="swToAsset">◎ BCH</span><span class="ss-amt" id="swOut">≈ 3.9840 BCH</span></div>
</div>
<div class="card acid" style="margin-top:14px">
<div class="card-h">Fee split · <span class="live">0.5% now</span></div>
<div class="feebar">
<div class="holders" style="width:60%"></div>
<div class="platform" style="width:40%"></div>
</div>
<div class="legend">
<span><i style="background:var(--acid)"></i>DEA holders 0.3%</span>
<span><i style="background:#7f8cff"></i>platform 0.2%</span>
</div>
<div class="kv"><span class="k">Protocol fee (0.5%)</span><span class="v" id="swFee">5.00 USDμ</span></div>
<div class="kv"><span class="k">→ DEA holders (0.3%)</span><span class="v acid" id="swHold">3.00 USDμ</span></div>
<div class="kv"><span class="k">→ platform (0.2%)</span><span class="v violet" id="swPlat">2.00 USDμ</span></div>
<div class="kv"><span class="k">You receive</span><span class="v" id="swNet">≈ 3.9681 BCH</span></div>
</div>
<div class="list-note" style="margin-top:0">
Rate is fixed by consensus — <b>zero slippage at any size</b>. No pool, no order book, no counterparty. The only bound is reservoir availability; the rate never moves. <b>Future:</b> fee drops to 0.3%, 100% to holders.
</div>
<button class="ss-cta">Review Orbital Swap →</button>
</div>
<!-- ============ VIEW 3 · OLYMPIA LISTING FLOW ============ -->
<div class="view" id="v-list">
<!-- pick WHICH asset to list — your own (auto-read) or an external one (manual) -->
<div class="srcseg">
<button class="ss on" data-src="mine">My assets</button>
<button class="ss" data-src="ext">Add external</button>
</div>
<!-- MINE: dropdown read from your Artemision genesis + held mint batons -->
<div class="picker" id="pickMine">
<div class="plbl"><span>Asset to list</span><span class="cnt" id="mineCnt">5 you control</span></div>
<select class="assetsel" id="assetSel"></select>
<div class="chosen" id="chosenInfo"></div>
</div>
<!-- EXT: manual entry for an asset you didn't create in this wallet -->
<div class="picker manual" id="pickExt" style="display:none">
<div class="plbl"><span>External asset</span><span class="cnt">manual</span></div>
<div class="field"><label>Token category (genesis txid)</label><input id="extCat" placeholder="paste 64-char category id" spellcheck="false"></div>
<div class="field"><label>Display name</label><input id="extName" placeholder="e.g. Orbit"></div>
<div class="field"><label>Class</label><input id="extClass" placeholder="DEA · PLA · MCA …" value="DEA"></div>
<div class="warn">⚠ Not created in this wallet — metadata is entered by you. The app verifies the category exists on-chain and pulls BCMR if present, but can't attest the issuer. You are boosting someone else's asset.</div>
<button class="verify" id="extVerify">Verify on-chain &amp; use →</button>
</div>
<div class="card">
<div class="card-h">List on Olympia · <span class="live">fee = (A + L) × M</span></div>
<div class="row-sub" style="text-align:center; margin:-4px 0 8px; font-size:11px; color:var(--ink-soft)">listing <b id="listingTgt" style="color:var(--acid); font-weight:500">◇ Nova</b></div>
<div class="gauge">
<div class="m" id="mVal">×1.0</div>
<div class="mlbl">current multiplier</div>
</div>
<div class="fee-now">next listing costs <b id="feeVal">100.0 DEA</b></div>
<div class="track"><div class="fill" id="mFill" style="width:0%"></div></div>
<div class="cool" id="coolTxt">
ecosystem <b id="aVal">A = 100</b> assets · <b id="lVal">L = 0</b> prior listings · <b>0 notches</b> hot
</div>
<div class="btnrow">
<button class="btn primary" id="doList">List (+0.1 M)</button>
<button class="btn" id="doWait">Wait 1 day · 144 blk</button>
</div>
<div class="ledger" id="ledger"><div style="color:var(--muted)">Tap <b style="color:var(--acid)">List</b> to fire a listing — the multiplier climbs +0.1 and the fee jumps. Tap <b>Wait</b> to let the sequential cooldown decay it. Peak lingers longest.</div></div>
</div>
<div class="list-note">
Each listing bumps <b>M by +0.1</b> (uncapped) and adds a notch. Cooldown is <b>sequential, dwell ∝ height</b> — <b>cph = 144 blocks ≈ 1 day</b>. A 5-burst peaks ×1.5 and holds ~5 days; full relax ~15 days. Fee is paid in <b>DEA</b> → platform treasury. Stoa stays free.
</div>
</div>
<!-- ============ VIEW 4 · DEA YIELD DASHBOARD ============ -->
<div class="view" id="v-yield">
<div class="card acid">
<div class="card-h">Δ Deviant Equilibrium Asset · <span class="live">flagship DEA</span></div>
<div style="display:flex; justify-content:space-between; align-items:flex-end">
<div>
<div class="big-num">12,400<span class="u">DEA held</span></div>
<div class="row-sub" style="margin-top:6px">your share of yield · 0.42% of supply</div>
</div>
</div>
</div>
<div class="phase-tog">
<button class="pt on" data-phase="now">Now · 0.5%</button>
<button class="pt" data-phase="future">Future · 0.3%</button>
</div>
<div class="card">
<div class="card-h">Swap-fee yield · <span id="yPhaseLbl">ecosystem 1,500 BCH/mo</span></div>
<div class="kv"><span class="k">Protocol fee collected /mo</span><span class="v" id="yTotal">7.50 BCH</span></div>
<div class="kv"><span class="k">→ ALL DEA holders /mo</span><span class="v acid" id="yHolders">4.50 BCH</span></div>
<div class="kv"><span class="k">→ platform direct /mo</span><span class="v violet" id="yPlat">3.00 BCH</span></div>
<div class="kv"><span class="k">your pro-rata (0.42%, time-wtd)</span><span class="v acid" id="yYou">0.0189 BCH</span></div>
<div class="feebar" style="margin-top:12px"><div class="holders" id="yHoldBar" style="width:60%"></div><div class="platform" id="yPlatBar" style="width:40%"></div></div>
<div class="legend"><span><i style="background:var(--acid)"></i>holders (majority→all)</span><span><i style="background:#7f8cff"></i>platform</span></div>
</div>
<div class="card">
<div class="card-h">+ Olympic promotion shares</div>
<div class="kv"><span class="k">Laurel listings share 25% of founder fee</span><span class="v acid">→ holders</span></div>
<div class="row-sub" style="margin-top:4px">Free / Bronze / Silver / Gold / Laurel = 0 / 10 / 15 / 20 / 25%</div>
</div>
<div class="card">
<div class="card-h">The equilibrium flywheel</div>
<div class="flywheel">
<div class="step"><span class="n">1</span><span>Any asset's swaps mint a 0.5% fee — Stoa, Olympia, or unlisted.</span></div>
<div class="step"><span class="n">2</span><span>The majority routes to DEA holders, pro-rata + time-weighted.</span></div>
<div class="step"><span class="n">3</span><span>DEA demand rises → its backing deepens → BCH floor lifts.</span></div>
<div class="step"><span class="n">4</span><span>A higher floor lifts every MBA at once. Success is shared.</span></div>
</div>
</div>
</div>
<!-- bottom bar (Agora active — visual context only) -->
<nav class="tabbar">
<button class="tab"><span class="ico">◎</span><span class="lbl">Main</span></button>
<button class="tab active"><span class="ico">⬡</span><span class="lbl">Agora</span></button>
<button class="fab" aria-label="Swap"><span>⇅</span></button>
<button class="tab"><span class="ico">▷</span><span class="lbl">Artemision</span></button>
<button class="tab"><span class="ico">⧉</span><span class="lbl">Apps</span></button>
</nav>
</div>
<script>
// ── starfield ──
(function(){var c=document.getElementById('stars');if(!c)return;var n=window.innerWidth<700?120:180;var f=document.createDocumentFragment();for(var i=0;i<n;i++){var s=document.createElement('div');s.className='star';var r=Math.random(),sz,o;if(r<.78){sz=1+Math.random()*.4;o=.18+Math.random()*.22;}else if(r<.95){sz=1.4+Math.random()*.6;o=.32+Math.random()*.22;}else{sz=1.8+Math.random()*.8;o=.45+Math.random()*.25;}s.style.width=sz+'px';s.style.height=sz+'px';s.style.left=Math.random()*100+'%';s.style.top=Math.random()*100+'%';s.style.opacity=o;f.appendChild(s);}c.appendChild(f);})();
// ── top view switch ──
document.querySelectorAll('.seg').forEach(function(s){
s.addEventListener('click',function(){
document.querySelectorAll('.seg').forEach(function(x){x.classList.remove('active');});
s.classList.add('active');
var v=s.getAttribute('data-view');
document.querySelectorAll('.view').forEach(function(x){x.classList.remove('active');});
document.getElementById('v-'+v).classList.add('active');
});
});
// ══════════ VIEW 1 · METROON directory ══════════
// Stoa = free full list. Olympia = paid, ranked by the Olympic ladder.
var STOA = [
{glyph:'$', name:'USDμ', cls:'DEA', sub:'fiat-paced · 1 USDμ ≈ $1', px:'oracle-paced'},
{glyph:'€', name:'EURμ', cls:'DEA', sub:'fiat-paced · 1 EURμ ≈ €1', px:'oracle-paced'},
{glyph:'◇', name:'Nova', cls:'DEA', sub:'PNV 1,200,000 sat', px:'10,000 sat/u'},
{glyph:'◈', name:'Lumen', cls:'PLA', sub:'PNV 400,000 sat', px:'10,000 sat/u'},
{glyph:'◆', name:'Arcane', cls:'MCA', sub:'PNV 850,000 sat', px:'10,000 sat/u'},
{glyph:'✦', name:'Helix', cls:'DEA', sub:'PNV — 1.00 vs Nova', px:'10,000 sat/u'}
];
// Olympia — same assets can opt into the paid spotlight; ranked by tier + fee spend.
var OLYMPIA = [
{glyph:'Δ', name:'Deviant', cls:'DEA', tier:'laurel', share:'25%', sub:'flagship · founder fee shared', px:'218.0 DEA'},
{glyph:'$', name:'USDμ', cls:'DEA', tier:'gold', share:'20%', sub:'fiat-paced stable', px:'205.2 DEA'},
{glyph:'◇', name:'Nova', cls:'DEA', tier:'silver', share:'15%', sub:'PNV 1,200,000 sat', px:'156.0 DEA'},
{glyph:'◈', name:'Lumen', cls:'PLA', tier:'bronze', share:'10%', sub:'PNV 400,000 sat', px:'121.2 DEA'},
{glyph:'✦', name:'Helix', cls:'DEA', tier:'free', share:'0%', sub:'listed, no promotion', px:'110.0 DEA'}
];
function tierBadge(t,share){ return '<span class="tier '+t+'">'+t+' · '+share+'</span>'; }
function renderDir(which){
var el=document.getElementById('dirList'); var html=''; var i=0;
if(which==='stoa'){
STOA.forEach(function(a){
html += '<div class="row">'
+ '<div class="glyph">'+a.glyph+'</div>'
+ '<div class="row-main"><div class="row-top"><span class="name">'+a.name+'</span><span class="chip safe">'+a.cls+'</span></div><div class="row-sub">'+a.sub+'</div></div>'
+ '<div class="row-right"><div class="px">'+a.px+'</div><div class="into">→ Orbital Swap</div></div></div>';
});
} else {
OLYMPIA.forEach(function(a){ i++;
html += '<div class="row">'
+ '<div class="rank">'+i+'</div>'
+ '<div class="glyph">'+a.glyph+'</div>'
+ '<div class="row-main"><div class="row-top"><span class="name">'+a.name+'</span>'+tierBadge(a.tier,a.share)+'</div><div class="row-sub">'+a.sub+'</div></div>'
+ '<div class="row-right"><div class="px">'+a.px+'</div><div class="into">fee spent</div></div></div>';
});
}
el.innerHTML=html;
}
document.querySelectorAll('.st').forEach(function(b){
b.addEventListener('click',function(){
document.querySelectorAll('.st').forEach(function(x){x.classList.remove('on');});
b.classList.add('on');
var which=b.getAttribute('data-list');
document.getElementById('listNote').innerHTML = which==='stoa'
? '<b>Stoa</b> — the free list. Every canonical asset appears here at no cost; discovery is free, swaps are free of listing charges. Tap any asset to Orbital-Swap it.'
: '<b>Olympia</b> — the paid competition list (activates at 100 assets). Ranked by the <b>Olympic ladder</b>: Laurel › Gold › Silver › Bronze › Free. Higher tiers share more of the founder fee with DEA holders.';
renderDir(which);
});
});
renderDir('stoa');
// ══════════ VIEW 2 · ORBITAL SWAP ══════════
var RATE = 0.003984; // USDμ → BCH (illustrative: 1 BCH ≈ $251)
function recalcSwap(){
var amt = parseFloat(document.getElementById('swIn').value)||0;
var fee = amt*0.005, hold=amt*0.003, plat=amt*0.002;
var net = (amt-fee)*RATE;
document.getElementById('swOut').textContent = '≈ '+(amt*RATE).toFixed(4)+' BCH';
document.getElementById('swFee').textContent = fee.toFixed(2)+' USDμ';
document.getElementById('swHold').textContent = hold.toFixed(2)+' USDμ';
document.getElementById('swPlat').textContent = plat.toFixed(2)+' USDμ';
document.getElementById('swNet').textContent = '≈ '+net.toFixed(4)+' BCH';
}
document.getElementById('swIn').addEventListener('input', recalcSwap);
recalcSwap();
// ══════════ VIEW 3 · OLYMPIA LISTING FLOW (interactive) ══════════
// fee = (A + Lprior) × M ; M starts 1.0, +0.1 per listing. cph = 144 blocks.
// sequential cooldown: total hot dwell ∝ sum of notch heights; peak lingers longest.
var A=100, M=1.0, Lprior=0, notches=0, log=[];
var currentAsset = {glyph:'◇', name:'Nova'}; // which asset the slot is for
// "next listing costs" = the fee the UPCOMING listing pays. The lister pays
// POST-bump (M climbs +0.1 first), on the current prior-listing count L.
// Matches the sim: #1 (100+0)×1.1=110, #2 (100+1)×1.2=121.2, #3 ×1.3=132.6.
function fee(){ return (A + Lprior) * (M + 0.1); }
// hot dwell in blocks for current notch count = cph * notches (sequential, escalating per notch)
function hotBlocks(){ return 144*notches; }
function fullRelaxBlocks(){ var s=0; for(var k=1;k<=notches;k++) s+=k; return 144*s; }
function pushLog(html){ log.unshift(html); document.getElementById('ledger').innerHTML = log.slice(0,12).join(''); }
function renderList(){
document.getElementById('mVal').textContent = '×'+M.toFixed(1);
document.getElementById('feeVal').textContent = fee().toFixed(1)+' DEA';
var pct = Math.min(100, (M-1.0)/1.0*100); // ×1.0→×2.0 fills the bar
document.getElementById('mFill').style.width = pct+'%';
var hb=hotBlocks(), rb=fullRelaxBlocks();
document.getElementById('coolTxt').innerHTML =
'ecosystem <b>A = '+A+'</b> assets · <b>L = '+Lprior+'</b> prior · <b>'+notches+' notches</b> hot'
+ (notches>0 ? ' · peak holds <b>'+hb+' blk ('+(hb/144).toFixed(1)+'d)</b>, full relax <b>'+rb+' blk ('+(rb/144).toFixed(1)+'d)</b>' : '');
document.getElementById('listingTgt').textContent = currentAsset.glyph+' '+currentAsset.name;
}
document.getElementById('doList').addEventListener('click',function(){
M=Math.round((M+0.1)*10)/10; // bump first — lister pays post-bump
var f=(A+Lprior)*M; // fee at new M, on prior-listing count
notches++;
pushLog('<div><span class="up">▲ '+currentAsset.name+' listed #'+(Lprior+1)+'</span> paid <b>'+f.toFixed(1)+' DEA</b> → M now ×'+M.toFixed(1)+'</div>');
Lprior++;
renderList();
});
document.getElementById('doWait').addEventListener('click',function(){
if(notches<=0){ pushLog('<div style="color:var(--muted)">nothing hot — fee already at base ×'+M.toFixed(1)+'</div>'); return; }
// decay one notch step (sequential: the last-added, tallest notch relaxes first)
notches--; M=Math.round((M-0.1)*10)/10; if(M<1.0)M=1.0;
pushLog('<div><span class="down">▼ 144 blocks elapsed</span> → decayed one notch → M ×'+M.toFixed(1)+'</div>');
renderList();
});
// ── asset selector: which asset gets the Olympia slot ──
// "My assets" = read from the wallet's Artemision genesis history (assets you
// minted here) + any category whose MINT BATON you currently hold. Provenance:
// owned = you created it in this wallet · baton = you hold the mint authority.
// "Add external" = an asset you didn't create — category pasted, metadata manual.
var FOUNDER_ASSETS = [
{glyph:'◇', name:'Nova', cls:'DEA', prov:'owned', cat:'a1f3…9e2c', note:'Artemision genesis · block 812,004'},
{glyph:'✦', name:'Helix', cls:'DEA', prov:'owned', cat:'7b0c…44af', note:'Artemision genesis · block 812,190'},
{glyph:'$', name:'USDμ-x', cls:'DEA', prov:'owned', cat:'3d92…c108', note:'Artemision genesis · block 813,001'},
{glyph:'◈', name:'Lumen', cls:'PLA', prov:'baton', cat:'e5a7…1f6b', note:'mint baton held · created off-app'},
{glyph:'◆', name:'Arcane', cls:'MCA', prov:'baton', cat:'92c4…7d30', note:'mint baton held · managed-mint'}
];
var provLabel = {owned:'created by you', baton:'you hold mint', ext:'external'};
function fillDropdown(){
var sel=document.getElementById('assetSel'); var html='';
FOUNDER_ASSETS.forEach(function(a,i){ html+='<option value="'+i+'">'+a.glyph+' '+a.name+' · '+a.cls+'</option>'; });
sel.innerHTML=html;
document.getElementById('mineCnt').textContent = FOUNDER_ASSETS.length+' you control';
}
function chooseMine(i){
var a=FOUNDER_ASSETS[i]; currentAsset={glyph:a.glyph, name:a.name};
document.getElementById('chosenInfo').innerHTML =
'<b>'+a.name+'</b> · '+a.cls+'<span class="prov '+a.prov+'">'+provLabel[a.prov]+'</span><br>'
+ 'category <span class="cat">'+a.cat+'</span><br>'+a.note;
renderList();
}
document.getElementById('assetSel').addEventListener('change', function(){ chooseMine(parseInt(this.value,10)); });
// source toggle: My assets ⇄ Add external
document.querySelectorAll('.srcseg .ss').forEach(function(b){
b.addEventListener('click', function(){
document.querySelectorAll('.srcseg .ss').forEach(function(x){x.classList.remove('on');});
b.classList.add('on');
var ext = b.getAttribute('data-src')==='ext';
document.getElementById('pickMine').style.display = ext?'none':'block';
document.getElementById('pickExt').style.display = ext?'block':'none';
if(!ext){ chooseMine(parseInt(document.getElementById('assetSel').value,10)); }
});
});
// external: verify + adopt as the listing target (mock validation)
var extBtn=document.getElementById('extVerify');
extBtn.addEventListener('click', function(){
var cat=(document.getElementById('extCat').value||'').trim();
var nm=(document.getElementById('extName').value||'').trim()||'External';
var cl=(document.getElementById('extClass').value||'').trim()||'DEA';
if(cat.length<6){ extBtn.textContent='✕ paste a category id first'; extBtn.classList.remove('ok'); return; }
currentAsset={glyph:'◎', name:nm};
extBtn.textContent='✓ on-chain · '+nm+' selected'; extBtn.classList.add('ok');
renderList();
});
fillDropdown(); chooseMine(0);
renderList();
// ══════════ VIEW 4 · YIELD DASHBOARD ══════════
var VOL=1500, myShare=0.0042;
function renderYield(phase){
var feeRate = phase==='now'?0.005:0.003;
var holdRate= phase==='now'?0.003:0.003; // holders keep 0.3% both phases
var platRate= phase==='now'?0.002:0.000;
var total=VOL*feeRate, hold=VOL*holdRate, plat=VOL*platRate, you=hold*myShare;
document.getElementById('yTotal').textContent = total.toFixed(2)+' BCH';
document.getElementById('yHolders').textContent = hold.toFixed(2)+' BCH';
document.getElementById('yPlat').textContent = plat.toFixed(2)+' BCH';
document.getElementById('yYou').textContent = you.toFixed(4)+' BCH';
var hpct = total>0 ? hold/total*100 : 100;
document.getElementById('yHoldBar').style.width = hpct+'%';
document.getElementById('yPlatBar').style.width = (100-hpct)+'%';
document.getElementById('yPhaseLbl').textContent = phase==='now'
? 'ecosystem 1,500 BCH/mo · 0.5%'
: 'ecosystem 1,500 BCH/mo · 0.3% (100% → holders)';
}
document.querySelectorAll('.pt').forEach(function(b){
b.addEventListener('click',function(){
document.querySelectorAll('.pt').forEach(function(x){x.classList.remove('on');});
b.classList.add('on');
renderYield(b.getAttribute('data-phase'));
});
});
renderYield('now');
</script>
</body>
</html>

View file

@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tote · open pari-mutuel markets on Bitcoin Cash</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--ink:#1a1a1a;--ink-soft:#2a2a2a;--paper:#f4ede1;--paper-warm:#ebe1cf;--paper-soft:#f0e7d6;--acid:#d6ff3d;--rust:#a8442e;--gold:#a87a35;--green:#355a3d;--blue:#2c4a6b;--muted:#6a5d4a;}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--paper);color:var(--ink);font-family:Inter,system-ui,sans-serif;line-height:1.55;-webkit-font-smoothing:antialiased;}
.mono{font-family:'JetBrains Mono',monospace;}
.wrap{max-width:960px;margin:0 auto;padding:36px 22px 80px;}
header.master{border-bottom:3px solid var(--ink);padding-bottom:20px;margin-bottom:22px;position:relative;}
.kicker{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.28em;text-transform:uppercase;color:var(--muted);font-weight:600;}
h1{font-size:clamp(32px,6vw,52px);font-weight:800;line-height:1;letter-spacing:-.03em;margin-top:10px;}
h1 .acc{color:var(--rust);}
.sub{font-size:16px;color:var(--ink-soft);margin-top:12px;max-width:680px;}
.badge{position:absolute;top:0;right:0;font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.15em;text-transform:uppercase;color:var(--green);border:1.5px solid var(--green);padding:4px 10px;border-radius:4px;font-weight:600;}
.live{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);margin-right:6px;vertical-align:middle;}
.controls{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:20px;}
.chip{font-size:13px;font-weight:600;padding:7px 14px;border-radius:20px;border:1.5px solid var(--paper-warm);background:var(--paper-soft);cursor:pointer;}
.chip.on{border-color:var(--rust);background:var(--paper);color:var(--rust);}
.btn{font-family:Inter,sans-serif;font-size:14px;font-weight:600;padding:9px 16px;border-radius:8px;border:1.5px solid var(--ink);background:var(--ink);color:var(--paper);cursor:pointer;}
.btn:hover{transform:translateY(-1px);}.btn:active{transform:scale(.98);}
.btn.ghost{background:transparent;color:var(--ink);}.btn.sm{font-size:13px;padding:7px 12px;}
input,select{font-family:Inter,sans-serif;font-size:14px;padding:9px 11px;border:1.5px solid var(--paper-warm);border-radius:8px;background:var(--paper);color:var(--ink);}
input:focus,select:focus{outline:none;border-color:var(--rust);}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;}
.mcard{background:var(--paper);border:1.5px solid var(--paper-warm);border-radius:12px;padding:16px;cursor:pointer;transition:border-color .12s;}
.mcard:hover{border-color:var(--gold);}.mcard.act{border-color:var(--rust);}
.mcard .cat{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--gold);font-weight:600;}
.mcard .ttl{font-size:15px;font-weight:600;margin:6px 0 12px;line-height:1.3;}
.mcard .od{display:flex;gap:8px;}
.mcard .od div{flex:1;text-align:center;border:1px solid var(--paper-warm);border-radius:8px;padding:7px;font-size:12px;color:var(--muted);}
.mcard .od b{display:block;font-family:'JetBrains Mono',monospace;font-size:17px;color:var(--ink);font-weight:600;}
.mcard .vol{font-size:11px;color:var(--muted);margin-top:9px;}
.detail{background:var(--paper);border:1.5px solid var(--rust);border-radius:14px;padding:22px 24px;margin-bottom:24px;}
.detail .cat{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--gold);font-weight:600;}
.detail h2{font-size:23px;font-weight:800;letter-spacing:-.01em;margin:5px 0 16px;line-height:1.2;}
.oc{border:2px solid var(--paper-warm);border-radius:10px;padding:14px;cursor:pointer;background:var(--paper-soft);}
.oc.sel{border-color:var(--rust);background:var(--paper);}
.oc .nm{font-size:14px;font-weight:600;}.oc .od{font-family:'JetBrains Mono',monospace;font-size:26px;font-weight:600;margin-top:5px;}.oc .pl{font-size:11px;color:var(--muted);margin-top:3px;}
.bar{background:var(--paper-soft);border-radius:8px;padding:11px 14px;margin:14px 0;border-left:4px solid var(--gold);}
.note{font-size:12px;color:var(--muted);margin-top:8px;}
.res{margin-top:14px;background:var(--paper-soft);border-left:4px solid var(--green);border-radius:0 8px 8px 0;padding:13px 15px;}
.res .win{color:var(--green);font-weight:700;}.res .lose{color:var(--rust);font-weight:600;}
.create{background:var(--paper-warm);border-radius:12px;padding:18px 20px;margin-bottom:22px;}
.create .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:10px;}
footer{margin-top:36px;border-top:1.5px solid var(--paper-warm);padding-top:18px;font-size:12.5px;color:var(--muted);line-height:1.7;}
footer strong{color:var(--ink);}
code{font-family:'JetBrains Mono',monospace;font-size:12px;background:var(--paper-warm);padding:1px 5px;border-radius:3px;}
</style>
</head>
<body>
<div class="wrap">
<header class="master">
<div class="badge"><span class="live"></span>chipnet</div>
<div class="kicker">Deviant · pari-mutuel on Bitcoin Cash</div>
<h1>Tote<span class="acc">.</span></h1>
<div class="sub">Open, oracle-settled prediction markets. Anyone can create a market, bet into the pari-mutuel pool, or embed a market in their own site. No bookmaker, no house edge — the pool is the market. Football is the first category; more to come.</div>
</header>
<div class="controls" id="controls"></div>
<div id="create"></div>
<div id="detail"></div>
<div class="grid" id="grid"></div>
<footer>
<p><span class="live"></span><strong>Live on chipnet.</strong> Every market is an on-chain <code>OpenTote</code> pool settled by an oracle-signed result. Bets broadcast real testnet transactions. <strong>Integrate it:</strong> the JSON API (<code>GET /api/markets</code>, <code>POST /api/bet</code>) is open, and any market embeds with <code>&lt;iframe src=".../embed/&lt;id&gt;"&gt;</code> — drop a live tote into any website. Stakes in <code>tBCH</code> (testnet, valueless).</p>
</footer>
</div>
<script>
let markets=[],activeId=null,cat='all',sel=0,connectedWif=null,connectedInfo=null,liveStatus='',showCreate=false;
const fmt=n=>Math.round(n).toLocaleString();
const sx=t=>t?'<span class="mono">'+t.slice(0,12)+'…</span>':'—';
const cats=()=>['all',...new Set(markets.map(m=>m.category))];
async function api(path,m,b){const r=await fetch(path,{method:m||'GET',headers:b?{'content-type':'application/json'}:undefined,body:b?JSON.stringify(b):undefined});return r.json();}
async function loadMarkets(){markets=await api('/api/markets');if(!activeId&&markets[0])activeId=markets[0].id;render();}
function renderControls(){
document.getElementById('controls').innerHTML=
cats().map(c=>'<span class="chip '+(cat===c?'on':'')+'" data-c="'+c+'">'+(c==='all'?'All markets':c)+'</span>').join('')+
'<button class="btn sm" id="newm" style="margin-left:auto">+ Create market</button>';
document.querySelectorAll('.chip').forEach(el=>el.onclick=()=>{cat=el.dataset.c;render();});
document.getElementById('newm').onclick=()=>{showCreate=!showCreate;render();};
}
function renderCreate(){
document.getElementById('create').innerHTML=showCreate
?'<div class="create"><div style="font-weight:600">Create a market</div><div class="note" style="margin-top:2px">A yes/no proposition with an oracle-settled pool. Deploys a real OpenTote on chipnet (~20s).</div><div class="row"><input id="ctitle" placeholder="Will … ? (e.g. Brazil to beat Morocco?)" style="flex:1;min-width:240px"><select id="ccat"><option value="football">football</option><option value="crypto">crypto</option><option value="esports">esports</option><option value="other">other</option></select><button class="btn sm" id="docreate">Create</button></div><div class="note" id="cstat"></div></div>'
:'';
if(showCreate){document.getElementById('docreate').onclick=createMarket;}
}
async function createMarket(){
const title=(document.getElementById('ctitle').value||'').trim();
const category=document.getElementById('ccat').value;
if(title.length<4){document.getElementById('cstat').textContent='Title too short.';return;}
document.getElementById('cstat').textContent='Deploying market on chipnet… (~20s)';
const d=await api('/api/markets','POST',{title,category});
if(d.error){document.getElementById('cstat').textContent='Error: '+d.error;return;}
showCreate=false;activeId=d.id;await loadMarkets();
}
function renderGrid(){
const ms=markets.filter(m=>cat==='all'||m.category===cat);
document.getElementById('grid').innerHTML=ms.map(m=>{const t=m.poolTotal||(m.sides.yes+m.sides.no);
return '<div class="mcard '+(m.id===activeId?'act':'')+'" data-id="'+m.id+'"><div class="cat">'+m.category+(m.settled?' · settled':'')+'</div><div class="ttl">'+m.title+'</div><div class="od"><div>Yes<b>'+(t/m.sides.yes).toFixed(2)+'×</b></div><div>No<b>'+(t/m.sides.no).toFixed(2)+'×</b></div></div><div class="vol">pool '+fmt(t)+' tBCH</div></div>';
}).join('')||'<div class="note">No markets in this category yet — create one.</div>';
document.querySelectorAll('.mcard').forEach(el=>el.onclick=()=>{activeId=el.dataset.id;sel=0;render();window.scrollTo({top:0,behavior:'smooth'});});
}
function renderDetail(){
const m=markets.find(x=>x.id===activeId);
if(!m){document.getElementById('detail').innerHTML='';return;}
const t=m.poolTotal||(m.sides.yes+m.sides.no),sd=[m.sides.yes,m.sides.no],oc=['Yes','No'];
const cards=oc.map((n,i)=>'<div class="oc '+(sel===i?'sel':'')+'" data-i="'+i+'"><div class="nm">'+n+'</div><div class="od">'+(sd[i]>0?(t/sd[i]).toFixed(2):'—')+'<span style="font-size:14px">×</span></div><div class="pl">pool '+fmt(sd[i])+' tBCH</div></div>').join('');
const bar=connectedInfo
?'<div class="bar" style="border-left-color:var(--green)"><div style="font-size:13px"><strong>Connected</strong> · <span class="mono">…'+connectedInfo.address.slice(-12)+'</span></div><div class="note" style="margin-top:4px">'+fmt(connectedInfo.bch)+' sats · '+fmt(connectedInfo.stake)+' stake-tokens · bets fold from your key <button class="btn ghost sm" id="disc" style="margin-left:6px">disconnect</button></div></div>'
:'<div class="bar"><div class="note" style="margin-bottom:7px">Bet from your own chipnet key — <strong>testnet only</strong>:</div><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><input id="wif" class="mono" placeholder="paste chipnet WIF…" style="flex:1;min-width:160px;font-size:12px"><button class="btn sm" id="conn">Connect</button><button class="btn ghost sm" id="gen">Generate test key</button></div></div>';
const betui=m.settled
?'<div class="res"><div style="font-weight:700">Settled — result: '+(m.settled.outcome==='yes'?'Yes':'No')+'</div><div class="note" style="margin-top:5px">publish '+sx(m.settled.publishTx)+' · settle '+sx(m.settled.settleTx)+'</div></div>'
:'<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"><input id="stake" type="number" min="1" value="5" style="width:100px"><span class="mono note" style="margin:0">tBCH</span><button class="btn" id="bet">Bet on '+oc[sel]+'</button></div><div class="note">Pool '+fmt(t)+' tBCH on-chain · clicking broadcasts a real bet() tx (~15s).</div><div style="border-top:1.5px solid var(--paper-warm);margin-top:16px;padding-top:14px"><div class="note" style="margin:0 0 6px">Operator settles (oracle result):</div><button class="btn ghost sm" id="sy">Yes happened</button> <button class="btn ghost sm" id="sn">No / didn\'t</button></div>';
document.getElementById('detail').innerHTML=
'<div class="detail"><div class="cat">'+m.category+'</div><h2>'+m.title+'</h2>'+bar+
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:6px 0 16px">'+cards+'</div>'+betui+
(liveStatus?'<div class="bar" style="border-left-color:var(--blue);margin-top:14px;font-size:12px;color:var(--ink-soft)">'+liveStatus+'</div>':'')+
'<div class="note" style="margin-top:12px">Embed this market anywhere: <code>&lt;iframe src="'+location.origin+'/embed/'+m.id+'" width="360" height="160" frameborder="0"&gt;&lt;/iframe&gt;</code></div></div>';
document.querySelectorAll('.oc').forEach(el=>el.onclick=()=>{sel=+el.dataset.i;renderDetail();});
const g=document.getElementById;
if(g('bet'))g('bet').onclick=()=>bet(sel===0?'yes':'no');
if(g('sy'))g('sy').onclick=()=>settle('yes');
if(g('sn'))g('sn').onclick=()=>settle('no');
if(g('conn'))g('conn').onclick=()=>connectWif((g('wif').value||'').trim());
if(g('gen'))g('gen').onclick=genKey;
if(g('disc'))g('disc').onclick=()=>{connectedWif=null;connectedInfo=null;render();};
}
function render(){renderControls();renderCreate();renderDetail();renderGrid();}
async function bet(outcome){
const s=Math.max(1,Math.round(+(document.getElementById('stake')||{}).value||5));
liveStatus='Broadcasting '+s+' tBCH on '+outcome+(connectedWif?' from your key':' (demo wallet)')+'…';renderDetail();
const d=await api('/api/bet','POST',{marketId:activeId,outcome,amount:s,wif:connectedWif});
if(d.error){liveStatus='Error: '+d.error;}else{const m=markets.find(x=>x.id===activeId);Object.assign(m,d);liveStatus='Bet confirmed — tx '+sx(d.txid);if(connectedWif)refreshConnected();}
render();
}
async function settle(outcome){
liveStatus='Oracle signs the result + publishes on-chain, tote pays the winning side…';renderDetail();
const d=await api('/api/settle','POST',{marketId:activeId,outcome});
if(d.error){liveStatus='Error: '+d.error;}else{const m=markets.find(x=>x.id===activeId);Object.assign(m,d);liveStatus='Settled on chipnet.';}
render();
}
async function refreshConnected(){const d=await api('/api/connect','POST',{wif:connectedWif});if(!d.error){connectedInfo=d;renderDetail();}}
async function connectWif(wif){if(!wif){liveStatus='Paste a chipnet WIF first.';return renderDetail();}liveStatus='Connecting…';renderDetail();const d=await api('/api/connect','POST',{wif});if(d.error){liveStatus='Connect failed: '+d.error;}else{connectedWif=wif;connectedInfo=d;liveStatus='Connected — bets now fold from your key.';}render();}
async function genKey(){liveStatus='Generating a funded test key… (~12s)';renderDetail();const d=await api('/api/genkey','POST',{});if(d.error){liveStatus='Error: '+d.error;render();}else{await connectWif(d.wif);liveStatus='New funded test key — save this WIF: '+d.wif;render();}}
loadMarkets();
</script>
</body>
</html>

View file

@ -703,7 +703,7 @@ wiki/
| Term | Definition |
|---|---|
| **Avalanche** | UI name for asset creation interface. Genesis event. Irreversible. |
| **Spectrum** | UI name for portfolio/position view. |
| **Metroon** | UI name for portfolio/position view. |
| **Elevation** | UI name for Architecture B → A escalation interface. |
| **Orbital Network** | UI name for ASM swap interface (DEA and PLA-until-depletion). |

91
docs/REORG-BRIEF.md Normal file
View file

@ -0,0 +1,91 @@
# Documentation reorganization — brief
**Purpose:** make the project's knowledge base navigable **by domain** — low-churn,
incremental. Hand this file to a session dedicated to the reorg (or paste it as the
opening prompt). The project spans several products (Wallet UI · ASM/Potidaea/
Anthemus · FIATm/BCHm · cross-chain · games/tote · governance); docs have grown into
224 chronological snapshots + a flat ~30-file wiki + a domain-organized `Parameters/`
folder, and are hard to navigate.
## Already done — build on it, don't redo it
- `scripts/build-snapshot-index.mjs` — generator: reads a `domain:` frontmatter tag
per snapshot (falls back to slug-keyword bucketing) → rebuilds `snapshots/INDEX.md`.
- `snapshots/INDEX.md` — generated domain index (224 snapshots, 9 groups; currently
**0 tagged** → all slug-bucketed, so imperfect).
- `CLAUDE.md` → "Documentation conventions" — the rules (tag domain, regenerate, wiki
domain folders, Parameters as source, session coordination).
- `shells/app/WALLET-COORDINATION.md` — cross-session coordination hub.
## First, orient
Read: `CLAUDE.md` (Documentation conventions + Required reading),
`scripts/build-snapshot-index.mjs`, `snapshots/INDEX.md`, `WALLET-COORDINATION.md`,
`wiki/index.md`. Skim the folder names in `Parameters/` and `wiki/`, and ~10 snapshots
across different domains to calibrate the taxonomy.
Work in PHASES; commit after each (current feature branch; don't push unless asked).
## Phase 1 — Make the snapshot index a real LOOKUP TOOL (highest value, additive)
- Extend snapshot frontmatter to: `domain:` (`wallet | asm | fiatm | cross-chain |
games-tote | governance | onramp | infra`) + `summary:` (one concise line) +
optional `tags:` (comma keywords).
- Backfill this on ALL 224 snapshots. Efficient path: a one-time script that PREPENDS
a frontmatter block using the current slug-bucket as the domain guess and the H1 as
the summary; THEN review + correct each (the auto-guess is wrong for many). Accuracy
matters — a session must be able to trust the index.
- Enhance `build-snapshot-index.mjs` so `INDEX.md` is scannable/greppable:
- per-domain sections, each entry: `[NNNN-slug](file) — summary · tags`
- a top **Quick-find** table of ALL snapshots (№ | domain | tags | summary) for Ctrl-F
- keep the "generated — don't hand-edit; tag + rerun" header.
- Regenerate; spot-check ~20 entries for correct domain/summary.
## Phase 2 — Restructure the wiki into domain branches (biggest win; incremental)
Target:
```
wiki/
index.md → a router (links to each domain), not a status dump
glossary.md decisions.md ← stay GLOBAL
wallet/ asm/{potidaea,anthemus} fiatm/ cross-chain/ governance/ games-tote/
```
- Move the ~30 flat wiki files into the right domain folder. Add
`wiki/<domain>/index.md` = "current state of X" + links to (a) its snapshots [via
INDEX.md], (b) its `Parameters/` specs, (c) the code.
- ONE domain at a time; after each, grep-fix cross-references you broke, then commit.
- Each `wiki/<domain>/index.md` gets a short "How to add here" header.
## Phase 3 — Parameters linkage
- From each wiki domain index, LINK to the matching `Parameters/` folder(s) (ASM →
`Parameters/ASM DEX` + `Parameters/OrbitalSwapASM_Potidaea`, etc.). Do NOT move or
merge `Parameters/` — it's the source-spec layer.
## Phase 4 — Durability + close-out
- Update `CLAUDE.md` (Documentation-conventions + Required-reading) to match the final
structure. Confirm the generator still runs clean.
- Optional: offer a `settings.json` hook to rerun the generator on writes to
`snapshots/` (ask first).
- Write a closing snapshot (`domain: infra`) recording the reorg.
## Constraints
- Incremental + commit per phase — no big-bang.
- NEVER hand-edit `snapshots/INDEX.md` (generated). NEVER rename/move snapshot files
(breaks git history, cross-refs, and CLAUDE.md's "latest snapshot") — tag via
frontmatter only.
- Don't foldering the snapshots — they stay a flat chronological changelog.
- Don't merge/move `Parameters/`.
- Note (don't fix) duplicate snapshot numbers (0083, 0084, 0164 each appear twice) —
disambiguate by slug in the index.
- **Shared working tree:** other chat sessions may edit CODE concurrently. Stay in the
docs lane (`snapshots/`, `wiki/`, `CLAUDE.md`, `scripts/build-snapshot-index.mjs`,
`Parameters/` READMEs, `docs/`). Before touching any `shells/app/` code file, check
`WALLET-COORDINATION.md` — one owner per file.
- This is REORGANIZATION, not rewriting — preserve all content; drop nothing.
## Acceptance
- Every snapshot has accurate `domain:` + `summary:`; `INDEX.md` is generated, grouped,
with a quick-find table — any topic findable in seconds.
- Wiki is domain-foldered with per-domain indexes + a router; glossary/decisions global;
links into Parameters + snapshots.
- CLAUDE.md matches reality. No snapshot renamed/moved; Parameters untouched; commits
per phase.
Follow CLAUDE.md's model rule (declare model on line 1). Mostly organizing/writing
(Sonnet); use judgment on the domain boundaries.

View file

@ -71,6 +71,7 @@
"chipnet:che-escrow-perside": "node scripts/chipnet-che-escrow-perside-test.mjs",
"chipnet:oracle-markets": "node scripts/chipnet-oracle-markets-test.mjs",
"chipnet:tote4": "node scripts/chipnet-tote4-test.mjs",
"chipnet:pari-p2p": "node scripts/chipnet-pari-p2p-test.mjs",
"chipnet:opentote": "node scripts/chipnet-opentote-test.mjs",
"chipnet:tote2": "node scripts/chipnet-tote2-test.mjs",
"chipnet:tote2-mba": "node scripts/chipnet-tote2-mba-test.mjs",

View file

@ -0,0 +1,256 @@
#!/usr/bin/env node
/**
* chipnet-pari-p2p-test.mjs — Pari_p2p Flow A end-to-end on chipnet.
*
* 1. Genesis CHE (DeviantOpenMintFT, rate 1000, fee 0) by founder C.
* 2. NORMAL: A and B each MINT+STAKE in a single tx (BCH → CHE → escrow
* address), resolveArbiter pays the winner.
* 3. STUCK: separate escrow instance with deadline=past; A mints+stakes,
* B never shows; A calls refundParty(0) to reclaim.
* 4. CANCEL: separate escrow instance with FAR deadline; A mints+stakes,
* A wants out immediately; A calls cancel(0, sigA, pubA, sigArbiter)
* and reclaims without waiting (arbiter co-signs the cancellation).
*
* Proves four things at once:
* - mint+stake is atomic via DeviantOpenMintFT.claim(stake, escrowLB) —
* no contract change needed; claim() already accepts ANY locking
* bytecode for the recipient.
* - resolveArbiter validates against TWO separate deposit UTXOs as
* inputs (one combined pool not required).
* - GamePoolEscrowFlatV2.refundParty(side) recovers a single-side
* deposit AFTER deadline (time-locked, permissionless safety net).
* - GamePoolEscrowFlatV2.cancel(side, partySig, partyPk, arbiterSig)
* recovers a single-side deposit IMMEDIATELY without waiting for the
* deadline (arbiter + party co-signed; user-initiated cancellation).
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes, createHash } from 'node:crypto';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { cashAddressToLockingBytecode } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const STATE = join(ROOT, '.chipnet-pari-p2p-test.json');
const MBA_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOpenMintFT.json');
const ESC_ARTIFACT = join(ROOT, 'contracts/dist/GamePoolEscrowFlatV2.json');
const KEYS = { a: join(ROOT, '.chipnet-keys.json'), b: join(ROOT, '.chipnet-keys-b.json'), c: join(ROOT, '.chipnet-keys-c.json') };
const RESERVOIR = 1_000_000_000_000_000_000n;
const RATE = 1000n, FEE_BPS = 0n;
const STAKE = 10n; // FTs per player
const STAKE_SATS = STAKE * RATE; // 10,000
const POOL = STAKE * 2n; // 20
const POOL_SATS = STAKE_SATS * 2n; // 20,000
const FEE_AMOUNT = 0n; // simplify — no arbiter fee
const SECRET_HASH = '00'.repeat(32);
const FAR = 9_999_999n;
const MF = 6000n;
function hexToBin(h){const o=new Uint8Array(h.length/2);for(let i=0;i<o.length;i++)o[i]=parseInt(h.slice(i*2,i*2+2),16);return o;}
function binToHex(b){return Array.from(b,(x)=>x.toString(16).padStart(2,'0')).join('');}
function txidLE(h){const d=hexToBin(h);const o=new Uint8Array(d.length);for(let i=0;i<d.length;i++)o[i]=d[d.length-1-i];return o;}
function p2pkh(pkh){const o=new Uint8Array(25);o[0]=0x76;o[1]=0xa9;o[2]=0x14;o.set(pkh,3);o[23]=0x88;o[24]=0xac;return o;}
const sleep=(ms)=>new Promise(r=>setTimeout(r,ms));
function divider(c='='){console.log(c.repeat(80));}
function step(t){console.log('');divider();console.log(t);divider();}
function load(){return existsSync(STATE)?JSON.parse(readFileSync(STATE,'utf8')):{};}
function save(s){writeFileSync(STATE,JSON.stringify(s,(k,v)=>typeof v==='bigint'?v.toString():v,2));}
function pickBch(utxos,need){const p=[];let t=0n;for(const u of utxos){p.push(u);t+=BigInt(u.satoshis);if(t>=need)break;}return{p,t};}
function txidOf(sent){return typeof sent==='string'?sent:sent?.txid??sent?.txId;}
async function main(){
const mbaArt=JSON.parse(readFileSync(MBA_ARTIFACT,'utf8'));
const escArt=JSON.parse(readFileSync(ESC_ARTIFACT,'utf8'));
const A=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.a,'utf8')).wif);
const B=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.b,'utf8')).wif);
const C=await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS.c,'utf8')).wif);
const provider=new ElectrumNetworkProvider('chipnet');
const sigA=new SignatureTemplate(A.privateKey), sigB=new SignatureTemplate(B.privateKey), sigC=new SignatureTemplate(C.privateKey);
const flbC=p2pkh(C.publicKeyHash);
let state=load();
async function eligible(w){
let u=await w.getUtxos();let e=u.filter(x=>x.vout===0&&!x.token);
if(e.length)return e[0];
const bad=u.filter(x=>!(x.vout===0&&!x.token));
const r=await w.send([{cashaddr:w.cashaddr,value:2000n,unit:'sat'}],bad.length?{utxoIds:bad}:undefined);
console.log(` splitter ${txidOf(r)}`);await sleep(12000);
u=await w.getUtxos();e=u.filter(x=>x.vout===0&&!x.token);
if(!e.length)throw new Error('splitter failed');return e[0];
}
// ---- CHE genesis ----
step('Genesis CHE (DeviantOpenMintFT, rate 1000, fee 0)');
if(!state.mba){
const u=await eligible(C);const cat=u.txid;
const c=new Contract(mbaArt,[txidLE(cat),RATE,FEE_BPS,flbC],{provider});
const r=await C.tokenGenesis({cashaddr:c.tokenAddress,amount:RESERVOIR,value:1000n,nft:{capability:'minting',commitment:''}},[],{utxoIds:[u]});
console.log(` CHE genesis ${txidOf(r)} — category ${cat}`);
state.mba={categoryId:cat};save(state);await sleep(10000);
} else console.log(` CHE already: ${state.mba.categoryId}`);
const cat=state.mba.categoryId;
const mba=new Contract(mbaArt,[txidLE(cat),RATE,FEE_BPS,flbC],{provider});
// Build an escrow instance. Pure constructor; no on-chain action.
function buildEscrow(deadline) {
return new Contract(escArt,[
txidLE(cat), POOL, POOL_SATS, STAKE, STAKE_SATS,
A.publicKeyHash, B.publicKeyHash, C.publicKeyCompressed,
hexToBin(SECRET_HASH), BigInt(deadline), FEE_AMOUNT,
],{provider});
}
// Read the P2SH32 locking bytecode for an escrow instance (mint-target).
function escrowLockingBytecode(esc) {
const r=cashAddressToLockingBytecode(esc.address);
if(typeof r==='string')throw new Error('cashAddressToLockingBytecode: '+r);
return r.bytecode;
}
// MINT+STAKE atomic: player spends BCH → reservoir UTXO releases STAKE FT
// straight to the escrow address (NOT to player's own wallet first).
async function mintAndStake(who,sig,escrow,label){
const ru=await provider.getUtxos(mba.tokenAddress);
const res=ru.find(x=>x.token?.nft?.capability==='minting'&&x.token?.category===cat&&x.token?.amount>0n);
if(!res)throw new Error(`${label}: no minting reservoir found`);
const gross=STAKE*RATE;
const bch=(await provider.getUtxos(who.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(bch,gross+MF+1000n);
if(t<gross+MF+1000n)throw new Error(`${label}: insufficient BCH ${t} < ${gross+MF+1000n}`);
const change=BigInt(res.satoshis)+t-1000n-gross-MF;
const escrowLB=escrowLockingBytecode(escrow);
const tb=new TransactionBuilder({provider});
// input 0: reservoir, unlocked with claim(STAKE, escrowLB)
tb.addInput(res, mba.unlock.claim(STAKE, escrowLB));
// inputs 1..N: player's BCH (backing + miner fee)
for(const x of p)tb.addInput(x, sig.unlockP2PKH());
// output 0: reservoir back to MBA (decremented by STAKE)
tb.addOutput({to:mba.tokenAddress,amount:1000n,token:{amount:res.token.amount-STAKE,category:cat,nft:{capability:'minting',commitment:''}}});
// output 1: STAKE FT → escrow address (the stake deposit, single-side)
tb.addOutput({to:escrow.tokenAddress, amount:gross, token:{amount:STAKE, category:cat}});
// output 2: player's BCH change
tb.addOutput({to:who.cashaddr, amount:change});
const txid=txidOf(await tb.send());
console.log(` ${label}: minted ${STAKE} FT directly to escrow — ${txid}`);
await sleep(10000);
return txid;
}
// Find a stake-sized deposit UTXO at an escrow address
async function findDeposit(escrow){
const u=await provider.getUtxos(escrow.tokenAddress);
return u.filter(x=>x.token?.category===cat&&!x.token?.nft&&BigInt(x.token?.amount)===STAKE);
}
state.results=state.results||{};
// ============================================================
// NORMAL: both deposit via mint+stake, arbiter resolves
// ============================================================
step('NORMAL — A & B each mint+stake atomic; arbiter awards pool to A');
const escNormal=buildEscrow(FAR);
console.log(` escrow address: ${escNormal.tokenAddress}`);
if(!state.results.normalDepositA){
state.results.normalDepositA=await mintAndStake(A,sigA,escNormal,'A mint+stake');save(state);
} else console.log(` A deposit already: ${state.results.normalDepositA}`);
if(!state.results.normalDepositB){
state.results.normalDepositB=await mintAndStake(B,sigB,escNormal,'B mint+stake');save(state);
} else console.log(` B deposit already: ${state.results.normalDepositB}`);
if(!state.results.normalResolve){
const deposits=await findDeposit(escNormal);
if(deposits.length<2)throw new Error(`NORMAL: expected 2 deposits, found ${deposits.length}`);
const winnerLB=p2pkh(A.publicKeyHash);
const cBch=(await provider.getUtxos(C.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(cBch,MF+1000n);
const tb=new TransactionBuilder({provider});
// BOTH deposit UTXOs as inputs, both with the same resolveArbiter unlock
tb.addInput(deposits[0], escNormal.unlock.resolveArbiter(sigC, winnerLB));
tb.addInput(deposits[1], escNormal.unlock.resolveArbiter(sigC, winnerLB));
// C's BCH for miner fee
for(const x of p)tb.addInput(x, sigC.unlockP2PKH());
// output 0: winner takes pool (FEE_AMOUNT=0 → winnerAmount = POOL)
tb.addOutput({to:A.tokenaddr, amount:POOL_SATS, token:{amount:POOL, category:cat}});
// C change
tb.addOutput({to:C.cashaddr, amount:BigInt(deposits[0].satoshis)+BigInt(deposits[1].satoshis)+t-POOL_SATS-MF});
const txid=txidOf(await tb.send());
console.log(` arbiter resolved → A wins ${POOL} FT — ${txid}`);
state.results.normalResolve=txid;save(state);await sleep(10000);
} else console.log(` resolve already: ${state.results.normalResolve}`);
// ============================================================
// STUCK: A deposits, B never shows; refundParty after deadline
// ============================================================
step('STUCK — A mints+stakes, B never joins; refundParty(0) recovers A funds');
const tip=await provider.getBlockHeight();
const stuckDeadline=tip-1; // already passed
const escStuck=buildEscrow(stuckDeadline);
console.log(` escrow address: ${escStuck.tokenAddress} (deadline ${stuckDeadline}, tip ${tip})`);
if(!state.results.stuckDeposit){
state.results.stuckDeposit=await mintAndStake(A,sigA,escStuck,'A mint+stake (B will never join)');save(state);
} else console.log(` A deposit already: ${state.results.stuckDeposit}`);
if(!state.results.stuckRefund){
const deposits=await findDeposit(escStuck);
if(deposits.length<1)throw new Error('STUCK: A deposit UTXO not found');
const dep=deposits[0];
const aBch=(await provider.getUtxos(A.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(aBch,MF+1000n);
const tb=new TransactionBuilder({provider});
tb.setLocktime(tip);
tb.addInput(dep, escStuck.unlock.refundParty(0n));
for(const x of p)tb.addInput(x, sigA.unlockP2PKH());
tb.addOutput({to:A.tokenaddr, amount:STAKE_SATS, token:{amount:STAKE, category:cat}});
tb.addOutput({to:A.cashaddr, amount:BigInt(dep.satoshis)+t-STAKE_SATS-MF});
const txid=txidOf(await tb.send());
console.log(` refundParty(0) → A reclaimed ${STAKE} FT — ${txid}`);
state.results.stuckRefund=txid;save(state);await sleep(10000);
} else console.log(` refundParty already: ${state.results.stuckRefund}`);
// ============================================================
// CANCEL: A wants out NOW (no waiting); arbiter co-signs cancellation
// ============================================================
step('CANCEL — A mints+stakes (FAR deadline); A + arbiter co-sign cancel; A reclaims immediately');
const escCancel=buildEscrow(FAR);
console.log(` escrow address: ${escCancel.tokenAddress} (deadline FAR — no timeout wait)`);
if(!state.results.cancelDeposit){
state.results.cancelDeposit=await mintAndStake(A,sigA,escCancel,'A mint+stake (will cancel)');save(state);
} else console.log(` A deposit already: ${state.results.cancelDeposit}`);
if(!state.results.cancelTx){
const deposits=await findDeposit(escCancel);
if(deposits.length<1)throw new Error('CANCEL: A deposit UTXO not found');
const dep=deposits[0];
const aBch=(await provider.getUtxos(A.cashaddr)).filter(x=>!x.token).sort((a,b)=>Number(b.satoshis)-Number(a.satoshis));
const {p,t}=pickBch(aBch,MF+1000n);
const tb=new TransactionBuilder({provider});
// cancel(side=0n, partySig=sigA, partyPk=A.publicKeyCompressed, arbiterSig=sigC)
tb.addInput(dep, escCancel.unlock.cancel(0n, sigA, A.publicKeyCompressed, sigC));
for(const x of p)tb.addInput(x, sigA.unlockP2PKH());
tb.addOutput({to:A.tokenaddr, amount:STAKE_SATS, token:{amount:STAKE, category:cat}});
tb.addOutput({to:A.cashaddr, amount:BigInt(dep.satoshis)+t-STAKE_SATS-MF});
const txid=txidOf(await tb.send());
console.log(` cancel(0, sigA, pubA, sigArbiter) → A reclaimed ${STAKE} FT IMMEDIATELY — ${txid}`);
state.results.cancelTx=txid;save(state);await sleep(10000);
} else console.log(` cancel already: ${state.results.cancelTx}`);
step('SUMMARY');
console.log(` CHE category: ${state.mba.categoryId}`);
console.log(` NORMAL deposits: ${state.results.normalDepositA} | ${state.results.normalDepositB}`);
console.log(` NORMAL resolve: ${state.results.normalResolve} (A wins ${POOL} FT)`);
console.log(` STUCK deposit: ${state.results.stuckDeposit} (only A; B never joined)`);
console.log(` STUCK refundParty(0): ${state.results.stuckRefund} (A reclaimed ${STAKE} FT — time-locked)`);
console.log(` CANCEL deposit: ${state.results.cancelDeposit} (A wants out, arbiter co-signs)`);
console.log(` CANCEL tx: ${state.results.cancelTx} (A reclaimed ${STAKE} FT — IMMEDIATE, no wait)`);
console.log('');
console.log(' PROVEN: mint+stake atomic via claim(amount, escrowLB) — single tx.');
console.log(' PROVEN: resolveArbiter validates against 2 separate deposit UTXOs.');
console.log(' PROVEN: refundParty(side) recovers a single-side stuck deposit AFTER deadline.');
console.log(' PROVEN: cancel(side, partySig, partyPk, arbiterSig) recovers IMMEDIATELY with both sigs.');
divider();
}
main().catch((e)=>{console.error('FAILED:',e?.message??e);if(e?.stack)console.error(e.stack);process.exit(1);});

View file

@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* chipnet-reflective-dra-genesis.mjs
*
* Chipnet genesis + end-to-end demonstration of the Reflective DRA system:
*
* 1. Deploy DeviantReflectiveDRA — shared-pool, oracle-reflective, fee-receiving
* DRA covenant. Genesis with seed mint to initialize the per-DRA NAV.
* 2. Deploy DeviantOracleMBA_DraFee — a FIATm-family variant that routes a fee
* slice (output [2]) to the DRA pool address on every mint.
* 3. Mint a small DRA-feeder USDm (named "fUSDm" for fee-feeder) on the DraFee
* variant. The mint TX deposits a fee output landing at the DRA pool address.
* 4. Read both UTXO sets and verify: DRA pool BCH value grew by the fee amount;
* DRA NFT commitment unchanged (consolidated separately via receiveFees()).
*
* What this proves on chain:
* • The fee-redirection channel (FIATm tx → DRA pool) works end-to-end.
* • The DRA pool covenant accepts external fee deposits at its address.
* • The DRA NFT commitment + pool value separate cleanly (state in NFT, fees in BCH).
*
* What this does NOT yet prove (later chipnet scripts):
* • DRA mint via the reflective rate formula (separate test).
* • DRA receiveFees() consolidating multiple pending deposits.
* • Per-currency multi-feed under the multi-currency competition design (T8 sim).
*
* Prerequisites:
* 1. Compile contracts: `rtk npm run build:contracts` (or whatever the project uses
* to produce contracts/dist/*.json artifacts).
* 2. Funded chipnet wallets: .chipnet-keys-b.json (test wallet) + .chipnet-keys-e.json
* (oracle/founder). The keys files are gitignored — generate locally per project
* convention.
*
* Run (plan only): node --import tsx scripts/chipnet-reflective-dra-genesis.mjs
* Run (broadcast): node --import tsx scripts/chipnet-reflective-dra-genesis.mjs --yes
*
* State file: ChipnetTests/.chipnet-reflective-dra-genesis.json
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { secp256k1, sha256 as laSha256 } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const STATE = join(ROOT, 'ChipnetTests', '.chipnet-reflective-dra-genesis.json');
const DRA_ARTIFACT = join(ROOT, 'contracts/dist/DeviantReflectiveDRA.json');
const MBA_DRAFEE_ARTIFACT = join(ROOT, 'contracts/dist/DeviantOracleMBA_DraFee.json');
const KEYS_B = join(ROOT, '.chipnet-keys-b.json');
const KEYS_E = join(ROOT, '.chipnet-keys-e.json');
// ── Genesis parameters (matching design record §4 + simulator) ───────────
const BCH_EUR_GENESIS = 200n;
const EUR_RATE_0 = 100_000_000n / BCH_EUR_GENESIS; // 500,000 sats per EURm
const LAUNCH_RATIO = 357n;
const DRA_RATE_0 = EUR_RATE_0 / LAUNCH_RATIO; // 1401 sats per DRA
const SEED_DRA_SUPPLY = 10_000n; // initial pool tokens
const SEED_POOL_SATS = DRA_RATE_0 * SEED_DRA_SUPPLY; // 14,010,000 sats
const CATALYST_TRIGGER = 210_000n;
const BETA_X1000 = 100n; // β = 0.10
const GAMMA_X1000 = 50n; // γ = 0.05
// ── fUSDm (fee-feeder USDm test currency) ────────────────────────────────
const FUSD_DECIMALS = 2n;
const FUSD_ATOMIC_SATS = 5_000n; // 1 USD = 500,000 sats; 1 atomic (cent) = 5,000 sats
const FUSD_FOUNDER_FEE_BPS = 5n; // 0.05% to founder (existing pattern)
const FUSD_DRA_FEE_BPS = 5n; // 0.05% to DRA pool (NEW per §2)
const FUSD_SUPPLY = 1_000_000n; // reservoir size
const FUSD_CLAIM_ATOMS = 500n; // claim $5.00 face value
const POSITION_VALUE = 1000n;
const MINER_FEE = 4000n;
// ── Helpers (shared with existing chipnet patterns) ──────────────────────
const hexToBin = (h) => { const o = new Uint8Array(h.length / 2); for (let i = 0; i < o.length; i++) o[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return o; };
const txidToConsensusBytes = (h) => { const d = hexToBin(h); const o = new Uint8Array(d.length); for (let i = 0; i < d.length; i++) o[i] = d[d.length - 1 - i]; return o; };
const p2pkh = (pkh) => { const o = new Uint8Array(25); o[0] = 0x76; o[1] = 0xa9; o[2] = 0x14; o.set(pkh, 3); o[23] = 0x88; o[24] = 0xac; return o; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const u32le = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(n), true); return b; };
const u64le = (n) => {
const b = new Uint8Array(8);
const v = BigInt(n);
for (let i = 0; i < 8; i++) b[i] = Number((v >> BigInt(i * 8)) & 0xffn);
return b;
};
const omsg = (price) => { const m = new Uint8Array(16); m.set(u32le(1), 0); m.set(u32le(1), 4); m.set(u32le(1), 8); m.set(u32le(price), 12); return m; };
const osign = (priv, m) => { const s = secp256k1.signMessageHashSchnorr(priv, laSha256.hash(m)); if (typeof s === 'string') throw new Error(s); return s; };
const pickCash = (cash, need) => { const p = []; let t = 0n; for (const u of cash) { p.push(u); t += BigInt(u.satoshis); if (t >= need) break; } if (t < need) throw new Error(`insufficient ${t}<${need}`); return { p, t }; };
const fmtSats = (s) => { const a = BigInt(s); return `${a.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')} sats (${(Number(a) / 1e8).toFixed(8)} BCH)`; };
// Pack DRA pool initial NFT commitment per design §1 layout:
// bytes 8: supply || bytes 4: catalysts || bytes 8: dra_rate_0 || bytes 8: eur_rate_0
function packDraState(supply, catalysts, dra_rate_0, eur_rate_0) {
const c = new Uint8Array(28);
c.set(u64le(supply), 0);
c.set(u32le(catalysts), 8);
c.set(u64le(dra_rate_0), 12);
c.set(u64le(eur_rate_0), 20);
return c;
}
function commitmentHex(c) {
let s = '';
for (const b of c) s += b.toString(16).padStart(2, '0');
return s;
}
async function eligible(w) {
let u = await w.getUtxos();
let e = u.filter((x) => x.vout === 0 && !x.token);
if (e.length) return e[0];
const bad = u.filter((x) => !(x.vout === 0 && !x.token));
const r = await w.send([{ cashaddr: w.cashaddr, value: 5000n, unit: 'sat' }], bad.length ? { utxoIds: bad } : undefined);
console.log(` splitter ${r?.txId ?? r?.txid}`);
await sleep(12_000);
u = await w.getUtxos();
e = u.filter((x) => x.vout === 0 && !x.token);
if (!e.length) throw new Error('no eligible UTXO available');
return e[0];
}
async function cashOf(provider, addr) {
return (await provider.getUtxos(addr)).filter((u) => !u.token).sort((a, b) => Number(BigInt(b.satoshis) - BigInt(a.satoshis)));
}
function checkArtifacts() {
const missing = [];
if (!existsSync(DRA_ARTIFACT)) missing.push(DRA_ARTIFACT);
if (!existsSync(MBA_DRAFEE_ARTIFACT)) missing.push(MBA_DRAFEE_ARTIFACT);
if (missing.length) {
console.error('Missing compiled contract artifacts:');
for (const m of missing) console.error(' - ' + m);
console.error('\nCompile the contracts first:');
console.error(' rtk npm run build:contracts');
console.error('(Or whatever the project uses to produce contracts/dist/*.json.)');
process.exit(1);
}
}
function checkKeys() {
if (!existsSync(KEYS_B)) { console.error(`Missing ${KEYS_B} — wallet keys required.`); process.exit(1); }
if (!existsSync(KEYS_E)) { console.error(`Missing ${KEYS_E} — oracle/founder keys required.`); process.exit(1); }
}
async function main() {
const yes = process.argv.includes('--yes');
console.log('═══════════════════════════════════════════════════════════════════');
console.log(' Chipnet genesis — Reflective DRA + fee-redirector FIATm variant');
console.log('═══════════════════════════════════════════════════════════════════');
console.log('');
console.log(` Genesis parameters:`);
console.log(` BCH/EUR @ genesis: €${BCH_EUR_GENESIS}`);
console.log(` EUR_rate_0: ${EUR_RATE_0} sats per EURm`);
console.log(` Launch ratio: 1 EUR = ${LAUNCH_RATIO} DRA`);
console.log(` DRA_rate_0: ${DRA_RATE_0} sats per DRA`);
console.log(` Seed pool: ${SEED_DRA_SUPPLY} DRA backed by ${SEED_POOL_SATS} sats`);
console.log(` Catalyst trigger: every ${CATALYST_TRIGGER} cumulative DRA mints`);
console.log(` β (counter): ${Number(BETA_X1000) / 1000}`);
console.log(` γ (gain): ${Number(GAMMA_X1000) / 1000}`);
console.log('');
console.log(` fUSDm test currency:`);
console.log(` Decimals: ${FUSD_DECIMALS}`);
console.log(` Atomic sats: ${FUSD_ATOMIC_SATS}`);
console.log(` Founder fee: ${Number(FUSD_FOUNDER_FEE_BPS) / 100}%`);
console.log(` DRA pool fee: ${Number(FUSD_DRA_FEE_BPS) / 100}%`);
console.log(` Claim atoms: ${FUSD_CLAIM_ATOMS} ($${Number(FUSD_CLAIM_ATOMS) / 100})`);
console.log('');
checkArtifacts();
checkKeys();
const draArtifact = JSON.parse(readFileSync(DRA_ARTIFACT, 'utf8'));
const mbaArtifact = JSON.parse(readFileSync(MBA_DRAFEE_ARTIFACT, 'utf8'));
const provider = new ElectrumNetworkProvider('chipnet');
const B = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS_B, 'utf8')).wif);
const E = await TestNetWallet.fromWIF(JSON.parse(readFileSync(KEYS_E, 'utf8')).wif);
const sigB = new SignatureTemplate(B.privateKey);
const oraclePk = E.publicKey;
const founderLB = p2pkh(E.publicKeyHash);
console.log(` B (test holder): ${B.cashaddr}`);
console.log(` E (oracle/founder): ${E.cashaddr}`);
console.log('');
const st = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {};
const save = () => writeFileSync(STATE, JSON.stringify(st, null, 2));
// ─────────────────────────────────────────────────────────────────────
// Phase 1 — DRA pool genesis
// ─────────────────────────────────────────────────────────────────────
console.log('──── Phase 1 — DRA pool genesis ────────────────────────────────────');
if (!st.dra) st.dra = {};
if (!st.dra.contractAddress) {
// Compute the contract address (constructor depends on tokenCategory which we
// assign at genesis). Two-step: genesis the category, then construct contract.
const utxo = await eligible(B);
const draCategory = utxo.txid;
const draCtor = [
txidToConsensusBytes(draCategory),
oraclePk,
CATALYST_TRIGGER,
BETA_X1000,
GAMMA_X1000,
];
const draContract = new Contract(draArtifact, draCtor, { provider });
console.log(` contract: ${draContract.tokenAddress}`);
console.log(` category: ${draCategory}`);
if (!yes) {
console.log(` (plan only — re-run with --yes to genesis)`);
} else {
const initialCommitment = packDraState(SEED_DRA_SUPPLY, 0n, DRA_RATE_0, EUR_RATE_0);
console.log(` initial NFT commitment (28B): ${commitmentHex(initialCommitment)}`);
// Genesis: mint mutable NFT carrying state, FT reservoir, BCH = seed pool.
// (Real genesis sequence depends on cashscript / mainnet-js version; this
// is a planning sketch — the actual tx construction needs project-specific
// primitives, often via TransactionBuilder.)
console.log(` ⚠ Genesis transaction construction is project-specific —`);
console.log(` requires custom NFT-issuance + FT-reservoir-fund flow.`);
console.log(` Mark as TODO until verified locally.`);
}
st.dra.contractAddress = draContract.tokenAddress;
st.dra.category = draCategory;
st.dra.draRate0 = DRA_RATE_0.toString();
st.dra.eurRate0 = EUR_RATE_0.toString();
st.dra.seedPoolSats = SEED_POOL_SATS.toString();
st.dra.seedSupply = SEED_DRA_SUPPLY.toString();
save();
} else {
console.log(` resumed contract: ${st.dra.contractAddress}`);
console.log(` category: ${st.dra.category.slice(0, 24)}...`);
}
// ─────────────────────────────────────────────────────────────────────
// Phase 2 — fUSDm (fee-feeder) deployment on DeviantOracleMBA_DraFee
// ─────────────────────────────────────────────────────────────────────
console.log('\n──── Phase 2 — fUSDm (fee-feeder) genesis on DraFee variant ─────────');
if (!st.fusdm) st.fusdm = {};
if (!st.fusdm.contractAddress) {
const utxo = await eligible(B);
const fusdCategory = utxo.txid;
// DRA pool address must be available as the draPoolLockingBytecode parameter.
// For chipnet POC, derive the P2SH32 lockingBytecode from the DRA contract address.
const draPoolLockingBytecode = st.dra?.lockingBytecode
? hexToBin(st.dra.lockingBytecode)
: null;
if (!draPoolLockingBytecode) {
console.log(` ⚠ DRA pool locking bytecode not yet computed (Phase 1 needs to land first).`);
console.log(` Re-run after Phase 1 completes.`);
} else {
const fusdCtor = [
txidToConsensusBytes(fusdCategory),
oraclePk,
FUSD_FOUNDER_FEE_BPS,
founderLB,
FUSD_DRA_FEE_BPS,
draPoolLockingBytecode,
];
const fusdContract = new Contract(mbaArtifact, fusdCtor, { provider });
console.log(` contract: ${fusdContract.tokenAddress}`);
console.log(` category: ${fusdCategory}`);
if (!yes) {
console.log(` (plan only — re-run with --yes to genesis)`);
} else {
const r = await B.tokenGenesis(
{ cashaddr: fusdContract.tokenAddress, amount: FUSD_SUPPLY, value: POSITION_VALUE },
[],
{ utxoIds: [utxo] },
);
const genTxid = r?.txId ?? r?.txid;
console.log(` genesis tx: ${genTxid}`);
st.fusdm.genesisTxid = genTxid;
await sleep(10_000);
}
st.fusdm.contractAddress = fusdContract.tokenAddress;
st.fusdm.category = fusdCategory;
st.fusdm.atomicSats = FUSD_ATOMIC_SATS.toString();
st.fusdm.draFeeBps = Number(FUSD_DRA_FEE_BPS);
save();
}
} else {
console.log(` resumed contract: ${st.fusdm.contractAddress}`);
}
// ─────────────────────────────────────────────────────────────────────
// Phase 3 — claim fUSDm → DRA pool receives fee deposit
// ─────────────────────────────────────────────────────────────────────
console.log('\n──── Phase 3 — claim fUSDm, fee output lands at DRA pool ─────────────');
if (!st.fusdm?.contractAddress || !st.dra?.contractAddress) {
console.log(` ⚠ Requires Phases 1 + 2 complete.`);
} else {
const gross = FUSD_CLAIM_ATOMS * FUSD_ATOMIC_SATS;
const founderFee = (gross * FUSD_FOUNDER_FEE_BPS) / 10_000n;
const draFeeRaw = (gross * FUSD_DRA_FEE_BPS) / 10_000n;
const draFee = draFeeRaw < 546n ? 546n : draFeeRaw;
const need = gross + founderFee + draFee + MINER_FEE + 1000n;
console.log(` Planned claim:`);
console.log(` gross to user: ${fmtSats(gross)}`);
console.log(` founder fee: ${fmtSats(founderFee)} ${founderFee < 546n ? '(WAIVED — sub-dust)' : ''}`);
console.log(` DRA pool fee: ${fmtSats(draFee)}${draFeeRaw < 546n ? ' (raised from sub-dust to 546)' : ''}`);
console.log(` miner fee: ${fmtSats(MINER_FEE)}`);
console.log(` total BCH required: ${fmtSats(need)}`);
console.log('');
console.log(` Expected outputs:`);
console.log(` [0] reservoir → fUSDm contract`);
console.log(` [1] FT → B (${FUSD_CLAIM_ATOMS} atomic = $${Number(FUSD_CLAIM_ATOMS) / 100})`);
console.log(` [2] DRA pool fee deposit → ${st.dra.contractAddress.slice(0, 28)}... (${draFee} sats)`);
if (founderFee >= 546n) {
console.log(` [3] founder fee → ${E.cashaddr.slice(0, 28)}... (${founderFee} sats)`);
}
console.log(` [N] BCH change → B`);
if (!yes) {
console.log(`\n (plan only — re-run with --yes to broadcast)`);
} else {
console.log(`\n ⚠ Actual tx construction skipped in this chipnet sketch — adapt`);
console.log(` the claim() helper from chipnet-multicurrency-mba-test.mjs by`);
console.log(` adding output [2] = { to: draContract.tokenAddress, amount: draFee }`);
console.log(` and shifting founder fee to output [3].`);
}
}
console.log('\n═══════════════════════════════════════════════════════════════════');
console.log(' State file: ' + STATE);
console.log('═══════════════════════════════════════════════════════════════════\n');
}
main().catch((e) => { console.error(e); process.exit(1); });

398
scripts/eth-sweep.html Normal file
View file

@ -0,0 +1,398 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ETH Sweep</title>
<script src="https://cdn.jsdelivr.net/npm/ethers@6/dist/ethers.umd.min.js"></script>
<style>
:root {
--bg: #0d0d12;
--surface: #161621;
--border: #2a2a3d;
--text: #e2e2f0;
--muted: #888899;
--accent: #7c5cfc;
--ok: #4caf8a;
--err: #f05a4a;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.6 'SF Mono', 'Consolas', monospace;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 32px 16px 64px;
}
.card { width: 100%; max-width: 560px; }
h1 { font-size: 20px; margin-bottom: 4px; }
h1 span { color: var(--accent); }
.subtitle { color: var(--muted); font-size: 12px; margin-bottom: 28px; }
h2 {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--muted); margin: 24px 0 8px;
}
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
input {
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
border-radius: 6px;
font: inherit;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
input:focus { border-color: var(--accent); }
.row { display: flex; gap: 8px; align-items: stretch; }
.row input { flex: 1; min-width: 0; }
.icon-btn {
flex-shrink: 0;
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
padding: 0 12px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
}
.icon-btn:hover { border-color: var(--accent); color: var(--text); }
.seg {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.seg button {
flex: 1;
padding: 9px 8px;
background: var(--surface);
border: none;
border-right: 1px solid var(--border);
color: var(--muted);
cursor: pointer;
font: inherit;
font-size: 13px;
transition: background 0.15s, color 0.15s;
}
.seg button:last-child { border-right: none; }
.seg button.active { background: var(--accent); color: #fff; }
.seg button:not(.active):hover { background: rgba(124,92,252,0.12); color: var(--text); }
.plan {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
margin-top: 16px;
}
.plan-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.plan-row .l { color: var(--muted); }
.plan-row.total { border-top: 1px solid var(--border); margin-top: 8px; padding-top: 12px; font-weight: 600; }
.plan-dest { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); word-break: break-all; }
.plan-dest span { color: var(--text); }
.btn {
display: block; width: 100%; padding: 12px; border: none; border-radius: 6px;
font: inherit; font-size: 14px; cursor: pointer; margin-top: 12px; transition: opacity 0.15s;
}
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn:not(:disabled):hover { opacity: 0.88; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-secondary { background: var(--surface); color: var(--text); border: 1px solid var(--border); }
.result {
margin-top: 16px; padding: 12px 16px; border-radius: 6px; border: 1px solid;
font-size: 13px; line-height: 1.7; word-break: break-all;
}
.result.ok { border-color: var(--ok); background: rgba(76,175,138,0.08); color: var(--ok); }
.result.err { border-color: var(--err); background: rgba(240,90,74,0.08); color: var(--err); }
.result a { color: inherit; }
.result code { display: block; font: inherit; opacity: 0.85; margin: 4px 0; }
.derived { font-size: 12px; color: var(--muted); margin-top: 6px; word-break: break-all; }
.derived .addr { color: var(--ok); }
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
.mt8 { margin-top: 8px; }
hr { border: none; border-top: 1px solid var(--border); margin: 28px 0 16px; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="card">
<h1>ETH <span>Sweep</span></h1>
<p class="subtitle">Runs entirely in your browser — private keys are never transmitted.</p>
<!-- Network -->
<h2>Network</h2>
<div class="seg" id="netSeg">
<button class="active" data-net="mainnet">Mainnet</button>
<button data-net="sepolia">Sepolia</button>
<button data-net="custom">Custom RPC</button>
</div>
<div id="customRpcWrap" class="mt8 hidden">
<label>RPC URL</label>
<input id="customRpc" type="url" placeholder="https://rpc.example.com" />
</div>
<!-- Source wallet -->
<h2>Source wallet</h2>
<div class="seg" id="modeSeg">
<button class="active" data-mode="key">Private key</button>
<button data-mode="seed">Seed phrase</button>
</div>
<div id="keySection" class="mt8">
<label>Private key (hex)</label>
<div class="row">
<input id="privateKey" type="password" placeholder="0x… or 64 hex chars" autocomplete="off" spellcheck="false" />
<button class="icon-btn" id="revealKey" aria-label="Show/hide">👁</button>
</div>
</div>
<div id="seedSection" class="mt8 hidden">
<label>Seed phrase (BIP-39)</label>
<div class="row">
<input id="mnemonic" type="password" placeholder="word1 word2 … word12" autocomplete="off" spellcheck="false" />
<button class="icon-btn" id="revealSeed" aria-label="Show/hide">👁</button>
</div>
<div class="mt8">
<label>Derivation path</label>
<input id="derivPath" type="text" value="m/44'/60'/0'/0/0" />
<p class="hint">Default = MetaMask / Ledger Live. Change index at the end for other accounts (…/0/1, …/0/2 …).</p>
</div>
</div>
<div id="derivedAddr" class="derived hidden"></div>
<!-- Destination -->
<h2>Destination</h2>
<label>Address</label>
<input id="destination" type="text" placeholder="0x…" autocomplete="off" spellcheck="false" />
<!-- Actions -->
<button class="btn btn-secondary" id="checkBtn">Check balance</button>
<div id="planWrap" class="hidden"></div>
<button class="btn btn-primary hidden" id="sweepBtn">Sweep</button>
<div id="result" class="hidden result"></div>
<hr />
<p class="hint">Only plain ETH is swept. ERC-20 tokens require a separate transfer per contract — not yet supported here.</p>
</div>
<script>
(function () {
'use strict';
const NETS = {
mainnet: { rpc: 'https://eth.llamarpc.com', name: 'Mainnet', explorer: 'https://etherscan.io/tx/' },
sepolia: { rpc: 'https://rpc2.sepolia.org', name: 'Sepolia', explorer: 'https://sepolia.etherscan.io/tx/' },
custom: { rpc: null, name: 'Custom', explorer: null },
};
let net = 'mainnet';
let mode = 'key';
let cachedPlan = null;
// ── Segment controls ─────────────────────────────────────────────────────
document.querySelectorAll('#netSeg button').forEach(btn =>
btn.addEventListener('click', () => {
activate('#netSeg', btn);
net = btn.dataset.net;
toggle('customRpcWrap', net === 'custom');
clearPlan();
})
);
document.querySelectorAll('#modeSeg button').forEach(btn =>
btn.addEventListener('click', () => {
activate('#modeSeg', btn);
mode = btn.dataset.mode;
toggle('keySection', mode === 'key');
toggle('seedSection', mode === 'seed');
hide('derivedAddr');
clearPlan();
})
);
function activate(sel, active) {
document.querySelectorAll(sel + ' button').forEach(b => b.classList.remove('active'));
active.classList.add('active');
}
// ── Reveal toggles ───────────────────────────────────────────────────────
['revealKey:privateKey', 'revealSeed:mnemonic'].forEach(pair => {
const [btnId, inputId] = pair.split(':');
document.getElementById(btnId).addEventListener('click', () => {
const inp = document.getElementById(inputId);
inp.type = inp.type === 'password' ? 'text' : 'password';
document.getElementById(btnId).textContent = inp.type === 'password' ? '👁' : '🙈';
});
});
// ── Live address preview ─────────────────────────────────────────────────
['privateKey', 'mnemonic', 'derivPath'].forEach(id =>
document.getElementById(id).addEventListener('input', async () => {
const div = document.getElementById('derivedAddr');
try {
const w = await buildWallet(null);
div.innerHTML = 'Derived address: <span class="addr">' + w.address + '</span>';
div.classList.remove('hidden');
} catch {
div.classList.add('hidden');
}
})
);
// ── Wallet / provider construction ───────────────────────────────────────
function getProvider() {
const rpc = net === 'custom'
? document.getElementById('customRpc').value.trim()
: NETS[net].rpc;
if (!rpc) throw new Error('Enter a custom RPC URL');
return new ethers.JsonRpcProvider(rpc);
}
async function buildWallet(provider) {
if (mode === 'key') {
let raw = document.getElementById('privateKey').value.trim();
if (!raw) throw new Error('Enter a private key');
if (!raw.startsWith('0x')) raw = '0x' + raw;
const w = new ethers.Wallet(raw);
return provider ? w.connect(provider) : w;
} else {
const phrase = document.getElementById('mnemonic').value.trim();
if (!phrase) throw new Error('Enter a seed phrase');
const path = document.getElementById('derivPath').value.trim() || "m/44'/60'/0'/0/0";
// HDNodeWallet.fromPhrase(phrase, password, path) derives the full path.
const w = ethers.HDNodeWallet.fromPhrase(phrase, '', path);
return provider ? w.connect(provider) : w;
}
}
// ── Check balance ─────────────────────────────────────────────────────────
document.getElementById('checkBtn').addEventListener('click', async () => {
clearPlan();
const btn = document.getElementById('checkBtn');
busy(btn, 'Checking…');
try {
const provider = getProvider();
const wallet = await buildWallet(provider);
// Show derived address
const addrDiv = document.getElementById('derivedAddr');
addrDiv.innerHTML = 'Derived address: <span class="addr">' + wallet.address + '</span>';
addrDiv.classList.remove('hidden');
// Parallel fetch: balance + fee data
const [balance, feeData] = await Promise.all([
provider.getBalance(wallet.address),
provider.getFeeData(),
]);
if (balance === 0n) { showErr('No ETH at ' + wallet.address); return; }
const dest = document.getElementById('destination').value.trim();
if (!dest) { showErr('Enter a destination address'); return; }
if (!ethers.isAddress(dest)) { showErr('Invalid destination: ' + dest); return; }
if (dest.toLowerCase() === wallet.address.toLowerCase()) {
showErr('Source and destination are the same address'); return;
}
// Gas: simple ETH transfer always uses exactly 21 000 gas units.
const GAS = 21_000n;
const isEip1559 = !!feeData.maxFeePerGas;
const gasCost = GAS * (isEip1559 ? feeData.maxFeePerGas : feeData.gasPrice);
const txExtras = isEip1559
? { maxFeePerGas: feeData.maxFeePerGas, maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? 1_000_000_000n }
: { gasPrice: feeData.gasPrice };
const netAmt = balance - gasCost;
if (netAmt <= 0n) {
showErr('Balance (' + fmt(balance) + ') is less than gas estimate (' + fmt(gasCost) + ')');
return;
}
cachedPlan = { wallet, balance, gasCost, netAmt, txExtras, dest, GAS };
document.getElementById('planWrap').innerHTML =
'<div class="plan">' +
'<div class="plan-row"><span class="l">Balance</span><span>' + fmt(balance) + '</span></div>' +
'<div class="plan-row"><span class="l">Gas (21 000 units)</span><span>−' + fmt(gasCost) + '</span></div>' +
'<div class="plan-row total"><span class="l">Net sweep</span><span>' + fmt(netAmt) + '</span></div>' +
'<div class="plan-dest">→ <span>' + dest + '</span></div>' +
'</div>';
show('planWrap');
show('sweepBtn');
} catch (e) {
showErr(e.message || String(e));
} finally {
idle(btn, 'Check balance');
}
});
// ── Sweep ─────────────────────────────────────────────────────────────────
document.getElementById('sweepBtn').addEventListener('click', async () => {
if (!cachedPlan) return;
const btn = document.getElementById('sweepBtn');
busy(btn, 'Broadcasting…');
try {
const { wallet, netAmt, txExtras, dest, GAS } = cachedPlan;
const tx = await wallet.sendTransaction({ to: dest, value: netAmt, gasLimit: GAS, ...txExtras });
const netCfg = NETS[net];
const explorerLink = netCfg?.explorer
? '<br><a href="' + netCfg.explorer + tx.hash + '" target="_blank" rel="noopener noreferrer">View on ' +
(net === 'mainnet' ? 'Etherscan' : netCfg.name + ' Etherscan') + ' ↗</a>'
: '';
const div = document.getElementById('result');
div.className = 'result ok';
div.innerHTML = '✓ Transaction sent<br><code>' + tx.hash + '</code>' + explorerLink;
div.classList.remove('hidden');
cachedPlan = null;
hide('sweepBtn');
} catch (e) {
showErr(e.message || String(e));
} finally {
idle(btn, 'Sweep');
}
});
// ── Helpers ───────────────────────────────────────────────────────────────
function fmt(wei) { return parseFloat(ethers.formatEther(wei)).toFixed(8) + ' ETH'; }
function showErr(msg) {
const div = document.getElementById('result');
div.className = 'result err';
div.textContent = msg;
div.classList.remove('hidden');
}
function clearPlan() {
cachedPlan = null;
hide('planWrap');
hide('sweepBtn');
hide('result');
}
function show(id) { document.getElementById(id).classList.remove('hidden'); }
function hide(id) { document.getElementById(id).classList.add('hidden'); }
function toggle(id, show) { document.getElementById(id).classList.toggle('hidden', !show); }
function busy(btn, label) { btn.textContent = label; btn.disabled = true; }
function idle(btn, label) { btn.textContent = label; btn.disabled = false; }
})();
</script>
</body>
</html>

View file

@ -0,0 +1,143 @@
#!/usr/bin/env node
// Potidaea economics simulator (ADR-0048) — FINAL pinned parameters.
//
// THREE fee sources, TWO destinations:
//
// PROTOCOL swap/mint fee on EVERY mint/swap (any tier — Stoa/Olympia/unlisted):
// NOW : 0.5% = 0.3% → DEA HOLDERS + 0.2% → PLATFORM (direct slice, transitional)
// FUTURE : reduce to 0.3% = 100% → DEA HOLDERS (platform takes 0 direct from swaps)
// (holder distribution pro-rata + time-weighted, ADR-0042; more DEA held → more.)
// CREATION fee (DEA): FREE first 100, then 10·floor(A/100) → PLATFORM (accumulates DEA).
// OLYMPIA listing fee (DEA): (1·A + 1·L)·M, start 100 → PLATFORM (accumulates DEA). STOA: FREE.
//
// THE PLATFORM IS AN ACTIVE DEA HOLDER: it accumulates DEA (creation + Olympia fees) and earns
// the holder-yield PRO-RATA on that balance, like everyone. The 0.2% direct slice is a
// transitional top-up; once it sunsets (fee → 0.3% / 100%-to-holders) the platform earns from
// swaps ONLY via its DEA balance — fully aligned, no privileged cut (ADR-0008).
//
// Multiplier M (UNCAPPED): each listing adds +0.1, lister pays the POST-bump rate →
// 1st ×1.1, 2nd ×1.2 … 5th ×1.5 … 10th ×2.0 … no peak, it keeps climbing.
// Cooldown (SEQUENTIAL, dwell ∝ height): the notch at height h takes cph·h time to clear,
// removed top-first, each waiting for the previous → the highest notch lingers LONGEST
// (slow at the top). Olympia cph = 1 DAY. (Stoa is free, so it has no cooldown.)
//
// Run: node scripts/potidaea-economics-sim.mjs
// BCH targets 10-minute blocks → 24h·60 / 10 = 144 blocks/day. Cooldown is denominated in
// BLOCKS on-chain (CLTV reads block height), so cph = 144 blocks ≡ "1 day per height-notch".
const BLOCKS_PER_DAY = (24 * 60) / 10; // = 144
const CONFIG = {
creationStepDEA: 10, // creation fee = 10·floor(A/100) (free first 100)
activationThreshold: 100, // Olympia opens once 100 assets are CREATED
olympia: { cA: 1.0, cL: 1.0, mStep: 0.1, cph: BLOCKS_PER_DAY, unit: 'block' },
};
const creationFee = (A) => CONFIG.creationStepDEA * Math.floor(A / 100); // 0 while A < 100
const listingFee = (t, A, Lbefore, M) => (t.cA * A + t.cL * Lbefore) * M;
// Sequential burst→relax at fixed ecosystem size A0; M is uncapped.
function burstRelax(t, A0, nBurst, L0 = 0) {
let active = 0, L = L0, tick = 0, nextDecay = null;
const M = () => +(1 + t.mStep * active).toFixed(4);
const fees = [], timeline = [{ tick: 0, M: M() }];
for (let i = 0; i < nBurst; i++) { // burst: 1 listing/tick, pay post-bump
tick++;
const Lbefore = L;
active++; L++;
fees.push({ tick, L, M: M(), fee: listingFee(t, A0, Lbefore, M()) });
nextDecay = tick + t.cph * active; // sequential: restart at the new top height
timeline.push({ tick, M: M() });
}
const peakTick = tick, peakM = M();
while (active > 0) { // relax: top notch first, slow at the top
tick++;
if (tick >= nextDecay) { active--; nextDecay = active > 0 ? tick + t.cph * active : null; }
timeline.push({ tick, M: M() });
}
const peakHeld = (timeline.find(x => x.tick > peakTick && x.M < peakM)?.tick ?? tick) - peakTick;
return { fees, timeline, totalTicks: tick, peakM, peakHeld };
}
const dea = (x) => x.toFixed(2) + ' DEA';
const hr = (s='') => console.log('\n──── ' + s + ' ' + '─'.repeat(Math.max(0, 60 - s.length)));
console.log('\n=== POTIDAEA ECONOMICS — FINAL parameters (ADR-0048) ===');
// 1) CREATION fee — free first 100
hr('CREATION fee = 10·floor(A/100) DEA (FREE first 100)');
for (const A of [1, 50, 99, 100, 150, 200, 300, 400, 500])
console.log(` A=${String(A).padStart(3)} → ${creationFee(A) === 0 ? 'FREE' : dea(creationFee(A))}`);
{
let s5=0,s3=0; for (let a=0;a<500;a++) s5+=creationFee(a); for (let a=0;a<300;a++) s3+=creationFee(a);
console.log(` Σ first 300 assets = ${dea(s3)} Σ first 500 = ${dea(s5)}`);
}
// 2) STOA — free
hr('STOA — ALWAYS FREE');
console.log(' No listing fee, ever. Free swap + free discovery; only Olympia (the spotlight) costs.');
// 3) OLYMPIA — uncapped multiplier (10-listing burst at A=100 reaches ×2.0)
const days = (blocks) => (blocks / BLOCKS_PER_DAY).toFixed(1);
hr(`OLYMPIA — 10-listing burst at A=100 (M UNCAPPED, cph = ${CONFIG.olympia.cph} BLOCKS = 1 day)`);
{
const t = CONFIG.olympia;
const r = burstRelax(t, 100, 10);
console.log(' listing → M fee');
r.fees.forEach((f, i) => console.log(` #${String(i+1).padStart(2)} (L=${String(f.L).padStart(2)}) ×${f.M.toFixed(1)} ${dea(f.fee)}`));
console.log(` ×1.5 is NOT a peak — it keeps +0.1/listing → ×2.0 at the 10th.`);
console.log(` peak ×${r.peakM.toFixed(1)} holds ${r.peakHeld} blocks (${days(r.peakHeld)} days); full relax ${r.totalTicks - 10} blocks (${days(r.totalTicks - 10)} days) (= cph·Σh).`);
}
// 3b) reference: a 5-listing burst → ×1.5
{
const r = burstRelax(CONFIG.olympia, 100, 5);
console.log(` (ref: a 5-burst peaks ×1.5, holds ${r.peakHeld} blocks (${days(r.peakHeld)} days), full relax ${r.totalTicks-5} blocks (${days(r.totalTicks-5)} days).)`);
}
// 4) Olympia base vs ecosystem size A (1st listing, M=×1.0)
hr('OLYMPIA base fee vs ecosystem size A (M=×1.0, L=0)');
for (const A of [100, 150, 200, 300, 500])
console.log(` A=${String(A).padStart(3)} → ${dea(listingFee(CONFIG.olympia, A, 0, 1.0))}`);
// 5) PLATFORM revenue — DEA fees (creation + Olympia) + swap-fee slice
hr('PLATFORM REVENUE — creation + Olympia (DEA) + swap-fee slice (BCH)');
{
let createSum = 0; for (let a = 0; a < 300; a++) createSum += creationFee(a);
let olySum = 0; for (let L = 0; L < 30; L++) olySum += listingFee(CONFIG.olympia, 100 + L * 6, L, 1.0);
console.log(` creation (300 assets, first 100 free) : ${dea(createSum)} (DEA, treasury)`);
console.log(` Olympia (30 listings) : ${dea(olySum)} (DEA, treasury)`);
console.log(` Stoa : FREE (0)`);
console.log(` + the platform's slice of the swap fee (BCH) — see split table below`);
}
// 6) The PROTOCOL swap/mint fee split — chosen now vs the future glide path
hr('SWAP/MINT FEE SPLIT — every mint/swap, any tier (the holder-yield engine)');
{
const volBCHmo = 1500; // total ecosystem mint/swap volume (e.g. 300 MBAs × 5 BCH/mo)
const splits = [
{ tag: 'NOW ', fee: 0.5, holder: 0.3, platform: 0.2 },
{ tag: 'FUTURE', fee: 0.3, holder: 0.3, platform: 0.0 },
];
console.log(` ecosystem mint/swap volume = ${volBCHmo} BCH/mo (illustrative)`);
console.log(` phase fee → DEA holders /mo (/yr) platform DIRECT /mo (/yr)`);
for (const s of splits) {
const h = volBCHmo * s.holder / 100, p = volBCHmo * s.platform / 100;
console.log(` ${s.tag} ${s.fee}% ${h.toFixed(1)} BCH (${(h*12).toFixed(0)}/yr) ${p.toFixed(1)} BCH (${(p*12).toFixed(0)}/yr)`);
}
console.log(` DEA holders get the MAJORITY (NOW) → ALL (FUTURE) of the swap fee, pro-rata + time-weighted.`);
console.log(` Every mint/swap counts — Stoa, Olympia, or unlisted — so the base is all volume.`);
}
// 7) Platform as an active DEA holder — earns yield on its accumulated DEA balance
hr('PLATFORM AS A DEA HOLDER — earns yield on the DEA it accumulates');
{
let createSum = 0; for (let a = 0; a < 300; a++) createSum += creationFee(a);
let olySum = 0; for (let L = 0; L < 30; L++) olySum += listingFee(CONFIG.olympia, 100 + L * 6, L, 1.0);
const platDEA = createSum + olySum;
console.log(` Platform accumulates ${dea(platDEA)} (creation ${dea(createSum)} + Olympia ${dea(olySum)}).`);
console.log(` Holding that DEA, it earns the holder-yield PRO-RATA (its share = platformDEA ÷ total DEA).`);
console.log(` The 0.2% direct slice (NOW) is transitional; FUTURE = platform earns from swaps ONLY`);
console.log(` as a holder — no privileged cut. It is an active participant, fully aligned (ADR-0008).`);
}
console.log('');

View file

@ -0,0 +1,542 @@
#!/usr/bin/env node
/**
* sim-dra-controller.mjs — simulator for the reflective Dynamic Rate Asset (DRA).
*
* Implements the asymmetric three-force model defined in
* Parameters/FIATm/dra_dynamic-rate-asset_FOR-design-record.md
*
* Three forces, all biased toward the §6 maturity ratio of 1 DRA = €357:
* 1. Fee accrual — always positive (from FIATm-family transactions)
* 2. Reflective rate — asymmetric (full positive on EUR weakness,
* dampened negative on EUR strength)
* 3. Catalyst counter — strengthens negative-side dampening with adoption
*
* Stage-1 tests (per §9 of design record):
* T1 Fee accrual lift at varied FIATm-family volumes (low/medium/high)
* T2 Reflective asymmetric response under sustained EUR cycles
* T3 Catalyst convergence — β sweep, which β reaches A=30 + maturity envelope?
* T4 Adversarial trajectory — early sustained EUR strengthening
* T5 Vintage analysis — fairness across mint-vintage cohorts
*
* Run: node scripts/sim-dra-controller.mjs
*/
// ── Genesis parameters ──────────────────────────────────────────────────
const BCH_EUR_GENESIS = 200; // BCH = €200 at launch
const EUR_RATE_0 = Math.round(1e8 / BCH_EUR_GENESIS); // sats per EURm at launch = 500,000
const LAUNCH_RATIO = 357; // 1 EUR = 357 DRA (1998 ERM-entry parity)
const DRA_RATE_0 = Math.round(EUR_RATE_0 / LAUNCH_RATIO); // sats per DRA at launch = 1,401
// ── Reflective formula parameters ───────────────────────────────────────
const ALPHA = 1.0; // base reflection amplitude (full linear mirror)
const BETA = 0.10; // per-catalyst counter-strengthening (negative-side dampening)
const GAMMA = 0.05; // per-catalyst gain (positive-side acceleration) — NEW
// ── Amplitude clamp (adoption-scaled, asymmetric widening) ──────────────
const LOWER_CLAMP_0 = 0.5; // [0.5×, 2.0×] at launch
const UPPER_CLAMP_0 = 2.0;
const LOWER_WIDEN_PER_CAT = 0.015; // lower bound drops 1.5% baseline per catalyst
const UPPER_WIDEN_PER_CAT = 0.10; // upper bound rises 10% baseline per catalyst
const LOWER_FLOOR = 0.05; // never below 5% of baseline
// ── Catalyst trigger ─────────────────────────────────────────────────────
const CATALYST_TRIGGER = 210_000; // matches §12a.2 (FIATm controller)
const MAX_CATALYSTS = 30; // hard cap (asymptote — matches design's A=30 target)
// ── Fee schedule (chipnet-tunable) ──────────────────────────────────────
const FEE_MINT_REDEEM = 0.0005; // 0.05% on FIATm mint/redeem
const FEE_SWAP = 0.0010; // 0.10% on FIATm-to-FIATm swap
// ── Maturity target ─────────────────────────────────────────────────────
const MATURITY_EUR_PER_DRA = 357; // 1 DRA = €357 at maturity (opposite reverse)
// ────────────────────────────────────────────────────────────────────────
// DRA controller — the shared-pool state machine
// ────────────────────────────────────────────────────────────────────────
class DraController {
constructor(beta = BETA) {
this.beta = beta;
this.pool_sats = 0; // BCH in pool (sats)
this.supply = 0; // DRA units issued
this.cumulative_mints = 0; // for catalyst trigger
this.catalysts = 0;
this.eur_rate_history = []; // for time-averaging
this.last_dra_rate = DRA_RATE_0;
}
// EUR_rate moving average — window lengthens with adoption
averagingWindowHours() {
if (this.catalysts === 0) return 6;
if (this.catalysts < 10) return 6 + (this.catalysts * 1.8); // 6h → 24h
if (this.catalysts < 30) return 24 + ((this.catalysts - 10) * 7.2); // 24h → 168h (7d)
return 168; // frozen 7-day reference at maturity
}
// Push EUR_rate observation; return time-averaged reference
observeEurRate(eur_rate_spot, hours_since_genesis) {
this.eur_rate_history.push({ t: hours_since_genesis, rate: eur_rate_spot });
// Drop observations older than averaging window
const window = this.averagingWindowHours();
const cutoff = hours_since_genesis - window;
this.eur_rate_history = this.eur_rate_history.filter(o => o.t >= cutoff);
const sum = this.eur_rate_history.reduce((s, o) => s + o.rate, 0);
return sum / this.eur_rate_history.length;
}
// counter(r, A) = max(r × (1 + A × β), 1) — dampens negative side
counter(r) {
if (r <= 1) return 1;
return r * (1 + this.catalysts * this.beta);
}
// gain(A) = 1 + A × γ — amplifies positive side (NEW per user revision 2026-06-28)
gain() {
return 1 + this.catalysts * GAMMA;
}
// Amplitude clamp bounds (adoption-scaled, asymmetric)
clampBounds() {
const lower = Math.max(LOWER_CLAMP_0 - this.catalysts * LOWER_WIDEN_PER_CAT, LOWER_FLOOR);
const upper = UPPER_CLAMP_0 + this.catalysts * UPPER_WIDEN_PER_CAT;
return { lower, upper };
}
// Compute current DRA mint rate given a (time-averaged) EUR rate.
//
// Doubly asymmetric:
// r > 1 (EUR strengthens vs BCH) → counter dampens negative response
// r ≤ 1 (EUR weakens vs BCH) → gain amplifies positive response
draRate(eur_rate_averaged) {
const r = eur_rate_averaged / EUR_RATE_0;
const delta = eur_rate_averaged - EUR_RATE_0;
let raw;
if (r > 1) {
// Negative side — dampened
const c = this.counter(r);
raw = DRA_RATE_0 - ALPHA * delta / c;
} else {
// Positive side — amplified
const g = this.gain();
raw = DRA_RATE_0 - ALPHA * delta * g;
}
const { lower, upper } = this.clampBounds();
const min_rate = DRA_RATE_0 * lower;
const max_rate = DRA_RATE_0 * upper;
const clamped = Math.max(min_rate, Math.min(raw, max_rate));
this.last_dra_rate = clamped;
return clamped;
}
// Mint N DRA at max(reflective DRA_rate, current NAV)
//
// The max-of-two pricing is structural: new mints cannot enter below NAV
// (no dilution of existing holders), and during EUR-weakening events the
// reflective rate exceeds NAV → new mints pay a PREMIUM that lifts NAV
// for all holders. This is the mechanism by which catalyst-driven gain
// (γ) actually accretes per-DRA NAV over the adoption arc.
mint(n_dra, dra_rate) {
const nav = this.navPerDra();
const mint_price = Math.max(dra_rate, nav);
const sats_in = Math.round(n_dra * mint_price);
this.pool_sats += sats_in;
this.supply += n_dra;
this.cumulative_mints += n_dra;
// Catalyst trigger — capped at MAX_CATALYSTS asymptote
const expected = Math.min(
Math.floor(this.cumulative_mints / CATALYST_TRIGGER),
MAX_CATALYSTS
);
while (expected > this.catalysts) {
this.catalysts += 1;
}
return sats_in;
}
// Add fees (no supply change — pure NAV lift)
receiveFees(sats) {
this.pool_sats += sats;
}
// Redeem N DRA at current NAV
redeem(n_dra) {
const nav = this.navPerDra();
const sats_out = Math.round(n_dra * nav);
this.pool_sats -= sats_out;
this.supply -= n_dra;
return sats_out;
}
navPerDra() {
return this.supply > 0 ? this.pool_sats / this.supply : DRA_RATE_0;
}
navInEur(bch_eur_spot) {
return this.navPerDra() * bch_eur_spot / 1e8;
}
}
// ────────────────────────────────────────────────────────────────────────
// Trajectory generators — BCH/EUR over time
// ────────────────────────────────────────────────────────────────────────
const trajectories = {
calm: (hours) => BCH_EUR_GENESIS, // flat
eur_weakens_2x: (hours, total) => BCH_EUR_GENESIS * (1 + hours / total),
eur_strengthens_50: (hours, total) => BCH_EUR_GENESIS * (1 - 0.5 * hours / total),
eur_strengthens_then_recovers: (hours, total) => {
const mid = total / 2;
if (hours < mid) return BCH_EUR_GENESIS * (1 - 0.5 * hours / mid); // drop to €100
return BCH_EUR_GENESIS * 0.5 + BCH_EUR_GENESIS * 1.5 * (hours - mid) / mid; // €100 → €400
},
oscillating: (hours, total) => {
const cycles = 4;
return BCH_EUR_GENESIS * (1 + 0.5 * Math.sin(2 * Math.PI * cycles * hours / total));
},
sustained_debasement: (hours, total) => BCH_EUR_GENESIS * Math.pow(5, hours / total), // BCH €200 → €1000
adversarial_early_strengthen: (hours, total) => {
const phase = total * 0.3;
if (hours < phase) return BCH_EUR_GENESIS * (1 - 0.7 * hours / phase); // drop 70% fast
return BCH_EUR_GENESIS * 0.3 * Math.pow(10, (hours - phase) / (total - phase)); // recover slowly
},
};
// ────────────────────────────────────────────────────────────────────────
// Adoption profile — how the FIATm family grows over time
// ────────────────────────────────────────────────────────────────────────
const adoptionProfiles = {
low: { mint_per_day: 2_000, tx_per_day: 50_000, notional_per_tx_eur: 50 },
med: { mint_per_day: 20_000, tx_per_day: 500_000, notional_per_tx_eur: 50 },
high:{ mint_per_day: 200_000, tx_per_day: 5_000_000, notional_per_tx_eur: 50 },
};
// Per-currency profiles for T8 multi-currency competition test.
//
// All FIATm currencies feed fees into the SAME DRA pool (architectural intent —
// the DraFee variant is the mainnet default for the whole family, not an
// opt-in variant). EUR is conspicuously NOT in this list — DRA's reflective
// rate is anchored to the EUR oracle, so EUR is the structural counterparty
// rather than a peer feeder currency.
//
// Volumes are MODERATED (~1/10 of original calibration) to keep the 5y
// multi-currency outcome approaching — not blowing past — the €357 maturity
// envelope. Realistic chipnet/mainnet calibration TBD; these numbers are
// plausible adoption assumptions, not commitments.
const currencyProfiles = {
USDm: { mint_per_day: 10_000, tx_per_day: 250_000, notional_per_tx_eur: 60 },
GBPm: { mint_per_day: 2_500, tx_per_day: 50_000, notional_per_tx_eur: 55 },
CHFm: { mint_per_day: 800, tx_per_day: 12_000, notional_per_tx_eur: 80 },
TRYm: { mint_per_day: 1_500, tx_per_day: 35_000, notional_per_tx_eur: 25 },
HUFm: { mint_per_day: 300, tx_per_day: 6_000, notional_per_tx_eur: 35 },
ILSm: { mint_per_day: 1_000, tx_per_day: 20_000, notional_per_tx_eur: 50 },
};
// ────────────────────────────────────────────────────────────────────────
// Single simulation run
// ────────────────────────────────────────────────────────────────────────
function runSim({
trajectoryName,
adoptionName,
total_days,
beta = BETA,
seed_mint_dra = 10_000, // genesis seed mint
verbose = false,
}) {
const ctrl = new DraController(beta);
const trajectory_fn = trajectories[trajectoryName];
const adoption = adoptionProfiles[adoptionName];
const total_hours = total_days * 24;
// Genesis seed mint
ctrl.mint(seed_mint_dra, DRA_RATE_0);
const samples = [];
const hours_per_day = 24;
const step_hours = 24; // daily granularity
for (let day = 0; day < total_days; day++) {
const hours = day * hours_per_day;
const bch_eur = trajectory_fn(hours, total_hours);
const eur_rate_spot = 1e8 / bch_eur;
const eur_rate_avg = ctrl.observeEurRate(eur_rate_spot, hours);
const dra_rate = ctrl.draRate(eur_rate_avg);
// Mints today (linear adoption)
const daily_mints = adoption.mint_per_day;
ctrl.mint(daily_mints, dra_rate);
// Fees today (from FIATm-family transactions)
// Daily FIATm tx notional in EUR → in sats:
const daily_notional_eur = adoption.tx_per_day * adoption.notional_per_tx_eur;
const daily_notional_sats = (daily_notional_eur / bch_eur) * 1e8;
// Assume 80% of tx are swaps, 20% are mint/redeem (typical pattern)
const fees_sats = Math.round(
0.8 * daily_notional_sats * FEE_SWAP +
0.2 * daily_notional_sats * FEE_MINT_REDEEM
);
ctrl.receiveFees(fees_sats);
// Sample every 30 days
if (day % 30 === 0 || day === total_days - 1) {
samples.push({
day,
bch_eur: Math.round(bch_eur),
eur_rate: Math.round(eur_rate_spot),
dra_rate: Math.round(dra_rate),
catalysts: ctrl.catalysts,
supply: ctrl.supply,
pool_sats: ctrl.pool_sats,
nav_per_dra_sats: Math.round(ctrl.navPerDra()),
nav_per_dra_eur: +ctrl.navInEur(bch_eur).toFixed(4),
});
}
}
return {
trajectoryName, adoptionName, total_days, beta,
final: samples[samples.length - 1],
samples,
maturityHit: samples[samples.length - 1].nav_per_dra_eur >= MATURITY_EUR_PER_DRA,
catalystsHit30: ctrl.catalysts >= MAX_CATALYSTS,
};
}
// ────────────────────────────────────────────────────────────────────────
// Stage-1 tests
// ────────────────────────────────────────────────────────────────────────
function fmt(n) {
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(2) + 'k';
return String(Math.round(n));
}
function printResult(label, r) {
const verdict = r.maturityHit ? '✓ MATURITY HIT' : '✗ short of maturity';
const catVerdict = r.catalystsHit30 ? '✓ A≥30' : `(A=${r.final.catalysts})`;
console.log(` ${label.padEnd(35)} → NAV €${String(r.final.nav_per_dra_eur).padStart(8)} | supply ${fmt(r.final.supply).padStart(8)} | catalysts ${String(r.final.catalysts).padStart(3)} | ${verdict} ${catVerdict}`);
}
function T1_fee_accrual_lift() {
console.log('\n=== T1 — Fee accrual lift at varied FIATm-family volumes (24-month calm trajectory) ===');
for (const adopt of ['low', 'med', 'high']) {
const r = runSim({ trajectoryName: 'calm', adoptionName: adopt, total_days: 730 });
printResult(`adoption=${adopt}`, r);
}
}
function T2_reflective_asymmetric_response() {
console.log('\n=== T2 — Reflective asymmetric response (5-year trajectories, medium adoption) ===');
const trajs = ['calm', 'eur_weakens_2x', 'eur_strengthens_50', 'oscillating', 'sustained_debasement'];
for (const traj of trajs) {
const r = runSim({ trajectoryName: traj, adoptionName: 'med', total_days: 1825 });
printResult(`trajectory=${traj}`, r);
}
}
function T3_catalyst_convergence_beta_sweep() {
console.log('\n=== T3 — β sweep on EUR-STRENGTHENING trajectory (where counter activates) — medium adoption, 5y ===');
for (const beta of [0.05, 0.10, 0.15, 0.20, 0.30]) {
const r = runSim({ trajectoryName: 'eur_strengthens_50', adoptionName: 'med', total_days: 1825, beta });
printResult(`β=${beta.toFixed(2)}`, r);
}
}
function T6_long_horizon_trajectory() {
console.log('\n=== T6 — Long-horizon trajectory (15y sustained debasement, medium adoption — multi-decade target) ===');
const r = runSim({ trajectoryName: 'sustained_debasement', adoptionName: 'med', total_days: 5475 });
printResult('15y med-debasement', r);
console.log(' Yearly NAV trajectory:');
for (let i = 0; i < r.samples.length; i += 12) {
const s = r.samples[i];
const year = (s.day / 365).toFixed(1);
console.log(` year ${String(year).padStart(5)}: BCH €${String(s.bch_eur).padStart(7)} | NAV €${String(s.nav_per_dra_eur).padStart(10)} | A=${String(s.catalysts).padStart(3)} | supply ${fmt(s.supply).padStart(8)}`);
}
}
function T7_three_horizons() {
console.log('\n=== T7 — Three-horizon comparison (calm baseline, medium adoption — long-arc design intent) ===');
for (const days of [1825, 3650, 5475, 10950, 18250]) {
const r = runSim({ trajectoryName: 'sustained_debasement', adoptionName: 'med', total_days: days });
printResult(`${(days / 365).toFixed(0)}y sustained debasement`, r);
}
}
// T8 — Multi-currency competition.
//
// Six FIATm currencies (USDm/GBPm/CHFm/TRYm/HUFm/ILSm) feed fees into ONE
// shared DRA pool simultaneously. Compares per-DRA NAV against the single-
// currency baseline at the same horizon. The structural question: does the
// combined family fee inflow accelerate DRA's trajectory to maturity?
//
// Each currency's mint contribution rises through the DRA catalyst counter
// (catalysts trigger on cumulative DRA mints, not per-currency mints).
// All currencies share the same EUR_rate reference (DRA's reflective anchor).
function T8_multicurrency_competition() {
console.log('\n=== T8 — Multi-currency competition (USDm/GBPm/CHFm/TRYm/HUFm/ILSm feed one DRA pool, 5y debasement) ===');
const ctrl = new DraController();
ctrl.mint(10_000, DRA_RATE_0);
const total_days = 1825;
const total_hours = total_days * 24;
const traj = trajectories.sustained_debasement;
const currencies = Object.entries(currencyProfiles);
// Per-currency fee tally (for reporting attribution)
const currencyFeeTally = Object.fromEntries(currencies.map(([code]) => [code, 0]));
let totalFeesIn = 0;
for (let day = 0; day < total_days; day++) {
const hours = day * 24;
const bch_eur = traj(hours, total_hours);
const eur_rate_spot = 1e8 / bch_eur;
const eur_rate_avg = ctrl.observeEurRate(eur_rate_spot, hours);
const dra_rate = ctrl.draRate(eur_rate_avg);
// Each currency contributes its own daily mint volume (acts as DRA-pool
// pressure via cumulative mints driving catalysts) and its own fee stream.
for (const [code, profile] of currencies) {
// Treat each currency's "mints" as contributing to DRA pool growth via
// their daily fees (the actual DRA mints are tracked separately for
// catalyst calculation, but in this sim we approximate that the DRA
// catalysts also fire from family-wide volume).
const daily_notional_sats = (profile.tx_per_day * profile.notional_per_tx_eur / bch_eur) * 1e8;
const fee_sats = Math.round(
0.8 * daily_notional_sats * FEE_SWAP +
0.2 * daily_notional_sats * FEE_MINT_REDEEM
);
ctrl.receiveFees(fee_sats);
currencyFeeTally[code] += fee_sats;
totalFeesIn += fee_sats;
}
// Also mint some DRA each day. Adoption assumption: ~0.5% of family
// transaction volume converts to DRA mints (a realistic yield-seeking
// fraction for a fee-aggregating asset). This keeps DRA supply in
// proportion to family volume so per-DRA NAV doesn't inflate from
// sparse supply against rich fee inflow.
const family_tx = currencies.reduce((sum, [, p]) => sum + p.tx_per_day, 0);
const daily_dra_mints = Math.round(family_tx * 0.005);
ctrl.mint(daily_dra_mints, dra_rate);
}
const final_bch_eur = traj((total_days - 1) * 24, total_hours);
const final_nav_eur = ctrl.navInEur(final_bch_eur);
const final_nav_sats = ctrl.navPerDra();
console.log(` Combined family fee flow over 5y:`);
const totalFamilyFees_bch = totalFeesIn / 1e8;
for (const [code, sats] of Object.entries(currencyFeeTally)) {
const share = (sats / totalFeesIn * 100).toFixed(1);
console.log(` ${code}: ${(sats / 1e8).toFixed(2)} BCH (${share}%)`);
}
console.log(` ─────────────────────────`);
console.log(` Total: ${totalFamilyFees_bch.toFixed(2)} BCH → DRA pool over 5y`);
console.log(` Final state:`);
console.log(` DRA supply: ${fmt(ctrl.supply)} | catalysts: A=${ctrl.catalysts} | pool: ${(ctrl.pool_sats / 1e8).toFixed(2)} BCH`);
console.log(` Per-DRA NAV: ${final_nav_sats.toFixed(0)} sats = €${final_nav_eur.toFixed(2)}`);
console.log(` Multi-currency vs single-currency (med adoption) baseline: 5y=€35.67 (sustained debasement)`);
console.log(` Multi-currency lift: ${(final_nav_eur / 35.67).toFixed(2)}× over single-currency baseline`);
}
function T4_adversarial_trajectory() {
console.log('\n=== T4 — Adversarial trajectory (early EUR strengthening, then recovery; medium adoption) ===');
const r = runSim({
trajectoryName: 'adversarial_early_strengthen',
adoptionName: 'med',
total_days: 1825,
verbose: true,
});
printResult('adversarial', r);
// Show samples to see the drawdown shape
console.log(' Quarterly sample of NAV trajectory:');
for (let i = 0; i < r.samples.length; i += 3) {
const s = r.samples[i];
console.log(` day ${String(s.day).padStart(4)}: BCH €${String(s.bch_eur).padStart(5)} | NAV €${String(s.nav_per_dra_eur).padStart(8)} | A=${s.catalysts}`);
}
}
function T5_vintage_analysis() {
console.log('\n=== T5 — Vintage analysis: holder fairness at different mint vintages (sustained debasement, high adoption, 5y) ===');
// Run main sim, snapshot pool state at vintage points, compute what a 100-DRA holder would redeem for at end.
const ctrl = new DraController();
ctrl.mint(10_000, DRA_RATE_0); // genesis seed
const adoption = adoptionProfiles.high;
const traj = trajectories.sustained_debasement;
const total_days = 1825;
const total_hours = total_days * 24;
const vintages = [0, 365, 730, 1095, 1460]; // years 0,1,2,3,4
const vintageSnapshots = {};
for (let day = 0; day < total_days; day++) {
const hours = day * 24;
const bch_eur = traj(hours, total_hours);
const eur_rate_spot = 1e8 / bch_eur;
const eur_rate_avg = ctrl.observeEurRate(eur_rate_spot, hours);
const dra_rate = ctrl.draRate(eur_rate_avg);
if (vintages.includes(day)) {
// Pretend a holder mints 100 DRA today; record their entry NAV
vintageSnapshots[day] = {
entry_dra_rate: dra_rate,
entry_bch_eur: bch_eur,
entry_pool_nav: ctrl.navPerDra(),
};
}
ctrl.mint(adoption.mint_per_day, dra_rate);
const daily_notional_sats = (adoption.tx_per_day * adoption.notional_per_tx_eur / bch_eur) * 1e8;
ctrl.receiveFees(Math.round(0.8 * daily_notional_sats * FEE_SWAP + 0.2 * daily_notional_sats * FEE_MINT_REDEEM));
}
const final_nav_sats = ctrl.navPerDra();
const final_bch_eur = traj((total_days - 1) * 24, total_hours);
const final_nav_eur = final_nav_sats * final_bch_eur / 1e8;
console.log(` Final per-DRA NAV: €${final_nav_eur.toFixed(2)} | Final BCH/EUR: €${final_bch_eur.toFixed(0)}`);
console.log(' Per-vintage entry vs final-redemption (100 DRA cohort):');
for (const day of vintages) {
const v = vintageSnapshots[day];
const entry_cost_eur = (v.entry_dra_rate / 1e8) * v.entry_bch_eur * 100;
const final_value_eur = final_nav_eur * 100;
const multiple = final_value_eur / entry_cost_eur;
console.log(` vintage day ${String(day).padStart(4)} (BCH €${v.entry_bch_eur.toFixed(0)}): paid €${entry_cost_eur.toFixed(2)}, worth €${final_value_eur.toFixed(2)} → ${multiple.toFixed(2)}×`);
}
}
// ────────────────────────────────────────────────────────────────────────
// Main
// ────────────────────────────────────────────────────────────────────────
function main() {
console.log('═══════════════════════════════════════════════════════════════════');
console.log(' DRA controller simulator — asymmetric three-force model');
console.log(' Maturity target: 1 DRA = €357 (the opposite reverse)');
console.log('═══════════════════════════════════════════════════════════════════');
console.log(` Genesis: BCH €${BCH_EUR_GENESIS}, EUR_rate ${EUR_RATE_0} sats, DRA_rate ${DRA_RATE_0} sats`);
console.log(` Formula: DRA = ${DRA_RATE_0} − ${ALPHA} × (EUR − ${EUR_RATE_0}) / max(r × (1 + A × β), 1)`);
console.log(` Catalyst: every ${fmt(CATALYST_TRIGGER)} cumulative DRA mints; β = ${BETA} default`);
console.log(` Clamp: [${LOWER_CLAMP_0} − A×${LOWER_WIDEN_PER_CAT}, ${UPPER_CLAMP_0} + A×${UPPER_WIDEN_PER_CAT}] × DRA_rate_0`);
T1_fee_accrual_lift();
T2_reflective_asymmetric_response();
T3_catalyst_convergence_beta_sweep();
T4_adversarial_trajectory();
T5_vintage_analysis();
T6_long_horizon_trajectory();
T7_three_horizons();
T8_multicurrency_competition();
console.log('\n───────────────────────────────────────────────────────────────────');
console.log(' Done. Maturity target: 1 DRA ≥ €357 (multi-decade horizon by design).');
console.log(' ✓ = maturity reached; ✗ = on the long arc — any positive trajectory is on-design.');
console.log('───────────────────────────────────────────────────────────────────\n');
}
main();

View file

@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* test-wif-property.mjs — smoke-test w.privateKeyWif on TestNetWallet.fromSeed()
*
* The sweep flow's deriveKeySet() and findKeyForAddress() read `w.privateKeyWif`
* after calling TestNetWallet.fromSeed(mnemonic, path). The property is verified
* on newRandom() wallets throughout the chipnet scripts, but fromSeed() is a
* different code path inside mainnet-js.
*
* This script confirms:
* 1. w.privateKeyWif is present and non-empty on a fromSeed() wallet
* 2. It matches the WIF regex (^[5KLc][1-9A-HJ-NP-Za-km-z]{50,51}$)
* 3. The fallback w.toWIF?.() either also works or is absent (either is fine)
* 4. A wallet loaded back with fromWIF(w.privateKeyWif) round-trips to the same cashaddr
*
* Run: rtk node scripts/test-wif-property.mjs
* No network calls; no funds needed.
*/
import { TestNetWallet } from 'mainnet-js';
// BIP-39 standard test vector — safe to use in scripts, never fund on mainnet.
const MNEMONIC = 'test test test test test test test test test test test junk';
const PATHS = [
"m/44'/145'/0'/0/0", // BCH canonical (Electron Cash)
"m/44'/145'/0'/0/1", // second index — same family
"m/44'/0'/0'/0/0", // legacy BTC coin-type
];
// Same regex used in sweep.ts looksLikeWif()
const WIF_RE = /^[5KLc][1-9A-HJ-NP-Za-km-z]{50,51}$/;
let passed = 0;
let failed = 0;
function ok(label, value) {
console.log(` ✓ ${label}: ${value}`);
passed++;
}
function fail(label, detail) {
console.error(` ✗ ${label}: ${detail}`);
failed++;
}
for (const path of PATHS) {
console.log(`\nPath: ${path}`);
const w = await TestNetWallet.fromSeed(MNEMONIC, path);
// 1. cashaddr present
if (w.cashaddr && w.cashaddr.startsWith('bchtest:')) {
ok('cashaddr', w.cashaddr);
} else {
fail('cashaddr', `unexpected value: ${w.cashaddr}`);
}
// 2. privateKeyWif present and valid
const wif = w.privateKeyWif;
if (typeof wif === 'string' && wif.length > 0) {
ok('privateKeyWif type', `string, length ${wif.length}`);
} else {
fail('privateKeyWif type', `got ${typeof wif} = ${wif}`);
}
if (WIF_RE.test(wif ?? '')) {
ok('privateKeyWif regex', wif);
} else {
fail('privateKeyWif regex', `"${wif}" does not match WIF pattern`);
}
// 3. toWIF() fallback — may or may not exist; log either way
const toWif = w.toWIF?.();
if (typeof toWif === 'string') {
if (toWif === wif) {
ok('toWIF() == privateKeyWif', 'same value — fallback redundant but safe');
} else {
fail('toWIF() != privateKeyWif', `${toWif} vs ${wif}`);
}
} else {
ok('toWIF()', 'not present — fallback will reach \'\' (expected for mainnet-js)');
}
// 4. round-trip: fromWIF(wif) → same cashaddr
const w2 = await TestNetWallet.fromWIF(wif);
if (w2.cashaddr === w.cashaddr) {
ok('round-trip fromWIF', 'cashaddr matches');
} else {
fail('round-trip fromWIF', `got ${w2.cashaddr}, expected ${w.cashaddr}`);
}
}
console.log(`\n── Result: ${passed} passed, ${failed} failed ──`);
if (failed > 0) {
console.error('FAIL — update the fallback chain in sweep.ts if w.privateKeyWif is absent');
process.exit(1);
}
console.log('PASS — w.privateKeyWif works on fromSeed() wallets; fallback chain is correct');

View file

@ -0,0 +1,211 @@
#!/usr/bin/env node
/**
* tote-platform-server.mjs — a standalone TOTE PLATFORM. Generalises the
* football demo into an event-agnostic, multi-market pari-mutuel platform:
*
* - Each MARKET is a binary yes/no proposition ("Mexico to beat South Africa?")
* with its own oracle-settled OpenTote pool. (Multi-outcome 1/X/2 is a later
* K-outcome-tote add-on; binary is the general primitive, Polymarket-style.)
* - ONE shared stake token (MBA) backs every pool; each market is its own tote.
* - Anyone can CREATE a market, BET on one, and SETTLE it (oracle result).
* - Football is the launch category (seeded); others just need a category tag.
* - The JSON API is the integration surface; /embed/:id is a drop-in iframe.
*
* API: GET /api/markets · GET /api/markets/:id · POST /api/markets {title,category,deadlineBlocks?}
* POST /api/bet {marketId,outcome,amount,wif?} · POST /api/settle {marketId,outcome}
* POST /api/connect {wif} · POST /api/genkey
* GET / (platform) · GET /embed/:id (single-market widget)
*
* Bets sign from a demo wallet, or from a connected WIF (chipnet-only). Run:
* node --import tsx scripts/tote-platform-server.mjs → http://localhost:4200
*/
import { createServer } from 'node:http';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { TestNetWallet } from 'mainnet-js';
import { Contract, ElectrumNetworkProvider, TransactionBuilder, SignatureTemplate } from 'cashscript';
import { secp256k1, sha256 } from '@bitauth/libauth';
const ROOT = join(import.meta.dirname, '..');
const STATE = join(ROOT, '.tote-platform-state.json');
const PAGE = join(ROOT, 'design/tote-platform/index.html');
const MBA_ARTIFACT = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/DeviantOpenMintFT.json'), 'utf8'));
const TOTE_ARTIFACT = JSON.parse(readFileSync(join(ROOT, 'contracts/dist/OpenTote.json'), 'utf8'));
const PORT = 4200, TAG = 'DEVGAME-RESULT';
const RESERVOIR = 1_000_000_000_000_000_000n, RATE = 1000n, FEE = 0n, MF = 6000n, SEED = 10n;
const hx = (h) => { const o = new Uint8Array(h.length / 2); for (let i = 0; i < o.length; i++) o[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return o; };
const bh = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
const txidLE = (h) => { const d = hx(h); const o = new Uint8Array(d.length); for (let i = 0; i < d.length; i++) o[i] = d[d.length - 1 - i]; return o; };
const p2pkh = (k) => { const o = new Uint8Array(25); o[0] = 0x76; o[1] = 0xa9; o[2] = 0x14; o.set(k, 3); o[23] = 0x88; o[24] = 0xac; return o; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const txidOf = (s) => (typeof s === 'string' ? s : s?.txid ?? s?.txId);
const pickBch = (u, need) => { const p = []; let t = 0n; for (const x of u) { p.push(x); t += BigInt(x.satoshis); if (t >= need) break; } return { p, t }; };
const load = () => (existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : null);
const save = () => writeFileSync(STATE, JSON.stringify(st, null, 2));
const slug = () => bh(randomBytes(4));
let A, C, provider, sigA, sigC, mba, st;
function oracleSign(privHex, ev, outcome) {
const message = new Uint8Array([...ev, outcome]);
const sig = secp256k1.signMessageHashDER(hx(privHex), sha256.hash(message));
if (typeof sig === 'string') throw new Error('sign failed: ' + sig);
return { message, sig };
}
const plainBch = async (w, need) => pickBch((await provider.getUtxos(w.cashaddr)).filter((x) => !x.token).sort((a, b) => Number(b.satoshis - a.satoshis)), need);
const toteFor = (m) => new Contract(TOTE_ARTIFACT, [txidLE(st.cat), RATE, hx(m.oraclePub), hx(m.gameId), C.publicKeyCompressed, BigInt(m.betDeadline), BigInt(m.refundDeadline)], { provider });
const poolUtxo = async (tote) => (await provider.getUtxos(tote.tokenAddress)).find((x) => x.token?.category === st.cat && !x.token?.nft);
const poolTotal = async (tote) => { const p = await poolUtxo(tote); return p ? Number(p.token.amount) : 0; };
async function mintStake(who, s, amount) {
let res = null;
for (let i = 0; i < 8; i++) { res = (await provider.getUtxos(mba.tokenAddress)).find((x) => x.token?.nft?.capability === 'minting' && x.token?.category === st.cat && x.token?.amount > 0n); if (res) break; await sleep(4000); }
if (!res) throw new Error('minting reservoir NFT not found');
const gross = amount * RATE;
const { p, t } = await plainBch(who, gross + MF + 1000n);
if (t < gross + MF + 1000n) throw new Error('wallet has insufficient tBCH for this stake');
const tb = new TransactionBuilder({ provider });
tb.addInput(res, mba.unlock.claim(amount, p2pkh(who.publicKeyHash)));
for (const x of p) tb.addInput(x, s.unlockP2PKH());
tb.addOutput({ to: mba.tokenAddress, amount: 1000n, token: { amount: res.token.amount - amount, category: st.cat, nft: { capability: 'minting', commitment: '' } } });
tb.addOutput({ to: who.tokenaddr, amount: gross, token: { amount, category: st.cat } });
tb.addOutput({ to: who.cashaddr, amount: BigInt(res.satoshis) + t - 1000n - gross - MF });
await tb.send();
let stake = null;
for (let i = 0; i < 8; i++) { await sleep(4000); stake = (await provider.getUtxos(who.cashaddr)).find((x) => x.token?.category === st.cat && !x.token?.nft && BigInt(x.token?.amount) === amount); if (stake) break; }
if (!stake) throw new Error('minted stake UTXO not found after mint');
return stake;
}
async function ensureMba() {
st = load();
if (st && st.cat) { mba = new Contract(MBA_ARTIFACT, [txidLE(st.cat), RATE, FEE, p2pkh(C.publicKeyHash)], { provider }); console.log(`[mba] reusing ${st.cat.slice(0, 16)}… · ${Object.keys(st.markets).length} markets`); return; }
console.log('[mba] deploying shared stake token…');
// make a dedicated, sufficiently-funded, confirmed vout-0 genesis input
await C.send([{ cashaddr: C.cashaddr, value: 6000n, unit: 'sat' }]); await sleep(13000);
const elig = (await C.getUtxos()).filter((x) => x.vout === 0 && !x.token && Number(x.satoshis) >= 4000)[0];
if (!elig) throw new Error('could not create a genesis-eligible UTXO');
const cat = elig.txid;
mba = new Contract(MBA_ARTIFACT, [txidLE(cat), RATE, FEE, p2pkh(C.publicKeyHash)], { provider });
await C.tokenGenesis({ cashaddr: mba.tokenAddress, amount: RESERVOIR, value: 1000n, nft: { capability: 'minting', commitment: '' } }, [], { utxoIds: [elig] });
let nft = null;
for (let i = 0; i < 12; i++) { await sleep(5000); nft = (await provider.getUtxos(mba.tokenAddress)).find((x) => x.token?.nft?.capability === 'minting' && x.token?.category === cat); if (nft) break; }
if (!nft) throw new Error('genesis did not produce a minting NFT (cat ' + cat.slice(0, 12) + '…) — aborting');
st = { cat, markets: {} }; save();
console.log(`[mba] live ${cat.slice(0, 16)}… (minting NFT confirmed)`);
}
async function createMarket({ title, category, deadlineBlocks }) {
let opriv; do { opriv = randomBytes(32); } while (typeof secp256k1.derivePublicKeyCompressed(opriv) === 'string');
const tip = await provider.getBlockHeight();
const id = slug();
const m = { id, title, category: category || 'other', gameId: bh(randomBytes(16)), oraclePub: bh(secp256k1.derivePublicKeyCompressed(opriv)), oraclePriv: bh(opriv), betDeadline: tip + (Number(deadlineBlocks) || 0), refundDeadline: tip + 500000, seed: { yes: Number(SEED) / 2, no: Number(SEED) / 2 }, bets: [], settled: null };
// open the pool with a house seed so odds start even (persist only on success)
const tote = toteFor(m);
const seedUtxo = await mintStake(C, sigC, SEED);
const { p, t } = await plainBch(C, MF + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(seedUtxo, sigC.unlockP2PKH());
for (const x of p) tb.addInput(x, sigC.unlockP2PKH());
tb.addOutput({ to: tote.tokenAddress, amount: SEED * RATE, token: { amount: SEED, category: st.cat } });
tb.addOutput({ to: C.cashaddr, amount: BigInt(seedUtxo.satoshis) + t - SEED * RATE - MF });
await tb.send(); await sleep(9000);
st.markets[id] = m; save();
console.log(`[market] "${title}" (${m.category}) — id ${id}, tote ${tote.tokenAddress.slice(0, 18)}…`);
return m;
}
async function placeBet(marketId, outcome, amount, w, s) {
const m = st.markets[marketId]; if (!m) throw new Error('no such market'); if (m.settled) throw new Error('market settled');
w = w || A; s = s || sigA;
const amt = BigInt(Math.max(1, Math.round(amount)));
const tote = toteFor(m);
let stake; try { stake = await mintStake(w, s, amt); } catch (e) { throw new Error('could not stake from your key — need ~' + (Number(amt) * 1000 + 9000) + ' tBCH-sats (' + (e?.message ?? e) + ')'); }
let pool = null; for (let i = 0; i < 8; i++) { pool = await poolUtxo(tote); if (pool) break; await sleep(4000); }
if (!pool) throw new Error('market pool UTXO not found');
const to = BigInt(pool.token.amount) + amt;
const { p, t } = await plainBch(w, MF + 1000n);
const tb = new TransactionBuilder({ provider });
tb.addInput(pool, tote.unlock.bet(amt));
tb.addInput(stake, s.unlockP2PKH());
for (const x of p) tb.addInput(x, s.unlockP2PKH());
tb.addOutput({ to: tote.tokenAddress, amount: to * RATE, token: { amount: to, category: st.cat } });
tb.addOutput({ to: w.cashaddr, amount: BigInt(pool.satoshis) + BigInt(stake.satoshis) + t - to * RATE - MF });
const txid = txidOf(await tb.send());
m.bets.push({ outcome, amount: Number(amt), txid, by: w.cashaddr.slice(-6) }); save(); await sleep(8000);
return txid;
}
async function settleMarket(marketId, outcome) {
const m = st.markets[marketId]; if (!m) throw new Error('no such market'); if (m.settled) throw new Error('already settled');
const w = outcome === 'yes' ? 0x00 : 0x01;
const ev = hx(m.gameId);
const { message, sig } = oracleSign(m.oraclePriv, ev, w);
const tote = toteFor(m);
let pp = await plainBch(C, MF + 1000n);
let tb = new TransactionBuilder({ provider });
for (const x of pp.p) tb.addInput(x, sigC.unlockP2PKH());
tb.addOpReturnOutput([TAG, '0x' + m.gameId, '0x' + (w === 0 ? '00' : '01'), '0x' + m.oraclePub, '0x' + bh(sig)]);
tb.addOutput({ to: C.cashaddr, amount: pp.t - MF });
const publishTx = txidOf(await tb.send()); await sleep(8000);
const pool = await poolUtxo(tote);
pp = await plainBch(C, MF + 1000n);
tb = new TransactionBuilder({ provider });
tb.setLocktime(Number(m.betDeadline));
tb.addInput(pool, tote.unlock.settle(sig, message, sigC));
for (const x of pp.p) tb.addInput(x, sigC.unlockP2PKH());
tb.addOutput({ to: A.tokenaddr, amount: BigInt(pool.token.amount) * RATE, token: { amount: BigInt(pool.token.amount), category: st.cat } });
tb.addOutput({ to: C.cashaddr, amount: BigInt(pool.satoshis) + pp.t - BigInt(pool.token.amount) * RATE - MF });
const settleTx = txidOf(await tb.send()); await sleep(9000);
m.settled = { outcome, publishTx, settleTx }; save();
return m.settled;
}
function sidesOf(m) { const s = { yes: m.seed.yes, no: m.seed.no }; for (const b of m.bets) s[b.outcome] += b.amount; return s; }
async function marketState(id) {
const m = st.markets[id]; if (!m) return null;
const tote = toteFor(m);
return { id, title: m.title, category: m.category, oraclePub: m.oraclePub, gameId: m.gameId, toteAddress: tote.tokenAddress, poolTotal: await poolTotal(tote), sides: sidesOf(m), bets: m.bets.slice(-8), settled: m.settled };
}
const listMarkets = async () => Promise.all(Object.keys(st.markets).map(marketState));
const wifCache = {};
async function walletFromWif(wif) { if (!wif) return { w: A, s: sigA }; if (!wifCache[wif]) { const w = await TestNetWallet.fromWIF(wif); wifCache[wif] = { w, s: new SignatureTemplate(w.privateKey) }; } return wifCache[wif]; }
async function walletInfo(w) { const u = await provider.getUtxos(w.cashaddr); let bch = 0, stake = 0; for (const x of u) { if (x.token?.category === st.cat && !x.token?.nft) stake += Number(x.token.amount); else if (!x.token) bch += Number(x.satoshis); } return { address: w.cashaddr, bch, stake }; }
async function genKey() { const w = await TestNetWallet.newRandom(); await C.send([{ cashaddr: w.cashaddr, value: 90000n, unit: 'sat' }]); await sleep(11000); return { wif: w.privateKeyWif, address: w.cashaddr }; }
const body = (req) => new Promise((res) => { let d = ''; req.on('data', (c) => (d += c)); req.on('end', () => res(d ? JSON.parse(d) : {})); });
const json = (r, code, obj) => { r.writeHead(code, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }); r.end(JSON.stringify(obj)); };
function embedPage(id) { return '<!DOCTYPE html><meta charset=utf-8><body style="margin:0;font-family:Inter,system-ui,sans-serif"><div id=e style="padding:14px"></div><script>const ID="' + id + '";async function R(){const m=await (await fetch("/api/markets/"+ID)).json();const t=m.poolTotal||1,sy=m.sides.yes,sn=m.sides.no;document.getElementById("e").innerHTML=\'<div style="border:1px solid #ddd;border-radius:10px;padding:14px;max-width:340px"><div style="font-weight:600;font-size:14px;margin-bottom:8px">\'+m.title+\'</div><div style="display:flex;gap:8px"><button onclick="B(0)" style="flex:1;padding:8px;border:1px solid #ccc;border-radius:8px;background:#f6f6f6;cursor:pointer">Yes <b>\'+(t/sy).toFixed(2)+\'×</b></button><button onclick="B(1)" style="flex:1;padding:8px;border:1px solid #ccc;border-radius:8px;background:#f6f6f6;cursor:pointer">No <b>\'+(t/sn).toFixed(2)+\'×</b></button></div><div style="font-size:11px;color:#888;margin-top:8px">pool \'+t+\' tBCH · powered by Deviant Tote</div></div>\';}window.B=async o=>{await fetch("/api/bet",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({marketId:ID,outcome:o?"no":"yes",amount:5})});R();};R();</script>'; }
async function main() {
A = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys.json'), 'utf8')).wif);
C = await TestNetWallet.fromWIF(JSON.parse(readFileSync(join(ROOT, '.chipnet-keys-c.json'), 'utf8')).wif);
provider = new ElectrumNetworkProvider('chipnet');
sigA = new SignatureTemplate(A.privateKey); sigC = new SignatureTemplate(C.privateKey);
await ensureMba();
if (Object.keys(st.markets).length === 0) {
console.log('[seed] creating launch football markets…');
await createMarket({ title: 'Mexico to beat South Africa? (WC2026 opener)', category: 'football' });
await createMarket({ title: 'Argentina to win World Cup 2026?', category: 'football' });
}
createServer(async (req, res) => {
try {
const u = (req.url || '').split('?')[0];
if (req.method === 'GET' && (u === '/' || u === '/index.html')) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(readFileSync(PAGE)); }
if (req.method === 'GET' && u.startsWith('/embed/')) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(embedPage(u.slice(7))); }
if (req.method === 'GET' && u === '/api/markets') return json(res, 200, await listMarkets());
if (req.method === 'GET' && u.startsWith('/api/markets/')) { const m = await marketState(u.slice(13)); return m ? json(res, 200, m) : json(res, 404, { error: 'no such market' }); }
if (req.method === 'POST' && u === '/api/markets') { const { title, category, deadlineBlocks } = await body(req); if (!title || title.length < 4) return json(res, 400, { error: 'title too short' }); const m = await createMarket({ title: title.slice(0, 90), category, deadlineBlocks }); return json(res, 200, await marketState(m.id)); }
if (req.method === 'POST' && u === '/api/genkey') return json(res, 200, await genKey());
if (req.method === 'POST' && u === '/api/connect') { const { wif } = await body(req); try { const { w } = await walletFromWif(wif); return json(res, 200, await walletInfo(w)); } catch (e) { return json(res, 400, { error: 'invalid WIF' }); } }
if (req.method === 'POST' && u === '/api/bet') { const { marketId, outcome, amount, wif } = await body(req); if (outcome !== 'yes' && outcome !== 'no') return json(res, 400, { error: 'outcome must be yes|no' }); const { w, s } = await walletFromWif(wif); const txid = await placeBet(marketId, outcome, amount || 5, w, s); return json(res, 200, { txid, ...(await marketState(marketId)) }); }
if (req.method === 'POST' && u === '/api/settle') { const { marketId, outcome } = await body(req); if (outcome !== 'yes' && outcome !== 'no') return json(res, 400, { error: 'outcome must be yes|no' }); const r = await settleMarket(marketId, outcome); return json(res, 200, { ...r, ...(await marketState(marketId)) }); }
json(res, 404, { error: 'not found' });
} catch (e) { console.error('ERR', e?.message ?? e); json(res, 500, { error: (e?.message ?? String(e)).slice(0, 200) }); }
}).listen(PORT, () => console.log(`[server] Tote platform on http://localhost:${PORT} · ${Object.keys(st.markets).length} markets`));
}
main().catch((e) => { console.error('FATAL', e?.message ?? e); process.exit(1); });

View file

@ -0,0 +1,56 @@
# Wallet work — cross-session coordination hub
Two Claude Code chats share this working tree. This file is the live status board
so we don't clobber each other. **Contract of record: `snapshots/0083-multi-wallet-direction-and-consolidate.md`.**
## Rules
1. **Contracts first.** The owning session writes the interface/spec (a snapshot);
the other implements to it *exactly*. Read the contract before coding.
2. **One owner per file.** Don't edit a file the other session owns while they're active.
3. **Log every substantive change here** (session, date, what, files).
## Ownership split (from the UI session's note)
- **UI session** — `screens/*`, `App.tsx` UI/flows, `components/*`, read-only features
(Sign/Verify, dropdowns, visual, Settings→Wallets placeholders). Deploys per change.
- **Keystore session** — `keystore/*`, `core/wallet.ts` — identity, multi-seed storage,
sealing. Anything that changes *how secrets are stored* crosses this seam.
## Status board
### Keystore (owner: keystore session)
- ✅ **DONE (2026-07-07)** — `MultiKeyStore` reworked to the **0083 all-in-one model**
(Bitcoin.com): `seeds{}` + `wallets{}`, kinds `independent` + `hd-account`, **shared
app PIN**, every wallet unlocked at once. `vault.ts` crypto reused. **tsc clean ·
149/149 vitest** (incl. the 0083 acceptance bar: 1 HD seed w/ 2 sub-accounts + 1
independent seed + 1 WIF coexisting + both migration paths). **API is stable — wire
against it.** File: `shells/app/src/keystore/MultiKeyStore.ts` (+ `vault.ts`, tests).
- **API the UI can wire against (final target):**
- `list(): WalletSummary[]` · `seeds(): SeedSummary[]`
- `unlockAll(secret): UnlockedAccount[]` — open every wallet (App renders all; no switcher)
- `importSeed({ mnemonic, secret, network, asMaster, label })` — tile 2 (master or independent)
- `importWif({ wif, secret, network, label })` — tile 3 (always independent)
- `addHdWallet(seedId, label?)` — tile 1 (+1 accountIndex on an existing seed; no new backup)
- `remove(id, secret)` / `removeSeed(seedId, secret)` / `rename(id, label)`
- legacy `KeyStore` still implemented (create/restore/unlock/getPublicIdentity → primary
wallet) so the current single-session boot keeps working until App migrates to `unlockAll`.
- App.tsx swap was **reverted** to WebKeyStore; UI session re-swaps to `new MultiKeyStore()`
when ready to adopt the all-in-one boot.
### UI (owner: UI session)
- ✅ Sign/Verify message (`SignMessageScreen`), wallet multi-select dropdown, topbar
WalletConnect icon, epigrams, starfield, ⇅ FAB — live on testchipnet.
- 🟡 Settings→Wallets: Add / Import / Consolidate = "Coming soon" placeholders,
waiting on the keystore API above.
- ✅ Currency picker + hero fiat (USD/EUR/+location, "+ add") — built by keystore session
in `App.tsx`/`MainScreen`/`components/CurrencyPicker`/`hooks/useFiatRates` (coordinate:
this touched UI-owned files — flag if it conflicts).
## Changelog
- 2026-07-06 — keystore session: built `vault.ts` + `MultiKeyStore` + tests; noted 0083
divergence; created this hub. Next: rework store to 0083 or await direction.
- 2026-07-07 — keystore session: **reworked `MultiKeyStore` to the 0083 all-in-one model**
(confirmed direction: Bitcoin.com — all wallets unlocked, HD sub-accounts + independent
seed/WIF, shared PIN). tsc + 149/149. API above is stable. **UI session next:** re-swap
App.tsx web keystore to `new MultiKeyStore()`, boot via `unlockAll(pin)` → render all
wallets, and wire Settings→Wallets (Add=`addHdWallet`/`importSeed`, Import WIF=`importWif`,
Consolidate=SweepFlow×N). Legacy `create/restore/unlock` still work for the existing boot.

View file

@ -18,6 +18,8 @@
"@reown/walletkit": "^1.2.10",
"@walletconnect/core": "^2.17.0",
"@walletconnect/sign-client": "2.17.0",
"@wizardconnect/core": "0.2.4",
"@wizardconnect/wallet": "0.2.2",
"capacitor-secure-storage-plugin": "^0.13.0",
"cashconnect": "0.0.25",
"jsqr": "^1.4.0",
@ -344,6 +346,15 @@
"node": ">=6.9.0"
}
},
"node_modules/@bch-wc2/interfaces": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@bch-wc2/interfaces/-/interfaces-0.0.8.tgz",
"integrity": "sha512-gRt95azHVqoViGI4X3F1ObgTpYBh3//KaCHRBWSMJbAKNM09T+TA/h9kx9u9SZmE8otMtpFSy4awSHWHUQZmbg==",
"license": "MIT",
"peerDependencies": {
"@bitauth/libauth": "^3.1.0-next.2"
}
},
"node_modules/@bitauth/libauth": {
"version": "3.1.0-next.8",
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
@ -3876,6 +3887,51 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/@wizardconnect/core": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@wizardconnect/core/-/core-0.2.4.tgz",
"integrity": "sha512-CHcm3YWEdZHGSH6+Ml2H8LE4HCe2DWKX6PbzvGIaExYg1LGbiTXABPoBWMhCOibcQFz3BIgjfcewP29M0JFrUw==",
"dependencies": {
"@bch-wc2/interfaces": "^0.0.8",
"@bitauth/libauth": "^3.1.0-next.2",
"eventemitter3": "^5.0.1",
"isomorphic-ws": "^5.0.0",
"lossless-json": "^4.3.0",
"nostr-tools": "^2.23.0",
"ws": "^8.18.0"
}
},
"node_modules/@wizardconnect/core/node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@wizardconnect/wallet": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@wizardconnect/wallet/-/wallet-0.2.2.tgz",
"integrity": "sha512-mP8xBSnzEDc2VKPQ2tWQfqXBCiyxL244VNhOYLAm3fXewPvs0mW18aPkfjjt9Cqca25/VeKLRtP37TzpG/t12A==",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",
"eventemitter3": "^5.0.1"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
@ -5656,6 +5712,15 @@
"unfetch": "^4.2.0"
}
},
"node_modules/isomorphic-ws": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
@ -6037,6 +6102,12 @@
"loose-envify": "cli.js"
}
},
"node_modules/lossless-json": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz",
"integrity": "sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -6299,6 +6370,110 @@
"node": ">=0.10.0"
}
},
"node_modules/nostr-tools": {
"version": "2.23.9",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.9.tgz",
"integrity": "sha512-PBN3TpGh+EszlTZWpQdvcqEMqhcKcY1vTw0Xkkccg+TyP7y/icu9/iXJw72aFZ/ETm35ehU0hneGTXLaTSkImw==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
"@scure/bip32": "2.0.1",
"@scure/bip39": "2.0.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-tools/node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",

View file

@ -32,6 +32,8 @@
"cashconnect": "0.0.25",
"capacitor-secure-storage-plugin": "^0.13.0",
"jsqr": "^1.4.0",
"@wizardconnect/core": "0.2.4",
"@wizardconnect/wallet": "0.2.2",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"

View file

@ -15,6 +15,8 @@ import {
} from './devWalletPicker';
import type { DevWallet } from './devWallets';
import { useAllDevBalances } from './hooks/useAllDevBalances';
import { useFiatRates } from './hooks/useFiatRates';
import { CurrencyPicker } from './components/CurrencyPicker';
import { WebKeyStore } from './keystore/WebKeyStore';
import { CapacitorKeyStore } from './keystore/CapacitorKeyStore';
import { TabBar, type TabId } from './components/TabBar';
@ -31,6 +33,7 @@ import { FuseFlow } from './screens/FuseFlow';
import { AssetDetail, type AssetDetailToken } from './screens/AssetDetail';
import { AsmCore, ORBITAL_MARKET } from './asm-core';
import { SweepFlow } from './screens/SweepFlow';
import { SignMessageScreen } from './screens/SignMessageScreen';
import { BetaBanner } from './components/BetaBanner';
import { MULTI_WALLET } from './buildInfo';
import { useConnect } from './connect/useConnect';
@ -51,6 +54,7 @@ type PushedScreen =
| { kind: 'swap' }
| { kind: 'fuse' }
| { kind: 'sweep' }
| { kind: 'sign' }
| { kind: 'asset-detail'; token: AssetDetailToken };
export function App() {
@ -269,7 +273,13 @@ export function App() {
// the AppsScreen UI simulation with live dApp pairing + co-sign. Re-runs only
// when the session/identity/network change; the WalletKit instance itself is
// a module-level singleton so pending sessions survive these changes.
const connect = useConnect(session, identity, network);
const connect = useConnect(session, identity, network, keyStore);
// Display currency for the hero fiat value (persisted) + live BCH→fiat feed.
const [currency, setCurrency] = useState<string>(() => localStorage.getItem('deviant.currency') || 'USD');
useEffect(() => { try { localStorage.setItem('deviant.currency', currency); } catch { /* ignore */ } }, [currency]);
const fiatRates = useFiatRates();
const bchFiatRate = fiatRates.rates?.[currency.toLowerCase()] ?? null;
// Render the pushed screen (overlays tab content; tab bar stays)
const renderPushed = (s: PushedScreen) => {
@ -299,6 +309,9 @@ export function App() {
onBack={popScreen}
/>
);
case 'sign':
if (!session) return null;
return <SignMessageScreen session={session} network={network} onBack={popScreen} />;
}
};
@ -330,6 +343,7 @@ export function App() {
onNetwork={changeNetwork}
onBack={() => setSettings(false)}
onLock={lock}
onSignMessage={() => { setSettings(false); setPushed({ kind: 'sign' }); }}
devWallets={MULTI_WALLET ? listDevWallets() : []}
activeDev={activeDev}
onSwitchDev={switchToDev}
@ -340,17 +354,29 @@ export function App() {
<div className="wordmark">Deviant<span className="acid-dot" /></div>
{/* Multi-wallet identity moved off the main surface → Settings → Wallets
(glossary "Shell UX rules"); the dev quick-switch is __DEV__-only. */}
<button className="gear" aria-label="Settings" onClick={() => setSettings(true)}>⚙</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CurrencyPicker value={currency} onChange={setCurrency} />
<button
className="gear"
aria-label="WalletConnect (scan or paste URI)"
onClick={() => openAppsConnect('')}
title="WalletConnect"
>⛶</button>
<button className="gear" aria-label="Settings" onClick={() => setSettings(true)}>⚙</button>
</div>
</header>
<MainScreen
balance={balance}
loading={loading}
error={error}
session={session}
currency={currency}
bchFiatRate={bchFiatRate}
onActivity={() => setPushed({ kind: 'activity' })}
onFuse={() => setPushed({ kind: 'fuse' })}
onSweep={() => setPushed({ kind: 'sweep' })}
onWalletConnect={() => openAppsConnect('')}
onSign={() => setPushed({ kind: 'sign' })}
onViewAgora={() => goTab('agora')}
aggregate={MULTI_WALLET && aggregateEnabled ? aggregate : null}
activeDev={activeDev}

View file

@ -0,0 +1,117 @@
import { useEffect, useMemo, useState } from 'react';
import { CURRENCIES } from '../hooks/useFiatRates';
// CurrencyPicker — a short favourites list (location currency + two majors) with a
// "+ add" affordance to pull more from the full CoinGecko-backed pool. The active
// currency lives in App (value/onChange); the favourites list is owned + persisted
// here so the topbar stays a single small control.
const LIST_KEY = 'deviant.currencyList';
// Region → currency for the location-based default (best-effort subset; eurozone
// members map to EUR). Unknown regions fall back to the USD/EUR base.
const REGION_CCY: Record<string, string> = {
TR: 'TRY', US: 'USD', GB: 'GBP', JP: 'JPY', CN: 'CNY', IN: 'INR', CA: 'CAD', AU: 'AUD',
BR: 'BRL', CH: 'CHF', KR: 'KRW', RU: 'RUB', MX: 'MXN', ZA: 'ZAR', AE: 'AED', SA: 'SAR',
ID: 'IDR', PL: 'PLN', SE: 'SEK', NO: 'NOK', DK: 'DKK', TH: 'THB', HK: 'HKD', SG: 'SGD',
NZ: 'NZD', PH: 'PHP', MY: 'MYR', TW: 'TWD', IL: 'ILS', CZ: 'CZK', HU: 'HUF', NG: 'NGN',
VN: 'VND', UA: 'UAH', PK: 'PKR',
DE: 'EUR', FR: 'EUR', ES: 'EUR', IT: 'EUR', NL: 'EUR', IE: 'EUR', PT: 'EUR', GR: 'EUR',
AT: 'EUR', BE: 'EUR', FI: 'EUR',
};
function localCurrency(): string | null {
try {
const region = new Intl.Locale(navigator.language).maximize().region;
return (region && REGION_CCY[region]) || null;
} catch {
return null;
}
}
function defaultFavourites(): string[] {
const loc = localCurrency();
const base = loc && loc !== 'USD' && loc !== 'EUR' ? [loc, 'USD', 'EUR'] : ['USD', 'EUR'];
return [...new Set(base)].filter((c) => (CURRENCIES as readonly string[]).includes(c));
}
export function CurrencyPicker({ value, onChange }: { value: string; onChange: (c: string) => void }) {
const [list, setList] = useState<string[]>(() => {
try {
const s = localStorage.getItem(LIST_KEY);
if (s) return [...new Set(JSON.parse(s) as string[])];
} catch { /* ignore */ }
return defaultFavourites();
});
const [open, setOpen] = useState(false);
const [adding, setAdding] = useState(false);
useEffect(() => { try { localStorage.setItem(LIST_KEY, JSON.stringify(list)); } catch { /* ignore */ } }, [list]);
// Keep the active currency present in the favourites list. Guard inside the
// updater (not the stale closure) so StrictMode's double-invoke can't duplicate.
useEffect(() => { setList((l) => (value && !l.includes(value) ? [...l, value] : l)); }, [value]);
const addable = useMemo(
() => (CURRENCIES as readonly string[]).filter((c) => !list.includes(c)),
[list],
);
const close = () => { setOpen(false); setAdding(false); };
return (
<div className="cur-wrap">
<button
className="cur-picker"
onClick={() => { setOpen((o) => !o); setAdding(false); }}
aria-haspopup="listbox"
aria-expanded={open}
title="Display currency"
>
{value} <span className="cur-caret">{open ? '▴' : '▾'}</span>
</button>
{open && (
<>
<div className="cur-backdrop" onClick={close} />
<div className="cur-menu" role="listbox">
{!adding ? (
<>
{list.map((c) => (
<button
key={c}
className={`cur-item${c === value ? ' on' : ''}`}
onClick={() => { onChange(c); close(); }}
role="option"
aria-selected={c === value}
>
{c}
{list.length > 1 && (
<span
className="cur-rm"
title={`Remove ${c}`}
onClick={(e) => { e.stopPropagation(); setList((l) => l.filter((x) => x !== c)); if (value === c) onChange(list.find((x) => x !== c) ?? 'USD'); }}
>×</span>
)}
</button>
))}
<button className="cur-item add" onClick={() => setAdding(true)}>+ add</button>
</>
) : (
<>
<div className="cur-head">Add currency</div>
<div className="cur-add-grid">
{addable.map((c) => (
<button
key={c}
className="cur-item"
onClick={() => { setList((l) => [...l, c]); onChange(c); close(); }}
>{c}</button>
))}
</div>
</>
)}
</div>
</>
)}
</div>
);
}

View file

@ -3,14 +3,58 @@ import type { CoreSession, ChipnetTokenFile } from '../core/wallet';
import { registerToken } from '../core/wallet';
import type { TokenTierA } from '@core/token/types.js';
// Token import modal — paste a token descriptor JSON (chip-net / mainnet format)
// to register a token into the persistent LocalStorageTokenRegistry.
// Called from Settings > Token Definitions.
// Add-asset sheet — register a token into the persistent LocalStorageTokenRegistry.
// Two modes share one validator + the same registerToken() plumbing:
// • Form — friendly field entry (name, category, rate, fee, founder…)
// • Paste — drop the token's JSON descriptor (the .chipnet-*-token.json artifact)
//
// After registration, the token appears in:
// - Assets tab (if the wallet holds units of it)
// - Swap flow From/To pickers (both sides use the registry)
// - ElevationFlow target contracts
// Reached from Agora → Metroon ("+ Add asset") and Settings → Token Definitions.
// After registration the asset appears in Metroon, the swap From/To pickers, and
// ElevationFlow targets — every surface reads the same registry.
type Mode = 'form' | 'json';
// Blank form state — keys mirror ChipnetTokenFile fields.
const EMPTY = {
name: '', ticker: '', categoryId: '', rateSats: '', founderFeeBps: '',
contractTokenAddress: '', founderLockingBytecodeHex: '',
contractAddress: '', founderAddress: '', genesisTxId: '',
};
// The registry's validateToken() requires BOTH contract addresses + a founder fee
// in 0..500 bps for tier-A, so the sheet enforces the same set up front (friendly
// message) rather than letting registry.add() throw a terse error.
const REQUIRED = ['categoryId', 'contractAddress', 'contractTokenAddress', 'rateSats', 'founderFeeBps', 'founderLockingBytecodeHex', 'name'];
// Module-scope so its identity is stable across parent re-renders — a component
// defined inside render() would remount on every keystroke and drop input focus.
function Field({
label, value, onChange, placeholder, req,
}: {
label: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder: string;
req?: boolean;
}) {
return (
<div style={{ marginBottom: 10 }}>
<label className="label-mono" style={{ display: 'block', marginBottom: 5 }}>
{label}{req && <span style={{ color: 'var(--acid)' }}> *</span>}
</label>
<input
className="input"
style={{ fontSize: 12 }}
value={value}
onChange={onChange}
placeholder={placeholder}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
/>
</div>
);
}
export function TokenImportModal({
session,
@ -21,11 +65,68 @@ export function TokenImportModal({
onClose: () => void;
onImported: (token: TokenTierA) => void;
}) {
const [mode, setMode] = useState<Mode>('form');
const [f, setF] = useState({ ...EMPTY });
const [raw, setRaw] = useState('');
const [error, setError] = useState<string | null>(null);
const [imported, setImported] = useState<TokenTierA | null>(null);
const validate = (): ChipnetTokenFile | null => {
const setField = (k: keyof typeof EMPTY) => (e: React.ChangeEvent<HTMLInputElement>) => {
setF((prev) => ({ ...prev, [k]: e.target.value }));
setError(null);
};
// One validator for both modes — returns a ChipnetTokenFile or sets an error.
const validateObj = (obj: Record<string, unknown>): ChipnetTokenFile | null => {
const missing = REQUIRED.filter((k) => {
const v = obj[k];
return v === undefined || v === null || String(v).trim() === '';
});
if (missing.length > 0) {
setError(`Missing required fields: ${missing.join(', ')}`);
return null;
}
if (typeof obj.categoryId !== 'string' || obj.categoryId.length !== 64) {
setError('categoryId must be a 64-character hex string (the genesis txid).');
return null;
}
if (!/^[0-9a-fA-F]{64}$/.test(obj.categoryId)) {
setError('categoryId must be hexadecimal.');
return null;
}
if (!/^\d+$/.test(String(obj.rateSats).trim())) {
setError('rateSats must be a whole number (backing sats per unit).');
return null;
}
if (!/^\d+$/.test(String(obj.founderFeeBps).trim())) {
setError('founderFeeBps must be a whole number (basis points, e.g. 100 = 1%).');
return null;
}
if (Number(obj.founderFeeBps) > 500) {
setError('founderFeeBps must be 0..500 (protocol cap is 5%).');
return null;
}
return obj as unknown as ChipnetTokenFile;
};
const buildFromForm = (): ChipnetTokenFile | null => {
const obj: Record<string, unknown> = {
network: 'chipnet',
name: f.name.trim(),
rateSats: f.rateSats.trim(),
founderFeeBps: f.founderFeeBps.trim(),
categoryId: f.categoryId.trim().toLowerCase(),
contractTokenAddress: f.contractTokenAddress.trim(),
founderLockingBytecodeHex: f.founderLockingBytecodeHex.trim(),
};
if (f.ticker.trim()) obj.ticker = f.ticker.trim();
if (f.contractAddress.trim()) obj.contractAddress = f.contractAddress.trim();
if (f.founderAddress.trim()) obj.founderAddress = f.founderAddress.trim();
if (f.genesisTxId.trim()) obj.genesisTxId = f.genesisTxId.trim();
return validateObj(obj);
};
const parseJson = (): ChipnetTokenFile | null => {
let obj: Record<string, unknown>;
try {
obj = JSON.parse(raw.trim());
@ -33,23 +134,12 @@ export function TokenImportModal({
setError('Invalid JSON — paste the full token descriptor object.');
return null;
}
const required = ['categoryId', 'contractTokenAddress', 'rateSats', 'founderFeeBps', 'founderLockingBytecodeHex', 'name'];
const missing = required.filter((k) => !(k in obj));
if (missing.length > 0) {
setError(`Missing required fields: ${missing.join(', ')}`);
return null;
}
// Minimal type check
if (typeof obj.categoryId !== 'string' || obj.categoryId.length !== 64) {
setError('categoryId must be a 64-character hex string.');
return null;
}
return obj as unknown as ChipnetTokenFile;
return validateObj(obj);
};
const handleImport = () => {
const handleRegister = () => {
setError(null);
const file = validate();
const file = mode === 'form' ? buildFromForm() : parseJson();
if (!file) return;
try {
const token = registerToken(session, file);
@ -60,11 +150,12 @@ export function TokenImportModal({
}
};
// ── success state ──────────────────────────────────────────────────────────
if (imported) {
return (
<div className="modal">
<div className="flow-head">
<div className="flow-title">Token registered</div>
<div className="flow-title">Asset registered</div>
</div>
<div className="result-wrap" style={{ paddingTop: 28 }}>
<div className="tick">✓</div>
@ -74,6 +165,7 @@ export function TokenImportModal({
</div>
<div className="rev-note" style={{ marginTop: 16 }}>
Rate: {imported.rateSats.toString()} sat/unit &middot; fee: {imported.founderFeeBps / 100}%
<br />Now visible in Metroon and the swap pickers.
</div>
</div>
<button className="cta" onClick={onClose}>Done</button>
@ -81,45 +173,73 @@ export function TokenImportModal({
);
}
const canSubmit = mode === 'form'
? f.name.trim() !== '' && f.categoryId.trim() !== ''
: raw.trim().length >= 10;
return (
<div className="modal">
<div className="flow-head">
<button className="back" onClick={onClose}>←</button>
<div className="flow-title">Import token</div>
<div className="flow-title">Add asset</div>
</div>
<div className="rev-note" style={{ margin: '0 0 14px' }}>
Paste the token&apos;s JSON descriptor (the <code>.chipnet-*-token.json</code> file or
the equivalent mainnet artifact). Required fields: <code>categoryId</code>,{' '}
<code>contractTokenAddress</code>, <code>rateSats</code>, <code>founderFeeBps</code>,{' '}
<code>founderLockingBytecodeHex</code>, <code>name</code>.
<div className="segmented" style={{ marginTop: 4 }}>
<button className={`seg${mode === 'form' ? ' active' : ''}`} onClick={() => { setMode('form'); setError(null); }}>Form</button>
<button className={`seg${mode === 'json' ? ' active' : ''}`} onClick={() => { setMode('json'); setError(null); }}>Paste JSON</button>
</div>
<textarea
className="input"
style={{ minHeight: 160, fontFamily: 'var(--font-mono)', fontSize: 11, resize: 'vertical' }}
placeholder={'{\n "name": "ASM",\n "categoryId": "09af8a…",\n "rateSats": "100",\n ...\n}'}
value={raw}
onChange={(e) => { setRaw(e.target.value); setError(null); }}
spellCheck={false}
/>
{error && (
<div className="seed-warn" style={{ marginTop: 10 }}>⚠ {error}</div>
{mode === 'form' ? (
<>
<div className="rev-note" style={{ margin: '0 0 12px' }}>
Register a backed asset (DEA-class) into your Metroon directory. Fields marked
<span style={{ color: 'var(--acid)' }}> *</span> are required. Get them from the issuer's
genesis artifact — the wallet can't attest them for you (ADR-0008).
</div>
<Field label="Name" value={f.name} onChange={setField('name')} placeholder="e.g. ASM3" req />
<Field label="Ticker (optional — defaults to name)" value={f.ticker} onChange={setField('ticker')} placeholder="ASM3" />
<Field label="Token category (genesis txid, 64 hex)" value={f.categoryId} onChange={setField('categoryId')} placeholder="09af8a5b…75d67231" req />
<Field label="Backing rate (sats per unit)" value={f.rateSats} onChange={setField('rateSats')} placeholder="100" req />
<Field label="Founder fee (bps · 100 = 1%)" value={f.founderFeeBps} onChange={setField('founderFeeBps')} placeholder="100" req />
<Field label="Contract token address (r-prefix, token-aware)" value={f.contractTokenAddress} onChange={setField('contractTokenAddress')} placeholder="bchtest:rw8ypez…" req />
<Field label="Contract address (p-prefix, non-token)" value={f.contractAddress} onChange={setField('contractAddress')} placeholder="bchtest:pw8ypez…" req />
<Field label="Founder locking bytecode (hex)" value={f.founderLockingBytecodeHex} onChange={setField('founderLockingBytecodeHex')} placeholder="76a914…88ac" req />
<div className="rev-note" style={{ margin: '2px 0 8px', opacity: 0.8 }}>Optional — improves display &amp; auditability:</div>
<Field label="Founder address" value={f.founderAddress} onChange={setField('founderAddress')} placeholder="bchtest:qzd2dw…" />
<Field label="Genesis txid" value={f.genesisTxId} onChange={setField('genesisTxId')} placeholder="0c0cd6f0…" />
</>
) : (
<>
<div className="rev-note" style={{ margin: '0 0 12px' }}>
Paste the token&apos;s JSON descriptor (a <code>.chipnet-*-token.json</code> artifact or the
mainnet equivalent). Required: <code>name</code>, <code>categoryId</code>, <code>rateSats</code>,{' '}
<code>founderFeeBps</code>, <code>contractTokenAddress</code>, <code>founderLockingBytecodeHex</code>.
</div>
<textarea
className="input"
style={{ minHeight: 180, fontFamily: 'var(--font-mono)', fontSize: 11, resize: 'vertical' }}
placeholder={'{\n "name": "ASM3",\n "categoryId": "09af8a…",\n "rateSats": "100",\n "founderFeeBps": "100",\n "contractTokenAddress": "bchtest:rw8ypez…",\n "founderLockingBytecodeHex": "76a914…88ac"\n}'}
value={raw}
onChange={(e) => { setRaw(e.target.value); setError(null); }}
spellCheck={false}
/>
</>
)}
{error && <div className="seed-warn" style={{ marginTop: 10 }}>⚠ {error}</div>}
<button
className={`cta${raw.trim().length < 10 ? ' disabled' : ''}`}
disabled={raw.trim().length < 10}
className={`cta${canSubmit ? '' : ' disabled'}`}
disabled={!canSubmit}
style={{ marginTop: 14 }}
onClick={handleImport}
onClick={handleRegister}
>
Register token
Register asset
</button>
<div className="rev-note" style={{ marginTop: 14, fontSize: 10 }}>
ADR-0008: Deviant ships no default registries. Every token you register is
your own decision — verify the descriptor from the issuer directly.
ADR-0008: Deviant ships no default registries. Every asset you register is your own
decision — verify the descriptor from the issuer directly.
</div>
</div>
);

View file

@ -0,0 +1,215 @@
/**
* cc-network.ts — real NetworkProvider callbacks for the CashConnect handler.
*
* Replaces the useConnect.ts stubs that previously threw on getSourceOutput
* and returned [] on getUnspents. Wires against the shell's existing
* cashscript `ElectrumNetworkProvider` and the active CoreSession.
*
* Two callbacks the cashconnect package's `CashRPC_V0` needs:
*
* 1. `getSourceOutput(chain, txHash, outpointIndex) → libauth Output`
* — resolves the source output an input references, so the wallet's
* VM verify can check spend authorization. We fetch the raw tx via
* Electrum, decode it with libauth, and return output[outpointIndex].
* Per-tx caching so a tx with N inputs from the same source only
* fetches once.
*
* 2. `getUnspents(chain) → cashconnect.Unspent[]`
* — the wallet's own UTXOs the dApp can request. We map cashscript
* UTXOs to the libauth-template-scoped shape cashconnect expects,
* binding libauth's canonical P2PKH template + the session's private
* key so the wallet can sign at request time.
*
* Byte-order convention: cashconnect passes `outpointTransactionHash` in
* libauth internal order (Bitcoin's on-wire little-endian, NOT the
* display-format big-endian hex). We reverse before handing to Electrum
* (which expects display hex) and reverse back when constructing the
* Unspent's outpoint. Getting this wrong = source outputs from the wrong
* transactions, silently corrupting VM verify.
*/
import {
binToHex,
hexToBin,
decodeTransactionBCH,
walletTemplateP2pkhNonHd,
} from '@bitauth/libauth';
import type { ElectrumNetworkProvider } from 'cashscript';
import type { NetworkProvider } from './responder';
import type { BchChain } from './index';
import { p2pkhLockingBytecode } from './signer';
/** The libauth Output shape cashconnect's `getSourceOutput` returns. Kept
* local so this module doesn't leak libauth's internal types up to the
* responder surface. */
interface LibauthOutput {
valueSatoshis: bigint;
lockingBytecode: Uint8Array;
token?: {
amount: bigint;
category: Uint8Array;
nft?: {
capability: 'none' | 'mutable' | 'minting';
commitment: Uint8Array;
};
};
}
/** Reverse a Uint8Array — Bitcoin's txid byte-order flip between wire
* (little-endian) and display (big-endian). Non-mutating. */
function reverse(bin: Uint8Array): Uint8Array {
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin[bin.length - 1 - i]!;
return out;
}
// ─── getSourceOutput ────────────────────────────────────────────────────
/** Build a `getSourceOutput` callback closed over the session's Electrum
* provider. Includes a small per-invocation cache so a batch of inputs
* from the same source tx only triggers one Electrum fetch. */
export function buildGetSourceOutput(
provider: ElectrumNetworkProvider,
): (chain: BchChain, txHash: Uint8Array, outpointIndex: number) => Promise<LibauthOutput> {
const cache = new Map<string, ReturnType<typeof decodeTransactionBCH>>();
return async (_chain, txHash, outpointIndex) => {
// Convert internal-order bytes → display-order hex (Electrum's txid format).
const displayTxid = binToHex(reverse(txHash));
let decoded = cache.get(displayTxid);
if (!decoded) {
const rawHex = await provider.getRawTransaction(displayTxid);
if (!rawHex) {
throw new Error(`getSourceOutput: transaction ${displayTxid} not found`);
}
decoded = decodeTransactionBCH(hexToBin(rawHex));
cache.set(displayTxid, decoded);
}
if (typeof decoded === 'string') {
throw new Error(`getSourceOutput: failed to decode ${displayTxid}: ${decoded}`);
}
const output = decoded.outputs[outpointIndex];
if (!output) {
throw new Error(`getSourceOutput: ${displayTxid} has no output at index ${outpointIndex}`);
}
// libauth decodeTransactionBCH already yields the { valueSatoshis,
// lockingBytecode, token? } shape cashconnect expects.
return output as LibauthOutput;
};
}
// ─── getUnspents ────────────────────────────────────────────────────────
/** cashscript UTXO shape — just the subset we care about. */
interface CashscriptUtxo {
readonly txid: string; // display-order hex
readonly vout: number;
readonly satoshis: bigint | number;
readonly token?: {
readonly amount: bigint | number;
readonly category: string; // display-order hex
readonly nft?: {
readonly capability: 'none' | 'mutable' | 'minting';
readonly commitment: string; // hex
};
};
}
/** Cashconnect Unspent shape — mirrors the type exported from
* `cashconnect/dist/index.d.ts` (kept as `unknown` in the callback type
* so we don't hard-couple to the SDK from this module). */
export interface CashConnectUnspent {
outpointTransactionHash: Uint8Array;
outpointIndex: number;
sequenceNumber: number;
unlockingBytecode: {
template: unknown; // libauth WalletTemplate
data: { keys: { privateKeys: { key: Uint8Array } } };
script: string;
valueSatoshis: bigint;
token?: LibauthOutput['token'];
};
lockingBytecode: Uint8Array;
}
export interface UnspentsContext {
/** cashscript provider (Electrum-backed) to fetch UTXOs. */
provider: ElectrumNetworkProvider;
/** The addresses to fetch UTXOs from — typically both the plain cashaddr
* and the tokenAddress; we dedupe by (txid, vout). */
addresses: readonly string[];
/** The wallet's 20-byte pubkey hash — used to construct the canonical
* P2PKH locking bytecode for each Unspent without an extra address
* decoding step. Match against `signer.ts`'s hash160(publicKey). */
pubkeyHash: Uint8Array;
/** Private key to bind into each Unspent's template data — cashconnect's
* wallet-side handler uses this at signing time. */
privateKey: Uint8Array;
}
/** Build a `getUnspents` callback that closes over the session's provider,
* addresses, and signing key. */
export function buildGetUnspents(
ctx: UnspentsContext,
): (chain: BchChain) => Promise<CashConnectUnspent[]> {
return async (_chain) => {
// Fetch per-address, dedupe by (txid:vout) — the same UTXO can appear at
// both cashaddr and tokenAddress when the address happens to match.
const seen = new Set<string>();
const allUtxos: CashscriptUtxo[] = [];
for (const addr of ctx.addresses) {
const rows = (await ctx.provider.getUtxos(addr)) as unknown as CashscriptUtxo[];
for (const u of rows) {
const k = `${u.txid}:${u.vout}`;
if (seen.has(k)) continue;
seen.add(k);
allUtxos.push(u);
}
}
// Precompute the canonical P2PKH locking script once — every UTXO the
// wallet holds spends TO this address, so they all share it.
const lockingBytecode = p2pkhLockingBytecode(ctx.pubkeyHash);
return allUtxos.map((u) => {
const outpointHash = reverse(hexToBin(u.txid)); // display → internal
const token = u.token
? {
amount: BigInt(u.token.amount),
category: reverse(hexToBin(u.token.category)),
nft: u.token.nft
? {
capability: u.token.nft.capability,
commitment: hexToBin(u.token.nft.commitment),
}
: undefined,
}
: undefined;
return {
outpointTransactionHash: outpointHash,
outpointIndex: u.vout,
sequenceNumber: 0xffffffff,
unlockingBytecode: {
template: walletTemplateP2pkhNonHd,
data: { keys: { privateKeys: { key: ctx.privateKey } } },
script: 'unlock',
valueSatoshis: BigInt(u.satoshis),
token,
},
lockingBytecode,
};
});
};
}
// ─── convenience: full NetworkProvider builder ──────────────────────────
/** One-shot builder that assembles a real NetworkProvider from the session
* context. Called by `useConnect.ts` in place of the stub object. */
export function buildCcNetworkProvider(ctx: UnspentsContext): NetworkProvider {
return {
getSourceOutput: buildGetSourceOutput(ctx.provider),
getUnspents: buildGetUnspents(ctx),
};
}

View file

@ -30,7 +30,7 @@
*/
import { CC_PROTOCOL } from './bch-namespace';
export type Protocol = 'wc' | 'cc';
export type Protocol = 'wc' | 'cc' | 'wz';
/** Subset of a WC v2 session proposal that's enough to classify. We accept either
* the full proposal event from `@reown/walletkit` or just its
@ -60,9 +60,17 @@ export function classifyRequest(method: string): Protocol {
/** Classify a pairing URI by its scheme prefix. Returns `null` on unknown shapes,
* signalling that the caller should fall back to proposal-shape classification
* once a session_proposal arrives. */
* once a session_proposal arrives.
*
* WizardConnect URIs are `wiz://?p=<pubkey_bech32>&s=<secret_bech32>` (default
* relay) or `wiz://<hostname>:<port>?p=…&s=…&pr=…` (custom relay). The
* QR-alphanumeric-safe form uses uppercase + percent-encoded punctuation
* (`WIZ://%3FP%3D…%26S%3D…`) — same scheme, different encoding — so we lowercase
* before matching. Pinned from docs.riftenlabs.com/wizardconnect/connection-uri
* and `@wizardconnect/core@0.2.4/key-exchange.js:encodeKeyExchangeURI`. */
export function classifyUri(uri: string): Protocol | null {
const t = uri.trim();
const t = uri.trim().toLowerCase();
if (t.startsWith('wiz:')) return 'wz';
if (t.startsWith('cc:') || t.startsWith('cashconnect:') || t.startsWith('bchconnect:')) {
return 'cc';
}

View file

@ -47,7 +47,11 @@ export type BchMethod =
export type BchAccount = `${BchChain}:${string}`;
/** Decoded form of an incoming `bch_signTransaction` after libauth
* `parseExtendedJson`. Mirror of cashscript's `WcTransactionObject`. */
* `parseExtendedJson`. Mirror of cashscript's `WcTransactionObject`.
*
* Extended with WizardConnect's `inputPaths` (docs.riftenlabs.com — the
* `hdwalletv1.js:SignTransactionRequest.inputPaths` field). Optional at this
* interface — WC requests don't include it; WZ requests do. */
export interface BchSignTxRequest {
/** Decoded transaction (libauth `TransactionCommon`). Re-encode with
* `encodeTransaction` after signing the user's inputs. */
@ -58,6 +62,11 @@ export interface BchSignTxRequest {
readonly broadcast?: boolean;
/** Human-readable prompt the wallet must render verbatim. */
readonly userPrompt?: string;
/** WizardConnect: per-input HD key mapping. Each triplet
* `[inputIndex, pathName, addressIndex]` tells the wallet which HD child
* key to sign that input with. Inputs NOT in this list fall back to the
* active account key (`KeyProvider.identity().publicKey`). */
readonly inputPaths?: ReadonlyArray<readonly [number, string, number]>;
}
/** Decoded form of `bch_signMessage`. */
@ -146,13 +155,34 @@ export interface Responder {
// ── Signer surface (implemented in signer.ts) ────────────────────────────────
/** What the libauth-based co-signer needs to sign one tx. */
/** What the libauth-based co-signer needs to sign one tx.
*
* Two auth models:
* - **WalletConnect (default):** the wallet has one active-account key.
* Every input we sign must be spend from that key's P2PKH address.
* - **WizardConnect:** `request.inputPaths` specifies per-input HD child
* keys. `deriveForInput` is the seam back to `KeyStore.deriveChildKey`;
* `sighashType` may override the default `SIGHASH_ALL | SIGHASH_FORKID`
* (0x41) to WizardConnect's required `… | SIGHASH_UTXOS` (0x61). */
export interface CoSignInput {
readonly request: BchSignTxRequest;
readonly ownPubkeyHash: Uint8Array;
readonly ownPublicKey: Uint8Array;
readonly privateKey: Uint8Array;
readonly network: 'chipnet' | 'testnet4' | 'mainnet';
/** WizardConnect: derive the HD child key for a `(pathName, addressIndex)`
* triplet from `request.inputPaths`. Returns `null` if the wallet doesn't
* own that path (structural non-custody). */
readonly deriveForInput?: (pathName: string, addressIndex: number) => Promise<{
readonly privateKey: Uint8Array;
readonly publicKey: Uint8Array;
readonly pubkeyHash: Uint8Array;
} | null>;
/** Override the sighash-type byte. Default is `SIGHASH_ALL | SIGHASH_FORKID`
* (0x41) — WalletConnect's canonical byte. WizardConnect requires the
* additional `SIGHASH_UTXOS` (0x20), so `sighashType = 0x61`. The signer
* rejects any other value. */
readonly sighashType?: number;
}
/** The signing function shape. The runtime implementation (`signer.ts`

View file

@ -36,9 +36,11 @@ import {
SUPPORTED_CHAINS,
WC_METHODS,
} from './bch-namespace';
import { classifyProposal, classifyRequest, type Protocol } from './classifier';
import { classifyProposal, classifyRequest, classifyUri, type Protocol } from './classifier';
import { handleGetAddresses } from './wc-handler';
import { buildCcSession, type CcSession } from './cc-handler';
import type { WzHandler } from './wz-handler';
import type { PendingSignRequest, RelayConnectionState } from '@wizardconnect/wallet';
import type {
AccountIdentity,
BchChain,
@ -89,11 +91,15 @@ interface ActiveSession {
readonly peer: SessionProposal['dApp'];
/** Present iff `protocol === 'cc'`. */
readonly cc?: CcSession;
/** Present iff `protocol === 'wz'`. WizardConnect uses a synthetic topic
* `wz:${connectionId}` since Nostr doesn't have WC2's session-topic concept. */
readonly wzConnectionId?: string;
}
/** Init configuration. The shell provides the `WalletKit` instance (already
* initialised against the relay) so the responder doesn't own the projectId or
* the WC2 socket lifecycle. */
* the WC2 socket lifecycle. `wzHandler` is optional — when present, the
* responder unifies WizardConnect sign requests into the same UI event stream. */
export interface ResponderConfig {
readonly walletKit: WalletKitInstance;
readonly key: KeyProvider;
@ -101,12 +107,20 @@ export interface ResponderConfig {
readonly events: ResponderEvents;
/** If set, restrict approvals to these chains (e.g. chipnet-only beta builds). */
readonly allowedChains?: readonly BchChain[];
/** WizardConnect handler (Nostr transport). When absent, `wiz://` URIs
* handed to `pair()` are rejected with a clear "WizardConnect not enabled"
* error. When present, WZ pairings + sign requests flow through the same
* UI events as WC/CC (see `SignRequestPreview` `sessionId` field — WZ
* sessions use `wz:${connectionId}`). The actual signing (with
* `SIGHASH_UTXOS` + KeyStore-backed HD derivation) lives in
* `useConnect.approveSignRequest`, not here. */
readonly wzHandler?: WzHandler;
}
/** Construct the responder. The returned object is the public surface the UI
* (`AppsScreen.tsx`) calls; the WalletKit listeners are wired internally. */
export function createResponder(config: ResponderConfig): Responder {
const { walletKit, key, network, events } = config;
const { walletKit, key, network, events, wzHandler } = config;
const allowedChains = config.allowedChains ?? SUPPORTED_CHAINS;
const sessions = new Map<string, ActiveSession>();
@ -115,6 +129,11 @@ export function createResponder(config: ResponderConfig): Responder {
const pending = new Map<number, { topic: string; resolve: (v: unknown) => void; reject: (e: Error) => void }>();
/** Pending session proposals keyed by proposal id, awaiting user approval. */
const proposals = new Map<number, { protocol: Protocol; chain: BchChain; raw: WalletKitTypes.SessionProposal }>();
/** WizardConnect: (requestId → wz connection + sequence). WZ requests get
* synthetic requestIds from a counter (Nostr doesn't have WC2's per-request
* id) so we can look up the wz handler call on approve/reject. */
const wzPending = new Map<number, { connectionId: string; sequence: number }>();
let wzRequestCounter = 1_000_000; // start high to avoid collision with WC ids (which start small)
// ─── session_proposal ──────────────────────────────────────────────────
walletKit.on('session_proposal', (proposal: WalletKitTypes.SessionProposal) => {
@ -214,6 +233,68 @@ export function createResponder(config: ResponderConfig): Responder {
events.onSessionDelete(topic);
});
// ─── WizardConnect events (when the wzHandler is provided) ─────────────
if (wzHandler) {
wzHandler.on('pendingSignRequest', (wzReq: PendingSignRequest) => {
// Auto-track the WZ session on first request. Nostr's handshake happens
// silently at connect() — there's no session_proposal event — so we
// manifest the session here from the first incoming sign request.
const topic = `wz:${wzReq.connectionId}`;
if (!sessions.has(topic)) {
// Peer identity comes from the manager's connection state.
const conn = wzHandler
.listConnections()
.find((c: RelayConnectionState) => c.id === wzReq.connectionId);
sessions.set(topic, {
topic,
protocol: 'wz',
chain: 'bch:bchtest', // WZ SignTx doesn't carry a chain — assume current network; wire from config in a follow-up.
peer: {
name: conn?.dappName || conn?.label || 'WizardConnect dApp',
url: '',
icons: conn?.dappIcon ? [conn.dappIcon] : [],
},
wzConnectionId: wzReq.connectionId,
});
}
const session = sessions.get(topic)!;
// Reserve a synthetic requestId so approve/reject can find this request.
const requestId = ++wzRequestCounter;
wzPending.set(requestId, {
connectionId: wzReq.connectionId,
sequence: wzReq.request.sequence,
});
// Surface as a SignRequestPreview to the UI, same shape as WC/CC.
const preview: SignRequestPreview = {
kind: 'sign-tx',
id: requestId,
sessionId: topic,
appName: session.peer.name,
request: {
transaction: wzReq.request.transaction,
sourceOutputs: (wzReq.request.transaction as { sourceOutputs?: readonly unknown[] })?.sourceOutputs ?? [],
inputPaths: wzReq.request.inputPaths,
} as BchSignTxRequest,
};
events.onSignRequest(preview);
});
wzHandler.on('connectionsChanged', (connections: readonly RelayConnectionState[]) => {
// If a WZ connection closes remotely, evict its session so the UI updates.
const liveTopics = new Set(connections.map((c) => `wz:${c.id}`));
for (const [topic, session] of sessions.entries()) {
if (session.protocol === 'wz' && !liveTopics.has(topic)) {
sessions.delete(topic);
events.onSessionDelete(topic);
}
}
});
wzHandler.on('error', (err: Error) => events.onError(toError(err)));
}
// ─── Helpers for the dispatcher ────────────────────────────────────────
function buildPreview(protocol: Protocol, method: string, decoded: unknown, id: number, session: ActiveSession): SignRequestPreview | null {
// bch_getAddresses doesn't need user approval; auto-resolve.
@ -273,6 +354,18 @@ export function createResponder(config: ResponderConfig): Responder {
// ─── Public API ─────────────────────────────────────────────────────────
return {
async pair(uri) {
// Route by URI scheme. WalletKit handles wc:/cc:; WzHandler handles wiz:.
const proto = classifyUri(uri);
if (proto === 'wz') {
if (!wzHandler) {
throw new Error(
'WizardConnect (wiz://) URIs are not supported by this wallet build. ' +
'Enable the WizardConnect handler in useConnect to pair with wiz:// dApps.',
);
}
wzHandler.pair(uri);
return;
}
await walletKit.pair({ uri });
},
@ -335,6 +428,22 @@ export function createResponder(config: ResponderConfig): Responder {
},
async approveSignRequest(requestId, response) {
// WizardConnect requests are tracked separately (synthetic requestIds).
// useConnect.approveSignRequest owns the signing (calls
// signer.coSignTransaction with SIGHASH_UTXOS + deriveForInput); the
// response here carries the signedTransaction hex.
const wz = wzPending.get(requestId);
if (wz) {
wzPending.delete(requestId);
if (!wzHandler) throw new Error('WizardConnect handler is not enabled');
const signed = response as { signedTransaction?: string };
if (!signed.signedTransaction) {
throw new Error('WizardConnect approveSignRequest expected { signedTransaction } hex');
}
await wzHandler.approve(wz.connectionId, wz.sequence, signed.signedTransaction);
return;
}
const cont = pending.get(requestId);
if (!cont) throw new Error(`No pending request ${requestId}`);
pending.delete(requestId);
@ -347,6 +456,14 @@ export function createResponder(config: ResponderConfig): Responder {
},
async rejectSignRequest(requestId, reason) {
const wz = wzPending.get(requestId);
if (wz) {
wzPending.delete(requestId);
if (wzHandler) {
await wzHandler.reject(wz.connectionId, wz.sequence, reason ?? 'User rejected');
}
return;
}
const cont = pending.get(requestId);
if (!cont) throw new Error(`No pending request ${requestId}`);
pending.delete(requestId);
@ -358,6 +475,13 @@ export function createResponder(config: ResponderConfig): Responder {
},
async disconnect(topic, reason) {
// WizardConnect sessions use `wz:` topic prefix — route to wzHandler.
if (topic.startsWith('wz:') && wzHandler) {
const connId = topic.slice('wz:'.length);
wzHandler.disconnect(connId);
sessions.delete(topic);
return;
}
await walletKit.disconnectSession({
topic,
reason: { code: 6000, message: reason ?? 'User disconnected' },

View file

@ -38,9 +38,24 @@ import {
} from '@bitauth/libauth';
import type { CoSignInput, SignedTxResponse } from './index';
/** The sighash byte we commit to. Documented + asserted in the unit test below. */
/** The default sighash byte — WalletConnect canonical: SIGHASH_ALL | SIGHASH_FORKID = 0x41. */
export const BCH_SIGHASH_ALL_FORKID = SigningSerializationFlag.allOutputs | SigningSerializationFlag.forkId;
/** WizardConnect's required sighash byte: SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS = 0x61.
* Per docs.riftenlabs.com/wizardconnect/wallet — *"The wallet must sign every input
* with `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. Other sighash flags must be
* rejected."* The extra `SIGHASH_UTXOS` flag is BCH-specific — it commits the
* signature to the value + lockingBytecode of ALL sourced UTXOs, not just the
* input being signed. Belt-and-braces protection against a UTXO-substitution attack
* in the pre-image. */
export const WIZARDCONNECT_SIGHASH =
SigningSerializationFlag.allOutputs |
SigningSerializationFlag.forkId |
SigningSerializationFlag.utxos;
/** Sighash bytes the signer accepts. Anything else is rejected. */
const ACCEPTED_SIGHASH = new Set<number>([BCH_SIGHASH_ALL_FORKID, WIZARDCONNECT_SIGHASH]);
/** Libauth-shape transaction input + output (kept loose so this module compiles
* without leaking libauth's internal types up to the index surface). */
interface MutableInput {
@ -100,7 +115,19 @@ export function isOwnedInput(
}
/** The libauth pass-through co-sign. Mutates a copy of the transaction's inputs
* to fill the unlocking bytecode of every ownable input, then encodes + hashes. */
* to fill the unlocking bytecode of every ownable input, then encodes + hashes.
*
* Routing per input:
* - If `request.inputPaths` has a triplet for input i → derive the child key
* via `deriveForInput(pathName, addressIndex)`, verify the derived address
* matches the source output's `lockingBytecode` (structural non-custody),
* sign with that key.
* - Otherwise → sign only if the source output's `lockingBytecode` matches
* the active-account key (`ownPubkeyHash`). Never touch inputs we don't own.
*
* Sighash byte defaults to `SIGHASH_ALL | SIGHASH_FORKID` (0x41, WC). WizardConnect
* requires `… | SIGHASH_UTXOS` (0x61). `input.sighashType`, when provided, must be
* one of the two accepted values — anything else is rejected. */
export async function coSignTransaction(input: CoSignInput): Promise<SignedTxResponse> {
const tx = input.request.transaction as MutableTx;
const sourceOutputs = input.request.sourceOutputs as readonly MutableOutput[];
@ -114,6 +141,35 @@ export async function coSignTransaction(input: CoSignInput): Promise<SignedTxRes
);
}
// Sighash byte — validated up front so we fail fast on unsupported flags.
const sighashByte = input.sighashType ?? BCH_SIGHASH_ALL_FORKID;
if (!ACCEPTED_SIGHASH.has(sighashByte)) {
throw new Error(
`coSignTransaction: sighash 0x${sighashByte.toString(16)} not accepted ` +
`(supported: 0x41 = WC, 0x61 = WizardConnect)`,
);
}
// Optional per-input HD-key lookup table.
const inputPathsMap = new Map<number, { pathName: string; addressIndex: number }>();
const rawInputPaths = input.request.inputPaths ?? [];
for (const triplet of rawInputPaths) {
if (!Array.isArray(triplet) || triplet.length !== 3) {
throw new Error(`coSignTransaction: malformed inputPaths triplet: ${JSON.stringify(triplet)}`);
}
const [idx, pathName, addressIndex] = triplet;
if (typeof idx !== 'number' || typeof pathName !== 'string' || typeof addressIndex !== 'number') {
throw new Error(`coSignTransaction: bad inputPaths triplet types: ${JSON.stringify(triplet)}`);
}
inputPathsMap.set(idx, { pathName, addressIndex });
}
if (inputPathsMap.size > 0 && !input.deriveForInput) {
throw new Error(
'coSignTransaction: request has inputPaths but no deriveForInput was provided; ' +
'the wallet cannot resolve HD child keys',
);
}
// Work on a fresh inputs array so we don't mutate the request object.
const signedInputs: MutableInput[] = tx.inputs.map((i) => ({ ...i }));
const ownP2pkhScript = p2pkhLockingBytecode(input.ownPubkeyHash);
@ -121,7 +177,42 @@ export async function coSignTransaction(input: CoSignInput): Promise<SignedTxRes
let signedCount = 0;
for (let i = 0; i < signedInputs.length; i++) {
const lock = sourceOutputs[i]?.lockingBytecode;
if (!lock || !binsAreEqual(lock, ownP2pkhScript)) continue; // not ours — skip
if (!lock) continue;
// Determine which key signs this input. Default is the active account;
// inputPaths override with HD-derived per-input keys (WizardConnect).
let effectivePubkey = input.ownPublicKey;
let effectivePrivateKey = input.privateKey;
let effectiveScript = ownP2pkhScript;
const mapping = inputPathsMap.get(i);
if (mapping) {
const derived = await input.deriveForInput!(mapping.pathName, mapping.addressIndex);
if (!derived) {
// Structural non-custody: dApp claimed we own an HD path we don't.
// Refuse the whole signature request rather than partial-signing.
throw new Error(
`coSignTransaction: inputPaths references path ${mapping.pathName}[${mapping.addressIndex}] ` +
`for input ${i}, but this wallet doesn't own that path`,
);
}
effectivePubkey = derived.publicKey;
effectivePrivateKey = derived.privateKey;
effectiveScript = p2pkhLockingBytecode(derived.pubkeyHash);
}
// Non-custody gate: derived (or active) address MUST match the source
// output. If they don't match — either the dApp asked us to sign
// someone else's input, or the HD map is wrong — refuse.
if (!binsAreEqual(lock, effectiveScript)) {
if (mapping) {
throw new Error(
`coSignTransaction: input ${i} sourceOutput lockingBytecode doesn't match derived ` +
`${mapping.pathName}[${mapping.addressIndex}] address; refusing to sign`,
);
}
continue; // not ours + no explicit mapping — skip silently
}
// Build the BCH-spec signing serialization for this input.
// libauth signature: (CompilationContextBch, { coveredBytecode, signingSerializationType }, sha256?)
@ -135,13 +226,13 @@ export async function coSignTransaction(input: CoSignInput): Promise<SignedTxRes
transaction: { ...tx, inputs: signedInputs } as any,
},
{
coveredBytecode: ownP2pkhScript,
signingSerializationType: new Uint8Array([BCH_SIGHASH_ALL_FORKID]),
coveredBytecode: effectiveScript,
signingSerializationType: new Uint8Array([sighashByte]),
},
);
const messageHash = hash256(preimage);
const sig = secp256k1.signMessageHashDER(input.privateKey, messageHash);
const sig = secp256k1.signMessageHashDER(effectivePrivateKey, messageHash);
if (typeof sig === 'string') {
throw new Error(`coSignTransaction: ECDSA sign failed for input ${i}: ${sig}`);
}
@ -149,9 +240,9 @@ export async function coSignTransaction(input: CoSignInput): Promise<SignedTxRes
// Append the sighash byte to the DER signature.
const sigWithSighash = new Uint8Array(sig.length + 1);
sigWithSighash.set(sig, 0);
sigWithSighash[sig.length] = BCH_SIGHASH_ALL_FORKID;
sigWithSighash[sig.length] = sighashByte;
signedInputs[i].unlockingBytecode = p2pkhUnlockingBytecode(sigWithSighash, input.ownPublicKey);
signedInputs[i].unlockingBytecode = p2pkhUnlockingBytecode(sigWithSighash, effectivePubkey);
signedCount++;
}

View file

@ -28,6 +28,10 @@ import {
} from './responder';
import { handleSignMessage, handleSignTransaction } from './wc-handler';
import { networkToChain, WC_METHODS } from './bch-namespace';
import { buildCcNetworkProvider } from './cc-network';
import { createWzHandler, type WzHandler } from './wz-handler';
import { coSignTransaction, WIZARDCONNECT_SIGHASH } from './signer';
import type { KeyStore } from '@core/wallet/keystore.js';
import type {
AccountIdentity,
BchAccount,
@ -112,14 +116,23 @@ export interface ConnectHandle {
}
/** Construct the connector for the active session/identity. Re-runs only when
* the session, identity, or current network change. */
* the session, identity, or current network change.
*
* When `keyStore` is provided AND its `supportedPathNames()` list includes
* the WizardConnect standard paths (`receive`/`change`/`defi`), we instantiate
* the `WzHandler` and pair the responder with it. This makes `wiz://` URIs
* work end-to-end. Without a keyStore, the responder still handles WC + CC —
* the WZ path just rejects wiz:// URIs with a clear "not enabled" error. */
export function useConnect(
session: CoreSession | null,
identity: AccountIdentity | null,
currentNetwork: Network,
keyStore: KeyStore | null = null,
): ConnectHandle {
const responderRef = useRef<Responder | null>(null);
const keyRef = useRef<KeyProvider | null>(null);
const wzHandlerRef = useRef<WzHandler | null>(null);
const keyStoreRef = useRef<KeyStore | null>(null);
const proposalRef = useRef<SessionProposal | null>(null);
const signRequestRef = useRef<SignRequestPreview | null>(null);
@ -155,21 +168,59 @@ export function useConnect(
};
keyRef.current = key;
const network: NetworkProvider = {
// CashConnect-only path — not exercised by the WC Checkers/tote flow.
// Stubbed so a CC session that calls a method needing source outputs
// surfaces a clear error rather than a silent hang.
getSourceOutput: async () => {
throw new Error('CashConnect source-output resolution not wired in this build');
},
getUnspents: async () => [],
};
// CashConnect network callbacks — resolves source outputs via the
// session's Electrum provider and maps the wallet's UTXOs into the
// libauth-template-scoped shape cashconnect expects.
const network: NetworkProvider = buildCcNetworkProvider({
provider: session.provider,
addresses: [identity.cashaddr, identity.tokenAddress],
pubkeyHash,
privateKey: session.privateKey,
});
const allowedChains: readonly BchChain[] = [networkToChain(currentNetwork)];
// WizardConnect handler — instantiate iff a KeyStore with HD support is
// available. Failure to build it is non-fatal (WC/CC still work); we
// record the error and continue without the WZ transport.
let wzHandler: WzHandler | undefined;
keyStoreRef.current = keyStore;
if (keyStore) {
try {
const paths = keyStore.supportedPathNames();
if (paths.includes('receive') && paths.includes('change') && paths.includes('defi')) {
wzHandler = await createWzHandler({
keyStore,
walletName: DEVIANT_METADATA.name,
walletIcon: DEVIANT_METADATA.icons[0],
// The manager calls this on incoming sign_transaction_request
// when its own message pipeline dispatches. But we intercept
// BEFORE this via wzHandler's pendingSignRequest event (routed
// through the responder → UI). So this callback is a fallback
// that shouldn't normally fire — if it does, throw so the dApp
// gets a clear error rather than a silent hang.
signApprovalFlow: async () => {
throw new Error(
'WizardConnect signTransaction reached the adapter fallback — ' +
'the responder should have intercepted via pendingSignRequest. ' +
'This indicates a wiring bug.',
);
},
});
if (cancelled) return;
wzHandlerRef.current = wzHandler;
}
} catch (e) {
// Log to the error surface so it's visible, but don't fail the whole
// responder init — WC/CC still work.
setError(e as Error);
}
}
const r = createResponder({
walletKit, key, network,
allowedChains,
wzHandler,
events: {
onSessionProposal: (p) => { if (!cancelled) setProposal(p); },
onSignRequest: (req) => { if (!cancelled) setSignRequest(req); },
@ -193,7 +244,7 @@ export function useConnect(
}
})();
return () => { cancelled = true; };
}, [session, identity, currentNetwork]);
}, [session, identity, currentNetwork, keyStore]);
const actions: ConnectActions = {
async pair(uri) {
@ -259,11 +310,36 @@ export function useConnect(
const r = responderRef.current;
const req = signRequestRef.current;
const key = keyRef.current;
const ks = keyStoreRef.current;
if (!r || !req || !key) throw new Error('No pending sign request');
try {
let response: unknown;
// WizardConnect sessions use `wz:` sessionId prefix. Sign with the
// WizardConnect sighash + HD-key mapping via inputPaths.
const isWz = req.sessionId.startsWith('wz:');
if (req.kind === 'sign-message') {
response = handleSignMessage(req.request, key);
} else if (isWz) {
if (!ks) throw new Error('WizardConnect sign requires a KeyStore for HD derivation');
const identity = key.identity();
const signed = await coSignTransaction({
request: req.request,
ownPubkeyHash: key.pubkeyHash(),
ownPublicKey: identity.publicKey,
privateKey: key.privateKey(),
network: identity.network,
sighashType: WIZARDCONNECT_SIGHASH,
deriveForInput: async (pathName, addressIndex) => {
const child = await ks.deriveChildKey(pathName, addressIndex);
return {
privateKey: child.privateKey,
publicKey: child.publicKey,
pubkeyHash: child.pubkeyHash,
};
},
});
// Responder recognizes wzPending → hands hex to wzHandler.approve.
response = { signedTransaction: signed.signedTransaction };
} else {
response = await handleSignTransaction(req.request, key);
}

View file

@ -0,0 +1,149 @@
/**
* wz-adapter.ts — bridge Deviant's async `KeyStore` to WizardConnect's sync
* `WalletAdapter` interface.
*
* WizardConnect's `WalletConnectionManager` calls three synchronous adapter
* methods (`getRelayPrivateKey`, `getPublicKey`, `getXpub`) at connect / handshake
* time. Our KeyStore is async by design (biometric prompts, native secure
* storage). We resolve the impedance mismatch by **pre-computing the xpubs at
* handler-init time** and passing them into the adapter as a Map — so the
* adapter's sync `getXpub` returns from cache with no I/O.
*
* Confirmed by reading `@wizardconnect/wallet@0.2.2/dist/wallet-connection-manager.js`:
* - `getRelayPrivateKey(uri)` is called once at `connect(uri)` (line ~90)
* - `getXpub(path)` is called three times at handshake — Receive/Change/Cauldron
* (lines 330-332) — to build the `wallet_ready` `PathXpub[]` payload
* - `getPublicKey(path, index)` is defined but NOT called by the manager itself;
* it exists in the interface for extension protocols
*
* WizardConnect path names: `DerivationPath.Cauldron` in the SDK enum maps to
* `defi` in our KeyStore's semantic path name (matching the wire-format string
* per `hdwalletv1.js:PATH_DEFI = 'defi'`). We translate at the enum ↔ string
* boundary.
*
* ⚠ LICENSE STATUS (2026-07-04): @wizardconnect/{core,wallet} publish
* with no LICENSE file, no `license` field in package.json, and no LICENSE
* at their gitlab. Community reports treat it as "open, no license" — which
* is technically copyright-restricted by default. Community usage precedent
* (Cauldron, Paytaca, Selene ship with it) makes chipnet-beta consumption
* defensible. Before mainnet ship, get an explicit permission from Riften
* Labs (Dagur / H4lvor). Tracked in `Parameters/WalletConnect/WizardConnect_reference.md`.
*/
import {
DerivationPath,
type WalletAdapter,
type SignTransactionResult,
} from '@wizardconnect/wallet';
import type { SignTransactionRequest } from '@wizardconnect/core';
import { sha256 } from '@bitauth/libauth';
/** Map the SDK's numeric enum to our KeyStore's semantic path-name string. */
export function pathNameFor(path: DerivationPath): 'receive' | 'change' | 'defi' {
switch (path) {
case DerivationPath.Receive:
return 'receive';
case DerivationPath.Change:
return 'change';
case DerivationPath.Cauldron:
return 'defi';
}
}
/** Inverse mapping — string path name → SDK enum. Throws on unknown names. */
export function derivationPathFor(pathName: string): DerivationPath {
switch (pathName) {
case 'receive':
return DerivationPath.Receive;
case 'change':
return DerivationPath.Change;
case 'defi':
return DerivationPath.Cauldron;
default:
throw new Error(`wz-adapter: unknown pathName ${pathName}`);
}
}
/** Pre-computed xpubs (from `KeyStore.exportXpub`) — the adapter's sync
* `getXpub` returns from this map. Handler builds it before constructing
* the adapter. */
export type XpubCache = ReadonlyMap<DerivationPath, string>;
/** Compute all three xpubs the WizardConnect handshake needs, in parallel. */
export async function buildXpubCache(
exportXpub: (pathName: string) => Promise<string>,
): Promise<XpubCache> {
const [receive, change, defi] = await Promise.all([
exportXpub('receive'),
exportXpub('change'),
exportXpub('defi'),
]);
return new Map<DerivationPath, string>([
[DerivationPath.Receive, receive],
[DerivationPath.Change, change],
[DerivationPath.Cauldron, defi],
]);
}
export interface DeviantWizardAdapterOpts {
readonly walletName?: string;
readonly walletIcon?: string;
/** Cache of xpubs at the branch level — one per supported WizardConnect
* path. Built via `buildXpubCache` at handler init. */
readonly xpubs: XpubCache;
/** Called when the manager delivers a sign_transaction_request. Wallet UI
* approval + `signer.coSignTransaction` call happens inside this callback. */
onSignTransaction(request: SignTransactionRequest): Promise<SignTransactionResult>;
}
/** Construct a WizardConnect `WalletAdapter` backed by Deviant's async KeyStore
* (via pre-computed xpubs) + a caller-supplied sign-transaction handler. */
export function createWizardConnectAdapter(opts: DeviantWizardAdapterOpts): WalletAdapter {
const { xpubs, onSignTransaction } = opts;
return {
walletName: opts.walletName ?? 'Deviant Wallet',
walletIcon: opts.walletIcon ?? 'https://testchipnet.duckdns.org/icon.png',
/** Deterministic per-URI Nostr identity. Different URIs yield different
* keys for privacy (dApps can't correlate). Same URI reconnected yields
* the same key (relay recognizes wallet after reload). v1 impl:
* sha256(sha256(uri-utf8)). No wallet-side salt: two devices restoring
* the same seed would sign with different real keys anyway (their WC
* signatures differ), so linkability between devices via the relay
* private key isn't a threat model add. */
getRelayPrivateKey(uri: string): Uint8Array {
return sha256.hash(sha256.hash(new TextEncoder().encode(uri)));
},
/** Not called by `WalletConnectionManager@0.2.2` itself — the interface
* reserves it for extension protocols. If an extension asks for a specific
* child pubkey synchronously, we can't derive from the KeyStore
* synchronously — surface a clear error rather than silently returning
* zeros. Extend the adapter when an extension protocol requires it. */
getPublicKey(_path: DerivationPath, _index: bigint): Uint8Array {
throw new Error(
'wz-adapter: sync getPublicKey not implemented — Deviant KeyStore is async, ' +
'and WalletConnectionManager@0.2.2 does not call this method. ' +
'If an extension protocol needs it, pre-cache like xpubs are cached.',
);
},
/** Return the pre-cached xpub for the requested path. */
getXpub(path: DerivationPath): string {
const xpub = xpubs.get(path);
if (!xpub) {
throw new Error(
`wz-adapter: no xpub cached for ${DerivationPath[path]} (${path}); ` +
'did the handler call buildXpubCache before creating the adapter?',
);
}
return xpub;
},
/** Delegate to the caller's handler — this is where UI approval + the
* real `signer.coSignTransaction` (with WizardConnect sighash + per-input
* HD derivation) happens. */
signTransaction(request: SignTransactionRequest): Promise<SignTransactionResult> {
return onSignTransaction(request);
},
};
}

View file

@ -0,0 +1,160 @@
/**
* wz-handler.ts — WizardConnect responder for the Deviant wallet.
*
* Wraps `@wizardconnect/wallet`'s `WalletConnectionManager` with:
* - a Deviant `KeyStore`-backed `WalletAdapter` (see wz-adapter.ts)
* - pre-cached xpubs (`buildXpubCache`) so the manager's sync interface can
* read from the async KeyStore
* - a `KeyProvider` seam so `signer.coSignTransaction`'s `deriveForInput`
* resolves per-input HD child keys from the KeyStore
*
* This module is INTENTIONALLY UI-agnostic. The responder (`responder.ts`) is
* responsible for surfacing `pendingSignRequest` events to the shell UI, waiting
* for user approval, and calling `handler.approve(sequence, coSignResult)` /
* `handler.reject(sequence, reason)`.
*
* ⚠ Phase 2 status (snapshot 0193): this file provides the type surface and a
* stubbed instance factory. Wiring into `responder.ts` (which currently holds
* only a WalletKit) is Phase 3 — the responder needs a second event-stream
* union + a URI-to-transport router. Both are structurally similar to how
* CashConnect was wired via `cc-handler.ts` and `useConnect.ts`.
*
* ⚠ LICENSE STATUS: see wz-adapter.ts header. @wizardconnect/{core,wallet} are
* proprietary-per-npm-view but publish no LICENSE file. Community usage
* precedent (Cauldron/Paytaca/Selene) supports chipnet-beta consumption;
* mainnet ship needs explicit Riften Labs permission.
*/
import {
WalletConnectionManager,
type RelayConnectionState,
type PendingSignRequest,
type SignTransactionResult,
} from '@wizardconnect/wallet';
import type { SignTransactionRequest } from '@wizardconnect/core';
import type { KeyStore } from '@core/wallet/keystore.js';
import {
buildXpubCache,
createWizardConnectAdapter,
type XpubCache,
} from './wz-adapter';
/** The KeyStore-backed sign flow the adapter's `signTransaction` invokes.
* The responder implements this — it shows the SigningPreview UI, waits for
* approve/reject, then calls `signer.coSignTransaction` with the KeyStore's
* per-input HD derivation. Returns `{ signedTransaction }` (hex) or throws. */
export type SignApprovalFlow = (req: SignTransactionRequest) => Promise<SignTransactionResult>;
export interface WzHandlerOpts {
readonly keyStore: KeyStore;
readonly signApprovalFlow: SignApprovalFlow;
readonly walletName?: string;
readonly walletIcon?: string;
}
/** Event names + listener shapes the handler surfaces to consumers (typically
* the responder). Mirrors the underlying `WalletConnectionManager` events but
* keeps our interface stable across SDK versions. */
export interface WzHandlerEventMap {
pendingSignRequest: (req: PendingSignRequest) => void;
connectionsChanged: (connections: readonly RelayConnectionState[]) => void;
error: (error: Error) => void;
}
export interface WzHandler {
/** Pair with a dApp by pasting/scanning a `wiz://` URI. Returns the
* connection ID the manager assigns; the caller surfaces this as a
* session topic to the UI. */
pair(uri: string): string;
/** List currently-active sessions. */
listConnections(): readonly RelayConnectionState[];
/** Approve a sign request — the responder calls this after the user
* approves in the UI. Sends signed tx hex back to the dApp. */
approve(connectionId: string, sequence: number, signedTransactionHex: string): Promise<void>;
/** Reject a sign request. */
reject(connectionId: string, sequence: number, message: string): Promise<void>;
/** Tear down a specific dApp session. */
disconnect(connectionId: string): void;
/** Tear down all sessions (e.g. on wallet lock). */
disconnectAll(): void;
/** Subscribe to handler events. Multiple listeners per event supported. */
on<E extends keyof WzHandlerEventMap>(event: E, listener: WzHandlerEventMap[E]): void;
/** Remove a listener. */
off<E extends keyof WzHandlerEventMap>(event: E, listener: WzHandlerEventMap[E]): void;
}
/** Construct the handler. Called by the responder once the KeyStore is
* unlocked and the wallet is ready to serve WizardConnect. Consumers attach
* event listeners via `handler.on(...)` after construction. */
export async function createWzHandler(opts: WzHandlerOpts): Promise<WzHandler> {
// Pre-compute xpubs so the adapter's sync interface can serve them.
const xpubs: XpubCache = await buildXpubCache((name) => opts.keyStore.exportXpub(name));
const adapter = createWizardConnectAdapter({
walletName: opts.walletName,
walletIcon: opts.walletIcon,
xpubs,
onSignTransaction: async (req) => opts.signApprovalFlow(req),
});
const manager = new WalletConnectionManager(adapter);
// Simple multi-listener event bus. Only the three event kinds our interface
// exposes are supported — the underlying manager has more (remoteDisconnect,
// signCancelled, message, connectionStatusChanged) but we don't surface them
// at v1; add them here when a caller needs one.
const listeners: { [K in keyof WzHandlerEventMap]: Set<WzHandlerEventMap[K]> } = {
pendingSignRequest: new Set(),
connectionsChanged: new Set(),
error: new Set(),
};
function emit<E extends keyof WzHandlerEventMap>(event: E, ...args: Parameters<WzHandlerEventMap[E]>) {
for (const listener of listeners[event]) {
try {
(listener as (...a: unknown[]) => void)(...args);
} catch (e) {
// A misbehaving listener shouldn't take down the bus; report and continue.
if (event !== 'error') {
for (const errListener of listeners.error) {
try { errListener(e as Error); } catch { /* swallow */ }
}
}
}
}
}
manager.on('pendingSignRequest', (req: PendingSignRequest) => {
emit('pendingSignRequest', req);
});
manager.on('connectionsChanged', () => {
emit('connectionsChanged', Object.values(manager.getConnections()));
});
return {
pair(uri: string): string {
return manager.connect(uri);
},
listConnections(): readonly RelayConnectionState[] {
return Object.values(manager.getConnections());
},
async approve(connectionId, sequence, signedTransactionHex) {
await manager.sendSignResponse(connectionId, sequence, signedTransactionHex);
},
async reject(connectionId, sequence, message) {
await manager.sendSignError(connectionId, sequence, message);
},
disconnect(connectionId: string): void {
manager.disconnect(connectionId);
},
disconnectAll(): void {
manager.disconnectAll();
},
on(event, listener) {
listeners[event].add(listener as never);
},
off(event, listener) {
listeners[event].delete(listener as never);
},
};
}

Some files were not shown because too many files have changed in this diff Show more