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
491 changes: 156 additions & 335 deletions README.md

Large diffs are not rendered by default.

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.

32 changes: 17 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@unicitylabs/state-transition-sdk",
"version": "1.5.0",
"version": "1.6.0",
"description": "Generic State Transition Flow engine for value-carrier agents",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
Expand All @@ -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