orgchain: the tree (lineage lab keys, act as any node, graduation by claim + countersign)
Start the tree: a fresh 24-word lab root and docs/lineage.md's drawing as keys. Orgs get BIP-85 seeds from their parent (m/83696968'/39'/0'/24'/i'), wallets are BIP-86 m/86'/1'/account'/0/0, npubs are NIP-06; seats are accounts 1..n of their org's seed; indices are never reused. Act as any node to mine, inscribe or sign with its key. Graduation retires a leaf org on a derived edge and adopts a fresh root in its place. The handoff is OpenOrdex's buy-order direction: the recipient builds a 2-in/2-out transaction and signs its fee input first, so its signature fixes where the inscription goes; the holder then countersigns the inscription input. Both are default signatures, so chain.js is unchanged. The keyring and open claims save with the lab (optional fields, same storage key). Tests: BIP-85, BIP-86 and NIP-06 spec vectors plus the handoff and its failure cases (36/36 unit). tests/tree.spec.js rehearses a full graduation in Chromium at desktop and phone sizes, with layout checks; tests/flow.spec.js now follows this branch's copy and theme, and waits for a mined block before reloading (28/28 e2e). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d48c5c8ff
commit
2ea7884688
14 files changed
+633
-30
No files matched your search
+20
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Super Testnet restyled for lineage, served at https://lineage.yolopreneur.lol/orgchain/.
|
||||
|
||||
- Same lab and code as `main`. The differences are a skin and copy: `src/lineage.css` (loads after `style.css` and
|
||||
- Same lab and chain as `main`, plus the tree (below). The rest of the difference is a skin and copy: `src/lineage.css` (loads after `style.css` and
|
||||
points its tokens at lineage's), a rehearsal guide and lineage branding in `index.html`, the lineage favicon,
|
||||
relative paths (`vite.config.js` `base: './'`, the recursive preview, license links), the theme following the system
|
||||
setting under its own storage key, and Inscriboor downloads linking to supertestnet.lol (the extension only connects
|
||||
@@ -11,3 +11,22 @@ Super Testnet restyled for lineage, served at https://lineage.yolopreneur.lol/or
|
||||
Headers (CSP and the rest, matching supertestnet.lol's) come from `ventures/lineage/_headers`, scoped to
|
||||
`/orgchain/*`.
|
||||
- Keep in step with `main`: rebase this branch onto `main`, rebuild, recopy.
|
||||
|
||||
## The tree (lineage lab keys)
|
||||
|
||||
- `src/adapters/lineage-keys.js`: a 24-word lab root; each org's seed is BIP-85 from its parent
|
||||
(`m/83696968'/39'/0'/24'/<index>'`); each node's wallet is BIP-86 `m/86'/1'/<account>'/0/0`; its npub is NIP-06
|
||||
`m/44'/1237'/<account>'/0/0`. Orgs use account 0; seats are accounts 1..n of their org's seed. Tested against the
|
||||
BIP-85, BIP-86 and NIP-06 spec vectors (`tests/lineage.test.js`).
|
||||
- Lab simplification: like every wallet in this lab, the derived key is used directly as the Taproot output key
|
||||
(no BIP-86 tweak), so addresses differ from a real BIP-86 wallet's.
|
||||
- `src/adapters/keyring.js`: the drawing in teahub `docs/lineage.md` as nodes. Indices are never reused. Graduation
|
||||
retires a leaf org on a derived edge and adds an adopted node with a fresh root.
|
||||
- `src/adapters/handoff.js`: the bound handoff (OpenOrdex's buy-order direction). The recipient builds a 2-in/2-out
|
||||
transaction and signs its fee input; the holder countersigns the inscription input. Both are default signatures, so
|
||||
`chain.js` is unchanged. Not yet: closing (burns), paid handoffs (a third output), open sell orders
|
||||
(`SIGHASH_SINGLE|ANYONECANPAY` would need the chain to accept 65-byte signatures).
|
||||
- `src/tree.js` is the panel. The keyring and open claims are saved with the lab (same storage key, optional fields).
|
||||
- Tests: `npm test` (unit) and `npx playwright test` (`tests/tree.spec.js` rehearses a full graduation;
|
||||
`tests/flow.spec.js` follows this branch's copy and theme).
|
||||
|
||||
+18
-5
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="theme-color" content="#141414">
|
||||
<meta name="description" content="Super Testnet in lineage's clothes: rehearse an org's lineage (root, births, badges) as inscriptions on a private toy Bitcoin chain in your browser, with pretend money.">
|
||||
<meta name="description" content="Super Testnet in lineage's clothes: rehearse an org's lineage (root, births, badges, graduation) as inscriptions on a private toy Bitcoin chain in your browser, with pretend money.">
|
||||
<title>Orgchain — rehearse an org on a small chain · Lineage</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
</head>
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="band-in">
|
||||
<div>
|
||||
<h1 class="wordmark">Orgchain</h1>
|
||||
<p class="lede"><b>Rehearse an org on a small chain.</b> Mint a root, give birth to children, award a badge, all as inscriptions on a private toy Bitcoin chain in this tab, with pretend money.</p>
|
||||
<p class="lede"><b>Rehearse an org on a small chain.</b> Mint a root, give birth to children, award a badge and graduate a venture onto its own root, all as inscriptions on a private toy Bitcoin chain in this tab, with pretend money.</p>
|
||||
</div>
|
||||
<div class="flags">
|
||||
<span class="flag hz">Test coins</span>
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section class="rehearse blk" aria-labelledby="t-rehearse"><h2 class="tab" id="t-rehearse">Rehearsal <span class="sub">a lineage in four moves</span></h2><div class="panel"><ol class="moves"><li><b>Root.</b> Fund a wallet, then inscribe the org with metaprotocol <code>lineage/v0</code> and metadata like <code>{"lineage":"root","name":"yolopreneur"}</code>.</li><li><b>Birth.</b> Inscribe a child with the root as its parent. Only the parent's holder can: the parent is spent to make the child, and the child carries the parent's ID on-chain (ord tag 3).</li><li><b>Generations.</b> Children can be parents too: <code>time</code> under the root, <code>docscope</code> under <code>time</code>, as deep as the tree goes.</li><li><b>Badge.</b> Inscribe a badge once, like <code>{"lineage":"badge","skill":"launch","belt":"green"}</code>, then mint each award as a child of it with the badge as delegate, so every award shows the same mark.</li></ol><p class="later"><b>Not in this lab yet:</b> handing an award to a seat, graduation (moving a venture's inscription to its own new root) and closing (burning a parent so no more children can be born). Those need transfers and burns, which this lab doesn't do.</p></div></section>
|
||||
<section class="rehearse blk" aria-labelledby="t-rehearse"><h2 class="tab" id="t-rehearse">Rehearsal <span class="sub">a lineage in six moves</span></h2><div class="panel"><ol class="moves"><li><b>Root.</b> Fund a wallet, then inscribe the org with metaprotocol <code>lineage/v0</code> and metadata like <code>{"lineage":"root","name":"yolopreneur"}</code>.</li><li><b>Birth.</b> Inscribe a child with the root as its parent. Only the parent's holder can: the parent is spent to make the child, and the child carries the parent's ID on-chain (ord tag 3).</li><li><b>Generations.</b> Children can be parents too: <code>time</code> under the root, <code>docscope</code> under <code>time</code>, as deep as the tree goes.</li><li><b>Badge.</b> Inscribe a badge once, like <code>{"lineage":"badge","skill":"launch","belt":"green"}</code>, then mint each award as a child of it with the badge as delegate, so every award shows the same mark.</li><li><b>Tree.</b> Start the tree: a fresh pretend lab root, orgs derived from it with BIP-85, seats as accounts of their org. <i>Act as</i> any node to mine, inscribe or sign with its key.</li><li><b>Graduate.</b> A leaf venture mints its own root, which claims the venture's inscription by signing first, so its signature fixes where the inscription goes. The parent countersigns: the vouch. Two ordinary signatures, so this toy chain takes it as it is. The same handoff delivers an award to a seat.</li></ol><p class="later"><b>Not in this lab yet:</b> closing (burning a parent so no more children can be born), paid handoffs (a buyout needs a third output) and open sell orders that anyone can fill (<code>SIGHASH_SINGLE|ANYONECANPAY</code>).</p></div></section>
|
||||
<aside class="notice"><strong>Test coins only.</strong> This is a private simulation, not Bitcoin testnet or mainnet. Local-wallet keys stay in this browser, unencrypted. Inscriboor keys stay in the extension. Never load real keys or send real funds here. Nothing on Orgchain is a real org, key or badge.</aside>
|
||||
<div id="status" role="status" aria-live="polite">Choose Inscriboor or a local test wallet to start your first loop.</div>
|
||||
<section class="workspace">
|
||||
@@ -37,9 +37,22 @@
|
||||
<div id="review-panel" hidden><h3>Review before signing</h3><div id="review-preview"></div><pre id="review-text"></pre><p id="review-parent"></p><pre id="review-metadata"></pre><p>Two transactions: commit + reveal. <strong>1,000 fake sats</strong> in fees; <strong>1,000 fake sats</strong> in a new wallet output. Any selected parent or reinscription output is also returned in full; reinscription appends to its existing sat stack.</p><p id="signing-note" class="muted">The next button asks your active signer to approve commit + reveal, then submits them to your private chain.</p><button id="sign" class="primary">Sign & inscribe</button> <button id="cancel-review" class="quiet">Cancel</button></div>
|
||||
</article>
|
||||
</section>
|
||||
<section class="collection-section"><div class="section-title"><div><p class="eyebrow">04 / THE THINGS YOU MADE</p><h2>Your inscriptions <span id="collection-count">0</span></h2></div><span class="muted">Owned by this wallet</span></div><div id="collectibles" class="collectibles"><p class="empty">Your first inscription belongs here.<br><span>Fund your wallet, write some text, and make it yours.</span></p></div></section>
|
||||
<section class="panel tree-section" id="tree" aria-labelledby="t-tree"><div class="panel-title"><span class="step">04</span><h2 id="t-tree">The tree</h2><span id="tree-state" class="tag">NO TREE</span></div>
|
||||
<div id="tree-empty"><p>Turn the drawing into keys: a fresh pretend lab root, orgs derived from it with BIP-85, seats as accounts of their org. Then act as any node, and graduate a venture onto its own root.</p><button id="start-tree" class="primary">Start the tree</button><p class="muted">Lab keys only: a new random root, kept unencrypted in this browser like the test wallet, and never a real key. An existing test wallet joins the tree as an imported node.</p></div>
|
||||
<div id="tree-active" hidden>
|
||||
<ol id="tree-list" class="tree-list" aria-label="Nodes in the tree"></ol>
|
||||
<div class="tree-forms">
|
||||
<fieldset class="tree-box"><legend>Add a child</legend><label for="child-parent">Under</label><select id="child-parent"></select><label for="child-kind">Kind</label><select id="child-kind"><option value="org">org · its own BIP-85 seed</option><option value="seat">seat · an account of its org</option></select><label for="child-name">Name</label><input id="child-name" placeholder="e.g. phly" autocomplete="off" spellcheck="false"><button id="add-child">Add child</button></fieldset>
|
||||
<fieldset class="tree-box"><legend>Hand off an inscription</legend><label for="handoff-inscription">Inscription</label><select id="handoff-inscription"></select><label for="handoff-to">To</label><select id="handoff-to"></select><p class="muted">The recipient builds the transaction and signs its fee input first; the holder countersigns the inscription input. Two inputs, two outputs, two ordinary signatures.</p><button id="fund-recipient" class="quiet">Mine a block to the recipient</button><button id="claim">1 · Recipient claims</button></fieldset>
|
||||
<fieldset class="tree-box"><legend>Graduate a venture</legend><label for="graduate-node">Venture (a leaf org on a derived edge)</label><select id="graduate-node"></select><button id="graduate">Mint its own root</button><p class="muted">The derived node retires and an adopted node with a fresh root takes its place. Then hand it its inscription: the claim is the venture accepting, the countersignature is the parent's vouch.</p></fieldset>
|
||||
</div>
|
||||
<h3 class="tree-subhead">Claims waiting for the holder</h3>
|
||||
<div id="claims" class="claims"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="collection-section"><div class="section-title"><div><p class="eyebrow">05 / THE THINGS YOU MADE</p><h2>Your inscriptions <span id="collection-count">0</span></h2></div><span class="muted">Owned by this wallet</span></div><div id="collectibles" class="collectibles"><p class="empty">Your first inscription belongs here.<br><span>Fund your wallet, write some text, and make it yours.</span></p></div></section>
|
||||
<section class="explorer-section">
|
||||
<div class="section-title"><div><p class="eyebrow">05 / FOLLOW THE TRAIL</p><h2>Chain explorer</h2></div><button id="export-lab" class="quiet">↓ Save complete lab</button></div>
|
||||
<div class="section-title"><div><p class="eyebrow">06 / FOLLOW THE TRAIL</p><h2>Chain explorer</h2></div><button id="export-lab" class="quiet">↓ Save complete lab</button></div>
|
||||
<div class="chain-board">
|
||||
<div class="mempool-head"><div><span class="chain-kicker">MEMPOOL</span><strong>Waiting for the next block</strong></div><span class="muted">commit + reveal transactions live here before mining</span></div>
|
||||
<div id="mempool-list" class="mempool-list"></div>
|
||||
|
||||
Generated
+40
-1
@@ -10,7 +10,10 @@
|
||||
"dependencies": {
|
||||
"@cmdcode/tapscript": "1.5.3",
|
||||
"@noble/curves": "1.9.7",
|
||||
"@noble/hashes": "1.8.0"
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@scure/base": "1.2.6",
|
||||
"@scure/bip32": "1.7.0",
|
||||
"@scure/bip39": "1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
@@ -878,6 +881,42 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
|
||||
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
|
||||
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.9.0",
|
||||
"@noble/hashes": "~1.8.0",
|
||||
"@scure/base": "~1.2.5"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
|
||||
"integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "~1.8.0",
|
||||
"@scure/base": "~1.2.5"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
|
||||
+5
-2
@@ -7,14 +7,17 @@
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"test": "node --test tests/adapters.test.js",
|
||||
"test": "node --test tests/adapters.test.js tests/lineage.test.js",
|
||||
"test:e2e": "npm run build && playwright test",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cmdcode/tapscript": "1.5.3",
|
||||
"@noble/curves": "1.9.7",
|
||||
"@noble/hashes": "1.8.0"
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@scure/base": "1.2.6",
|
||||
"@scure/bip32": "1.7.0",
|
||||
"@scure/bip39": "1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// A bound handoff, the buy-order direction of OpenOrdex: the recipient builds the transaction and signs
|
||||
// its own fee input first, so its signature already fixes where the inscription goes; the holder then
|
||||
// countersigns the inscription input. Two inputs, two outputs, default (64-byte) signatures, so the
|
||||
// local chain accepts it unchanged. Free handoffs only: a price would need a third output.
|
||||
import { Script, Tx } from '@cmdcode/tapscript';
|
||||
import { getVin, getVout } from '../upstream/primitives.js';
|
||||
import { FEE } from './inscription.js';
|
||||
|
||||
export const CLAIM_KIND = 'lineage-claim';
|
||||
const DUST = 330;
|
||||
const scriptHex = script => Script.encode(script, false).hex;
|
||||
const outpoint = coin => ({ txid: coin.txid, vout: coin.vout, value: coin.value, script: scriptHex(coin.scriptPubKey) });
|
||||
const HEX64 = /^[0-9a-f]{64}$/, TAPROOT = /^5120[0-9a-f]{64}$/;
|
||||
|
||||
export function holderCoin(chain, inscriptionId) {
|
||||
for (const coin of chain.utxos.values()) if (coin.inscriptions?.some(i => i.id === inscriptionId)) return coin;
|
||||
return null;
|
||||
}
|
||||
function build(claim) {
|
||||
return Tx.create({
|
||||
vin: [getVin(claim.holder.txid, claim.holder.vout, claim.holder.value, claim.holder.script),
|
||||
getVin(claim.payer.txid, claim.payer.vout, claim.payer.value, claim.payer.script)],
|
||||
vout: [getVout(claim.holder.value, ['OP_1', claim.to]), getVout(claim.payer.value - FEE, ['OP_1', claim.to])],
|
||||
});
|
||||
}
|
||||
export function makeClaim(chain, inscriptionId, recipient, now = Date.now()) {
|
||||
if (recipient?.kind !== 'test-wallet') throw new Error('Claims are signed by local test wallets.');
|
||||
const held = holderCoin(chain, inscriptionId);
|
||||
if (!held) throw new Error('That inscription is not in an unspent output.');
|
||||
if (scriptHex(held.scriptPubKey) === scriptHex(recipient.script)) throw new Error('The recipient already holds that inscription.');
|
||||
const fee = chain.coins(recipient.script).find(c => !c.inscriptions?.length && c.value >= FEE + DUST);
|
||||
if (!fee) throw new Error('The recipient needs a plain coin for the 500-sat fee. Mine a block to it first.');
|
||||
const claim = { kind: CLAIM_KIND, version: 1, inscription: inscriptionId, carried: held.inscriptions.map(i => i.id),
|
||||
holder: outpoint(held), payer: outpoint(fee), to: recipient.pubkey, at: now };
|
||||
const tx = build(claim);
|
||||
claim.txid = Tx.util.getTxid(tx);
|
||||
claim.sig = recipient.sign(tx, undefined, 1);
|
||||
return claim;
|
||||
}
|
||||
export function countersign(chain, claim, holder) {
|
||||
const valid = decodeClaim(claim);
|
||||
if (holder?.kind !== 'test-wallet') throw new Error('Countersigning needs a local test wallet.');
|
||||
if (scriptHex(holder.script) !== valid.holder.script) throw new Error('Only the current holder can countersign.');
|
||||
if (claimStatus(chain, valid) !== 'open') throw new Error('This claim is stale: an input it spends was already spent.');
|
||||
const tx = build(valid);
|
||||
tx.vin[1].witness = [valid.sig];
|
||||
tx.vin[0].witness = [holder.sign(tx, undefined, 0)];
|
||||
chain.submitPackage([Tx.encode(tx).hex]);
|
||||
return Tx.util.getTxid(tx);
|
||||
}
|
||||
// open: both inputs still unspent; stale: one was spent elsewhere, so the claim can never confirm.
|
||||
export function claimStatus(chain, claim) {
|
||||
return chain.utxos.has(`${claim.holder.txid}:${claim.holder.vout}`) && chain.utxos.has(`${claim.payer.txid}:${claim.payer.vout}`) ? 'open' : 'stale';
|
||||
}
|
||||
export function decodeClaim(data) {
|
||||
const point = p => p && HEX64.test(p.txid) && Number.isSafeInteger(p.vout) && p.vout >= 0 && Number.isSafeInteger(p.value) && p.value >= DUST && TAPROOT.test(p.script);
|
||||
if (data?.kind !== CLAIM_KIND || data.version !== 1 || !/^[0-9a-f]{64}i\d+$/.test(data.inscription) ||
|
||||
!Array.isArray(data.carried) || !point(data.holder) || !point(data.payer) || data.payer.value < FEE + DUST ||
|
||||
!HEX64.test(data.to) || !HEX64.test(data.txid) || !/^[0-9a-f]{128}$/.test(data.sig) || !Number.isSafeInteger(data.at)) throw new Error('Invalid claim.');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// The lineage tree as lab keys. Orgs get BIP-85 seeds from their parent; seats are accounts 1..n of
|
||||
// their org's seed; graduation retires a derived org and adopts a fresh root in its place.
|
||||
import { TestWallet } from './wallet.js';
|
||||
import { newMnemonic, assertMnemonic, childMnemonic, walletSecret, nostrKey, PATHS } from './lineage-keys.js';
|
||||
|
||||
export const KEYRING_KIND = 'lineage-lab-keyring';
|
||||
const NAME = /^[a-z0-9][a-z0-9.-]{0,31}$/;
|
||||
const EDGES = ['root', 'derived', 'adopted', 'imported'];
|
||||
// docs/lineage.md's drawing: [name, kind, parent name]. Order sets the indices (well-known 0', time 1', ...).
|
||||
export const DRAWING = [
|
||||
['well-known', 'org', 'yolopreneur'], ['mailr', 'org', 'well-known'], ['lineage', 'org', 'well-known'],
|
||||
['time', 'org', 'yolopreneur'], ['luna', 'seat', 'time'], ['sol', 'seat', 'time'], ['astra', 'seat', 'time'],
|
||||
['docscope', 'org', 'time'], ['dollhouse', 'org', 'time'],
|
||||
];
|
||||
|
||||
const add = (ring, node) => { const full = { id: `k${ring.seq++}`, ...node }; ring.nodes.push(full); return full; };
|
||||
export function nodeOf(ring, id) {
|
||||
const node = ring.nodes.find(n => n.id === id);
|
||||
if (!node) throw new Error('Unknown tree node.');
|
||||
return node;
|
||||
}
|
||||
export function createKeyring({ mnemonic = newMnemonic(), drawing = true, imported = null } = {}) {
|
||||
const ring = { kind: KEYRING_KIND, version: 1, root: assertMnemonic(mnemonic, 24), seq: 0, nodes: [] };
|
||||
const ids = { yolopreneur: add(ring, { name: 'yolopreneur', kind: 'org', parent: null, edge: 'root' }).id };
|
||||
if (drawing) for (const [name, kind, parent] of DRAWING) ids[name] = addChild(ring, ids[parent], name, kind).id;
|
||||
if (imported) add(ring, { name: 'test-wallet', kind: 'org', parent: null, edge: 'imported', secret: new TestWallet(imported).secret });
|
||||
return ring;
|
||||
}
|
||||
export function addChild(ring, parentId, name, kind = 'org') {
|
||||
const parent = nodeOf(ring, parentId);
|
||||
if (parent.kind !== 'org' || parent.edge === 'imported') throw new Error('Only orgs with a seed can have children.');
|
||||
if (parent.retired) throw new Error(`${parent.name} has graduated; its derived keys are retired.`);
|
||||
if (!NAME.test(name ?? '')) throw new Error('Names are lowercase letters, digits, dots and dashes, up to 32 characters.');
|
||||
if (ring.nodes.some(n => n.parent === parentId && n.name === name && !n.retired)) throw new Error(`${name} already exists under ${parent.name}.`);
|
||||
// Retired siblings keep their numbers: indices are never reused.
|
||||
const siblings = ring.nodes.filter(n => n.parent === parentId && n.kind === kind && n.edge === 'derived');
|
||||
if (kind === 'org') return add(ring, { name, kind, parent: parentId, edge: 'derived', index: siblings.length ? Math.max(...siblings.map(s => s.index)) + 1 : 0 });
|
||||
if (kind === 'seat') return add(ring, { name, kind, parent: parentId, edge: 'derived', account: siblings.length ? Math.max(...siblings.map(s => s.account)) + 1 : 1 });
|
||||
throw new Error('Unknown node kind.');
|
||||
}
|
||||
// Graduation flips an edge: the venture mints its own root; the derived node and its keys retire.
|
||||
export function graduate(ring, id) {
|
||||
const node = nodeOf(ring, id);
|
||||
if (node.kind !== 'org' || node.edge !== 'derived' || node.retired) throw new Error('Only a live org on a derived edge can graduate.');
|
||||
if (ring.nodes.some(n => n.parent === id && !n.retired)) throw new Error(`Graduate leaves first: ${node.name} still has live children.`);
|
||||
const own = add(ring, { name: node.name, kind: 'org', parent: node.parent, edge: 'adopted', mnemonic: newMnemonic(), from: id });
|
||||
node.retired = true; node.graduatedTo = own.id;
|
||||
return own;
|
||||
}
|
||||
export function mnemonicOf(ring, node) {
|
||||
if (node.edge === 'root') return ring.root;
|
||||
if (node.edge === 'adopted') return node.mnemonic;
|
||||
if (node.edge === 'imported') return null;
|
||||
const parentSeed = mnemonicOf(ring, nodeOf(ring, node.parent));
|
||||
return node.kind === 'seat' ? parentSeed : childMnemonic(parentSeed, node.index);
|
||||
}
|
||||
const accountOf = node => node.kind === 'seat' ? node.account : 0;
|
||||
export function keysOf(ring, node) {
|
||||
if (node.edge === 'imported') return { secret: node.secret, pubkey: null, npub: null };
|
||||
const mnemonic = mnemonicOf(ring, node), account = accountOf(node);
|
||||
return { secret: walletSecret(mnemonic, account), ...nostrKey(mnemonic, account) };
|
||||
}
|
||||
export const walletOf = (ring, node) => new TestWallet(keysOf(ring, node).secret);
|
||||
export function depthOf(ring, node) { let depth = 0; for (let n = node; n.parent; n = nodeOf(ring, n.parent)) depth++; return depth; }
|
||||
// Human-readable derivation, e.g. "root › 1' › 0' · m/86'/1'/0'/0/0".
|
||||
export function pathOf(ring, node) {
|
||||
if (node.edge === 'imported') return 'random key, imported from the old test wallet';
|
||||
const steps = [];
|
||||
const seedNode = node.kind === 'seat' ? nodeOf(ring, node.parent) : node;
|
||||
for (let n = seedNode; n.edge === 'derived'; n = nodeOf(ring, n.parent)) steps.unshift(`${n.index}'`);
|
||||
const origin = (() => { let n = seedNode; while (n.edge === 'derived') n = nodeOf(ring, n.parent); return n.edge === 'adopted' ? `own root (${n.name})` : 'lab root'; })();
|
||||
return `${[origin, ...steps].join(' › ')} · ${PATHS.wallet(accountOf(node))}`;
|
||||
}
|
||||
export const live = ring => ring.nodes.filter(n => !n.retired);
|
||||
export function encodeKeyring(ring) { return structuredClone(ring); }
|
||||
export function decodeKeyring(data) {
|
||||
if (data?.kind !== KEYRING_KIND || data.version !== 1) throw new Error('Unsupported tree keyring.');
|
||||
assertMnemonic(data.root, 24);
|
||||
if (!Number.isSafeInteger(data.seq) || !Array.isArray(data.nodes)) throw new Error('Invalid tree keyring.');
|
||||
const ids = new Set(data.nodes.map(n => n.id));
|
||||
if (ids.size !== data.nodes.length) throw new Error('Duplicate tree node ids.');
|
||||
if (data.nodes.filter(n => n.edge === 'root').length !== 1) throw new Error('A tree has exactly one lab root.');
|
||||
for (const n of data.nodes) {
|
||||
if (!/^k\d+$/.test(n.id) || !NAME.test(n.name) || !EDGES.includes(n.edge) || !['org', 'seat'].includes(n.kind)) throw new Error('Invalid tree node.');
|
||||
if ((n.parent === null) !== (n.edge === 'root' || n.edge === 'imported') || (n.parent !== null && !ids.has(n.parent))) throw new Error('Invalid tree parent.');
|
||||
if (n.edge === 'adopted') assertMnemonic(n.mnemonic, 24);
|
||||
if (n.edge === 'imported') new TestWallet(n.secret);
|
||||
if (n.edge === 'derived' && (n.kind === 'org' ? !Number.isSafeInteger(n.index) || n.index < 0 : !Number.isSafeInteger(n.account) || n.account < 1)) throw new Error('Invalid tree index.');
|
||||
}
|
||||
return structuredClone(data);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Lineage lab keys (docs/lineage.md): a BIP-85 seed per org, BIP-86 Taproot keys and NIP-06
|
||||
// Nostr keys per account. Lab only: the real root is the operator's call; nothing here is a real key.
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { generateMnemonic, mnemonicToSeedSync, entropyToMnemonic, validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import { bech32 } from '@scure/base';
|
||||
import { hmac } from '@noble/hashes/hmac';
|
||||
import { sha512 } from '@noble/hashes/sha2';
|
||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
|
||||
import { schnorr } from '@noble/curves/secp256k1';
|
||||
|
||||
export const PATHS = {
|
||||
// BIP-85, application 39 (BIP-39), English, 24 words: lineage spec v0.1's org seed.
|
||||
orgSeed: index => `m/83696968'/39'/0'/24'/${index}'`,
|
||||
// BIP-86 single-key Taproot. Coin type 1' (test networks) in the lab.
|
||||
wallet: (account, coin = 1) => `m/86'/${coin}'/${account}'/0/0`,
|
||||
// NIP-06, as on the lineage key tree page.
|
||||
nostr: account => `m/44'/1237'/${account}'/0/0`,
|
||||
};
|
||||
const MAX_INDEX = 2 ** 31 - 1;
|
||||
const masters = new Map(), children = new Map();
|
||||
|
||||
export const newMnemonic = () => generateMnemonic(wordlist, 256);
|
||||
export function assertMnemonic(mnemonic, words = 0) {
|
||||
if (typeof mnemonic !== 'string' || !validateMnemonic(mnemonic, wordlist) ||
|
||||
(words && mnemonic.split(' ').length !== words)) throw new Error('Invalid lab mnemonic.');
|
||||
return mnemonic;
|
||||
}
|
||||
function assertIndex(value, what) {
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > MAX_INDEX) throw new Error(`Invalid ${what}.`);
|
||||
return value;
|
||||
}
|
||||
// PBKDF2 is the slow step, so each mnemonic's master key is computed once per page.
|
||||
function master(mnemonic) {
|
||||
if (!masters.has(mnemonic)) masters.set(mnemonic, HDKey.fromMasterSeed(mnemonicToSeedSync(assertMnemonic(mnemonic))));
|
||||
return masters.get(mnemonic);
|
||||
}
|
||||
// BIP-85: entropy = HMAC-SHA512(key = "bip-entropy-from-k", msg = k) for the hardened child key k.
|
||||
export function bip85Entropy(root, path) {
|
||||
const key = root.derive(path).privateKey;
|
||||
if (!key) throw new Error('BIP-85 path produced no private key.');
|
||||
return hmac(sha512, utf8ToBytes('bip-entropy-from-k'), key);
|
||||
}
|
||||
export function childMnemonic(parentMnemonic, index) {
|
||||
const id = `${parentMnemonic}|${assertIndex(index, 'child index')}`;
|
||||
if (!children.has(id)) children.set(id, entropyToMnemonic(bip85Entropy(master(parentMnemonic), PATHS.orgSeed(index)).slice(0, 32), wordlist));
|
||||
return children.get(id);
|
||||
}
|
||||
export function walletSecret(mnemonic, account = 0, coin = 1) {
|
||||
return bytesToHex(master(mnemonic).derive(PATHS.wallet(assertIndex(account, 'account'), coin)).privateKey);
|
||||
}
|
||||
export function nostrKey(mnemonic, account = 0) {
|
||||
const pubkey = schnorr.getPublicKey(master(mnemonic).derive(PATHS.nostr(assertIndex(account, 'account'))).privateKey);
|
||||
return { pubkey: bytesToHex(pubkey), npub: bech32.encode('npub', bech32.toWords(pubkey)) };
|
||||
}
|
||||
@@ -151,3 +151,34 @@ footer .made { color: var(--ink-2); font-weight: 400; letter-spacing: 0.04em; }
|
||||
|
||||
/* long ids and JSON wrap instead of widening the page */
|
||||
.moves, .moves code, .collectible, .inspector, #review-panel, #status { overflow-wrap: anywhere; }
|
||||
|
||||
/* The tree: lineage lab keys, act-as, graduation and the claim/countersign handoff. */
|
||||
.tree-section { margin-top: 28px; }
|
||||
.tree-list { list-style: none; margin: 0 0 22px; padding: 0; display: grid; gap: 8px; }
|
||||
.tree-node { border: 2px solid var(--ink); background: var(--concrete); padding: 10px 12px; display: grid; gap: 4px; min-width: 0; }
|
||||
.tree-node.acting { box-shadow: 5px 5px 0 var(--signal); }
|
||||
.tree-node.retired { border-style: dashed; opacity: 0.62; }
|
||||
.tree-node.depth-1 { margin-left: 18px; } .tree-node.depth-2 { margin-left: 36px; } .tree-node.depth-3 { margin-left: 54px; }
|
||||
.tree-node.depth-4 { margin-left: 72px; } .tree-node.depth-5 { margin-left: 90px; }
|
||||
.tree-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
|
||||
.tree-head strong { font: 700 var(--t-m)/1.2 var(--mono); overflow-wrap: anywhere; }
|
||||
.tree-head .tag { margin-left: 0; }
|
||||
.tag.tag-signal { background: var(--signal); color: var(--on-signal); }
|
||||
.tree-node p { margin: 0; overflow-wrap: anywhere; }
|
||||
.tree-node button, .tree-box button { justify-self: start; }
|
||||
.tree-path { font-family: var(--mono); }
|
||||
.tree-forms { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin: 0 0 8px; }
|
||||
.tree-box { border: 2px solid var(--ink); background: var(--slab); margin: 0; padding: 4px 14px 14px; display: grid; gap: 4px; align-content: start; min-width: 0; }
|
||||
.tree-box legend { font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; padding: 0 6px; }
|
||||
.tree-box label { margin-top: 10px; }
|
||||
.tree-box button { margin-top: 10px; }
|
||||
.tree-subhead { margin: 22px 0 10px; }
|
||||
.claims { display: grid; gap: 12px; }
|
||||
.claim { border: 2px solid var(--ink); background: var(--concrete); padding: 12px; display: grid; gap: 6px; min-width: 0; }
|
||||
.claim p { margin: 0; overflow-wrap: anywhere; }
|
||||
.claim pre { max-height: 220px; overflow: auto; margin: 4px 0; }
|
||||
.claim-actions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
@media (max-width: 700px) {
|
||||
.tree-node.depth-1 { margin-left: 8px; } .tree-node.depth-2 { margin-left: 16px; } .tree-node.depth-3 { margin-left: 24px; }
|
||||
.tree-node.depth-4 { margin-left: 32px; } .tree-node.depth-5 { margin-left: 40px; }
|
||||
}
|
||||
+4
-1
@@ -11,6 +11,7 @@ import { LocalRPC } from './adapters/chain.js';
|
||||
import { InscriptionAdapter, MAX_BYTES, MIME_TYPES } from './adapters/inscription.js';
|
||||
import { preview, clearPreviews } from './preview.js';
|
||||
import { STORAGE_KEY, readLab, mutateLab, decodeLab, encodeLab } from './storage.js';
|
||||
import { initTree, renderTree, actingLabel } from './tree.js';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const btc = sats => (sats / 1e8).toFixed(8);
|
||||
@@ -170,7 +171,7 @@ function render() {
|
||||
const wallet = lab?.wallet, chain = lab?.chain;
|
||||
const disabled = busy || failed;
|
||||
$('empty-wallet').hidden = !!wallet; $('active-wallet').hidden = !wallet;
|
||||
$('wallet-state').textContent = wallet ? (wallet.kind === 'extension-wallet' ? 'INSCRIBOOR / TEST' : 'LOCAL / TEST ONLY') : 'NOT CREATED';
|
||||
$('wallet-state').textContent = wallet ? (wallet.kind === 'extension-wallet' ? 'INSCRIBOOR / TEST' : actingLabel(lab) ?? 'LOCAL / TEST ONLY') : 'NOT CREATED';
|
||||
$('create-wallet').disabled = disabled; $('connect-inscriboor').disabled = disabled; $('load-wallet').disabled = disabled;
|
||||
const inscriboorNeedsUpdate = !!inscriboorProvider && inscriboorProvider.version !== INSCRIBOOR_RELEASE.version;
|
||||
$('connect-inscriboor').textContent = inscriboorNeedsUpdate ? 'Update Inscriboor ↓' : inscriboorProvider ? 'Connect Inscriboor ↗' : 'Get Inscriboor ↓';
|
||||
@@ -223,6 +224,7 @@ function render() {
|
||||
renderIndexes(chain);
|
||||
renderMempool(chain);
|
||||
renderBlocks(chain);
|
||||
renderTree(lab, disabled);
|
||||
inspect(false);
|
||||
}
|
||||
|
||||
@@ -413,5 +415,6 @@ function load() {
|
||||
}
|
||||
window.addEventListener('inscriboor#initialized', () => { inscriboorProvider = window.inscriboor; if (lab?.wallet?.kind === 'extension-wallet') lab.wallet.attach(inscriboorProvider); render(); });
|
||||
inscriboorProvider = window.inscriboor?.isInscriboor ? window.inscriboor : null;
|
||||
initTree({ mutate, message });
|
||||
initTheme();
|
||||
load();
|
||||
+7
-2
@@ -1,16 +1,21 @@
|
||||
import { minerPreference } from './miner.js';
|
||||
import { LocalChain } from './adapters/chain.js';
|
||||
import { loadWalletBackup, NETWORK } from './adapters/wallet.js';
|
||||
import { decodeKeyring, encodeKeyring } from './adapters/keyring.js';
|
||||
import { decodeClaim } from './adapters/handoff.js';
|
||||
export const STORAGE_KEY = 'super-testnet:lab:v1';
|
||||
export function decodeLab(raw) {
|
||||
if (!raw) return { wallet:null, chain:new LocalChain(), miner:minerPreference() };
|
||||
if (!raw) return { wallet:null, chain:new LocalChain(), miner:minerPreference(), keyring:null, claims:[] };
|
||||
const data = JSON.parse(raw);
|
||||
if (data.kind !== 'test-lab' || data.network !== NETWORK || data.version !== 1) throw new Error('Unsupported lab backup.');
|
||||
return { wallet:data.wallet ? loadWalletBackup(data.wallet) : null, chain:new LocalChain(data.chain), miner:minerPreference(data.miner) };
|
||||
if (data.claims !== undefined && !Array.isArray(data.claims)) throw new Error('Invalid claims in lab backup.');
|
||||
return { wallet:data.wallet ? loadWalletBackup(data.wallet) : null, chain:new LocalChain(data.chain), miner:minerPreference(data.miner),
|
||||
keyring:data.keyring ? decodeKeyring(data.keyring) : null, claims:(data.claims ?? []).map(decodeClaim) };
|
||||
}
|
||||
export const encodeLab = lab => JSON.stringify({
|
||||
kind:'test-lab', network:NETWORK, version:1,
|
||||
wallet:lab.wallet?.backup() ?? null, chain:lab.chain.snapshot(), miner:minerPreference(lab.miner),
|
||||
...(lab.keyring ? { keyring:encodeKeyring(lab.keyring) } : {}), ...(lab.claims?.length ? { claims:lab.claims } : {}),
|
||||
});
|
||||
export const readLab = () => decodeLab(localStorage.getItem(STORAGE_KEY));
|
||||
export function saveLab(lab) {
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
// The tree panel: lineage lab keys, acting as any node, graduation and the claim/countersign handoff.
|
||||
import { Script } from '@cmdcode/tapscript';
|
||||
import { createKeyring, addChild, graduate, walletOf, keysOf, nodeOf, pathOf, depthOf, live } from './adapters/keyring.js';
|
||||
import { makeClaim, countersign, claimStatus } from './adapters/handoff.js';
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const el = (tag, text, className) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; };
|
||||
const short = id => `${id.slice(0, 12)}…${id.slice(-8)}`;
|
||||
const btc = sats => (sats / 1e8).toFixed(8);
|
||||
const scriptHex = script => Script.encode(script, false).hex;
|
||||
const wallets = new Map();
|
||||
let ctx, preset = null;
|
||||
|
||||
function walletFor(ring, node) {
|
||||
const key = `${ring.root}|${node.id}|${node.edge}|${node.mnemonic ?? node.secret ?? ''}`;
|
||||
if (!wallets.has(key)) wallets.set(key, walletOf(ring, node));
|
||||
return wallets.get(key);
|
||||
}
|
||||
const nameFor = i => i.metadata?.name ? `${i.metadata.name}${i.metadata.lineage ? ` (${i.metadata.lineage})` : ''}` : i.text ? i.text.slice(0, 24) : short(i.id);
|
||||
const actingNode = lab => lab?.keyring?.nodes.find(n => walletFor(lab.keyring, n).pubkey === lab.wallet?.pubkey) ?? null;
|
||||
export function actingLabel(lab) { const node = actingNode(lab); return node ? `TREE · ${node.name.toUpperCase()}` : null; }
|
||||
function fill(id, options, keep = $(id).value) {
|
||||
$(id).replaceChildren(...options.map(([value, text]) => new Option(text, value)));
|
||||
if (options.some(([value]) => value === keep)) $(id).value = keep;
|
||||
}
|
||||
const nodeName = node => `${node.name}${node.edge === 'adopted' ? ' (own root)' : ''}`;
|
||||
|
||||
export function initTree(context) {
|
||||
ctx = context;
|
||||
$('start-tree').onclick = () => ctx.mutate(next => {
|
||||
if (next.keyring) throw new Error('This lab already has a tree.');
|
||||
if (next.wallet?.kind === 'extension-wallet') throw new Error('The tree signs with local lab keys. Reset the lab to start a tree; Inscriboor is untouched.');
|
||||
next.keyring = createKeyring({ imported: next.wallet?.kind === 'test-wallet' ? next.wallet.secret : null });
|
||||
next.wallet = walletOf(next.keyring, next.keyring.nodes[0]);
|
||||
}, 'Tree started from a fresh lab root: the drawing in docs/lineage.md, as keys. Acting as yolopreneur.');
|
||||
$('add-child').onclick = () => {
|
||||
const parent = $('child-parent').value, kind = $('child-kind').value, name = $('child-name').value.trim();
|
||||
ctx.mutate(next => { addChild(next.keyring, parent, name, kind); },
|
||||
`${name} added: a new ${kind === 'seat' ? 'seat (an account of its org)' : 'org (a BIP-85 seed)'}.`, () => { $('child-name').value = ''; });
|
||||
};
|
||||
$('fund-recipient').onclick = () => {
|
||||
const to = $('handoff-to').value, label = $('handoff-to').selectedOptions[0]?.textContent ?? 'the recipient';
|
||||
ctx.mutate(next => { next.chain.mine(walletOf(next.keyring, nodeOf(next.keyring, to)).script); },
|
||||
`Mined a block to ${label}: 50 fake BTC for its fees. Anything pending confirmed too.`);
|
||||
};
|
||||
$('claim').onclick = () => {
|
||||
const inscription = $('handoff-inscription').value, to = $('handoff-to').value, label = $('handoff-to').selectedOptions[0]?.textContent ?? 'the recipient';
|
||||
ctx.mutate(next => {
|
||||
if (!inscription) throw new Error('Choose an inscription held by a node in the tree.');
|
||||
const claim = makeClaim(next.chain, inscription, walletOf(next.keyring, nodeOf(next.keyring, to)));
|
||||
next.claims = [...(next.claims ?? []).filter(c => c.txid !== claim.txid), claim];
|
||||
}, `Claim signed by ${label}: its signature already fixes where the inscription goes. Next, the holder countersigns.`);
|
||||
};
|
||||
$('graduate').onclick = () => {
|
||||
const id = $('graduate-node').value, label = $('graduate-node').selectedOptions[0]?.textContent ?? 'The venture';
|
||||
ctx.mutate(next => {
|
||||
if (!id) throw new Error('No leaf org on a derived edge to graduate.');
|
||||
const own = graduate(next.keyring, id);
|
||||
preset = { to: own.id, name: own.name };
|
||||
}, `${label} minted its own root; its derived keys are retired. Next: mine a block to the new root, then hand it its inscription.`);
|
||||
};
|
||||
}
|
||||
function claimCard(lab, claim, disabled) {
|
||||
const ring = lab.keyring, chain = lab.chain;
|
||||
const holder = ring.nodes.find(n => scriptHex(walletFor(ring, n).script) === claim.holder.script);
|
||||
const to = ring.nodes.find(n => walletFor(ring, n).pubkey === claim.to);
|
||||
const status = claimStatus(chain, claim);
|
||||
const item = [...chain.transactions.values()].find(r => r.inscription?.id === claim.inscription)?.inscription;
|
||||
const card = el('article', undefined, 'claim'), head = el('div', undefined, 'tree-head');
|
||||
head.append(el('strong', `${item ? nameFor(item) : short(claim.inscription)} → ${to ? nodeName(to) : short(claim.to)}`), el('span', status.toUpperCase(), 'tag'));
|
||||
card.append(head, el('p', `Holder: ${holder ? nodeName(holder) : 'outside this tree'} · fee from the recipient · txid ${short(claim.txid)}`, 'muted'));
|
||||
card.append(el('p', status === 'open' ? 'Signed by the recipient. This object is what would travel over A2A or a relay.' : 'Stale: an input it spends was already spent, so it can never confirm.', 'muted'), el('pre', JSON.stringify(claim, null, 2)));
|
||||
const sign = el('button', '2 · Holder countersigns', 'primary'), drop = el('button', 'Drop', 'quiet');
|
||||
sign.disabled = disabled || status !== 'open' || !holder;
|
||||
sign.onclick = () => ctx.mutate(next => {
|
||||
const found = (next.claims ?? []).find(c => c.txid === claim.txid);
|
||||
if (!found) throw new Error('That claim is gone; another tab may have handled it.');
|
||||
const signer = next.keyring.nodes.find(n => scriptHex(walletFor(next.keyring, n).script) === found.holder.script);
|
||||
if (!signer) throw new Error('The holder is not in this tree, so nothing here can countersign.');
|
||||
countersign(next.chain, found, walletFor(next.keyring, signer));
|
||||
next.claims = next.claims.filter(c => c.txid !== claim.txid);
|
||||
}, 'Countersigned: the parent vouched. The handoff is in the mempool; mine a block to confirm it.');
|
||||
drop.disabled = disabled;
|
||||
drop.onclick = () => ctx.mutate(next => { next.claims = (next.claims ?? []).filter(c => c.txid !== claim.txid); }, 'Claim dropped.');
|
||||
const actions = el('div', undefined, 'claim-actions'); actions.append(sign, drop); card.append(actions);
|
||||
return card;
|
||||
}
|
||||
function row(lab, node, acting, disabled) {
|
||||
const ring = lab.keyring, chain = lab.chain, wallet = walletFor(ring, node), keys = keysOf(ring, node);
|
||||
const held = chain.coins(wallet.script).flatMap(c => c.inscriptions ?? []);
|
||||
const li = el('li', undefined, `tree-node depth-${Math.min(depthOf(ring, node), 5)}${node.retired ? ' retired' : ''}${node === acting ? ' acting' : ''}`);
|
||||
const head = el('div', undefined, 'tree-head');
|
||||
head.append(el('strong', node.name), el('span', node.retired ? 'RETIRED' : node.kind === 'seat' ? 'SEAT' : node.edge.toUpperCase(), 'tag'));
|
||||
if (node === acting) head.append(el('span', 'ACTING', 'tag tag-signal'));
|
||||
li.append(head, el('p', `${btc(chain.balance(wallet.script))} BTC · ${held.length} inscription${held.length === 1 ? '' : 's'}${held.length ? `: ${held.map(nameFor).join(', ')}` : ''}`, 'muted'));
|
||||
li.append(el('p', `${pathOf(ring, node)}${keys.npub ? ` · ${keys.npub.slice(0, 16)}…` : ''} · ${short(wallet.address)}`, 'muted tree-path'));
|
||||
const act = el('button', node === acting ? 'Acting' : 'Act as', 'quiet');
|
||||
act.disabled = disabled || node === acting;
|
||||
act.setAttribute('aria-label', `Act as ${node.name}${node.retired ? ' (retired)' : ''}`);
|
||||
act.onclick = () => ctx.mutate(next => { next.wallet = walletOf(next.keyring, nodeOf(next.keyring, node.id)); },
|
||||
`Acting as ${node.name}${node.retired ? ' (retired keys)' : ''}. Mining, inscribing and signing above now use its key.`);
|
||||
li.append(act);
|
||||
return li;
|
||||
}
|
||||
export function renderTree(lab, disabled) {
|
||||
const ring = lab?.keyring;
|
||||
$('tree-empty').hidden = !!ring; $('tree-active').hidden = !ring;
|
||||
$('start-tree').disabled = disabled || lab?.wallet?.kind === 'extension-wallet';
|
||||
if (!ring) { $('tree-state').textContent = 'NO TREE'; return; }
|
||||
const chain = lab.chain, acting = actingNode(lab);
|
||||
$('tree-state').textContent = acting ? `ACTING AS ${acting.name.toUpperCase()}` : 'NOT ACTING AS A NODE';
|
||||
const order = [], visit = node => { order.push(node); ring.nodes.filter(c => c.parent === node.id).forEach(visit); };
|
||||
ring.nodes.filter(n => n.parent === null).forEach(visit);
|
||||
$('tree-list').replaceChildren(...order.map(node => row(lab, node, acting, disabled)));
|
||||
fill('child-parent', live(ring).filter(n => n.kind === 'org' && n.edge !== 'imported').map(n => [n.id, nodeName(n)]));
|
||||
const byScript = new Map(ring.nodes.map(n => [scriptHex(walletFor(ring, n).script), n]));
|
||||
const held = [];
|
||||
for (const coin of chain.utxos.values()) {
|
||||
const holder = byScript.get(scriptHex(coin.scriptPubKey));
|
||||
if (holder) for (const i of coin.inscriptions ?? []) held.push([i, holder]);
|
||||
}
|
||||
const presetInscription = preset ? held.filter(([i]) => i.metadata?.name === preset.name).at(-1)?.[0].id : undefined;
|
||||
fill('handoff-inscription', held.map(([i, holder]) => [i.id, `${nameFor(i)} · held by ${nodeName(holder)}`]), presetInscription ?? $('handoff-inscription').value);
|
||||
fill('handoff-to', live(ring).map(n => [n.id, nodeName(n)]), preset?.to ?? $('handoff-to').value);
|
||||
preset = null;
|
||||
fill('graduate-node', live(ring).filter(n => n.kind === 'org' && n.edge === 'derived' && !ring.nodes.some(c => c.parent === n.id && !c.retired)).map(n => [n.id, nodeName(n)]));
|
||||
for (const id of ['child-parent', 'child-kind', 'child-name', 'add-child', 'handoff-inscription', 'handoff-to', 'fund-recipient', 'graduate-node']) $(id).disabled = disabled;
|
||||
$('claim').disabled = disabled || !held.length;
|
||||
$('graduate').disabled = disabled || !$('graduate-node').options.length;
|
||||
const claims = lab.claims ?? [];
|
||||
$('claims').replaceChildren(...(claims.length ? claims.map(c => claimCard(lab, c, disabled)) : [el('p', 'No claims yet. A recipient signs one under “Hand off an inscription”.', 'muted')]));
|
||||
}
|
||||
+17
-18
@@ -7,7 +7,7 @@ test('create → mine → mempool → inscription → block/transaction → relo
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button',{name:'Mine now'})).toBeDisabled();
|
||||
await expect(page.locator('#mempool-list')).toContainText('Mempool empty');
|
||||
await page.getByRole('button',{name:'Create test wallet'}).click();
|
||||
await page.getByRole('button',{name:'Use local test wallet'}).click();
|
||||
await expect(page.locator('#address')).toHaveValue(/^bcrt1p/);
|
||||
await page.getByRole('button',{name:'Mine now'}).click();
|
||||
await expect(page.locator('#balance')).toHaveText('50.00000000');
|
||||
@@ -54,7 +54,7 @@ test('create → mine → mempool → inscription → block/transaction → relo
|
||||
const backupPath=await download.path();
|
||||
page.on('dialog',dialog=>dialog.accept());
|
||||
await page.getByRole('button',{name:'Reset local lab'}).click();
|
||||
await expect(page.getByRole('button',{name:'Create test wallet'})).toBeVisible();
|
||||
await expect(page.getByRole('button',{name:'Use local test wallet'})).toBeVisible();
|
||||
await page.locator('#load-wallet').setInputFiles(backupPath);
|
||||
await expect(page.locator('.collectible pre')).toHaveText(payload);
|
||||
await expect(page.locator('#height')).toHaveText('2');
|
||||
@@ -67,26 +67,25 @@ test('create → mine → mempool → inscription → block/transaction → relo
|
||||
});
|
||||
|
||||
for (const colorScheme of ['light', 'dark']) {
|
||||
test(`dark default and explicit theme persistence with ${colorScheme} system preference`, async ({page}) => {
|
||||
test(`system default and explicit theme persistence with ${colorScheme} system preference`, async ({page}) => {
|
||||
const other = colorScheme === 'dark' ? 'light' : 'dark';
|
||||
await page.emulateMedia({colorScheme});
|
||||
await page.goto('/');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme','dark');
|
||||
await expect(page.getByRole('button',{name:'Switch to light mode'})).toBeVisible();
|
||||
await page.getByRole('button',{name:'Switch to light mode'}).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme','light');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', colorScheme);
|
||||
await page.getByRole('button',{name:`Switch to ${other} mode`}).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', other);
|
||||
await page.reload();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme','light');
|
||||
await expect(page.getByRole('button',{name:'Switch to dark mode'})).toBeVisible();
|
||||
expect(await page.evaluate(() => localStorage.getItem('super-testnet-theme'))).toBe('light');
|
||||
await page.getByRole('button',{name:'Switch to dark mode'}).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', other);
|
||||
expect(await page.evaluate(() => localStorage.getItem('orgchain-theme'))).toBe(other);
|
||||
await page.getByRole('button',{name:`Switch to ${colorScheme} mode`}).click();
|
||||
await page.reload();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme','dark');
|
||||
expect(await page.evaluate(() => localStorage.getItem('super-testnet-theme'))).toBe('dark');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', colorScheme);
|
||||
expect(await page.evaluate(() => localStorage.getItem('orgchain-theme'))).toBe(colorScheme);
|
||||
});
|
||||
}
|
||||
|
||||
test('wallet backup reload and rejected oversized or invalid input',async({page})=>{
|
||||
await page.goto('/'); await page.getByRole('button',{name:'Create test wallet'}).click();
|
||||
await page.goto('/'); await page.getByRole('button',{name:'Use local test wallet'}).click();
|
||||
await expect(page.locator('#address')).toHaveValue(/^bcrt1p/);
|
||||
const address=await page.locator('#address').inputValue();
|
||||
const [download]=await Promise.all([page.waitForEvent('download'),page.getByRole('button',{name:'Save test wallet'}).click()]);
|
||||
@@ -106,7 +105,7 @@ test('wallet backup reload and rejected oversized or invalid input',async({page}
|
||||
});
|
||||
|
||||
test('two tabs serialize mining and refresh shared storage',async({page,context})=>{
|
||||
await page.goto('/'); await page.getByRole('button',{name:'Create test wallet'}).click();
|
||||
await page.goto('/'); await page.getByRole('button',{name:'Use local test wallet'}).click();
|
||||
await expect(page.locator('#address')).toHaveValue(/^bcrt1p/);
|
||||
const other=await context.newPage(); await other.goto('/');
|
||||
await Promise.all([page.getByRole('button',{name:'Mine now'}).click(),other.getByRole('button',{name:'Mine now'}).click()]);
|
||||
@@ -117,7 +116,7 @@ test('two tabs serialize mining and refresh shared storage',async({page,context}
|
||||
|
||||
test('storage failure leaves the displayed and persisted chain unchanged', async ({page}) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button',{name:'Create test wallet'}).click();
|
||||
await page.getByRole('button',{name:'Use local test wallet'}).click();
|
||||
await expect(page.locator('#address')).toHaveValue(/^bcrt1p/);
|
||||
await page.evaluate(()=>{ Storage.prototype.setItem = () => { throw new DOMException('Quota exceeded','QuotaExceededError'); }; });
|
||||
await page.getByRole('button',{name:'Mine now'}).click();
|
||||
@@ -294,7 +293,7 @@ ord.render(JSON.stringify(result));
|
||||
await expect(page.locator('.collectible').nth(1)).toContainText('Delegated by');
|
||||
// Chromium emits request events for CSP-denied XHR attempts; none reached the network.
|
||||
for (const request of remote) expect(request.failure()?.errorText).toMatch(/csp|ERR_BLOCKED_BY_CSP/i);
|
||||
await expect(page.locator('h1')).toContainText('Bitcoin universe');
|
||||
await expect(page.locator('h1')).toContainText('Orgchain');
|
||||
});
|
||||
|
||||
test('recursive template and reinscription stack persist across mining and reload', async ({page}) => {
|
||||
@@ -310,7 +309,7 @@ test('recursive template and reinscription stack persist across mining and reloa
|
||||
await expect(page.locator('.collectible').first()).toContainText('stack position 1');
|
||||
await expect(page.locator('.collectible').last()).toContainText('stack position 0');
|
||||
await expect(page.locator('.collectible').first().getByRole('link',{name:/Sat history/})).toHaveCount(2);
|
||||
await page.locator('#mine').click(); await page.reload();
|
||||
await page.locator('#mine').click(); await expect(page.locator('#height')).toHaveText('2'); await page.reload();
|
||||
await expect(page.locator('.collectible')).toHaveCount(2);
|
||||
await expect(page.frameLocator('.collectible iframe').locator('#recursive-output')).toContainText('"height": 2');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { entropyToMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import { bytesToHex } from '@noble/hashes/utils';
|
||||
import { schnorr } from '@noble/curves/secp256k1';
|
||||
import { bip85Entropy, childMnemonic, walletSecret, nostrKey, PATHS, newMnemonic } from '../src/adapters/lineage-keys.js';
|
||||
import { createKeyring, addChild, graduate, walletOf, keysOf, nodeOf, mnemonicOf, pathOf, encodeKeyring, decodeKeyring } from '../src/adapters/keyring.js';
|
||||
import { makeClaim, countersign, claimStatus, decodeClaim } from '../src/adapters/handoff.js';
|
||||
import { TestWallet } from '../src/adapters/wallet.js';
|
||||
import { LocalChain } from '../src/adapters/chain.js';
|
||||
import { InscriptionAdapter } from '../src/adapters/inscription.js';
|
||||
import { encodeLab, decodeLab } from '../src/storage.js';
|
||||
|
||||
const XPRV = 'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb';
|
||||
const named = (ring, name) => ring.nodes.find(n => n.name === name && !n.retired);
|
||||
|
||||
test('BIP-85 spec vectors: core entropy and the 24-word BIP-39 application', () => {
|
||||
const root = HDKey.fromExtendedKey(XPRV);
|
||||
assert.equal(bytesToHex(root.derive("m/83696968'/0'/0'").privateKey), 'cca20ccb0e9a90feb0912870c3323b24874b0ca3d8018c4b96d0b97c0e82ded0');
|
||||
assert.equal(bytesToHex(bip85Entropy(root, "m/83696968'/0'/0'")), 'efecfbccffea313214232d29e71563d941229afb4338c21f9517c41aaa0d16f00b83d2a09ef747e7a64e8e2bd5a14869e693da66ce94ac2da570ab7ee48618f7');
|
||||
const entropy = bip85Entropy(root, PATHS.orgSeed(0)).slice(0, 32);
|
||||
assert.equal(bytesToHex(entropy), 'ae131e2312cdc61331542efe0d1077bac5ea803adf24b313a4f0e48e9c51f37f');
|
||||
assert.equal(entropyToMnemonic(entropy, wordlist), 'puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano');
|
||||
});
|
||||
test('BIP-86 and NIP-06 spec vectors', () => {
|
||||
const abandon = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
assert.equal(bytesToHex(schnorr.getPublicKey(walletSecret(abandon, 0, 0))), 'cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115');
|
||||
assert.deepEqual(nostrKey('leader monkey parrot ring guide accident before fence cannon height naive bean', 0), {
|
||||
pubkey: '17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917', npub: 'npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu' });
|
||||
});
|
||||
test('the drawing as keys: deterministic, indices from the doc, seats as accounts, never reused', () => {
|
||||
const mnemonic = newMnemonic(), ring = createKeyring({ mnemonic }), again = createKeyring({ mnemonic });
|
||||
const root = ring.nodes[0], wk = named(ring, 'well-known'), time = named(ring, 'time'), luna = named(ring, 'luna'), astra = named(ring, 'astra');
|
||||
assert.deepEqual([wk.index, time.index, named(ring, 'docscope').index, named(ring, 'dollhouse').index, named(ring, 'lineage').index], [0, 1, 0, 1, 1]);
|
||||
assert.deepEqual([luna.account, astra.account], [1, 3]);
|
||||
assert.equal(mnemonicOf(ring, time), childMnemonic(mnemonic, 1));
|
||||
assert.equal(walletOf(ring, time).address, walletOf(again, named(again, 'time')).address);
|
||||
assert.equal(keysOf(ring, luna).npub, nostrKey(mnemonicOf(ring, time), 1).npub);
|
||||
assert.notEqual(walletOf(ring, luna).address, walletOf(ring, time).address);
|
||||
assert.equal(new Set(ring.nodes.map(n => walletOf(ring, n).address)).size, ring.nodes.length);
|
||||
assert.match(pathOf(ring, named(ring, 'docscope')), /^lab root › 1' › 0' · m\/86'\/1'\/0'\/0\/0$/);
|
||||
assert.throws(() => addChild(ring, time.id, 'docscope'), /already exists/);
|
||||
assert.throws(() => addChild(ring, luna.id, 'x'), /Only orgs/);
|
||||
assert.throws(() => addChild(ring, root.id, 'Bad Name'), /lowercase/);
|
||||
const own = graduate(ring, named(ring, 'dollhouse').id);
|
||||
assert.equal(addChild(ring, time.id, 'phly').index, 2, 'the retired index 1 is not reused');
|
||||
assert.equal(own.edge, 'adopted'); assert.equal(nodeOf(ring, own.from).retired, true);
|
||||
assert.match(pathOf(ring, own), /^own root \(dollhouse\) · /);
|
||||
assert.throws(() => graduate(ring, time.id), /leaves first/);
|
||||
assert.throws(() => graduate(ring, root.id), /derived edge/);
|
||||
assert.deepEqual(decodeKeyring(JSON.parse(JSON.stringify(encodeKeyring(ring)))), ring);
|
||||
assert.throws(() => decodeKeyring({ ...ring, root: 'not words' }), /mnemonic/);
|
||||
assert.throws(() => decodeKeyring({ ...ring, nodes: [...ring.nodes, { ...ring.nodes[1] }] }), /Duplicate/);
|
||||
const legacy = TestWallet.create(), withLegacy = createKeyring({ imported: legacy.secret, drawing: false });
|
||||
assert.equal(walletOf(withLegacy, withLegacy.nodes[1]).address, legacy.address);
|
||||
});
|
||||
test('graduation handoff: the venture claims, the parent countersigns, the inscription moves', () => {
|
||||
const ring = createKeyring(), chain = new LocalChain();
|
||||
const time = walletOf(ring, named(ring, 'time'));
|
||||
chain.mine(time.script);
|
||||
const ins = new InscriptionAdapter(chain, time);
|
||||
const timeId = ins.inscribe('time', null, { lineage: 'root', name: 'time' }, { metaprotocol: 'lineage/v0' });
|
||||
chain.mine(time.script);
|
||||
const docId = ins.inscribe('docscope', timeId, { lineage: 'birth', name: 'docscope' }, { metaprotocol: 'lineage/v0' });
|
||||
chain.mine(time.script);
|
||||
const own = walletOf(ring, graduate(ring, named(ring, 'docscope').id));
|
||||
assert.throws(() => makeClaim(chain, docId, own), /plain coin/);
|
||||
chain.mine(own.script);
|
||||
assert.throws(() => makeClaim(chain, docId, time), /already holds/);
|
||||
const claim = makeClaim(chain, docId, own);
|
||||
assert.equal(claimStatus(chain, claim), 'open');
|
||||
assert.deepEqual(decodeClaim(JSON.parse(JSON.stringify(claim))), claim);
|
||||
assert.throws(() => countersign(chain, claim, own), /current holder/);
|
||||
assert.throws(() => countersign(chain, { ...claim, to: time.pubkey }, time), /signature/i, 'a claim edited after signing fails');
|
||||
assert.throws(() => countersign(chain, { ...claim, sig: '00'.repeat(64) }, time), /signature/i);
|
||||
const restored = decodeLab(encodeLab({ wallet: time, chain, keyring: ring, claims: [claim] }));
|
||||
assert.deepEqual(restored.claims, [claim]); assert.deepEqual(restored.keyring, ring);
|
||||
assert.equal(countersign(chain, claim, time), claim.txid);
|
||||
chain.mine(time.script);
|
||||
assert.ok(new InscriptionAdapter(chain, own).list().some(i => i.id === docId && i.parent === timeId));
|
||||
assert.ok(!new InscriptionAdapter(chain, time).list().some(i => i.id === docId));
|
||||
assert.ok(new InscriptionAdapter(chain, time).list().some(i => i.id === timeId), 'the parent keeps its own inscription');
|
||||
assert.equal(claimStatus(chain, claim), 'stale');
|
||||
assert.throws(() => countersign(chain, claim, time), /spent/);
|
||||
assert.deepEqual(decodeLab(encodeLab({ wallet: time, chain })).claims, []);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const outside = page => page.evaluate(() => {
|
||||
const box = document.getElementById('tree').getBoundingClientRect();
|
||||
return [...document.querySelectorAll('#tree *')].filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && (r.right > box.right + 1 || r.left < box.left - 1); })
|
||||
.map(e => `${e.tagName.toLowerCase()}.${e.className}`).slice(0, 5);
|
||||
});
|
||||
|
||||
test('tree: act as time, birth docscope, graduate it, recipient claims, holder countersigns', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Start the tree' }).click();
|
||||
await expect(page.locator('#tree-state')).toHaveText('ACTING AS YOLOPRENEUR');
|
||||
await expect(page.locator('#wallet-state')).toHaveText('TREE · YOLOPRENEUR');
|
||||
await expect(page.locator('#tree-list .tree-node')).toHaveCount(10);
|
||||
await page.getByRole('button', { name: 'Act as time', exact: true }).click();
|
||||
await expect(page.locator('#tree-state')).toHaveText('ACTING AS TIME');
|
||||
await page.getByRole('button', { name: 'Mine now' }).click();
|
||||
await expect(page.locator('#balance')).toHaveText('50.00000000');
|
||||
const inscribe = async (text, metadata, withParent = false) => {
|
||||
await page.getByLabel('What will you put on your chain?').fill(text);
|
||||
await page.getByLabel('JSON metadata (optional, independent of body)').fill(metadata);
|
||||
await page.getByLabel('Metaprotocol (optional, independent field)').fill('lineage/v0');
|
||||
if (withParent) await page.getByLabel('Parent inscription (optional, immutable)').selectOption({ index: 1 });
|
||||
await page.getByRole('button', { name: 'Review inscription' }).click();
|
||||
await page.getByRole('button', { name: 'Sign & inscribe' }).click();
|
||||
await page.getByRole('button', { name: 'Mine now' }).click();
|
||||
await expect(page.locator('#pending')).toHaveText('0');
|
||||
};
|
||||
await inscribe('time', '{"lineage":"root","name":"time"}');
|
||||
await expect(page.locator('#tree-list .tree-node.acting')).toContainText('1 inscription: time (root)');
|
||||
await inscribe('docscope', '{"lineage":"birth","name":"docscope"}', true);
|
||||
await expect(page.locator('#tree-list .tree-node.acting')).toContainText('2 inscriptions');
|
||||
await page.locator('#graduate-node').selectOption({ label: 'docscope' });
|
||||
await page.getByRole('button', { name: 'Mint its own root' }).click();
|
||||
await expect(page.locator('#tree-list .tree-node.retired')).toContainText('docscope');
|
||||
await expect(page.locator('#handoff-to option:checked')).toHaveText('docscope (own root)');
|
||||
await expect(page.locator('#handoff-inscription option:checked')).toHaveText('docscope (birth) · held by time');
|
||||
await page.getByRole('button', { name: '1 · Recipient claims' }).click();
|
||||
await expect(page.locator('#status')).toContainText('plain coin');
|
||||
await page.getByRole('button', { name: 'Mine a block to the recipient' }).click();
|
||||
await page.getByRole('button', { name: '1 · Recipient claims' }).click();
|
||||
await expect(page.locator('.claim')).toHaveCount(1);
|
||||
await expect(page.locator('.claim .tag')).toHaveText('OPEN');
|
||||
await expect(page.locator('.claim pre')).toContainText('"kind": "lineage-claim"');
|
||||
expect(await outside(page)).toEqual([]);
|
||||
await page.getByRole('button', { name: '2 · Holder countersigns' }).click();
|
||||
await expect(page.locator('#pending')).toHaveText('1');
|
||||
await expect(page.locator('.claim')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Mine now' }).click();
|
||||
const own = page.locator('#tree-list .tree-node').filter({ hasText: 'ADOPTED' });
|
||||
await expect(own).toContainText('1 inscription: docscope (birth)');
|
||||
await expect(own).toContainText('own root (docscope)');
|
||||
await expect(page.locator('#tree-list .tree-node.acting')).toContainText('1 inscription: time (root)');
|
||||
await page.reload();
|
||||
await expect(own).toContainText('1 inscription: docscope (birth)');
|
||||
await expect(page.locator('#tree-state')).toHaveText('ACTING AS TIME');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy();
|
||||
expect(await outside(page)).toEqual([]);
|
||||
await page.locator('#tree').screenshot({ path: `test-results/tree-${test.info().project.name}.png` });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
Reference in new issue
Block a user