Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
4,786 changes: 3,090 additions & 1,696 deletions package-lock.json

Large diffs are not rendered by default.

17 changes: 11 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,24 @@
"uuid": "14.0.0"
},
"devDependencies": {
"@babel/preset-env": "7.29.5",
"@babel/preset-typescript": "7.28.5",
"@babel/preset-env": "8.0.2",
"@babel/preset-typescript": "8.0.1",
"@eslint/js": "9.39.4",
"@types/jest": "30.0.0",
"@types/node": "20.19.41",
"@types/node": "22.13.14",
"babel-jest": "30.4.1",
"eslint": "9.39.4",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-prettier": "5.5.5",
"globals": "17.6.0",
"eslint-plugin-prettier": "5.5.6",
"globals": "17.7.0",
"jest": "30.4.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.4"
"typescript-eslint": "8.62.0"
},
"overrides": {
"@istanbuljs/load-nyc-config": {
"js-yaml": "5.1.0"
}
}
}
16 changes: 10 additions & 6 deletions src/api/AggregatorClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@ import { HexConverter } from '../util/HexConverter.js';
*/
export class AggregatorClient implements IAggregatorClient {
private readonly transport: JsonRpcHttpTransport;
readonly #key: string | null;

/**
* Create a new client pointing to the given aggregator URL.
*
* @param url Base URL of the aggregator JSON-RPC endpoint
* @param key API key for authenticating
* @throws {Error} If an API key is supplied for a non-HTTPS URL.
*/
public constructor(
url: string,
private readonly key: string | null = null,
) {
public constructor(url: string, key: string | null = null) {
if (key != null && new URL(url).protocol !== 'https:') {
Comment thread
martti007 marked this conversation as resolved.
Outdated
throw new Error('API key must not be sent over plaintext HTTP; use an https URL.');
}

this.#key = key;
this.transport = new JsonRpcHttpTransport(url);
}

Expand Down Expand Up @@ -64,8 +68,8 @@ export class AggregatorClient implements IAggregatorClient {
const request = await CertificationRequest.create(certificationData);

const headers = new Headers([['X-State-ID', HexConverter.encode(request.stateId.data)]]);
if (this.key) {
headers.set('X-API-Key', this.key);
if (this.#key) {
headers.set('X-API-Key', this.#key);
}

const response = await this.transport.request(
Expand Down
2 changes: 1 addition & 1 deletion src/api/CertificationResponse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InvalidJsonStructureError } from '../InvalidJsonStructureError.js';
import { InvalidJsonStructureError } from '../serialization/json/InvalidJsonStructureError.js';

/**
* Possible results from the aggregator when submitting a certification request.
Expand Down
94 changes: 59 additions & 35 deletions src/api/InclusionCertificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class InclusionCertificate {
const sibling = isRight ? node.left : node.right;

if (sibling != null) {
bitmap[Math.floor(node.depth / 8)] |= 1 << node.depth % 8;
bitmap[Math.floor(node.depth / 8)] |= 1 << (node.depth % 8);
siblings.push(sibling.hash);
}

Expand Down Expand Up @@ -102,42 +102,15 @@ export class InclusionCertificate {
}

/**
* @returns {Uint8Array} Encoded certificate bytes.
*/
public encode(): Uint8Array {
const bytes = new Uint8Array(this.bitmap.length + this.siblings.length * HashAlgorithm.SHA256.length);
bytes.set(this.bitmap);
let position = this.bitmap.length;
for (const sibling of this.siblings) {
const data = sibling.data;
bytes.set(data, position);
position += data.length;
}

return bytes;
}

/**
* @returns {string} String representation of the inclusion certificate.
*/
public toString(): string {
return dedent`
Inclusion Certificate
Bitmap: ${HexConverter.encode(this.bitmap)}
Siblings: [
${this.siblings.map((sibling) => sibling.toString()).join('\n')}
]`;
}

/**
* Verify the certificate path against the expected root hash.
* Reconstruct the sparse Merkle tree root hash for this certificate by hashing
* from the leaf upward.
*
* @param {StateId} leafKey Leaf key.
* @param {DataHash} leafValue Leaf value hash.
* @param {DataHash} expectedRootHash Expected sparse Merkle tree root hash.
* @returns {Promise<boolean>} True if the path is valid.
* @returns {Promise<DataHash>} Reconstructed root hash.
* @throws {Error} If the certificate siblings do not match the bitmap.
*/
public async verify(leafKey: StateId, leafValue: DataHash, expectedRootHash: DataHash): Promise<boolean> {
public async calculateRoot(leafKey: StateId, leafValue: DataHash): Promise<DataHash> {
const key = leafKey.data;
const value = leafValue.data;

Expand All @@ -155,7 +128,9 @@ export class InclusionCertificate {
if (!((bitmapPath >> BigInt(depth)) & 1n)) continue;

position -= 1;
if (position < 0) return false;
if (position < 0) {
throw new Error('Inclusion certificate has fewer siblings than its bitmap requires.');
}

const sibling = this.siblings[position];

Expand All @@ -175,6 +150,55 @@ export class InclusionCertificate {
.digest();
}

return position === 0 && hash.equals(expectedRootHash);
if (position !== 0) {
throw new Error('Inclusion certificate has more siblings than its bitmap requires.');
}

return hash;
}

/**
* @returns {Uint8Array} Encoded certificate bytes.
*/
public encode(): Uint8Array {
const bytes = new Uint8Array(this.bitmap.length + this.siblings.length * HashAlgorithm.SHA256.length);
bytes.set(this.bitmap);
let position = this.bitmap.length;
for (const sibling of this.siblings) {
const data = sibling.data;
bytes.set(data, position);
position += data.length;
}

return bytes;
}

/**
* @returns {string} String representation of the inclusion certificate.
*/
public toString(): string {
return dedent`
Inclusion Certificate
Bitmap: ${HexConverter.encode(this.bitmap)}
Siblings: [
${this.siblings.map((sibling) => sibling.toString()).join('\n')}
]`;
}

/**
* Verify the certificate path against the expected root hash.
*
* @param {StateId} leafKey Leaf key.
* @param {DataHash} leafValue Leaf value hash.
* @param {DataHash} expectedRootHash Expected sparse Merkle tree root hash.
* @returns {Promise<boolean>} True if the path is valid.
*/
public async verify(leafKey: StateId, leafValue: DataHash, expectedRootHash: DataHash): Promise<boolean> {
try {
const root = await this.calculateRoot(leafKey, leafValue);
return root.equals(expectedRootHash);
} catch {
return false;
}
}
}
10 changes: 5 additions & 5 deletions src/api/bft/InputRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,13 @@ export class InputRecord {
CborSerializer.encodeUnsignedInteger(this.version),
CborSerializer.encodeUnsignedInteger(this.roundNumber),
CborSerializer.encodeUnsignedInteger(this.epoch),
CborSerializer.encodeNullable(this.previousHash, CborSerializer.encodeByteString),
CborSerializer.encodeByteString(this.hash),
CborSerializer.encodeByteString(this.summaryValue),
CborSerializer.encodeNullable(this._previousHash, CborSerializer.encodeByteString),
CborSerializer.encodeByteString(this._hash),
CborSerializer.encodeByteString(this._summaryValue),
CborSerializer.encodeUnsignedInteger(this.timestamp),
CborSerializer.encodeNullable(this.blockHash, CborSerializer.encodeByteString),
CborSerializer.encodeNullable(this._blockHash, CborSerializer.encodeByteString),
CborSerializer.encodeUnsignedInteger(this.sumOfEarnedFees),
CborSerializer.encodeNullable(this.executedTransactionsHash, CborSerializer.encodeByteString),
CborSerializer.encodeNullable(this._executedTransactionsHash, CborSerializer.encodeByteString),
),
);
}
Expand Down
89 changes: 73 additions & 16 deletions src/api/bft/RootTrustBase.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { InvalidJsonStructureError } from '../../InvalidJsonStructureError.js';
import { InvalidJsonStructureError } from '../../serialization/json/InvalidJsonStructureError.js';
import { JsonError } from '../../serialization/json/JsonError.js';
import { HexConverter } from '../../util/HexConverter.js';
import { dedent } from '../../util/StringUtils.js';
import { NetworkId } from '../NetworkId.js';
Expand Down Expand Up @@ -55,7 +56,13 @@ export class RootTrustBaseNodeInfo {
if (!RootTrustBaseNodeInfo.isJSON(input)) {
throw new InvalidJsonStructureError();
}
return new RootTrustBaseNodeInfo(input.nodeId, HexConverter.decode(input.sigKey), BigInt(input.stake));

const stake = BigInt(input.stake);
if (stake <= 0n) {
throw new JsonError('Each trust base root node must have positive stake.');
}

return new RootTrustBaseNodeInfo(input.nodeId, HexConverter.decode(input.sigKey), stake);
}

/**
Expand All @@ -66,6 +73,17 @@ export class RootTrustBaseNodeInfo {
public static isJSON(input: unknown): input is INodeInfoJson {
return typeof input === 'object' && input !== null && 'nodeId' in input && 'sigKey' in input && 'stake' in input;
}

/**
* @returns {string} String representation of the node info.
*/
public toString(): string {
return dedent`
RootTrustBaseNodeInfo:
NodeId: ${this.nodeId},
SigningKey: ${HexConverter.encode(this._signingKey)},
Stake: ${this.stakedAmount}`;
}
}

/**
Expand All @@ -88,19 +106,20 @@ interface IRootTrustBaseJson {
* Root trust base information.
*/
export class RootTrustBase {
public constructor(
public readonly version: bigint,
private static readonly VERSION = 1n;

private constructor(
public readonly networkId: NetworkId,
public readonly epoch: bigint,
public readonly epochStartRound: bigint,
public readonly _rootNodes: RootTrustBaseNodeInfo[],
public readonly _rootNodes: Map<string, RootTrustBaseNodeInfo>,
public readonly quorumThreshold: bigint,
public readonly _stateHash: Uint8Array,
public readonly _changeRecordHash: Uint8Array | null,
public readonly _previousEntryHash: Uint8Array | null,
private readonly _signatures: Map<string, Uint8Array>,
) {
this._rootNodes = _rootNodes.slice();
this._rootNodes = new Map(_rootNodes);
this._stateHash = new Uint8Array(_stateHash);
this._changeRecordHash = _changeRecordHash ? new Uint8Array(_changeRecordHash) : null;
this._previousEntryHash = _previousEntryHash ? new Uint8Array(_previousEntryHash) : null;
Expand All @@ -126,8 +145,8 @@ export class RootTrustBase {
/**
* Get the root nodes.
*/
public get rootNodes(): RootTrustBaseNodeInfo[] {
return this._rootNodes.slice();
public get rootNodes(): Map<string, RootTrustBaseNodeInfo> {
return new Map(this._rootNodes);
}

/**
Expand All @@ -146,6 +165,13 @@ export class RootTrustBase {
return new Uint8Array(this._stateHash);
}

/**
* @returns {bigint} Wire-format version of the trust base.
*/
public get version(): bigint {
return RootTrustBase.VERSION;
}

/**
* Create a root trust base from JSON string.
*
Expand All @@ -157,13 +183,42 @@ export class RootTrustBase {
throw new InvalidJsonStructureError();
}

const version = BigInt(input.version);
if (version !== RootTrustBase.VERSION) {
throw new JsonError(`Unsupported RootTrustBase version: ${version}`);
}

const rootNodes = new Map<string, RootTrustBaseNodeInfo>();
const signingKeys = new Set<string>();
for (const node of input.rootNodes) {
const info = RootTrustBaseNodeInfo.fromJSON(node);
if (rootNodes.has(info.nodeId)) {
throw new JsonError(`Duplicate trust base node id: ${info.nodeId}`);
}

const signingKey = HexConverter.encode(info.signingKey);
if (signingKeys.has(signingKey)) {
throw new JsonError('Duplicate trust base signing key.');
}
signingKeys.add(signingKey);
rootNodes.set(info.nodeId, info);
}

if (rootNodes.size === 0) {
throw new JsonError('Trust base must contain at least one root node.');
}

const quorumThreshold = BigInt(input.quorumThreshold);
if (quorumThreshold <= 0n || quorumThreshold > BigInt(rootNodes.size)) {
throw new JsonError('Trust base quorum threshold must be between 1 and the root node count.');
}

return new RootTrustBase(
BigInt(input.version),
NetworkId.fromId(input.networkId),
BigInt(input.epoch),
BigInt(input.epochStartRound),
input.rootNodes.map((node) => RootTrustBaseNodeInfo.fromJSON(node)),
BigInt(input.quorumThreshold),
rootNodes,
quorumThreshold,
HexConverter.decode(input.stateHash),
input.changeRecordHash ? HexConverter.decode(input.changeRecordHash) : null,
input.previousEntryHash ? HexConverter.decode(input.previousEntryHash) : null,
Expand Down Expand Up @@ -206,13 +261,15 @@ export class RootTrustBase {
NetworkId: ${this.networkId.toString()},
Epoch: ${this.epoch},
EpochStartRound: ${this.epochStartRound},
RootNodes: [${this.rootNodes.map((node) => node.nodeId).join(', ')}],
RootNodes: [${Array.from(this._rootNodes.values())
.map((node) => node.toString())
.join(', ')}],
QuorumThreshold: ${this.quorumThreshold},
StateHash: ${HexConverter.encode(this.stateHash)},
ChangeRecordHash: ${this.changeRecordHash ? HexConverter.encode(this.changeRecordHash) : 'null'},
PreviousEntryHash: ${this.previousEntryHash ? HexConverter.encode(this.previousEntryHash) : 'null'},
StateHash: ${HexConverter.encode(this._stateHash)},
ChangeRecordHash: ${this._changeRecordHash ? HexConverter.encode(this._changeRecordHash) : 'null'},
PreviousEntryHash: ${this._previousEntryHash ? HexConverter.encode(this._previousEntryHash) : 'null'},
Signatures: [
${Array.from(this.signatures.entries())
${Array.from(this._signatures.entries())
.map(([id, sig]) => `${id}: ${HexConverter.encode(sig)}`)
.join(', ')}
]`;
Expand Down
Loading
Loading