Orgchain v1.5: beats pay the holding, and test BTC moves down the tree
Each beat pays its 50 test BTC block reward to the holding being worked in, at its SegWit (BIP-84) address. Send moves test BTC between active nodes of a holding, signed by the sender's BIP-84 key, with the toy chain's 500-sat fee: at most two coins in and one block reward out. Sends land like acts, and Beat now mines a beat even when nothing is cued. A graduation moves each re-keyed node's money to its new address with its inscription. Balances follow the slider, the timeline lists every reward and send, and Inspect shows exact amounts and the raw transaction. The sample passes some of yolopreneur's rewards down its tree. The engine's one local change: the toy chain also takes P2WPKH outputs and SIGHASH_ALL P2WPKH spends (BIP-143), in src/adapters/chain.js. Super Testnet's tests pass unchanged; tests/money.test.js covers the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
This commit is contained in:
1 parent
aeb11d334f
commit
6d5806d45b
12 files changed
+590
-59
No files matched your search
@@ -0,0 +1,121 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Tx } from '@cmdcode/tapscript';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { bytesToHex } from '@noble/curves/abstract/utils';
|
||||
import { LocalChain } from '../src/adapters/chain.js';
|
||||
import { getVin, getVout } from '../src/upstream/primitives.js';
|
||||
import { MetadataIndex } from '../src/adapters/metadata-index.js';
|
||||
import { lineageEvents, reduce } from '../src/orgchain/lineage.js';
|
||||
import { decodeLab, emptyLab, encodeLab, setTempo, tick } from '../src/orgchain/store.js';
|
||||
import { act, minerOf, seedSample, send } from '../src/orgchain/actions.js';
|
||||
import { FEE, PayWallet, REWARD, SAT, balances, brief, btc, ledger, most, owners, payerOf, satsOf } from '../src/orgchain/money.js';
|
||||
|
||||
const key = byte => { const secret = byte.repeat(32); return new PayWallet(secret, bytesToHex(secp256k1.getPublicKey(secret, true)), "m/84'/1'/0'/0/0"); };
|
||||
// A lab's money as the app sees it: who owns each SegWit script, balances now (or at a beat), and the ledger.
|
||||
function money(lab, upTo) {
|
||||
const events = lineageEvents(new MetadataIndex(lab.chain)), live = reduce(events, { pending: true }), owner = owners(lab.keys, events, live);
|
||||
const named = map => Object.fromEntries([...map].map(([id, v]) => [live.nodes.get(id).name, v]));
|
||||
return { live, owner, now: named(balances(lab.chain, owner, upTo)), log: ledger(lab.chain, owner).map(m =>
|
||||
[m.kind, m.beat, live.nodes.get(m.from)?.name ?? null, live.nodes.get(m.to)?.name, m.amount]) };
|
||||
}
|
||||
|
||||
test('the toy chain mines to SegWit and moves P2WPKH spends signed by the key they pay', () => {
|
||||
const chain = new LocalChain(), a = key('11'), b = key('22');
|
||||
chain.mine(a.script);
|
||||
const [coin] = chain.coins(a.script);
|
||||
assert.equal(coin.value, REWARD);
|
||||
const build = (amount = SAT) => Tx.create({ vin: [getVin(coin.txid, coin.vout, coin.value, coin.scriptPubKey)],
|
||||
vout: [getVout(amount, b.script), getVout(coin.value - amount - FEE, a.script)] });
|
||||
const signed = (signer, tx = build(), witness = null) => { tx.vin[0].witness = witness ?? signer.sign(tx, 0); return Tx.encode(tx).hex; };
|
||||
const good = build();
|
||||
const sig = a.sign(good, 0);
|
||||
assert.throws(() => chain.submitPackage([signed(b)]), /P2WPKH spends need a SIGHASH_ALL signature by the key they pay/);
|
||||
assert.throws(() => chain.submitPackage([signed(a, build(), [`${sig[0].slice(0, -2)}83`, sig[1]])]), /P2WPKH spends need/);
|
||||
assert.throws(() => chain.submitPackage([signed(a, build(), ['ab'.repeat(64)])]), /Unsupported witness/);
|
||||
assert.throws(() => chain.submitPackage([signed(a, build(SAT + 1), sig)]), /Invalid signature/);
|
||||
chain.submitPackage([signed(a, good, sig)]);
|
||||
chain.mine(a.script);
|
||||
assert.equal(chain.balance(b.script), SAT);
|
||||
assert.equal(chain.balance(a.script), 2 * REWARD - SAT - FEE);
|
||||
const copy = new LocalChain(chain.snapshot());
|
||||
assert.deepEqual([copy.balance(a.script), copy.balance(b.script)], [2 * REWARD - SAT - FEE, SAT]);
|
||||
});
|
||||
|
||||
test('each beat pays the holding being worked in, and sends move test BTC down its tree', () => {
|
||||
const lab = emptyLab();
|
||||
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
|
||||
const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' });
|
||||
assert.deepEqual(money(lab).now, { acme: 3 * REWARD }); // beats 2-4; beat 1 funded the lab wallet
|
||||
send(lab, acme, ops, 12.5 * SAT);
|
||||
send(lab, ops, bot, SAT);
|
||||
assert.equal(lab.chain.blocks.length, 6);
|
||||
assert.deepEqual(money(lab).now, { acme: 5 * REWARD - 12.5 * SAT - FEE, ops: 11.5 * SAT - FEE, bot: SAT });
|
||||
assert.deepEqual(money(lab).log, [['mine', 2, null, 'acme', REWARD], ['mine', 3, null, 'acme', REWARD], ['mine', 4, null, 'acme', REWARD],
|
||||
['mine', 5, null, 'acme', REWARD], ['pay', 5, 'acme', 'ops', 12.5 * SAT], ['mine', 6, null, 'acme', REWARD], ['pay', 6, 'ops', 'bot', SAT]]);
|
||||
assert.deepEqual(money(lab, 4).now, { acme: 3 * REWARD });
|
||||
const s = money(lab).live, opsKey = payerOf(lab.keys, s, s.nodes.get(ops));
|
||||
assert.match(opsKey.address, /^bcrt1q/);
|
||||
assert.equal(opsKey.path, "m/84'/1'/0'/0/0");
|
||||
assert.equal(most(lab.chain.coins(opsKey.script)), 11.5 * SAT - 2 * FEE);
|
||||
const before = encodeLab(lab);
|
||||
assert.throws(() => send(lab, bot, ops, 2 * SAT), /^Error: bot has 1 tBTC, and sending 2 tBTC also costs the 500-sat fee\.$/);
|
||||
assert.throws(() => send(lab, acme, acme, SAT), /cannot send to itself/);
|
||||
assert.throws(() => send(lab, acme, ops, 51 * SAT), /at most 50 test BTC/);
|
||||
assert.throws(() => send(lab, acme, ops, 100), /at least 330 sats/);
|
||||
assert.throws(() => send(lab, ops, bot, 11.5 * SAT - 2 * FEE - 100),
|
||||
/^Error: Sending 11\.499989 tBTC would leave 100 sats of change, under the 330-sat floor\. Send 11\.49999 tBTC, or at least 230 sats less\.$/);
|
||||
assert.equal(encodeLab(lab), before);
|
||||
const other = act(lab, 'found', null, { name: 'other' });
|
||||
assert.throws(() => send(lab, acme, other, SAT), /inside one holding/);
|
||||
assert.equal(money(lab).now.other, REWARD); // its founding beat paid it
|
||||
const copy = decodeLab(encodeLab(lab));
|
||||
assert.deepEqual(money(copy).now, money(lab).now);
|
||||
});
|
||||
|
||||
test('a timed tempo cues sends; the beat that lands them pays whoever the clock is told', () => {
|
||||
const lab = emptyLab();
|
||||
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
|
||||
setTempo(lab, 10, 1000);
|
||||
send(lab, acme, ops, SAT, 2000);
|
||||
assert.deepEqual(lab.clock, { tempo: 10, nextAt: 12000 });
|
||||
assert.deepEqual(money(lab).log.at(-1), ['pay', null, 'acme', 'ops', SAT]);
|
||||
assert.equal(money(lab).now.ops, SAT); // now includes what is cued
|
||||
assert.equal(money(lab, 3).now.ops, undefined);
|
||||
assert.equal(tick(lab, 12000, minerOf(lab, acme)), true);
|
||||
assert.deepEqual(money(lab).log.slice(-2), [['mine', 4, null, 'acme', REWARD], ['pay', 4, 'acme', 'ops', SAT]]); // a coinbase leads its block
|
||||
assert.equal(tick(lab, 99999, minerOf(lab, acme)), false); // nothing cued: no empty beats
|
||||
});
|
||||
|
||||
test('closed nodes neither send nor receive, and a closed holding earns nothing', () => {
|
||||
const lab = emptyLab();
|
||||
const acme = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', acme, { name: 'ops' });
|
||||
const lab2 = act(lab, 'birth', acme, { name: 'lab' });
|
||||
send(lab, acme, ops, SAT);
|
||||
act(lab, 'close', ops, {});
|
||||
assert.throws(() => send(lab, acme, ops, SAT), /ops is closed/);
|
||||
assert.throws(() => send(lab, ops, lab2, 1000), /ops is closed/);
|
||||
const paid = money(lab).now.acme;
|
||||
act(lab, 'close', acme, {});
|
||||
assert.equal(money(lab).now.acme, paid); // the beat that closed it paid the lab wallet
|
||||
assert.equal(minerOf(lab, acme), null);
|
||||
});
|
||||
|
||||
test('graduation moves money to the new keys; the sample shows it', () => {
|
||||
const lab = emptyLab();
|
||||
seedSample(lab);
|
||||
const { now, log, live } = money(lab);
|
||||
assert.deepEqual(now, { yolopreneur: 300 * SAT - 50 * SAT - 2 * FEE, 'well-known': 8 * SAT - FEE, lineage: 2 * SAT - FEE,
|
||||
time: 38.5 * SAT - 2 * FEE, sol: SAT, luna: 0.5 * SAT });
|
||||
assert.deepEqual(log.at(-1), ['move', 7, 'lineage', 'lineage', 2 * SAT - FEE]);
|
||||
const lineage = [...live.nodes.values()].find(n => n.name === 'lineage');
|
||||
assert.equal(lab.chain.balance(payerOf(lab.keys, live, lineage).script), 2 * SAT - FEE);
|
||||
assert.deepEqual(money(lab, 5).now.lineage, 2 * SAT); // before graduating, at its derived address
|
||||
});
|
||||
|
||||
test('amounts read and print as test BTC', () => {
|
||||
assert.deepEqual(['1.5', '0.00000001', '.5', '7', ' 2.25 ', '1.123456789', '', 'abc', '1e3', '-1'].map(satsOf),
|
||||
[1.5 * SAT, 1, 0.5 * SAT, 7 * SAT, 2.25 * SAT, null, null, null, null, null]);
|
||||
assert.deepEqual([4999999500, 0, REWARD, 1].map(btc), ['49.999995 tBTC', '0 tBTC', '50 tBTC', '0.00000001 tBTC']);
|
||||
assert.deepEqual([4999999500, 1250000000, 125000000, 99999, 1149999500].map(brief), ['≈50 tBTC', '12.5 tBTC', '1.25 tBTC', '0.00099999 tBTC', '≈11.5 tBTC']);
|
||||
});
|
||||
+59
-2
@@ -71,9 +71,9 @@ test('a timed tempo cues acts; Beat now and the clock land them', async ({ page
|
||||
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.');
|
||||
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.', { timeout: 15_000 });
|
||||
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');
|
||||
@@ -83,6 +83,61 @@ test('a timed tempo cues acts; Beat now and the clock land them', async ({ page
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
test('beats pay the holding, and test BTC moves down its tree', async ({ page }) => {
|
||||
const problems = await open(page), inspect = page.locator('#inspect-body');
|
||||
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 birth(page, 'bot', 'agent');
|
||||
await verb(page, 'send');
|
||||
await expect(page.locator('#act-why')).toHaveText('ops has no test BTC to send yet. Each beat pays acme, which can send some down.');
|
||||
await row(page, 'acme').click();
|
||||
await verb(page, 'send');
|
||||
await expect(page.locator('#send-has')).toHaveText('150 tBTC');
|
||||
await expect(page.locator('#act-key')).toHaveText("Signs as acme: acme root · m/84'/1'/0'/0/0");
|
||||
await page.locator('#send-to').selectOption({ label: 'acme / ops' });
|
||||
await page.locator('#send-amount').fill('12.5');
|
||||
await submit(page, 'send');
|
||||
await expect(say(page)).toHaveText('acme sent 12.5 tBTC to ops, at beat 5.');
|
||||
await expect(row(page, 'ops')).toContainText('12.5 tBTC');
|
||||
await expect(row(page, 'acme')).toContainText('≈187.5 tBTC');
|
||||
await row(page, 'ops').click();
|
||||
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.499995');
|
||||
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.499995 tBTC back to ops', "ops's SegWit key (BIP-143, SIGHASH_ALL)"]) await expect(inspect).toContainText(text);
|
||||
await page.locator('#beat-now').click();
|
||||
await expect(say(page)).toHaveText('Beat 7: acme earned 50 tBTC.');
|
||||
await page.locator('[data-tempo="manual"]').click();
|
||||
await row(page, 'acme').click();
|
||||
await verb(page, 'send');
|
||||
await page.locator('#send-to').selectOption({ label: 'acme / ops / bot (agent)' });
|
||||
await page.locator('#send-amount').fill('0.25');
|
||||
await submit(page, 'send');
|
||||
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 8: 1 cued send landed, and acme earned 50 tBTC.');
|
||||
await expect(row(page, 'bot')).toContainText('1.25 tBTC');
|
||||
await slide(page, 4);
|
||||
await expect(row(page, 'ops')).not.toContainText('tBTC');
|
||||
await expect(row(page, 'acme')).toContainText('150 tBTC');
|
||||
await row(page, 'acme').click();
|
||||
await expect(inspect).toContainText('as of beat 4');
|
||||
await page.locator('#to-now').click();
|
||||
await expect(page.locator('#holdings-list')).toContainText('tBTC');
|
||||
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();
|
||||
@@ -90,6 +145,8 @@ test('the sample replays, any beat can be inspected, and the lab survives a relo
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user