The chain takes ordinary spends of up to eight inputs and outputs, whose key-path signatures may carry SIGHASH_SINGLE|ANYONECANPAY (0x83); reveals keep their two-in, two-out shape. A new make op records an item, an inscription its maker holds. Any node can offer test BTC for an item another node holds, in any holding: the buyer's SegWit key signs the whole deal, the offer stands in the lab, and the holder accepts by countersigning the item's input, so the item and the payment swap in one transaction. Offers go stale when a promised coin moves or a side re-keys or closes, and payments spend unpromised coins first. Sends take up to eight coins. Mainnet is a third mining setting: no holding mines, beats pay the lab wallet, and holdings are funded from it. The sample ends on lineage's open offer for sol's launch-kit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
303 lines
18 KiB
JavaScript
303 lines
18 KiB
JavaScript
import { test, expect } from '@playwright/test';
|
||
|
||
async function open(page) {
|
||
const problems = [];
|
||
page.on('console', m => { if (m.type() === 'error') problems.push(m.text()); });
|
||
page.on('pageerror', e => problems.push(String(e)));
|
||
await page.goto('./');
|
||
await expect(page.getByRole('heading', { name: 'Orgchain' })).toBeVisible();
|
||
return problems;
|
||
}
|
||
const say = page => page.locator('#say');
|
||
const row = (page, name) => page.locator('#tree-rows .sel').filter({ has: page.locator('.l', { hasText: new RegExp(`^${name}$`) }) });
|
||
const verb = (page, name) => page.locator(`[data-verb="${name}"]`).click();
|
||
const submit = (page, form) => page.locator(`#f-${form} [type="submit"]`).click();
|
||
async function found(page, name) { await page.locator('#found-name').fill(name); await submit(page, 'found'); }
|
||
async function birth(page, name, kind = 'org') {
|
||
await verb(page, 'birth');
|
||
await page.locator('#birth-name').fill(name);
|
||
await page.locator(`#f-birth input[value="${kind}"]`).check();
|
||
await submit(page, 'birth');
|
||
}
|
||
async function pay(page, to, amount) {
|
||
await verb(page, 'send');
|
||
await page.locator('#send-to').selectOption({ label: to });
|
||
await page.locator('#send-amount').fill(amount);
|
||
await submit(page, 'send');
|
||
}
|
||
const slide = (page, beat) => page.locator('#beat').evaluate((el, v) => { el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); }, String(beat));
|
||
|
||
test('found a holding, then birth, award, decide, graduate and close at the default tempo', async ({ page }) => {
|
||
const problems = await open(page);
|
||
await found(page, 'Acme Holdings');
|
||
await expect(say(page)).toHaveText('acme-holdings is founded, at beat 2.');
|
||
await birth(page, 'studio');
|
||
await expect(say(page)).toHaveText('studio is born to acme-holdings, at beat 3.');
|
||
await row(page, 'studio').click();
|
||
await verb(page, 'birth');
|
||
await expect(page.locator('#act-why')).toHaveText('studio has no test BTC for gas. acme-holdings can send some.');
|
||
await row(page, 'acme-holdings').click();
|
||
await pay(page, 'acme-holdings / studio', '5');
|
||
await expect(say(page)).toHaveText('acme-holdings sent 5 tBTC to studio, at beat 4.');
|
||
await row(page, 'studio').click();
|
||
await birth(page, 'bot', 'agent');
|
||
await expect(say(page)).toHaveText('bot is born to studio, at beat 5.');
|
||
await verb(page, 'award');
|
||
await page.locator('#award-to').selectOption({ label: 'acme-holdings / studio / bot (agent)' });
|
||
await page.locator('#award-skill').fill('launch');
|
||
await page.locator('#award-belt').selectOption('green');
|
||
await submit(page, 'award');
|
||
await expect(say(page)).toHaveText('bot holds a green belt in launch, at beat 6.');
|
||
await verb(page, 'decide');
|
||
await page.locator('#decide-title').fill('Ship weekly');
|
||
await submit(page, 'decide');
|
||
await expect(say(page)).toHaveText('studio decided “Ship weekly”, at beat 7.');
|
||
await verb(page, 'graduate');
|
||
await page.locator('#graduate-go').click();
|
||
await expect(say(page)).toHaveText('studio graduates from acme-holdings, at beat 8. Its edge is adopted now.');
|
||
await expect(row(page, 'studio')).toContainText('graduated');
|
||
await row(page, 'acme-holdings').click();
|
||
await verb(page, 'close');
|
||
await page.locator('#close-go').click();
|
||
await expect(page.locator('#close-go')).toHaveText('Yes, close acme-holdings');
|
||
await page.locator('#close-go').click();
|
||
await expect(say(page)).toHaveText('acme-holdings is closed, at beat 9.');
|
||
await expect(row(page, 'acme-holdings')).toContainText('closed');
|
||
await expect(row(page, 'studio')).not.toContainText('closed'); // graduated: it holds its own root
|
||
await row(page, 'bot').click();
|
||
await verb(page, 'birth');
|
||
await expect(page.locator('#act-why')).toHaveText('Agents do not give birth.');
|
||
await page.locator('#timeline-list .ev', { hasText: 'Ship weekly' }).click();
|
||
await expect(page.locator('#inspect-body')).toContainText('"op": "decide"');
|
||
await expect(page.locator('#inspect-body')).toContainText('applied');
|
||
expect(problems).toEqual([]);
|
||
});
|
||
|
||
test('a timed tempo cues acts; Beat now and the clock land them', async ({ page }) => {
|
||
const problems = await open(page);
|
||
await found(page, 'acme');
|
||
await page.locator('[data-tempo="10"]').click();
|
||
await birth(page, 'a');
|
||
await expect(say(page)).toHaveText('a is born to acme, cued for the next beat.');
|
||
await birth(page, 'b');
|
||
await expect(page.locator('#clock-cued')).toContainText('2 cued · next beat in');
|
||
await expect(row(page, 'b')).toContainText('cued');
|
||
await page.locator('#beat-now').click();
|
||
await expect(say(page)).toHaveText('Beat 3: 2 cued acts landed, and acme earned 50 tBTC.');
|
||
await birth(page, 'c');
|
||
await expect(say(page)).toHaveText('Beat 4: 1 cued act landed, and acme earned 50 tBTC.', { timeout: 15_000 });
|
||
await page.locator('[data-tempo="manual"]').click();
|
||
await birth(page, 'd');
|
||
await expect(page.locator('#clock-cued')).toHaveText('1 cued · waiting for Beat now');
|
||
await page.locator('[data-tempo="each"]').click();
|
||
await expect(page.locator('#clock-cued')).toHaveText('nothing cued');
|
||
await expect(page.locator('#clock-beat')).toHaveText('5');
|
||
expect(problems).toEqual([]);
|
||
});
|
||
|
||
test('beats pay the holdings, every act pays its gas, and test BTC moves across holdings', async ({ page }) => {
|
||
const problems = await open(page), inspect = page.locator('#inspect-body'), why = page.locator('#act-why');
|
||
await found(page, 'acme');
|
||
await expect(row(page, 'acme')).toContainText('50 tBTC');
|
||
await expect(page.locator('#clock-pays')).toHaveText('beats pay acme');
|
||
await birth(page, 'ops');
|
||
await row(page, 'ops').click();
|
||
await verb(page, 'birth');
|
||
await expect(why).toHaveText('ops has no test BTC for gas. acme can send some.');
|
||
await verb(page, 'send');
|
||
await expect(why).toHaveText('ops has no test BTC to send yet. acme can send some.');
|
||
await row(page, 'acme').click();
|
||
await verb(page, 'send');
|
||
await expect(page.locator('#send-has')).toHaveText('99.999895 tBTC');
|
||
await expect(page.locator('#act-key')).toHaveText("Signs as acme: acme root · m/84'/1'/0'/0/0");
|
||
await expect(page.locator('#act-gas')).toHaveText("Fee: 500 sats, from acme's balance.");
|
||
await pay(page, 'acme / ops', '12.5');
|
||
await expect(say(page)).toHaveText('acme sent 12.5 tBTC to ops, at beat 4.');
|
||
await expect(row(page, 'ops')).toContainText('12.5 tBTC');
|
||
await row(page, 'ops').click();
|
||
await verb(page, 'birth');
|
||
await expect(page.locator('#act-gas')).toHaveText("Gas: 2,500 sats from ops's tank (0 tBTC), which tops up from its balance.");
|
||
await birth(page, 'bot', 'agent');
|
||
await expect(say(page)).toHaveText('bot is born to ops, at beat 5.');
|
||
await expect(row(page, 'ops')).toContainText('≈12.5 tBTC');
|
||
await verb(page, 'send');
|
||
await page.locator('#send-to').selectOption({ label: 'acme / ops / bot (agent)' });
|
||
await page.locator('#send-max').click();
|
||
await expect(page.locator('#send-amount')).toHaveValue('12.49989');
|
||
await page.locator('#send-amount').fill('1');
|
||
await submit(page, 'send');
|
||
await expect(say(page)).toHaveText('ops sent 1 tBTC to bot, at beat 6.');
|
||
await expect(row(page, 'bot')).toContainText('1 tBTC');
|
||
await page.locator('#timeline-list .ev', { hasText: 'ops → bot' }).click();
|
||
for (const text of ['500 sats, retired', '11.49989 tBTC back to ops', "ops's SegWit key (BIP-143, SIGHASH_ALL)"]) await expect(inspect).toContainText(text);
|
||
await page.locator('#timeline-list .ev', { hasText: "ops's gas tank" }).click();
|
||
await expect(inspect).toContainText("ops's gas tank, at its Taproot key");
|
||
await page.locator('#beat-now').click();
|
||
await expect(say(page)).toHaveText('Beat 7: acme earned 50 tBTC.');
|
||
await found(page, 'beta');
|
||
await expect(say(page)).toHaveText('beta is founded, at beat 8.');
|
||
await expect(page.locator('#clock-pays')).toHaveText('beats pay 2 holdings, split by hashrate');
|
||
await expect(page.locator('#holdings-list')).toContainText('50% of each beat');
|
||
await page.getByRole('button', { name: "Raise acme's hashrate" }).click();
|
||
await expect(page.locator('#holdings-list')).toContainText('67% of each beat');
|
||
await page.locator('#beat-now').click();
|
||
await expect(say(page)).toHaveText('Beat 9: acme earned ≈33.33 tBTC, beta ≈16.67 tBTC.');
|
||
await page.locator('[data-rule="last"]').click();
|
||
await expect(page.locator('#clock-pays')).toHaveText('beats pay the holding that acts');
|
||
await page.locator('#beat-now').click();
|
||
await expect(say(page)).toHaveText('Beat 10: beta earned 50 tBTC.');
|
||
await pay(page, 'acme / ops', '2');
|
||
await expect(say(page)).toHaveText('beta sent 2 tBTC to acme / ops, at beat 11.');
|
||
await page.locator('#holdings-list .hold', { hasText: 'acme' }).click();
|
||
await expect(row(page, 'ops')).toContainText('≈13.5 tBTC');
|
||
await expect(page.locator('#timeline-list')).toContainText('2 tBTC beta → ops');
|
||
await page.locator('[data-tempo="manual"]').click();
|
||
await pay(page, 'acme / ops / bot (agent)', '0.25');
|
||
await expect(say(page)).toHaveText('acme sent 0.25 tBTC to bot, cued for the next beat.');
|
||
await expect(page.locator('#clock-cued')).toHaveText('1 cued · waiting for Beat now');
|
||
await page.locator('#beat-now').click();
|
||
await expect(say(page)).toHaveText('Beat 12: 1 cued send landed, and acme earned 50 tBTC.');
|
||
await expect(row(page, 'bot')).toContainText('1.25 tBTC');
|
||
await slide(page, 3);
|
||
await expect(row(page, 'ops')).not.toContainText('tBTC');
|
||
await expect(row(page, 'acme')).toContainText('≈100 tBTC');
|
||
await row(page, 'acme').click();
|
||
await expect(inspect).toContainText('as of beat 3');
|
||
await page.locator('#to-now').click();
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
expect(problems).toEqual([]);
|
||
});
|
||
|
||
test('the sample replays, any beat can be inspected, and the lab survives a reload', async ({ page }) => {
|
||
const problems = await open(page);
|
||
await page.locator('#sample').click();
|
||
await expect(say(page)).toContainText('Loaded the yolopreneur sample', { timeout: 20_000 });
|
||
await expect(page.locator('#tree-rows .sel')).toHaveCount(12);
|
||
await expect(page.locator('#beat-label')).toHaveText('Now · beat 7');
|
||
await expect(row(page, 'lineage')).toContainText('graduated');
|
||
await expect(row(page, 'time')).toContainText('≈38.5 tBTC');
|
||
await expect(page.locator('#timeline-list')).toContainText('40 tBTC yolopreneur → time');
|
||
await expect(page.locator('#sample')).toBeDisabled();
|
||
await slide(page, 3);
|
||
await expect(page.locator('#beat-label')).toHaveText('Beat 3 of 7');
|
||
await expect(page.locator('#tree-rows .sel')).toHaveCount(3);
|
||
await expect(page.locator('#act-past')).toBeVisible();
|
||
await page.locator('#replay').click();
|
||
await expect(page.locator('#beat-label')).toHaveText('Now · beat 7', { timeout: 15_000 });
|
||
await page.locator('#timeline-list .ev', { hasText: 'graduate lineage from well-known' }).click();
|
||
await expect(page.locator('#inspect-body')).toContainText('"op": "graduate"');
|
||
await expect(page.locator('#inspect-body')).toContainText("well-known's key");
|
||
await row(page, 'lineage').click();
|
||
await expect(page.locator('#inspect-body')).toContainText('lineage root · account 0');
|
||
await expect(page.locator('#inspect-body')).toContainText("BIP-86 at m/86'/1'/0'/0/0");
|
||
await expect(page.locator('#inspect-body')).toContainText('its own key');
|
||
await row(page, 'time').click();
|
||
await expect(page.locator('#act-key')).toHaveText("Signs as time: yolopreneur root › 1' · m/86'/1'/0'/0/0");
|
||
const inspect = page.locator('#inspect-body');
|
||
for (const text of ['ssh-ca', 'lineage-ca@time.yolopreneur', 'mkey:']) await expect(inspect).toContainText(text);
|
||
await row(page, 'sol').click();
|
||
const fact = name => inspect.locator('.f').filter({ has: page.locator('dt', { hasText: new RegExp(`^${name}$`) }) });
|
||
for (const [name, text] of [['Taproot', 'bcrt1p'], ['Taproot', "BIP-86 at m/86'/1'/2'/0/0"], ['SegWit', 'bcrt1q'], ['SegWit', "BIP-84 at m/84'/1'/2'/0/0"],
|
||
['npub', 'npub1'], ['npub', "NIP-06 at m/44'/1237'/2'/0/0"], ['From', "yolopreneur root › 1' · account 2"]]) await expect(fact(name)).toContainText(text);
|
||
for (const text of ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5', 'sol@time.yolopreneur namespaces="git"', 'sol._domainkey.time.yolopreneur. IN TXT "v=DKIM1; k=ed25519; p=', 'PublicKey = ']) {
|
||
await expect(inspect.locator('pre')).toContainText(text);
|
||
}
|
||
await expect(fact('Tapkey')).toContainText('bcrt1p');
|
||
await expect(page.locator('#reveal')).toHaveAttribute('aria-expanded', 'false');
|
||
await page.locator('#reveal').click();
|
||
const secrets = inspect.locator('.sb');
|
||
for (const text of ['nsec1', 'BEGIN OPENSSH PRIVATE KEY', 'PrivateKey = ', 'account 2 of time']) await expect(secrets).toContainText(text);
|
||
await expect(secrets).toContainText(/tr\(c[1-9A-HJ-NP-Za-km-z]{51}\)#[a-z0-9]{8}/);
|
||
await secrets.locator('.cp').first().click();
|
||
await expect(secrets.locator('.cp').first()).toHaveText(/Copied|Selected/);
|
||
await row(page, 'time').click();
|
||
await expect(page.locator('#reveal')).toHaveAttribute('aria-expanded', 'false');
|
||
await page.locator('#reveal').click();
|
||
await expect(secrets).toContainText("Seed · yolopreneur root › 1'");
|
||
await expect(secrets).toContainText('BEGIN PRIVATE KEY');
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
await page.locator('#reveal').click();
|
||
await expect(secrets).toHaveCount(0);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
await page.reload();
|
||
await expect(page.locator('#tree-rows .sel')).toHaveCount(12);
|
||
await page.locator('#theme').click();
|
||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||
await page.locator('#theme').click();
|
||
await page.reload();
|
||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||
await expect(page.locator('#theme')).toHaveText('Theme: dark');
|
||
await page.locator('#reset').click();
|
||
await page.locator('#confirm button', { hasText: 'Erase' }).click();
|
||
await expect(say(page)).toHaveText('Erased. This is a fresh lab.');
|
||
await expect(page.locator('#holdings-list li')).toHaveCount(0);
|
||
expect(problems).toEqual([]);
|
||
});
|
||
|
||
test('a node makes an item, another holding bids on it, the holder accepts; on mainnet holdings are funded', async ({ page }) => {
|
||
const problems = await open(page), inspect = page.locator('#inspect-body'), why = page.locator('#act-why');
|
||
const hold = name => page.locator('#holdings-list .hold', { has: page.locator('.hn', { hasText: new RegExp(`^${name}$`) }) });
|
||
await found(page, 'acme');
|
||
await expect(say(page)).toHaveText('acme is founded, at beat 2.');
|
||
await found(page, 'beta');
|
||
await expect(say(page)).toHaveText('beta is founded, at beat 3.');
|
||
await verb(page, 'offer');
|
||
await expect(why).toHaveText('Nobody else holds an item yet. Items come from Make.');
|
||
await hold('acme').click();
|
||
await verb(page, 'make');
|
||
await expect(page.locator('#f-make .hint')).toContainText('public and permanent');
|
||
await page.locator('#make-name').fill('Kit');
|
||
await page.locator('#make-note').fill('A starter kit.');
|
||
await submit(page, 'make');
|
||
await expect(say(page)).toHaveText('acme made kit, at beat 4.');
|
||
await expect(page.locator('#timeline-list')).toContainText('make kit · acme');
|
||
await expect(inspect).toContainText('kit · A starter kit. (made at beat 4)');
|
||
await hold('beta').click();
|
||
await verb(page, 'offer');
|
||
await expect(page.locator('#act-gas')).toHaveText("Fee: 500 sats, from beta's balance, paid only if the holder accepts.");
|
||
await page.locator('#offer-item').selectOption({ label: 'kit · held by acme' });
|
||
await page.locator('#offer-price').fill('2');
|
||
await submit(page, 'offer');
|
||
await expect(say(page)).toHaveText('beta offers 2 tBTC for kit. The offer stands until acme accepts it.');
|
||
await expect(page.locator('#clock-beat')).toHaveText('4'); // an offer is signatures, not a transaction
|
||
await expect(inspect).toContainText('2 tBTC for kit (open until acme accepts)');
|
||
await hold('acme').click();
|
||
await expect(inspect).toContainText('beta offers 2 tBTC');
|
||
await inspect.getByRole('button', { name: "Accept beta's offer for kit" }).click();
|
||
await expect(say(page)).toHaveText('acme accepted: kit goes to beta for 2 tBTC, at beat 5.');
|
||
await expect(inspect).toContainText('kit · A starter kit. (now held by beta)');
|
||
await page.locator('#timeline-list .ev', { hasText: 'kit acme → beta, for 2 tBTC' }).click();
|
||
for (const text of ['Price', '2 tBTC', "beta's SegWit key (SIGHASH_ALL) when it made the offer, then acme's Taproot key when it accepted"]) {
|
||
await expect(inspect).toContainText(text);
|
||
}
|
||
await page.locator('[data-rule="none"]').click();
|
||
await expect(say(page)).toHaveText('Mainnet: no holding mines. Beats pay the lab wallet, and you fund holdings from it.');
|
||
await expect(page.locator('#clock-pays')).toHaveText('beats pay the lab wallet');
|
||
await page.locator('#fund-to').selectOption({ label: 'beta' });
|
||
await page.locator('#fund-amount').fill('3');
|
||
await page.locator('#f-fund [type="submit"]').click();
|
||
await expect(say(page)).toHaveText('The lab wallet funded beta with 3 tBTC, at beat 6.');
|
||
await hold('beta').click();
|
||
await expect(page.locator('#timeline-list')).toContainText('3 tBTC lab wallet → beta');
|
||
await expect(page.locator('#holdings-list')).not.toContainText('of each beat');
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
expect(problems).toEqual([]);
|
||
});
|
||
|
||
test('the sample ends on a standing offer, and its holder accepts it', async ({ page }) => {
|
||
const problems = await open(page), inspect = page.locator('#inspect-body');
|
||
await page.locator('#sample').click();
|
||
await expect(say(page)).toContainText('Loaded the yolopreneur sample', { timeout: 20_000 });
|
||
await row(page, 'lineage').click();
|
||
await expect(inspect).toContainText('0.1 tBTC for launch-kit (open until sol accepts)');
|
||
await row(page, 'sol').click();
|
||
await expect(inspect).toContainText('launch-kit · The page, the post and the checklist every venture launches with.');
|
||
await inspect.getByRole('button', { name: "Accept lineage's offer for launch-kit" }).click();
|
||
await expect(say(page)).toHaveText('sol accepted: launch-kit goes to lineage for 0.1 tBTC, at beat 8.');
|
||
await expect(page.locator('#timeline-list')).toContainText('launch-kit sol → lineage, for 0.1 tBTC');
|
||
await row(page, 'lineage').click();
|
||
await expect(inspect).toContainText('launch-kit · The page, the post and the checklist every venture launches with. (made by sol at beat 6)');
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||
expect(problems).toEqual([]);
|
||
});
|