Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 changes: 3 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ export default {
transform: {
'^.+\\.[tj]sx?$': 'babel-jest',
},
transformIgnorePatterns: ['/node_modules/(?!@unicitylabs)'],
transformIgnorePatterns: [
"/node_modules/(?!uuid)/"
]
};
1,745 changes: 884 additions & 861 deletions package-lock.json

Large diffs are not rendered by default.

30 changes: 16 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"lint": "eslint \"src/**/*\" \"tests/**/*\"",
"lint:fix": "eslint \"src/**/*\" \"tests/**/*\" --fix",
"test": "jest --testPathPatterns=tests --testPathIgnorePatterns=tests/e2e",
"test:unit": "jest --testPathPatterns=tests/unit",
"test:unit": "jest --testPathPatterns=tests/unit --testPathPatterns=tests/functional",
"test:integration": "jest --testPathPatterns=tests/integration",
"test:e2e": "jest --testPathPatterns=tests/e2e",
"test:ci": "DEBUG=testcontainers:containers jest --testPathPatterns=tests --testPathIgnorePatterns=tests/e2e --ci --reporters=default",
Expand All @@ -32,22 +32,24 @@
"license": "ISC",
"homepage": "https://unicitynetwork.github.io/state-transition-sdk/",
"dependencies": {
"@unicitylabs/commons": "2.4.0-rc.f631bc4"
"@noble/hashes": "2.0.1",
"@noble/curves": "2.0.1",
"uuid": "13.0.0"
},
"devDependencies": {
"@babel/preset-env": "7.27.2",
"@babel/preset-env": "7.28.3",
"@babel/preset-typescript": "7.27.1",
"@eslint/js": "9.29.0",
"@eslint/js": "9.37.0",
"@types/jest": "30.0.0",
"babel-jest": "30.0.0",
"eslint": "9.29.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "5.4.1",
"globals": "16.2.0",
"jest": "30.0.0",
"testcontainers": "11.0.3",
"typescript": "5.8.3",
"typescript-eslint": "8.34.0"
"babel-jest": "30.2.0",
"eslint": "9.37.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-prettier": "5.5.4",
"globals": "16.4.0",
"jest": "30.2.0",
"testcontainers": "11.7.1",
"typescript": "5.9.3",
"typescript-eslint": "8.46.1"
}
}
5 changes: 5 additions & 0 deletions src/InvalidJsonStructureError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class InvalidJsonStructureError extends Error {
public constructor() {
super('Invalid JSON structure.');
}
}
207 changes: 58 additions & 149 deletions src/StateTransitionClient.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,18 @@
import { InclusionProof, InclusionProofVerificationStatus } from '@unicitylabs/commons/lib/api/InclusionProof.js';
import { RequestId } from '@unicitylabs/commons/lib/api/RequestId.js';
import {
SubmitCommitmentResponse,
SubmitCommitmentStatus,
} from '@unicitylabs/commons/lib/api/SubmitCommitmentResponse.js';
import { HashAlgorithm } from '@unicitylabs/commons/lib/hash/HashAlgorithm.js';
import { SigningService } from '@unicitylabs/commons/lib/signing/SigningService.js';
import { HexConverter } from '@unicitylabs/commons/lib/util/HexConverter.js';

import { DirectAddress } from './address/DirectAddress.js';
import { IAggregatorClient } from './api/IAggregatorClient.js';
import { ISerializable } from './ISerializable.js';
import { NameTagToken } from './token/NameTagToken.js';
import { InclusionProofResponse } from './api/InclusionProofResponse.js';
import { RequestId } from './api/RequestId.js';
import { SubmitCommitmentResponse } from './api/SubmitCommitmentResponse.js';
import { RootTrustBase } from './bft/RootTrustBase.js';
import { PredicateEngineService } from './predicate/PredicateEngineService.js';
import { Token } from './token/Token.js';
import { TokenState } from './token/TokenState.js';
import { Commitment } from './transaction/Commitment.js';
import { IMintTransactionReason } from './transaction/IMintTransactionReason.js';
import { InclusionProofVerificationStatus } from './transaction/InclusionProof.js';
import { MintCommitment } from './transaction/MintCommitment.js';
import { MintTransactionData } from './transaction/MintTransactionData.js';
import { Transaction } from './transaction/Transaction.js';
import { TransactionData } from './transaction/TransactionData.js';

// I_AM_UNIVERSAL_MINTER_FOR_ string bytes
/**
* Secret prefix for the signing used internally when minting tokens.
*/
export const MINTER_SECRET = HexConverter.decode('495f414d5f554e4956455253414c5f4d494e5445525f464f525f');
import { TransferTransaction } from './transaction/TransferTransaction.js';
import { TransferTransactionData } from './transaction/TransferTransactionData.js';

