Trades
This commit is contained in:
+63
-12
@@ -1,8 +1,8 @@
|
||||
import { createHash, createPublicKey, verify } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessage, statsSnapshotTuple,
|
||||
import { NETWORK, CONSENSUS, headerMessage, blockClaimMessage, nftTransferMessage, nftTradeMessage, statsSnapshotTuple,
|
||||
type BlockHeader, type TestnetBlock, type NftTransfer, type NftState, type NftStateLayer,
|
||||
type NftStateView, type MiningTemplate } from '../../shared/testnet.ts';
|
||||
isNftTrade, type NftStateView, type NftTrade, type TestnetTransaction, type MiningTemplate } from '../../shared/testnet.ts';
|
||||
import type { Article } from '../../shared/protocol.ts';
|
||||
import { deriveStats, type MintStatsSnapshot } from '../../shared/enrichment.ts';
|
||||
import type { PowEngine } from './randomx.ts';
|
||||
@@ -20,6 +20,25 @@ const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88);
|
||||
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
export const transferSchema = z.object({ network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
|
||||
cardHash: hex, from: hex, to: hex, sequence: integer, publicKey: key, signature }).strict();
|
||||
const tradeLegSchema = z.object({ cardHash: hex, sequence: integer }).strict();
|
||||
export const tradeSchema = z.object({ type: z.literal('trade'), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
|
||||
proposer: hex, counterparty: hex, offered: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards),
|
||||
requested: z.array(tradeLegSchema).min(1).max(CONSENSUS.maxTradeCards),
|
||||
proposerPublicKey: key, proposerSignature: signature, counterpartyPublicKey: key,
|
||||
counterpartySignature: signature }).strict().superRefine((trade, ctx) => {
|
||||
const offered = new Set(trade.offered.map(leg => leg.cardHash));
|
||||
const requested = new Set(trade.requested.map(leg => leg.cardHash));
|
||||
if (trade.proposer === trade.counterparty) ctx.addIssue({ code: 'custom', message: 'Trade parties must differ' });
|
||||
if (offered.size !== trade.offered.length || requested.size !== trade.requested.length ||
|
||||
trade.requested.some(leg => offered.has(leg.cardHash))) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Trade cards must be unique and cannot appear on both sides' });
|
||||
}
|
||||
if (!verifySignature(trade.proposerPublicKey, trade.proposer, nftTradeMessage(trade), trade.proposerSignature) ||
|
||||
!verifySignature(trade.counterpartyPublicKey, trade.counterparty, nftTradeMessage(trade), trade.counterpartySignature)) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Trade requires valid signatures from both parties' });
|
||||
}
|
||||
});
|
||||
export const transactionSchema = z.union([transferSchema, tradeSchema]);
|
||||
const articleSchema = z.object({ pageId: integer.positive(), title: z.string().trim().min(1).max(200),
|
||||
extract: z.string().trim().min(1).max(1000), image: z.string().url().max(1500),
|
||||
url: z.string().url().max(500), revisionId: integer.positive(),
|
||||
@@ -71,10 +90,10 @@ const statsSnapshotSchema = z.object({ capturedAt: z.string().datetime(), revisi
|
||||
ctx.addIssue({ code: 'custom', message: 'Derived statistics do not match the page metrics', path: ['stats'] });
|
||||
}
|
||||
});
|
||||
export const blockSchema = z.object({ version: z.literal(5), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
|
||||
export const blockSchema = z.object({ version: z.literal(6), network: z.literal(NETWORK), chainId: z.literal(CHAIN_ID),
|
||||
height: integer, previousHash: hex, createdAt: integer, article: articleSchema, statsSnapshot: statsSnapshotSchema, owner: hex,
|
||||
publicKey: key, nonce: integer, target: hex, key: hex, transactionsRoot: hex, stateRoot: hex,
|
||||
hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transferSchema).max(CONSENSUS.maxTransactions) }).strict();
|
||||
hash: hex, tokenId: hex, powHash: hex, signature, transactions: z.array(transactionSchema).max(CONSENSUS.maxTransactions) }).strict();
|
||||
|
||||
export function address(publicKey: string): string { return sha256(Buffer.from(publicKey, 'base64')); }
|
||||
export function verifySignature(publicKey: string, owner: string, message: string, sig: string): boolean {
|
||||
@@ -91,6 +110,10 @@ export function transferId(t: NftTransfer): string {
|
||||
// Exclude malleable ECDSA signature bytes from transaction identity.
|
||||
return sha256(nftTransferMessage(t));
|
||||
}
|
||||
export function transactionId(transaction: TestnetTransaction): string {
|
||||
if (isNftTrade(transaction)) return sha256(nftTradeMessage(transaction));
|
||||
return transferId(transaction);
|
||||
}
|
||||
export function merkleRoot(leaves: string[]): string {
|
||||
if (!leaves.length) return sha256(`${NETWORK}:empty`);
|
||||
let layer = leaves.map(leaf => sha256(Buffer.concat([Buffer.from([0]), Buffer.from(leaf,'hex')])));
|
||||
@@ -179,14 +202,42 @@ export function applyTransferOverlay(state: NftStateView, tx: NftTransfer, heigh
|
||||
changes[tx.cardHash] = { ...nft, owner: tx.to, sequence: tx.sequence + 1 };
|
||||
return { kind: 'layer', parent: state, changes };
|
||||
}
|
||||
export function transition(parent: NftStateView, header: BlockHeader, txs: NftTransfer[]): NftStateLayer {
|
||||
export function applyTradeOverlay(state: NftStateView, trade: NftTrade, height: number): NftStateLayer {
|
||||
const parsed = tradeSchema.parse(trade);
|
||||
const allLegs = [...parsed.offered, ...parsed.requested];
|
||||
const current = allLegs.map(leg => stateEntry(state, leg.cardHash));
|
||||
if (current.some((entry, index) => !entry || entry.sequence !== allLegs[index].sequence ||
|
||||
height - entry.mintedHeight < CONSENSUS.coinbaseMaturity)) {
|
||||
throw new Error('Trade contains missing, stale, or immature NFTs');
|
||||
}
|
||||
if (current.slice(0, parsed.offered.length).some(entry => entry!.owner !== parsed.proposer) ||
|
||||
current.slice(parsed.offered.length).some(entry => entry!.owner !== parsed.counterparty)) {
|
||||
throw new Error('Trade NFT owners do not match the signed parties');
|
||||
}
|
||||
const changes: NftState = Object.create(null) as NftState;
|
||||
parsed.offered.forEach((leg, index) => {
|
||||
const nft = current[index]!;
|
||||
changes[leg.cardHash] = { ...nft, owner: parsed.counterparty, sequence: nft.sequence + 1 };
|
||||
});
|
||||
parsed.requested.forEach((leg, index) => {
|
||||
const nft = current[parsed.offered.length + index]!;
|
||||
changes[leg.cardHash] = { ...nft, owner: parsed.proposer, sequence: nft.sequence + 1 };
|
||||
});
|
||||
return { kind: 'layer', parent: state, changes };
|
||||
}
|
||||
export function applyTransactionOverlay(state: NftStateView, transaction: TestnetTransaction, height: number): NftStateLayer {
|
||||
return isNftTrade(transaction)
|
||||
? applyTradeOverlay(state, transaction, height)
|
||||
: applyTransferOverlay(state, transaction, height);
|
||||
}
|
||||
export function transition(parent: NftStateView, header: BlockHeader, txs: TestnetTransaction[]): NftStateLayer {
|
||||
let next = parent;
|
||||
const ids = new Set<string>();
|
||||
for (const tx of txs) {
|
||||
const id = transferId(tx);
|
||||
for (const transaction of txs) {
|
||||
const id = transactionId(transaction);
|
||||
if (ids.has(id)) throw new Error('Duplicate transfer');
|
||||
ids.add(id);
|
||||
next = applyTransferOverlay(next, tx, header.height);
|
||||
next = applyTransactionOverlay(next, transaction, header.height);
|
||||
}
|
||||
const id = mintId(header);
|
||||
if (stateEntry(next, id)) throw new Error('Duplicate NFT issuance');
|
||||
@@ -195,16 +246,16 @@ export function transition(parent: NftStateView, header: BlockHeader, txs: NftTr
|
||||
return { kind: 'layer', parent: next, changes };
|
||||
}
|
||||
export function makeTemplate(ancestors: TestnetBlock[], state: NftStateView, owner: string,
|
||||
publicKey: string, transactions: NftTransfer[], article: Article, statsSnapshot: MintStatsSnapshot,
|
||||
publicKey: string, transactions: TestnetTransaction[], article: Article, statsSnapshot: MintStatsSnapshot,
|
||||
now = Math.floor(Date.now()/1000)): MiningTemplate {
|
||||
if (!hex.safeParse(owner).success || address(publicKey) !== owner) throw new Error('Invalid miner identity');
|
||||
const parsedArticle = articleSchema.parse(article);
|
||||
const parsedStats = statsSnapshotSchema.parse(statsSnapshot);
|
||||
const previousHash = ancestors.at(-1)?.hash ?? GENESIS_HASH;
|
||||
const header: BlockHeader = { version: 5, network: NETWORK, chainId: CHAIN_ID,
|
||||
const header: BlockHeader = { version: 6, network: NETWORK, chainId: CHAIN_ID,
|
||||
height: ancestors.length + 1, previousHash, createdAt: Math.max(now, medianTime(ancestors) + 1),
|
||||
article: parsedArticle, statsSnapshot: parsedStats, owner, publicKey, nonce: 0, target: expectedTarget(ancestors),
|
||||
key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transferId)), stateRoot: '' };
|
||||
key: expectedKey(ancestors, ancestors.length + 1), transactionsRoot: merkleRoot(transactions.map(transactionId)), stateRoot: '' };
|
||||
header.stateRoot = stateRoot(transition(state, header, transactions));
|
||||
return { header, transactions, blob: miningBlob(header).toString('hex'), nonceOffset: 32 };
|
||||
}
|
||||
@@ -215,7 +266,7 @@ export async function validateBlock(raw: unknown, ancestors: TestnetBlock[], par
|
||||
if (b.height !== ancestors.length + 1 || b.previousHash !== (ancestors.at(-1)?.hash ?? GENESIS_HASH)) throw new Error('Invalid parent/height');
|
||||
if (b.createdAt <= medianTime(ancestors) || b.createdAt > now + CONSENSUS.maxFutureSeconds) throw new Error('Invalid block timestamp');
|
||||
if (b.target !== expectedTarget(ancestors) || b.key !== expectedKey(ancestors, b.height)) throw new Error('Invalid difficulty or RandomX epoch key');
|
||||
if (b.transactionsRoot !== merkleRoot(b.transactions.map(transferId))) throw new Error('Invalid transaction root');
|
||||
if (b.transactionsRoot !== merkleRoot(b.transactions.map(transactionId))) throw new Error('Invalid transaction root');
|
||||
const next = transition(parentState, b, b.transactions);
|
||||
if (b.stateRoot !== stateRoot(next) || b.tokenId !== mintId(b)) throw new Error('Invalid NFT state commitment');
|
||||
const blob = miningBlob(b);
|
||||
|
||||
Reference in New Issue
Block a user