Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 262 additions & 23 deletions src/node.ts

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions src/store/mempool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Transaction, UtxoEntry, UtxoSnapshot } from "../types.js";

function utxoKey(txid: string, index: number): string {
return `${txid}:${index}`;
}

export class Mempool {
private readonly txids = new Set<string>();
private readonly utxoMap = new Map<string, UtxoEntry>();

initialize(snapshot: UtxoSnapshot): void {
this.txids.clear();
this.utxoMap.clear();

for (const entry of snapshot.entries) {
this.utxoMap.set(utxoKey(entry.outpoint.txid, entry.outpoint.index), entry);
}
}

getUtxoMap(): Map<string, UtxoEntry> {
return this.utxoMap;
}

getTxids(): string[] {
return [...this.txids];
}

has(txid: string): boolean {
return this.txids.has(txid);
}

tryAddTransaction(txid: string, transaction: Transaction): boolean {
if (!this.canApply(transaction)) {
return false;
}

this.addTransaction(txid, transaction);
return true;
}

addTransaction(txid: string, transaction: Transaction): void {
this.txids.add(txid);

for (const input of transaction.inputs) {
this.utxoMap.delete(utxoKey(input.outpoint.txid, input.outpoint.index));
}

for (const [index, output] of transaction.outputs.entries()) {
this.utxoMap.set(utxoKey(txid, index), {
outpoint: { txid, index },
output
});
}
}

rebuild(
snapshot: UtxoSnapshot,
candidates: { txid: string; transaction: Transaction }[]
): void {
this.initialize(snapshot);

const remaining = [...candidates];
let appliedAny = true;

while (appliedAny) {
appliedAny = false;

for (let index = 0; index < remaining.length; index += 1) {
const candidate = remaining[index];
if (candidate === undefined || !this.canApply(candidate.transaction)) {
continue;
}

this.addTransaction(candidate.txid, candidate.transaction);
remaining.splice(index, 1);
index -= 1;
appliedAny = true;
}
}
}

private canApply(transaction: Transaction): boolean {
return transaction.inputs.every((input) =>
this.utxoMap.has(utxoKey(input.outpoint.txid, input.outpoint.index))
);
}
}
20 changes: 10 additions & 10 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ export interface IHaveObjectMessage {
objectid: string;
}

// export interface GetMemPool {
// type: "getmempool";
// }
export interface GetMemPool {
type: "getmempool";
}

// export interface Mempool {
// type: "mempool";
// txids: string[];
// }
export interface Mempool {
type: "mempool";
txids: string[];
}