/**
* High level client implementing the token state transition workflow.
Expand All @@ -35,46 +24,20 @@ export class StateTransitionClient {
public constructor(public readonly client: IAggregatorClient) {}

/**
* Create and submit a mint transaction for a new token.
* @param transactionData Mint transaction data containing token information and address.
* @returns Commitment containing the transaction data and authenticator
* @throws Error when the aggregator rejects the transaction
* Submit a mint commitment to the aggregator.
*
* @example
* ```ts
* const commitment = await client.submitMintTransaction(
* await MintTransactionData.create(
* TokenId.create(crypto.getRandomValues(new Uint8Array(32))),
* TokenType.create(crypto.getRandomValues(new Uint8Array(32))),
* new Uint8Array(),
* null,
* await DirectAddress.create(mintTokenData.predicate.reference),
* crypto.getRandomValues(new Uint8Array(32)),
* null,
* null
* )
* );
* ```
* @param {MintCommitment} commitment Mint commitment
* @returns Commitment ready for inclusion proof retrieval
* @throws Error if aggregator rejects
*/
public async submitMintTransaction<T extends MintTransactionData<ISerializable | null>>(
transactionData: T,
): Promise<Commitment<T>> {
const commitment = await Commitment.create(
transactionData,
await SigningService.createFromSecret(MINTER_SECRET, transactionData.tokenId.bytes),
);

const result = await this.client.submitTransaction(
public async submitMintCommitment<R extends IMintTransactionReason>(
commitment: MintCommitment<R>,
): Promise<SubmitCommitmentResponse> {
return this.client.submitCommitment(
commitment.requestId,
commitment.transactionData.hash,
await commitment.transactionData.calculateHash(),
commitment.authenticator,
);

if (result.status !== SubmitCommitmentStatus.SUCCESS) {
throw new Error(`Could not submit transaction: ${result.status}`);
}

return commitment;
}

/**
Expand All @@ -89,125 +52,71 @@ export class StateTransitionClient {
* const commitment = await client.submitTransaction(data, signingService);
* ```
*/
public submitCommitment(commitment: Commitment<TransactionData>): Promise<SubmitCommitmentResponse> {
if (!commitment.transactionData.sourceState.unlockPredicate.isOwner(commitment.authenticator.publicKey)) {
public async submitTransferCommitment(
commitment: Commitment<TransferTransactionData>,
): Promise<SubmitCommitmentResponse> {
const predicate = await PredicateEngineService.createPredicate(commitment.transactionData.sourceState.predicate);
if (!(await predicate.isOwner(commitment.authenticator.publicKey))) {
throw new Error('Ownership verification failed: Authenticator does not match source state predicate.');
}

return this.client.submitTransaction(
return this.client.submitCommitment(
commitment.requestId,
commitment.transactionData.hash,
await commitment.transactionData.calculateHash(),
commitment.authenticator,
);
}

/**
* Build a {@link Transaction} object once an inclusion proof is obtained.
*
* @param param0 Commitment returned from submit* methods
* @param inclusionProof Proof of inclusion from the aggregator
* @returns Constructed transaction object
* @throws Error if the inclusion proof is invalid
*
* @example
* ```ts
* const tx = await client.createTransaction(commitment, inclusionProof);
* ```
*/
public async createTransaction<T extends TransactionData | MintTransactionData<ISerializable | null>>(
{ requestId, transactionData }: Commitment<T>,
inclusionProof: InclusionProof,
): Promise<Transaction<T>> {
const status = await inclusionProof.verify(requestId);
if (status != InclusionProofVerificationStatus.OK) {
throw new Error('Inclusion proof verification failed.');
}

if (!inclusionProof.authenticator || !HashAlgorithm[inclusionProof.authenticator.stateHash.algorithm]) {
throw new Error('Invalid inclusion proof hash algorithm.');
}

if (!inclusionProof.transactionHash?.equals(transactionData.hash)) {
throw new Error('Payload hash mismatch');
}

return new Transaction(transactionData, inclusionProof);
}

/**
* Finalise a transaction and produce the next token state.
*
* @param token Token being transitioned
* @param state New state after the transition
* @param transaction Transaction proving the state change
* @param nametagTokens Optional name tag tokens associated with the transfer
* @returns Updated token instance
* @throws Error if validation checks fail
* Finalizes a transaction by updating the token state based on the provided transaction data and
* nametags.
*
* @example
* ```ts
* const updated = await client.finishTransaction(token, state, tx);
* ```
* @param trustBase The root trust base for inclusion proof verification.
* @param token The token to be updated.
* @param state The current state of the token.
* @param transaction The transaction containing transfer data.
* @param nametags A list of tokens used as nametags in the transaction.
* @return The updated token after applying the transaction.
*/
public async finishTransaction<T extends Transaction<MintTransactionData<ISerializable | null>>>(
token: Token<T>,
public finalizeTransaction<R extends IMintTransactionReason>(
trustBase: RootTrustBase,
token: Token<R>,
state: TokenState,
transaction: Transaction<TransactionData>,
nametagTokens: NameTagToken[] = [],
): Promise<Token<T>> {
if (!(await transaction.data.sourceState.unlockPredicate.verify(transaction))) {
throw new Error('Predicate verification failed');
}

// TODO: Move address processing to a separate method
// TODO: Resolve proxy address
const expectedAddress = await DirectAddress.create(state.unlockPredicate.reference);
if (expectedAddress.toJSON() !== transaction.data.recipient) {
throw new Error('Recipient address mismatch');
}

const transactions: Transaction<TransactionData>[] = [...token.transactions, transaction];

if (!(await transaction.containsData(state.data))) {
throw new Error('State data is not part of transaction.');
}

return new Token(state, token.genesis, transactions, nametagTokens);
transaction: TransferTransaction,
nametags: Token<IMintTransactionReason>[] = [],
): Promise<Token<R>> {
return token.update(trustBase, state, transaction, nametags);
}

/**
* Query the ledger to see if the token's current state has been spent.
*
* @param token Token to check
* @param publicKey Public key of the owner
* @returns Verification status reported by the aggregator
* Retrieves the inclusion proof for a token and verifies its status against the provided public
* key and trust base.
*
* @example
* ```ts
* const status = await client.getTokenStatus(token, ownerPublicKey);
* ```
* @param token The token for which to retrieve the inclusion proof.
* @param publicKey The public key associated with the token.
* @param trustBase The root trust base for verification.
* @return inclusion proof verification status.
*/
public async getTokenStatus(
token: Token<Transaction<MintTransactionData<ISerializable | null>>>,
trustBase: RootTrustBase,
token: Token<IMintTransactionReason>,
publicKey: Uint8Array,
): Promise<InclusionProofVerificationStatus> {
const requestId = await RequestId.create(publicKey, token.state.hash);
const inclusionProof = await this.client.getInclusionProof(requestId);
// TODO: Check ownership?
return inclusionProof.verify(requestId);
const requestId = await RequestId.create(publicKey, await token.state.calculateHash());
return this.client
.getInclusionProof(requestId)
.then((response) => response.inclusionProof.verify(trustBase, requestId));
}

/**
* Convenience helper to retrieve the inclusion proof for a commitment.
* Retrieves the inclusion proof for a given commitment.
*
* @example
* ```ts
* const proof = await client.getInclusionProof(commitment);
* ```
* @param commitment The commitment for which to retrieve the inclusion proof.
* @return inclusion proof response from the aggregator.
*/
public getInclusionProof(
commitment: Commitment<TransactionData | MintTransactionData<ISerializable | null>>,
): Promise<InclusionProof> {
commitment: Commitment<TransferTransactionData | MintTransactionData<IMintTransactionReason>>,
): Promise<InclusionProofResponse> {
return this.client.getInclusionProof(commitment.requestId);
}
}
45 changes: 45 additions & 0 deletions src/address/AddressFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { AddressScheme } from './AddressScheme.js';
import { DirectAddress } from './DirectAddress.js';
import { IAddress } from './IAddress.js';
import { ProxyAddress } from './ProxyAddress.js';
import { DataHash } from '../hash/DataHash.js';
import { TokenId } from '../token/TokenId.js';
import { HexConverter } from '../util/HexConverter.js';

/**
* Factory for creating Address instances from string representations.
*/
export class AddressFactory {
/**
* Create an Address from its string representation.
*
* @param {string} address The address string.
* @return The corresponding Address instance.
*/
public static async createAddress(address: string): Promise<IAddress> {
const result = address.split('://', 2);
if (result.length != 2) {
throw new Error('Invalid address format');
}

let expectedAddress: IAddress;
const bytes = HexConverter.decode(result[1]);

switch (result.at(0)) {
case AddressScheme.DIRECT:
expectedAddress = await DirectAddress.create(DataHash.fromImprint(bytes.slice(0, -4)));
break;
case AddressScheme.PROXY:
expectedAddress = await ProxyAddress.fromTokenId(new TokenId(bytes.slice(0, -4)));
break;
default:
throw new Error(`Invalid address format: ${result.at(0)}`);
}

if (expectedAddress.address !== address) {
throw new Error('Address checksum mismatch');
}

return expectedAddress;
}
}
Loading