From 706c42eabf0b2fc7be73d69b6f53820ce88886ca Mon Sep 17 00:00:00 2001 From: Roman Volosatovs Date: Tue, 20 Oct 2026 17:01:36 +0200 Subject: [PATCH] js: query the ledger over `/rpc` The `Ledger` interface predated `starstream-ledger` and its only implementation, `InMemoryLedger`, threw `todo` from every method. Redesign `Ledger` around the ledger's model: outputs are referenced by `TransactionInput`, a `(transaction digest, index)` pair with an empty transaction meaning genesis, as in the Rust client; contracts and transactions are fetched by digest along with the signed envelopes they were submitted in; and fund, publish and transaction submission take an Ed25519 `Account` and, for fund and publish, an explicit nonce. The publish doc comment now says that republishing fails with `409 Conflict`, which is what the server does. Add `RpcLedger`, which implements the read-only queries over `POST /rpc` with `application/x.wrpc`: network name, block height and block envelopes, admin nonce, account balance and nonce, contract Wasm and envelopes, transactions and their envelopes, genesis outputs, and UTXO method calls. A relative base URL resolves against `location`, when there is one. Contract Wasm is checked against its digest, and responses must carry the wRPC content type. Submission still throws `todo`. Invocations go through `invoke` from `@bytecodealliance/wrpc`, over a transport that buffers the request into a single POST body and streams the response body back. UTXO method calls take their parameter and result types from the caller, built with the re-exported `t`, and check the argument count against them before adding the output reference. Add `digest`, computing the ledger's multibase base32-lower sha2-256 multihash, in its own module so that `ledger_rpc.ts` does not import `index.ts` at runtime. `Contract.getId` now computes that digest on each call. `InMemoryLedger` keeps throwing `todo`, now against the new interface, and takes genesis outputs instead of `Utxo` instances. The API sketch in `test/example.ts` follows the new interface: it reads an output, wraps UTXO method calls in its pseudocode bindings, fetches and publishes contracts by digest and Wasm, and posts a `Transaction`. The new vitest compiles `examples/score.star`, spawns `starstream node` on port 0 with a genesis script, reading the bound address and keeping the node's log from its stderr, funds an account and publishes the contract with `starstream ledger`, and runs every query, including genesis UTXO method calls, against it. Each test only relies on ledger state it creates itself. It runs `starstream` through the repository's `cargo run` wrapper, so the CLI is built from the current source; `STARSTREAM` overrides it with a prebuilt binary. Breaking: `Ledger` drops `getUtxo`, `getContract`, `uploadContract` and the `UploadContractOptions` and `PostTransactionOptions` types, `Transaction` is now an interface mirroring the ledger's, and `Contract.getId` returns the ledger digest instead of a hex sha-256. Assisted-by: anthropic:claude-opus-5-5 Signed-off-by: Roman Volosatovs --- starstream-js/package-lock.json | 12 + starstream-js/package.json | 3 + starstream-js/src/digest.ts | 24 ++ starstream-js/src/index.ts | 175 ++++++++++---- starstream-js/src/ledger_memory.ts | 77 ++++-- starstream-js/src/ledger_rpc.ts | 360 +++++++++++++++++++++++++++++ starstream-js/test/example.ts | 63 +++-- starstream-js/test/ledger.test.ts | 232 +++++++++++++++++++ starstream-js/test/misc.test.ts | 41 +++- 9 files changed, 909 insertions(+), 78 deletions(-) create mode 100644 starstream-js/src/digest.ts create mode 100644 starstream-js/src/ledger_rpc.ts create mode 100644 starstream-js/test/ledger.test.ts diff --git a/starstream-js/package-lock.json b/starstream-js/package-lock.json index 21f0dd08..dc10d4be 100644 --- a/starstream-js/package-lock.json +++ b/starstream-js/package-lock.json @@ -8,6 +8,9 @@ "name": "starstream", "version": "0.0.0", "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@bytecodealliance/wrpc": "^0.1.1" + }, "devDependencies": { "tsdown": "^0.22.1", "typescript": "^6.0.3", @@ -82,6 +85,15 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@bytecodealliance/wrpc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@bytecodealliance/wrpc/-/wrpc-0.1.1.tgz", + "integrity": "sha512-G5/nhVSdwycAg12MAHO0uPd95YagX+CmPi+etHamdcbn6JepoQEchAAfeM/Yj3um9N3OR7TmEVRz7LHZ2ZwyAQ==", + "license": "Apache-2.0 WITH LLVM-exception", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/starstream-js/package.json b/starstream-js/package.json index c2bbcbeb..da7512f2 100644 --- a/starstream-js/package.json +++ b/starstream-js/package.json @@ -17,5 +17,8 @@ "scripts": { "prepack": "tsdown", "test": "vitest" + }, + "dependencies": { + "@bytecodealliance/wrpc": "^0.1.1" } } diff --git a/starstream-js/src/digest.ts b/starstream-js/src/digest.ts new file mode 100644 index 00000000..a5d1aaed --- /dev/null +++ b/starstream-js/src/digest.ts @@ -0,0 +1,24 @@ +/** Compute the ledger digest of `data`: the multibase base32-lower sha2-256 multihash. */ +export async function digest(data: Uint8Array): Promise { + const hash = new Uint8Array(await crypto.subtle.digest("sha-256", data)); + return "b" + base32Lower(Uint8Array.of(0x12, 0x20, ...hash)); +} + +function base32Lower(data: Uint8Array): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz234567"; + let s = ""; + let bits = 0; + let acc = 0; + for (const byte of data) { + acc = (acc << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + s += alphabet[(acc >> bits) & 31]; + } + } + if (bits > 0) { + s += alphabet[(acc << (5 - bits)) & 31]; + } + return s; +} diff --git a/starstream-js/src/index.ts b/starstream-js/src/index.ts index ea6a4428..76921a31 100644 --- a/starstream-js/src/index.ts +++ b/starstream-js/src/index.ts @@ -1,44 +1,150 @@ // JS bindings to the Starstream runtime. +import type { Type } from "@bytecodealliance/wrpc"; + // ---------------------------------------------------------------------------- // Ledgers -export interface UploadContractOptions {} -export interface PostTransactionOptions {} +/** Ed25519 account signing ledger envelopes. */ +export interface Account { + /** Raw 32-byte Ed25519 public key identifying the account. */ + readonly publicKey: Uint8Array; + /** Sign `message` with the account's Ed25519 secret key. */ + sign(message: Uint8Array): Promise; +} export interface Ledger { - /** Look up a Utxo on the ledger by ID. */ - getUtxo(id: string): Promise; - /** Look up a Utxo on the ledger by ID, and cast it to a specific type. Throws on type mismatch. */ - getUtxo(id: string, type: UtxoConstructor): Promise; - - /** Fetch an abstract contract from the ledger by ID. */ - getContract(id: string): Promise; - /** Fetch a typed contract from the ledger by ID. Throws on type mismatch. */ - getContract( - id: string, - type: ContractConstructor, - ): Promise; - /** Upload a contract to the ledger. Fast and cheap if it has already been uploaded. */ - uploadContract( - contract: Contract, - options?: UploadContractOptions, + /** Get the name of the ledger network. */ + getNetworkName(): Promise; + + /** Get the height of the latest ledger block. */ + getBlockHeight(): Promise; + /** Get the signed envelopes of the block at `height`, if any. Genesis, at height 0, has none. */ + getBlock(height: bigint): Promise; + + /** Get the last nonce the ledger admin used for funding. */ + getAdminNonce(): Promise; + /** Get the balance of the account with the Ed25519 public key `key`, if it exists. */ + getAccountBalance(key: Uint8Array): Promise; + /** Get the last nonce the account `key` used to publish contracts, if it exists. */ + getAccountNonce(key: Uint8Array): Promise; + + /** Get the Wasm of the published contract with `digest`, if any. */ + getContractWasm(digest: string): Promise | undefined>; + /** Get the signed envelope the contract with `digest` was published in, if any. */ + getContractEnvelope(digest: string): Promise; + + /** Get the transaction with `digest`, if any. */ + getTransaction(digest: string): Promise; + /** Get the signed envelope the transaction with `digest` was submitted in, if any. */ + getTransactionEnvelope(digest: string): Promise; + + /** Get the outputs of the genesis block. */ + getGenesisOutputs(): Promise; + /** Get the output referenced by `output`, if any, whether or not it has been spent. */ + getOutput(output: TransactionInput): Promise; + + /** + * Call the method `name` of the UTXO created by `output` with `args` of + * `ty.params`, discarding the resulting state, and return its result of + * `ty.result`. + */ + callUtxoMethod( + output: TransactionInput, + name: string, + ty: FuncType, + args: unknown[], + ): Promise; + + /** + * Credit `account` with `amount`, signed by the ledger `admin`. + * `nonce` must be greater than the last nonce `admin` used for funding. + */ + fund( + admin: Account, + nonce: bigint, + account: Uint8Array, + amount: bigint, ): Promise; - - postTransaction( - proof: Proof, - options?: PostTransactionOptions, - ): Promise; + /** + * Publish the contract `wasm`, signed and paid for by `account`, and return + * its digest. `nonce` must be greater than the last nonce of `account`. + * Republishing a contract fails with `409 Conflict`. + */ + publishContract( + account: Account, + nonce: bigint, + wasm: Uint8Array, + ): Promise; + /** + * Submit `transaction` signed by `signer` and return its digest. + * Spending genesis outputs requires the admin signature. + * Resubmitting a transaction fails with `409 Conflict`. + */ + postTransaction(signer: Account, transaction: Transaction): Promise; } -// TODO: class RpcLedger { constructor(url: string); } - +export { RpcLedger } from "./ledger_rpc.ts"; export { InMemoryLedger } from "./ledger_memory.ts"; +export { t, type Type } from "@bytecodealliance/wrpc"; + +/** Parameter and result types of a function. */ +export interface FuncType { + params?: Type[]; + result?: Type; +} // ---------------------------------------------------------------------------- // Transactions -export class Transaction {} +/** Reference to a transaction output. An empty `transaction` refers to a genesis output. */ +export interface TransactionInput { + transaction: string; + index: number; +} + +export type GlobalValue = + | { tag: "i32"; val: number } + | { tag: "i64"; val: bigint } + | { tag: "f32"; val: number } + | { tag: "f64"; val: bigint } + | { tag: "v128"; val: [bigint, bigint] }; + +export interface DataState { + memoryIndex: number; + offset: number; + data: Uint8Array; +} + +export interface ModuleState { + memories: bigint[]; + globals: GlobalValue[]; + data: DataState[]; +} + +export interface TransactionOutput { + contract: string; + instance: string; + methods: [bigint, bigint, bigint, bigint][]; + storage: Uint8Array; + state: ModuleState[]; +} + +export interface TransactionEvent { + abiName: string; + name: string; + params: Uint8Array; +} + +export interface Transaction { + inputs: TransactionInput[]; + outputs: TransactionOutput[]; + events: TransactionEvent[]; + proof: Uint8Array; +} + +import { digest } from "./digest.ts"; +export { digest }; // ---------------------------------------------------------------------------- // Contracts @@ -47,14 +153,14 @@ type ContractConstructor = new (wasm: Uint8Array) => T; /** Starstream contract Wasm file loaded in memory. */ export class Contract { + private readonly wasm: Uint8Array; private readonly module: Promise; - private readonly id: Promise; /** Load a contract from a Wasm module directly. */ constructor(wasm: Uint8Array) { // TODO: use our Wasmtime variant as a library rather than browser's Wasm implementation + this.wasm = wasm; this.module = WebAssembly.compile(wasm); - this.id = crypto.subtle.digest("sha-256", wasm); } /** Load a contract from a file path (Node only). */ @@ -64,9 +170,9 @@ export class Contract { return new this(buffer); } - // sha-256 of wasm + /** Get the ledger digest of the contract Wasm. */ async getId(): Promise { - return uint8ArrayToHexString(new Uint8Array(await this.id)); + return digest(this.wasm); } downcast(type: ContractConstructor): T { @@ -218,12 +324,3 @@ export function prove(trace: Trace): Promise { import * as std from "./std.ts"; export { std }; - -// ---------------------------------------------------------------------------- -// Private utilities - -function uint8ArrayToHexString(uint8Array: Uint8Array): string { - return Array.prototype.map - .call(uint8Array, (byte) => ("0" + byte.toString(16)).slice(-2)) - .join(""); -} diff --git a/starstream-js/src/ledger_memory.ts b/starstream-js/src/ledger_memory.ts index 0e7ad939..5e92ff6e 100644 --- a/starstream-js/src/ledger_memory.ts +++ b/starstream-js/src/ledger_memory.ts @@ -1,35 +1,78 @@ import type { + Account, + FuncType, Ledger, - Utxo, - Proof, - PostTransactionOptions, Transaction, - Contract, - UploadContractOptions, + TransactionInput, + TransactionOutput, } from "."; export class InMemoryLedger implements Ledger { - constructor(options?: { genesisUtxos?: Utxo[] }) { + constructor(options?: { genesisOutputs?: TransactionOutput[] }) { throw new Error("todo"); } - postTransaction( - proof: Proof, - options?: PostTransactionOptions, - ): Promise { + getNetworkName(): Promise { throw new Error("todo"); } - getUtxo(id: string): Promise { + getBlockHeight(): Promise { throw new Error("todo"); } - getContract(id: string): Promise { + getBlock(height: bigint): Promise { throw new Error("todo"); } - uploadContract( - contract: Contract, - options?: UploadContractOptions, + getAdminNonce(): Promise { + throw new Error("todo"); + } + getAccountBalance(key: Uint8Array): Promise { + throw new Error("todo"); + } + getAccountNonce(key: Uint8Array): Promise { + throw new Error("todo"); + } + getContractWasm( + digest: string, + ): Promise | undefined> { + throw new Error("todo"); + } + getContractEnvelope(digest: string): Promise { + throw new Error("todo"); + } + getTransaction(digest: string): Promise { + throw new Error("todo"); + } + getTransactionEnvelope(digest: string): Promise { + throw new Error("todo"); + } + getGenesisOutputs(): Promise { + throw new Error("todo"); + } + getOutput(output: TransactionInput): Promise { + throw new Error("todo"); + } + callUtxoMethod( + output: TransactionInput, + name: string, + ty: FuncType, + args: unknown[], + ): Promise { + throw new Error("todo"); + } + fund( + admin: Account, + nonce: bigint, + account: Uint8Array, + amount: bigint, ): Promise { throw new Error("todo"); } - - readonly genesisUtxos: readonly Utxo[]; + publishContract( + account: Account, + nonce: bigint, + wasm: Uint8Array, + ): Promise { + throw new Error("todo"); + } + postTransaction(signer: Account, transaction: Transaction): Promise { + throw new Error("todo"); + } } diff --git a/starstream-js/src/ledger_rpc.ts b/starstream-js/src/ledger_rpc.ts new file mode 100644 index 00000000..cd064cd2 --- /dev/null +++ b/starstream-js/src/ledger_rpc.ts @@ -0,0 +1,360 @@ +import { digest as computeDigest } from "./digest.ts"; +import type { + Account, + FuncType, + Ledger, + Transaction, + TransactionInput, + TransactionOutput, +} from "./index.ts"; +import { invoke, t, type Transport, type Value } from "@bytecodealliance/wrpc"; + +const APPLICATION_WRPC = "application/x.wrpc"; + +const LEDGER_ADMIN_INSTANCE = "starstream:ledger/admin"; +const LEDGER_ACCOUNT_INSTANCE = "starstream:ledger/account"; +const LEDGER_BLOCK_INSTANCE = "starstream:ledger/block"; +const LEDGER_CONTRACT_INSTANCE = "starstream:ledger/contract"; +const LEDGER_TRANSACTION_INSTANCE = "starstream:ledger/transaction"; +const LEDGER_GENESIS_INSTANCE = "starstream:ledger/genesis"; +const LEDGER_UTXO_INSTANCE = "starstream:ledger/utxo"; +const LEDGER_NETWORK_INSTANCE = "starstream:ledger/network"; + +const bytes = t.list(t.u8); + +const getError = t.variant({ "not-found": null, malformed: t.string }); + +const getBytesResult = t.result(bytes, getError); + +const getU64Result = t.result(t.u64, getError); + +const transactionOutput = t.record({ + contract: t.string, + instance: t.string, + methods: t.list(t.tuple(t.u64, t.u64, t.u64, t.u64)), + storage: bytes, + state: t.list( + t.record({ + memories: t.list(t.u64), + globals: t.list( + t.variant({ + i32: t.s32, + i64: t.s64, + f32: t.u32, + f64: t.u64, + v128: t.tuple(t.u64, t.u64), + }), + ), + data: t.list( + t.record({ memoryIndex: t.u32, offset: t.u32, data: bytes }), + ), + }), + ), +}); + +const transaction = t.record({ + inputs: t.list(t.record({ transaction: t.string, index: t.u32 })), + outputs: t.list(transactionOutput), + events: t.list( + t.record({ abiName: t.string, name: t.string, params: bytes }), + ), + proof: bytes, +}); + +const DIGEST = /^bciq[a-p][a-z2-7]{50}[aiqy]$/; + +function checkDigest(digest: string) { + if (!DIGEST.test(digest)) { + throw new Error( + `invalid digest \`${digest}\`: expected a multibase base32-lower sha2-256 multihash`, + ); + } +} + +const getTransactionResult = t.result(transaction, getError); + +type Variant = { tag: string; val?: Value }; + +function unwrapGet(res: unknown, what: string): T | undefined { + const variant = res as Variant; + if (variant.tag === "ok") { + return variant.val as T; + } + const err = variant.val as Variant; + if (err.tag === "not-found") { + return undefined; + } + throw new Error(`invalid ${what}: ${err.val}`); +} + +/** Ledger accessed over the HTTP API of `starstream-ledger-node`. */ +export class RpcLedger implements Ledger { + private readonly url: string; + private readonly fetch: typeof fetch; + + constructor(url: string | URL, options?: { fetch?: typeof fetch }) { + const base = new URL(url, globalThis.location?.href); + if (/[?#]/.test(base.href)) { + throw new Error( + `base URL \`${base}\` must not contain a query or fragment`, + ); + } + this.url = base.href.replace(/\/+$/, ""); + this.fetch = options?.fetch ?? globalThis.fetch; + } + + private async post( + instance: string, + name: string, + body: Blob, + signal: AbortSignal, + ): Promise> { + const fetch = this.fetch; + const res = await fetch(`${this.url}/rpc`, { + method: "POST", + headers: { "content-type": APPLICATION_WRPC }, + body, + signal, + }); + if (!res.ok) { + throw new Error( + `\`${instance}#${name}\` failed with status ${res.status}: ${await res.text()}`, + ); + } + const contentType = res.headers.get("content-type"); + if (contentType?.split(";")[0].trim().toLowerCase() !== APPLICATION_WRPC) { + await res.body?.cancel(); + throw new Error( + `\`${instance}#${name}\` returned content-type \`${contentType}\`, expected \`${APPLICATION_WRPC}\``, + ); + } + return (res.body ?? new Blob().stream()).getReader(); + } + + private transport(instance: string, name: string): Transport { + const req: Uint8Array[] = []; + const res = + Promise.withResolvers>(); + const ctrl = new AbortController(); + return { + write: (buf) => { + req.push(new Uint8Array(buf)); + }, + closeWrite: async () => { + res.resolve(this.post(instance, name, new Blob(req), ctrl.signal)); + await res.promise; + }, + read: async () => { + const { value, done } = await (await res.promise).read(); + return done ? null : value; + }, + abort: async (reason) => { + ctrl.abort(reason); + res.reject(reason); + await (await res.promise).cancel(reason); + }, + }; + } + + private async call( + instance: string, + name: string, + ty: FuncType, + args: unknown[], + ): Promise { + const { results, done } = await invoke( + this.transport(instance, name), + instance, + name, + ty.params ?? [], + args as Value[], + ty.result ? [ty.result] : [], + ); + await done; + return results[0] as T; + } + + async getNetworkName(): Promise { + return this.call( + LEDGER_NETWORK_INSTANCE, + "get-name", + { result: t.string }, + [], + ); + } + + async getBlockHeight(): Promise { + return this.call( + LEDGER_BLOCK_INSTANCE, + "get-height", + { result: t.u64 }, + [], + ); + } + + async getBlock(height: bigint): Promise { + return this.call( + LEDGER_BLOCK_INSTANCE, + "get-block", + { params: [t.u64], result: t.option(t.list(bytes)) }, + [height], + ); + } + + async getAdminNonce(): Promise { + return this.call( + LEDGER_ADMIN_INSTANCE, + "get-nonce", + { result: t.u64 }, + [], + ); + } + + async getAccountBalance(key: Uint8Array): Promise { + const res = await this.call( + LEDGER_ACCOUNT_INSTANCE, + "get-balance", + { params: [bytes], result: getU64Result }, + [key], + ); + return unwrapGet(res, "key"); + } + + async getAccountNonce(key: Uint8Array): Promise { + const res = await this.call( + LEDGER_ACCOUNT_INSTANCE, + "get-nonce", + { params: [bytes], result: getU64Result }, + [key], + ); + return unwrapGet(res, "key"); + } + + async getContractWasm( + digest: string, + ): Promise | undefined> { + checkDigest(digest); + const res = await this.call( + LEDGER_CONTRACT_INSTANCE, + "get-wasm", + { params: [t.string], result: getBytesResult }, + [digest], + ); + const wasm = unwrapGet>(res, "digest"); + if (!wasm) { + return undefined; + } + const got = await computeDigest(wasm); + if (got !== digest) { + throw new Error( + `contract digest mismatch: expected \`${digest}\`, got \`${got}\``, + ); + } + return wasm; + } + + async getContractEnvelope(digest: string): Promise { + checkDigest(digest); + const res = await this.call( + LEDGER_CONTRACT_INSTANCE, + "get-envelope", + { params: [t.string], result: getBytesResult }, + [digest], + ); + return unwrapGet(res, "digest"); + } + + async getTransaction(digest: string): Promise { + checkDigest(digest); + const res = await this.call( + LEDGER_TRANSACTION_INSTANCE, + "get-transaction", + { params: [t.string], result: getTransactionResult }, + [digest], + ); + return unwrapGet(res, "digest"); + } + + async getTransactionEnvelope( + digest: string, + ): Promise { + checkDigest(digest); + const res = await this.call( + LEDGER_TRANSACTION_INSTANCE, + "get-envelope", + { params: [t.string], result: getBytesResult }, + [digest], + ); + return unwrapGet(res, "digest"); + } + + async getGenesisOutputs(): Promise { + return this.call( + LEDGER_GENESIS_INSTANCE, + "get-outputs", + { result: t.list(transactionOutput) }, + [], + ); + } + + async getOutput( + output: TransactionInput, + ): Promise { + if (!output.transaction) { + const outputs = await this.getGenesisOutputs(); + return outputs[output.index]; + } + const tx = await this.getTransaction(output.transaction); + return tx?.outputs[output.index]; + } + + async callUtxoMethod( + output: TransactionInput, + name: string, + ty: FuncType, + args: unknown[], + ): Promise { + if (output.transaction) { + checkDigest(output.transaction); + } + const params = ty.params ?? []; + if (args.length !== params.length) { + throw new RangeError( + `expected ${params.length} argument(s), got ${args.length}`, + ); + } + return this.call( + LEDGER_UTXO_INSTANCE, + name, + { + params: [t.option(t.string), t.u32, ...params], + result: ty.result, + }, + [output.transaction || undefined, output.index, ...args], + ); + } + + async fund( + admin: Account, + nonce: bigint, + account: Uint8Array, + amount: bigint, + ): Promise { + throw new Error("todo"); + } + + async publishContract( + account: Account, + nonce: bigint, + wasm: Uint8Array, + ): Promise { + throw new Error("todo"); + } + + async postTransaction( + signer: Account, + transaction: Transaction, + ): Promise { + throw new Error("todo"); + } +} diff --git a/starstream-js/test/example.ts b/starstream-js/test/example.ts index 446c7f72..d91041ca 100644 --- a/starstream-js/test/example.ts +++ b/starstream-js/test/example.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; import * as Starstream from "../src/index.ts"; // -------------------------------------------------------------------------- @@ -26,9 +27,14 @@ class Adder extends Starstream.Contract { my_effect: "my-effect", }; - static MyUtxo = class MyUtxo extends Starstream.Utxo { - new(): Promise { - throw new Error("stub"); + static MyUtxo = class MyUtxo { + constructor( + readonly ledger: Starstream.Ledger, + readonly output: Starstream.TransactionInput, + ) {} + + async abi_method(): Promise { + await this.ledger.callUtxoMethod(this.output, "abi-method", {}, []); } }; @@ -45,33 +51,43 @@ declare namespace Adder { } // -------------------------------------------------------------------------- -// 1. Acquire ledger -const ledger: Starstream.Ledger = new Starstream.InMemoryLedger({ - genesisUtxos: [], -}); +// 1. Acquire ledger and account +const ledger: Starstream.Ledger = new Starstream.RpcLedger( + "http://localhost:8080", +); +declare const account: Starstream.Account; // 2. Acquire Utxo -const untypedUtxo: Starstream.Utxo = await ledger.getUtxo("0x42"); -const typedUtxo: Adder.MyUtxo = await ledger.getUtxo("0x42", Adder.MyUtxo); -const manualDowncast = untypedUtxo.downcast(Adder.MyUtxo); +// Outputs are referenced by transaction digest and index, genesis by an empty digest +const input: Starstream.TransactionInput = { transaction: "", index: 0 }; +const output = await ledger.getOutput(input); +if (!output) { + throw new Error("no such output"); +} +const utxo = new Adder.MyUtxo(ledger, input); +// Runs on the ledger, which discards the resulting state +await utxo.abi_method(); // 3. Acquire contract -const adder: Adder = await ledger.getContract("0x4494494", Adder); -// Local, non-uploaded contracts are OK for coordination scripts -const localContract = await Starstream.Contract.fromFile( - "./synthetic_coordination_script.wasm", -); -// If you want to use Utxos from a contract, it must be uploaded -await ledger.uploadContract(localContract, { fee: 1 }); // Can get a Utxo's contract -const existingContract = await untypedUtxo.getContract(); +const wasm = await ledger.getContractWasm(output.contract); +if (!wasm) { + throw new Error("no such contract"); +} +const adder = new Adder(wasm); +// Local, non-published contracts are OK for coordination scripts +const localWasm = await readFile("./synthetic_coordination_script.wasm"); +const localContract = new Starstream.Contract(localWasm); +// If you want to use Utxos from a contract, it must be published +const nonce = ((await ledger.getAccountNonce(account.publicKey)) ?? 0n) + 1n; +await ledger.publishContract(account, nonce, localWasm); // 4. Call coordination script to produce proof const trace = await Starstream.call( // coordination script adder.my_coordination, // array of arguments to coordination script - [typedUtxo], + [utxo], // "imports" = std lib + event + effect handlers { ...new Starstream.std.Cardano(ledger), @@ -89,6 +105,11 @@ console.log("return value:", trace.returnValue); const proof = await Starstream.prove(trace); console.log("proof:", proof); -// 5. Post proof to ledger as transaction -const tx = await ledger.postTransaction(proof, { fee: 1 }); +// 5. Post transaction to ledger +const tx = await ledger.postTransaction(account, { + inputs: [input], + outputs: [], // TODO: from the trace + events: [], // TODO: from the trace + proof: new Uint8Array(), // TODO: serialized `proof` +}); console.log("posted transaction:", tx); diff --git a/starstream-js/test/ledger.test.ts b/starstream-js/test/ledger.test.ts new file mode 100644 index 00000000..2fdea6e4 --- /dev/null +++ b/starstream-js/test/ledger.test.ts @@ -0,0 +1,232 @@ +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { createHash, createPrivateKey, createPublicKey } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, expect, test } from "vitest"; +import * as starstream from "../src/index.ts"; + +const root = resolve(import.meta.dirname, "../.."); +const STARSTREAM = process.env.STARSTREAM ?? join(root, "starstream"); + +function ed25519PublicKey(seed: Buffer): Buffer { + return Buffer.from( + createPublicKey( + createPrivateKey({ + key: Buffer.concat([ + Buffer.from("302e020100300506032b657004220420", "hex"), + seed, + ]), + format: "der", + type: "pkcs8", + }), + ).export({ format: "jwk" }).x!, + "base64url", + ); +} + +const ADMIN_SEED = createHash("sha256").update("admin").digest(); +const ADMIN_PUBLIC_KEY = ed25519PublicKey(ADMIN_SEED); + +const run = promisify(execFile); + +let dir: string; +let node: ChildProcess; +let url: string; +let ledger: starstream.Ledger; +let wasm: Uint8Array; +let contract: string; + +async function cli(...args: string[]): Promise { + const { stdout } = await run(STARSTREAM, ["ledger", "--url", url, ...args]); + return stdout.trim(); +} + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "starstream-js-")); + const wasmPath = join(dir, "score.wasm"); + await run(STARSTREAM, [ + "wasm", + "-c", + join(root, "examples/score.star"), + "--output-component", + wasmPath, + ]); + wasm = new Uint8Array(await readFile(wasmPath)); + contract = await starstream.digest(wasm); + + const genesisPath = join(dir, "genesis.toml"); + await writeFile( + genesisPath, + `[[scripts]]\ncontract = "${contract}"\nscript = "example"\n`, + ); + await writeFile(join(dir, "admin.key"), ADMIN_SEED.toString("hex")); + + node = spawn( + STARSTREAM, + [ + "node", + "--addr", + "127.0.0.1:0", + "--genesis", + genesisPath, + "--import", + wasmPath, + ], + { + env: { ...process.env, NO_COLOR: "1", STARSTREAM_LOG: "info" }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + let log = ""; + node.stderr!.setEncoding("utf8"); + const addr = await new Promise((resolve, reject) => { + node.once("error", reject); + node.once("close", (code, signal) => { + reject(new Error(`ledger node exited with ${code ?? signal}:\n${log}`)); + }); + const onData = (chunk: string) => { + log += chunk; + const m = /ledger running addr=(\S+)/.exec(log); + if (m) { + node.stderr!.off("data", onData); + resolve(m[1]); + } + }; + node.stderr!.on("data", onData); + }); + url = `http://${addr}`; + ledger = new starstream.RpcLedger(url); +}, 600_000); + +afterAll(async () => { + node?.kill(); + if (dir) { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("genesis", async () => { + expect(await ledger.getNetworkName()).toBe("dev"); + expect(await ledger.getBlock(0n)).toBeUndefined(); + + const outputs = await ledger.getGenesisOutputs(); + expect(outputs).toHaveLength(1); + const [output] = outputs; + expect(output.contract).toBe(contract); + expect(output.instance).toBe("score-progress"); + expect(output.methods).toHaveLength(6); + expect(output.state).not.toHaveLength(0); + + expect(await ledger.getOutput({ transaction: "", index: 0 })).toEqual(output); + expect(await ledger.getOutput({ transaction: "", index: 1 })).toBeUndefined(); +}); + +test("missing", async () => { + const missing = await starstream.digest(new Uint8Array()); + expect(await ledger.getContractWasm(missing)).toBeUndefined(); + expect(await ledger.getContractEnvelope(missing)).toBeUndefined(); + expect(await ledger.getTransaction(missing)).toBeUndefined(); + expect(await ledger.getTransactionEnvelope(missing)).toBeUndefined(); + expect( + await ledger.getOutput({ transaction: missing, index: 0 }), + ).toBeUndefined(); + await expect(ledger.getTransaction("invalid")).rejects.toThrow( + "invalid digest", + ); + await expect(ledger.getContractWasm(missing.toUpperCase())).rejects.toThrow( + "invalid digest", + ); + + const key = ed25519PublicKey(createHash("sha256").update("missing").digest()); + expect(await ledger.getAccountBalance(key)).toBeUndefined(); + expect(await ledger.getAccountNonce(key)).toBeUndefined(); + await expect(ledger.getAccountNonce(key.subarray(1))).rejects.toThrow( + "invalid key", + ); +}); + +test("contract and UTXO method calls", async () => { + expect(await ledger.getBlockHeight()).toBe(0n); + expect(await ledger.getAdminNonce()).toBe(0n); + expect(await ledger.getAccountBalance(ADMIN_PUBLIC_KEY)).toBeUndefined(); + await cli( + "account", + "fund", + "--key", + join(dir, "admin.key"), + "--nonce", + "1", + ADMIN_PUBLIC_KEY.toString("hex"), + "1000000000", + ); + expect(await ledger.getAdminNonce()).toBe(1n); + expect(await ledger.getAccountBalance(ADMIN_PUBLIC_KEY)).toBe(1000000000n); + expect(await ledger.getAccountNonce(ADMIN_PUBLIC_KEY)).toBe(0n); + expect( + await cli( + "contract", + "publish", + "--key", + join(dir, "admin.key"), + "--nonce", + "1", + join(dir, "score.wasm"), + ), + ).toBe(contract); + expect(await ledger.getAccountNonce(ADMIN_PUBLIC_KEY)).toBe(1n); + expect(await ledger.getBlockHeight()).toBe(2n); + expect(await ledger.getBlock(1n)).toHaveLength(1); + expect(await ledger.getBlock(3n)).toBeUndefined(); + + expect(await ledger.getContractWasm(contract)).toEqual(wasm); + const envelope = await ledger.getContractEnvelope(contract); + expect(envelope).toBeInstanceOf(Uint8Array); + expect(await ledger.getBlock(2n)).toEqual([envelope]); + + const genesis = { transaction: "", index: 0 }; + expect( + await ledger.callUtxoMethod( + genesis, + "get-chips", + { result: starstream.t.u64 }, + [], + ), + ).toBe(42n); + expect( + await ledger.callUtxoMethod( + genesis, + "get-mult", + { result: starstream.t.u64 }, + [], + ), + ).toBe(4n); + expect( + await ledger.callUtxoMethod( + genesis, + "plus-chips", + { params: [starstream.t.u64] }, + [1n], + ), + ).toBeUndefined(); + expect( + await ledger.callUtxoMethod( + genesis, + "get-chips", + { result: starstream.t.u64 }, + [], + ), + ).toBe(42n); + await expect( + ledger.callUtxoMethod( + genesis, + "plus-chips", + { params: [starstream.t.u64] }, + [], + ), + ).rejects.toThrow("expected 1 argument(s), got 0"); + await expect( + ledger.callUtxoMethod(genesis, "missing", {}, []), + ).rejects.toThrow("status 404"); +}); diff --git a/starstream-js/test/misc.test.ts b/starstream-js/test/misc.test.ts index 57b5b731..8a44a1ae 100644 --- a/starstream-js/test/misc.test.ts +++ b/starstream-js/test/misc.test.ts @@ -1,6 +1,45 @@ -import { expect, test } from "vitest"; +import { expect, test, vi } from "vitest"; import * as starstream from "../src"; test("derp", () => { expect(starstream.Contract).toBeDefined(); }); + +test("RpcLedger rejects a base URL with a query or fragment", () => { + expect(() => new starstream.RpcLedger("http://localhost/?net=x")).toThrow( + "must not contain a query", + ); + expect(() => new starstream.RpcLedger("http://localhost/#x")).toThrow( + "must not contain a query", + ); +}); + +test("RpcLedger rejects a non-wRPC response", async () => { + const ledger = new starstream.RpcLedger("http://localhost", { + fetch: async () => + new Response("", { + headers: { "content-type": "text/html" }, + }), + }); + await expect(ledger.getBlockHeight()).rejects.toThrow( + "returned content-type `text/html`", + ); +}); + +test("RpcLedger resolves a relative base URL against the location", async () => { + const urls: string[] = []; + vi.stubGlobal("location", new URL("http://localhost/app/index.html")); + let ledger: starstream.RpcLedger; + try { + ledger = new starstream.RpcLedger("ledger/", { + fetch: async (input) => { + urls.push(String(input)); + return new Response(null, { status: 503 }); + }, + }); + } finally { + vi.unstubAllGlobals(); + } + await expect(ledger.getBlockHeight()).rejects.toThrow("status 503"); + expect(urls).toEqual(["http://localhost/app/ledger/rpc"]); +});