export interface GetChainTipMessage {
type: "getchaintip";
Expand Down Expand Up @@ -149,6 +149,6 @@ export type AnyMessage =
| IHaveObjectMessage
| ObjectMessage
| GetChainTipMessage
| ChainTip;
// | GetMemPool
// | Mempool
| ChainTip
| GetMemPool
| Mempool;
63 changes: 57 additions & 6 deletions src/validation/messageSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import type {
OutPoint,
Block,
GetChainTipMessage,
ChainTip
ChainTip,
GetMemPool,
Mempool
} from "../types.js";
import {
ValidationError,
Expand Down Expand Up @@ -59,6 +61,10 @@ export function validateWireMessage(value: unknown): AnyMessage {
return validateGetChainTipMessage(record);
case "chaintip":
return validateChainTipMessage(record);
case "getmempool":
return validateGetMempoolMessage(record);
case "mempool":
return validateMempoolMessage(record);
default:
throw new MessageValidationError("Unknown message type");
}
Expand Down Expand Up @@ -232,13 +238,33 @@ function validateTransactionMessage(value: RecordValue): Transaction {
);
}

// Parse and validate each input object. This ensures every input in the array is a JSON object
// and properly shaped per the Input schema.
const inputs = value.inputs.map((input, index) =>
validateInput(
assertRecord(input, `transaction.inputs[${index}] must be a JSON object`)
)
);

// Check for duplicate outpoints within the transaction inputs,
// which is a protocol-level stateless violation.
const seen = new Set<string>();
for (const input of inputs) {
// Form a unique key for each outpoint using its txid and index.
const key = `${input.outpoint.txid}:${input.outpoint.index}`;
if (seen.has(key)) {
// If the same outpoint is referenced more than once in the inputs, reject the message.
throw new MessageValidationError(
`transaction.inputs contains duplicate outpoint ${key}`
);
}
seen.add(key);
}

// Construct and return the normalized Transaction
return {
type: "transaction",
inputs: value.inputs.map((input, index) =>
validateInput(
assertRecord(input, `transaction.inputs[${index}] must be a JSON object`)
)
),
inputs,
outputs: value.outputs.map((output, index) =>
validateOutput(
assertRecord(output, `transaction.outputs[${index}] must be a JSON object`)
Expand Down Expand Up @@ -363,6 +389,31 @@ function validateChainTipMessage(value: RecordValue): ChainTip {
};
}

// Validates a "getmempool" request message.
function validateGetMempoolMessage(value: RecordValue): GetMemPool {
assertExactKeys(value, ["type"]);

return {
type: "getmempool"
};
}

// Validates a "mempool" response message.
function validateMempoolMessage(value: RecordValue): Mempool {
assertExactKeys(value, ["type", "txids"]);

if (!Array.isArray(value.txids)) {
throw new MessageValidationError("mempool.txids must be an array");
}

return {
type: "mempool",
txids: value.txids.map((txid, index) =>
assertObjectId(txid, `mempool.txids[${index}]`)
)
};
}

/*//////////////////////////////////////////////////////////////
SPECIFIC HELPERS
//////////////////////////////////////////////////////////////*/
Expand Down
14 changes: 10 additions & 4 deletions src/validation/objectState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
isValidEd25519PublicKey
} from "./utils.js";
import { computeObjectId } from "../protocol/hashing.js";
import { error } from "node:console";

const REQUIRED_BLOCK_TARGET =
"00000000abc00000000000000000000000000000000000000000000000000000";
Expand Down Expand Up @@ -295,7 +294,6 @@ async function validateTransactionState(
validateTransactionConservation(transaction, referencedOutputs);
}


// Resolves each input's outpoint to the referenced output, performing existence and type checks.
async function resolveOutpoints(
transaction: Transaction,
Expand Down Expand Up @@ -346,6 +344,7 @@ async function resolveOutpoints(
return referencedOutputs;
}


// Validates that each output has a valid Ed25519 pubkey and a non-negative integer value.
function validateOutputs(outputs: unknown): void {
const context = "transaction";
Expand Down Expand Up @@ -479,6 +478,11 @@ function isObjectWaitTimeoutError(error: unknown): boolean {
return error instanceof Error && error.name === "ObjectWaitTimeoutError";
}

// Returns true when a matching object arrived but failed validation before storage.
function isObjectDependencyValidationError(error: unknown): boolean {
return error instanceof Error && error.name === "ObjectDependencyValidationError";
}

// Loads a block transaction immediately or waits for it to arrive after requesting it.
async function findBlockTransactionObject(
txid: string,
Expand All @@ -499,7 +503,8 @@ async function findBlockTransactionObject(
} catch (error: unknown) {
if (
!isMissingReferencedObjectError(error) &&
!isObjectWaitTimeoutError(error)
!isObjectWaitTimeoutError(error) &&
!isObjectDependencyValidationError(error)
) {
throw error;
}
Expand Down Expand Up @@ -607,7 +612,8 @@ async function checkPrevId(block: Block, objectLookup: ObjectLookup): Promise<Bl
} catch (error: unknown) {
if (
!isMissingReferencedObjectError(error) &&
!isObjectWaitTimeoutError(error)
!isObjectWaitTimeoutError(error) &&
!isObjectDependencyValidationError(error)
) {
throw error;
}
Expand Down
37 changes: 27 additions & 10 deletions test/pset2/object-exchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ function assertNoError(messages: ParsedMessage[], context: string): void {
assert.ok(!error, `unexpected error during ${context}`);
}

function assertError(
messages: ParsedMessage[],
expectedName: string,
context: string
): void {
const error = findMessage(messages, "error") as { name?: unknown } | undefined;
assert.ok(error, `expected error during ${context}`);
assert.equal(error.name, expectedName);
}

function randomObjectId(): string {
return randomBytes(32).toString("hex");
}
Expand Down Expand Up @@ -132,17 +142,24 @@ test("PSET2: object exchange", async () => {
port,
lines: [helloLine, JSON.stringify({ type: "object", object: vectors.gossipTx })]
});
assertNoError(gossipResponses, "gossip transaction");

const ihave = await peer.waitFor(
(message) =>
typeof message === "object" &&
message !== null &&
"type" in message &&
(message as Record<string, unknown>).type === "ihaveobject" &&
(message as Record<string, unknown>).objectid === vectors.gossipTxId
assertError(
gossipResponses,
"INVALID_TX_OUTPOINT",
"transaction outside active mempool"
);

await assert.rejects(
peer.waitFor(
(message) =>
typeof message === "object" &&
message !== null &&
"type" in message &&
(message as Record<string, unknown>).type === "ihaveobject" &&
(message as Record<string, unknown>).objectid === vectors.gossipTxId,
500
),
/timeout/
);
assert.ok(ihave, "expected ihaveobject gossip to peer");
} finally {
peer.socket.end();
}
Expand Down
20 changes: 19 additions & 1 deletion test/pset2/tx-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ async function buildVectors(): Promise<{
coinbase: Record<string, unknown>;
coinbaseId: string;
validTx: Record<string, unknown>;
validTxId: string;
invalidSigTx: Record<string, unknown>;
invalidOutpointTx: Record<string, unknown>;
unknownObjectTx: Record<string, unknown>;
Expand Down Expand Up @@ -93,6 +94,7 @@ async function buildVectors(): Promise<{
};

const validTx = await makeTx(coinbaseId, 0, 10);
const validTxId = objectId(validTx);
const invalidOutpointTx = await makeTx(coinbaseId, 1, 10);
const unknownObjectTx = await makeTx("00".repeat(32), 0, 10);
const invalidConservationTx = await makeTx(coinbaseId, 0, 60);
Expand All @@ -110,6 +112,7 @@ async function buildVectors(): Promise<{
coinbase,
coinbaseId,
validTx,
validTxId,
invalidSigTx,
invalidOutpointTx,
unknownObjectTx,
Expand All @@ -134,7 +137,22 @@ test("PSET2: transaction validation", async () => {
port,
lines: [helloLine, JSON.stringify({ type: "object", object: vectors.validTx })]
});
assertNoError(validResponses, "valid transaction");
assertError(
validResponses,
"INVALID_TX_OUTPOINT",
"valid transaction outside active mempool"
);

const getStoredTxResponses = await sendLines({
host,
port,
lines: [
helloLine,
JSON.stringify({ type: "getobject", objectid: vectors.validTxId })
]
});
const storedTx = findMessage(getStoredTxResponses, "object");
assert.ok(storedTx, "expected mempool-rejected valid transaction to be stored");

const unknownResponses = await sendLines({
host,
Expand Down
Loading
Loading