Trades
This commit is contained in:
@@ -208,6 +208,8 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
await (await request(first.base, '/api/mining/template', 400, {})).text();
|
||||
await (await request(first.base, '/api/blocks', 400, {})).text();
|
||||
await (await request(first.base, '/api/transfer', 400, {})).text();
|
||||
await (await request(first.base, '/api/trades', 400, {})).text();
|
||||
await (await request(first.base, `/api/decks/${'x'.repeat(64)}`, 400)).text();
|
||||
await (await request(first.base, '/api/mining/template', 403, {}, { Origin: 'https://evil.example' })).text();
|
||||
await (await request(first.base, '/api/mining/template', 415, {}, { 'Content-Type': 'text/plain' })).text();
|
||||
const malformed = await fetch(`${first.base}/api/blocks`, { method: 'POST',
|
||||
@@ -240,6 +242,13 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du
|
||||
const block2 = await mine(first.base, alice);
|
||||
assert.equal(block2.height, 2);
|
||||
await submit(first.base, block2);
|
||||
const aliceDeck = await json<{ address: string; tip: string; total: number; nfts: { block: TestnetBlock }[] }>(
|
||||
first.base, `/api/decks/${alice.owner}`);
|
||||
assert.equal(aliceDeck.address, alice.owner);
|
||||
assert.equal(aliceDeck.tip, block2.hash);
|
||||
assert.equal(aliceDeck.total, 2);
|
||||
assert.deepEqual(aliceDeck.nfts.map(item => item.block.tokenId), [block1.tokenId, block2.tokenId]);
|
||||
await (await request(first.base, `/api/decks/${'z'.repeat(64)}`, 400)).text();
|
||||
|
||||
// A truly independent process reconstructs the canonical chain over HTTP and checks real work.
|
||||
const second = await start(secondDir, first.base);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -54,7 +54,7 @@ test('Node WebCrypto backup roundtrip preserves SPKI SHA256 identity and signing
|
||||
key: createPublicKey({ key: spki, format: 'der', type: 'spki' }), dsaEncoding: 'ieee-p1363',
|
||||
}, Buffer.from(signature, 'base64')), true);
|
||||
assert.equal(backup.iterations, 600000);
|
||||
assert.equal(backup.network, 'wikideck-randomx-testnet-5');
|
||||
assert.equal(backup.network, 'wikideck-randomx-testnet-6');
|
||||
assert.equal(Buffer.from(backup.salt, 'base64').length, 16);
|
||||
assert.equal(Buffer.from(backup.iv, 'base64').length, 12);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(backup)) < 32 * 1024);
|
||||
|
||||
Reference in New Issue
Block a user