This commit is contained in:
2026-10-19 21:47:38 -04:00
parent f8eeae826f
commit b1d2b5c10e
17 changed files with 549 additions and 92 deletions
+56 -2
View File
@@ -13,8 +13,8 @@ import { PeerNetwork } from '../server/testnet/peers.ts';
import { CHAIN_ID, GENESIS_HASH, GENESIS_TIME, MAX_TARGET, address, makeTemplate, miningBlob,
mintId, sha256, stateRoot, transferId, transition, workFor } from '../server/testnet/consensus.ts';
import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, testnetOwnerOf,
type MiningTemplate, type NftState, type NftStateLayer, type NftStateView,
type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
nftTradeMessage, type MiningTemplate, type NftState, type NftStateLayer, type NftStateView,
type NftTrade, type NftTransfer, type TestnetBlock } from '../shared/testnet.ts';
import { deriveStats } from '../shared/enrichment.ts';
import type { PowEngine } from '../server/testnet/randomx.ts';
@@ -77,6 +77,15 @@ function transfer(cardHash: string, from: Identity = alice, to: Identity = bob,
sequence, publicKey: from.publicKey };
return { ...tx, signature: from.sign(nftTransferMessage(tx)) };
}
function trade(offered: TestnetBlock[], requested: TestnetBlock[], proposer: Identity = alice,
counterparty: Identity = bob): NftTrade {
const terms = { network: NETWORK, chainId: CHAIN_ID, proposer: proposer.owner, counterparty: counterparty.owner,
offered: offered.map(block => ({ cardHash: block.tokenId, sequence: 0 })),
requested: requested.map(block => ({ cardHash: block.tokenId, sequence: 0 })) };
const message = nftTradeMessage(terms);
return { type: 'trade', ...terms, proposerPublicKey: proposer.publicKey, proposerSignature: proposer.sign(message),
counterpartyPublicKey: counterparty.publicKey, counterpartySignature: counterparty.sign(message) };
}
async function fixture(t: { after: (fn: () => Promise<void>) => void }) {
const dir = await mkdtemp(join(tmpdir(), 'wikideck-testnet-'));
const engine = new FakePow();
@@ -192,6 +201,51 @@ test('NFT maturity, ordered mempool, block commitments and replay protection', a
await assert.rejects(restarted.submitTransfer(transfer(minted.tokenId, carol, alice, 0)), /owner\/sequence/);
});
test('signed multi-card trade is validated, mempooled, mined, and applied atomically', async t => {
const { ledger } = await fixture(t);
const chain = branch();
const aliceFirst = await extend(chain, alice); await ledger.submit(aliceFirst);
const bobFirst = await extend(chain, bob); await ledger.submit(bobFirst);
const aliceSecond = await extend(chain, alice); await ledger.submit(aliceSecond);
const bobSecond = await extend(chain, bob); await ledger.submit(bobSecond);
await ledger.submit(await extend(chain, alice)); // All four NFTs are mature at the next inclusion height.
const offered = [aliceFirst, aliceSecond], requested = [bobFirst, bobSecond];
const invalid = trade(offered, requested);
const badTerms = { ...invalid, requested: invalid.requested.map((leg, index) =>
index === 1 ? { ...leg, sequence: 1 } : leg) };
const badMessage = nftTradeMessage(badTerms);
const badSequence = { ...badTerms, proposerSignature: alice.sign(badMessage), counterpartySignature: bob.sign(badMessage) };
const before = ledger.snapshot();
await assert.rejects(ledger.submitTransaction(badSequence), /missing, stale, or immature/);
const badSignature = { ...invalid, proposerSignature: bob.sign(nftTradeMessage(invalid)) };
await assert.rejects(ledger.submitTransaction(badSignature), /signature/i);
assert.deepEqual(ledger.snapshot().pendingTransfers, []);
for (const block of offered) assert.equal(testnetOwnerOf(before, block.tokenId), alice.owner);
for (const block of requested) assert.equal(testnetOwnerOf(before, block.tokenId), bob.owner);
const bundle = trade(offered, requested);
await ledger.submitTransaction(bundle);
assert.deepEqual(ledger.snapshot().pendingTransfers, [bundle]);
const template = ledger.template(alice.owner, alice.publicKey, articleFor(ledger.snapshot().blocks), statsSnapshot());
assert.deepEqual(template.transactions, [bundle]);
await ledger.submit(await mine(template));
const after = ledger.snapshot();
assert.equal(after.pendingTransfers.length, 0);
for (const block of offered) assert.equal(testnetOwnerOf(after, block.tokenId), bob.owner);
for (const block of requested) assert.equal(testnetOwnerOf(after, block.tokenId), alice.owner);
assert.deepEqual(after.transfers.at(-1), bundle);
const deck = ledger.deck(alice.owner, 0, 2);
assert.equal(deck.total, after.blocks.filter(block => testnetOwnerOf(after, block.tokenId) === alice.owner).length);
assert.equal(deck.nfts.length, 2);
assert.equal(deck.next, 2);
assert.deepEqual(ledger.deck(alice.owner, deck.next!, 2).nfts.map(item => item.block.tokenId).sort(),
ledger.deck(alice.owner).nfts.slice(2).map(item => item.block.tokenId).sort());
assert.deepEqual(ledger.deck(alice.owner).nfts.map(item => item.block.tokenId).sort(),
after.blocks.filter(block => testnetOwnerOf(after, block.tokenId) === alice.owner)
.map(block => block.tokenId).sort());
});
test('reorg rolls back NFT ownership and requeues only still-valid disconnected transfers in order', async t => {
const { ledger, dir } = await fixture(t);
const original = branch();