UNPKG

@provablehq/sdk

Version:

A Software Development Kit (SDK) for Zero-Knowledge Transactions

1,191 lines (1,183 loc) 429 kB
import 'core-js/proposals/json-parse-with-source.js'; import { ProvingKey, VerifyingKey, ViewKey, ComputeKey, Address, PrivateKeyCiphertext, PrivateKey, RecordCiphertext, EncryptionToolkit, Group, Metadata, Program, Plaintext, Transaction, ProvingRequest, RecordPlaintext, Field, Poseidon4, Scalar, BHP256, ProgramManager as ProgramManager$1, CallbackQuery, ProgramImports, QueryOption, ExecutionRequest, stringToField, verifyFunctionExecution, Value, Proof, Signature } from '@provablehq/wasm/testnet.js'; export { Address, Authorization, BHP1024, BHP256, BHP512, BHP768, Boolean, Ciphertext, ComputeKey, DynamicRecord, EncryptionToolkit, ExecutionRequest, ExecutionResponse, Field, Execution as FunctionExecution, GraphKey, Group, I128, I16, I32, I64, I8, OfflineQuery, Pedersen128, Pedersen64, Plaintext, Poseidon2, Poseidon4, Poseidon8, PrivateKey, PrivateKeyCiphertext, Program, ProgramImports as ProgramImportsBuilder, ProgramManager as ProgramManagerBase, Proof, ProvingKey, ProvingRequest, QueryOption, RecordCiphertext, RecordPlaintext, Scalar, Signature, Transaction, Transition, U128, U16, U32, U64, U8, Value, VerifyingKey, ViewKey, getOrInitConsensusVersionTestHeights, initThreadPool, setWasmLogLevel, snarkVerify, snarkVerifyBatch, stringToField, verifyFunctionExecution } from '@provablehq/wasm/testnet.js'; import { cryptoBoxSeal } from '@serenity-kit/noble-sodium'; import { base64, bech32m } from '@scure/base'; const LEVEL_PRIORITY = { silent: 0, error: 1, warn: 2, info: 3, debug: 4, }; let currentLevel = "info"; /** * Set the SDK log level. Levels are hierarchical: * - "silent" — suppress all SDK logging * - "error" — only errors * - "warn" — errors + warnings * - "info" — errors + warnings + info (default) * - "debug" — everything including debug traces * * This controls both TS-side and WASM-side logging. WASM log calls * (e.g., "Loading program", "Executing program") are silenced when * the level is below "info". */ function setLogLevel(level) { currentLevel = level; import('./wasm-BCrMb35a.js') .then(({ setWasmLogLevel }) => setWasmLogLevel(LEVEL_PRIORITY[level])) .catch(() => { }); // WASM not loaded yet — will use default level (info) } /** Returns the current SDK log level. */ function getLogLevel() { return currentLevel; } function shouldLog(level) { return LEVEL_PRIORITY[level] <= LEVEL_PRIORITY[currentLevel]; } /** * SDK logger object following console.log/warn/error/debug syntax. * Respects the current log level set via setLogLevel(). */ const logger = { log(...args) { if (shouldLog("info")) console.log(...args); }, warn(...args) { if (shouldLog("warn")) console.warn(...args); }, error(...args) { if (shouldLog("error")) console.error(...args); }, debug(...args) { if (shouldLog("debug")) console.debug(...args); }, }; /** * Error thrown when a key locator is invalid for filesystem use. * Used to prevent path traversal and other filesystem injection when deriving paths from locators. * * @extends Error */ class InvalidLocatorError extends Error { locator; reason; /** * @param message - Human-readable description of the validation failure. * @param locator - The invalid locator string that failed validation. * @param reason - Machine-readable reason code for the failure. */ constructor(message, locator, reason) { super(message); this.locator = locator; this.reason = reason; this.name = "InvalidLocatorError"; Object.setPrototypeOf(this, InvalidLocatorError.prototype); } } /** * Error thrown when there is a mismatch between expected and actual key metadata. * This can occur during verification of either the checksum or size of a key. * * @extends Error */ class KeyVerificationError extends Error { locator; field; expected; actual; /** * Creates a new KeyVerificationError instance (error.name is "ChecksumMismatchError"). * * @param {string} locator - The key locator where the mismatch occurred. * @param {"checksum" | "size"} field - The field that failed verification (either "checksum" or "size"). * @param {string} expected - The expected value of the field. * @param {string} actual - The actual value encountered. */ constructor(locator, field, expected, actual) { super(`Key verification ${locator} ${field} mismatch: expected ${expected}, got ${actual}`); this.locator = locator; this.field = field; this.expected = expected; this.actual = actual; this.name = "ChecksumMismatchError"; Object.setPrototypeOf(this, KeyVerificationError.prototype); } } /** * Computes the SHA-256 checksum of a given set of bytes. * * @param {Uint8Array} bytes - The bytes to compute the checksum of. */ async function sha256Hex(bytes) { const hash = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } /** * In-memory implementation of KeyVerifier that stores and verifies key fingerprints. * Provides functionality to compute and verify cryptographic checksums of keys, storing them * in memory for subsequent verification. This implementation is primarily used for testing * and development purposes where persistence is not required. * * Key features: * - Computes SHA-256 checksums and sizes for key bytes. * - Stores key fingerprints in memory using string locators. * - Verifies key bytes against stored or provided fingerprints. * * @implements {KeyVerifier} */ class MemKeyVerifier { keyStore = {}; /** * Computes and optionally stores key metadata. If a keyFingerprint is provided, this function will verify the computed checksum against it before storing it. * * @param {KeyMetadata} keyMetadata - Object containing key bytes and optional verification data. * @throws {KeyVerificationError} When provided keyFingerprint doesn't match computed values. * @returns {Promise<KeyFingerprint>} Computed key metadata. */ async computeKeyMetadata(keyMetadata) { // Compute the metadata from the key bytes const computedFingerprint = { checksum: await sha256Hex(keyMetadata.keyBytes), size: keyMetadata.keyBytes.length }; // If a KeyFingerprint is provided, verify it matches computed values. if (keyMetadata.fingerprint) { if (keyMetadata.fingerprint.size !== computedFingerprint.size) { throw new KeyVerificationError(keyMetadata.locator ? keyMetadata.locator : "", "size", String(keyMetadata.fingerprint.size), String(computedFingerprint.size)); } if (keyMetadata.fingerprint.checksum !== computedFingerprint.checksum) { throw new KeyVerificationError(keyMetadata.locator ? keyMetadata.locator : "", "checksum", keyMetadata.fingerprint.checksum, computedFingerprint.checksum); } } // If locator is provided, store only the fingerprint if (keyMetadata.locator) { this.keyStore[keyMetadata.locator] = computedFingerprint; } return computedFingerprint; } /** * Verifies key bytes against stored or provided metadata. Follows a priority verification scheme: * 1. If KeyFingerprint is provided in the metadata, this method verifies against that first. * 2. If a locator is provided, attempts to verify against stored fingerprint. * 3. If neither is available, throws an error. * * @param {KeyMetadata} keyMetadata - Object containing the key bytes and optional verification metadata. * @throws {Error} When neither fingerprint nor valid locator is provided for verification. * @throws {KeyVerificationError} When size or checksum verification fails. * @returns {Promise<void>} Promise that resolves when verification succeeds. */ async verifyKeyBytes(keyMetadata) { if (!keyMetadata.keyBytes) { throw new Error("Key bytes must be provided for verification."); } // Compute the fingerprint for the provided bytes. const computedFingerprint = await this.computeKeyMetadata({ keyBytes: keyMetadata.keyBytes }); // Determine which fingerprint to verify against. let fingerprintToVerify; if (keyMetadata.fingerprint) { // If a key fingerprint is provided, use it. fingerprintToVerify = keyMetadata.fingerprint; } else if (keyMetadata.locator) { // Otherwise try to get stored fingerprint by locator. fingerprintToVerify = this.keyStore[keyMetadata.locator]; } if (!fingerprintToVerify) { throw new Error("Either fingerprint or a valid locator must be provided for verification."); } // Verify the key size. if (fingerprintToVerify.size !== computedFingerprint.size) { throw new KeyVerificationError(keyMetadata.locator || "", "size", String(fingerprintToVerify.size), String(computedFingerprint.size)); } // Verify the key checksum. if (fingerprintToVerify.checksum !== computedFingerprint.checksum) { throw new KeyVerificationError(keyMetadata.locator || "", "checksum", fingerprintToVerify.checksum, computedFingerprint.checksum); } } } /** * Browser-compatible {@link KeyStore} backed by IndexedDB. * * This is the browser counterpart to {@link LocalFileKeyStore} (which requires Node.js `fs`). * It persists proving and verifying keys across page reloads and browser sessions using the * IndexedDB API available in all modern browsers and Web Workers. * * **Environment**: Browser / Web Worker only. Instantiating this class is safe in any * environment, but the first IndexedDB operation will reject with a clear error when * `globalThis.indexedDB` is not available (e.g., Node.js, SSR contexts). Guard your * imports accordingly if you bundle for server-side rendering. * * **Security**: Keys are stored as plaintext `Uint8Array` in IndexedDB. Any script * running on the same origin — including scripts injected via XSS — can read the stored * proving and verifying keys. Do **not** use this keystore for data that must remain * confidential under an XSS-capable adversary; encrypt sensitive material at the * application layer before persisting, or use an in-memory store. * * @example * ```ts * import { IndexedDBKeyStore, ProgramManager } from "@provablehq/sdk"; * * const keyStore = new IndexedDBKeyStore(); * const pm = new ProgramManager(); * pm.setKeyStore(keyStore); * // Keys synthesized during execution are now cached in IndexedDB * // and reloaded automatically on subsequent runs. * ``` */ class IndexedDBKeyStore { dbName; storeName = "keys"; keyVerifier = new MemKeyVerifier(); dbPromise = null; /** * @param dbName IndexedDB database name. Defaults to `"aleo-keystore"`. */ constructor(dbName = "aleo-keystore") { this.dbName = dbName; } // ------------------------------------------------------- // IndexedDB helpers // ------------------------------------------------------- /** Opens (or creates) the database, returning a cached promise. */ openDB() { if (this.dbPromise) return this.dbPromise; // Env check sits outside the Promise constructor so a failure doesn't // poison the cache; a later call (e.g. after a polyfill loads) can retry. if (typeof indexedDB === "undefined") { return Promise.reject(new Error("IndexedDBKeyStore requires a browser or Web Worker environment: " + "`indexedDB` is not defined. If you are in Node.js or an SSR " + "context, use LocalFileKeyStore or an in-memory key provider instead.")); } this.dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName, { keyPath: "locator" }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => { this.dbPromise = null; reject(request.error); }; request.onblocked = () => { this.dbPromise = null; reject(new DOMException("Database open blocked", "AbortError")); }; }); return this.dbPromise; } /** Runs a single read-write transaction and returns the request result. */ async tx(mode, fn) { const db = await this.openDB(); return new Promise((resolve, reject) => { const txn = db.transaction(this.storeName, mode); const store = txn.objectStore(this.storeName); const req = fn(store); let result; req.onsuccess = () => { result = req.result; }; txn.oncomplete = () => resolve(result); txn.onerror = () => reject(txn.error); txn.onabort = () => reject(txn.error ?? new DOMException("Transaction aborted", "AbortError")); }); } // ------------------------------------------------------- // Locator serialization (mirrors LocalFileKeyStore) // ------------------------------------------------------- validateComponent(value, label) { if (value === "" || value === ".") { throw new InvalidLocatorError(`KeyLocator ${label} must not be empty or "." (got "${value}")`, value, "reserved_name"); } if (value.includes("..")) { throw new InvalidLocatorError(`KeyLocator ${label} must not contain ".." (got "${value}")`, value, "path_traversal"); } if (value.includes("/") || value.includes("\\") || value.includes("\0")) { throw new InvalidLocatorError(`KeyLocator ${label} must not contain path separators or null bytes (got "${value}")`, value, "path_separator"); } } validateNonNegative(value, label) { if (!Number.isInteger(value) || value < 0) { throw new InvalidLocatorError(`KeyLocator ${label} must be a non-negative integer (got ${value})`, String(value), "negative_value"); } } serializeLocator(locator) { this.validateComponent(locator.program, "program"); this.validateComponent(locator.functionName, "functionName"); this.validateComponent(locator.network, "network"); this.validateNonNegative(locator.edition, "edition"); this.validateNonNegative(locator.amendment, "amendment"); const base = `${locator.program}.${locator.functionName}.e${locator.edition}.a${locator.amendment}.${locator.network}.${locator.keyType}`; if (locator.keyType === "translation") { this.validateComponent(locator.recordName, "recordName"); this.validateNonNegative(locator.recordInputPosition, "recordInputPosition"); return `${base}.${locator.recordName}.${locator.recordInputPosition}`; } return base; } checksumToFingerprint(checksum, keyBytes) { if (!checksum) return undefined; return { checksum, size: keyBytes.length }; } // ------------------------------------------------------- // KeyStore interface // ------------------------------------------------------- async getKeyBytes(locator) { const key = this.serializeLocator(locator); const record = await this.tx("readonly", (store) => store.get(key)); if (!record) return null; const fingerprint = this.checksumToFingerprint(locator.checksum, record.bytes) ?? record.metadata; if (fingerprint) { await this.keyVerifier.verifyKeyBytes({ keyBytes: record.bytes, locator: key, fingerprint, }); } return record.bytes; } async getProvingKey(locator) { const bytes = await this.getKeyBytes(locator); if (!bytes) return null; return ProvingKey.fromBytes(bytes); } async getVerifyingKey(locator) { const bytes = await this.getKeyBytes(locator); if (!bytes) return null; return VerifyingKey.fromBytes(bytes); } async setKeys(proverLocator, verifierLocator, keys) { const proverKey = this.serializeLocator(proverLocator); const verifierKey = this.serializeLocator(verifierLocator); const [provingKey, verifyingKey] = keys; const [provingKeyBytes, verifyingKeyBytes] = [ provingKey.toBytes(), verifyingKey.toBytes(), ]; const [proverFingerprint, verifierFingerprint] = await Promise.all([ this.keyVerifier.computeKeyMetadata({ keyBytes: provingKeyBytes, locator: proverKey, fingerprint: this.checksumToFingerprint(proverLocator.checksum, provingKeyBytes), }), this.keyVerifier.computeKeyMetadata({ keyBytes: verifyingKeyBytes, locator: verifierKey, fingerprint: this.checksumToFingerprint(verifierLocator.checksum, verifyingKeyBytes), }), ]); const proverRecord = { locator: proverKey, bytes: provingKeyBytes, metadata: proverFingerprint }; const verifierRecord = { locator: verifierKey, bytes: verifyingKeyBytes, metadata: verifierFingerprint }; // Write both in a single transaction for atomicity. const db = await this.openDB(); await new Promise((resolve, reject) => { const txn = db.transaction(this.storeName, "readwrite"); const store = txn.objectStore(this.storeName); store.put(proverRecord); store.put(verifierRecord); txn.oncomplete = () => resolve(); txn.onerror = () => reject(txn.error); txn.onabort = () => reject(txn.error ?? new DOMException("Transaction aborted", "AbortError")); }); } async setKeyBytes(keyBytes, locator) { const key = this.serializeLocator(locator); const computedMetadata = await this.keyVerifier.computeKeyMetadata({ keyBytes, locator: key, fingerprint: this.checksumToFingerprint(locator.checksum, keyBytes), }); const record = { locator: key, bytes: keyBytes, metadata: computedMetadata }; await this.tx("readwrite", (store) => store.put(record)); } async getKeyMetadata(locator) { const key = this.serializeLocator(locator); const record = await this.tx("readonly", (store) => store.get(key)); return record?.metadata ?? null; } async has(locator) { const key = this.serializeLocator(locator); const count = await this.tx("readonly", (store) => store.count(IDBKeyRange.only(key))); return count > 0; } async delete(locator) { const key = this.serializeLocator(locator); await this.tx("readwrite", (store) => store.delete(key)); } async clear() { await this.tx("readwrite", (store) => store.clear()); } } /** * Encrypt an authorization with a cryptobox X25519 public key (libsodium-compatible wire format). * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {Authorization} authorization the authorization to encrypt. * * @returns {string} the encrypted authorization in RFC 4648 standard Base64. */ function encryptAuthorization(publicKey, authorization) { // Zeroize the intermediate plaintext bytes regardless of success or // failure — same pattern as encryptRegistrationRequest. const bytes = authorization.toBytesLe(); try { return encryptMessage(publicKey, bytes); } finally { zeroizeBytes(bytes); } } /** * Encrypt a ProvingRequest with a cryptobox X25519 public key (libsodium-compatible wire format). * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {ProvingRequest} provingRequest the ProvingRequest to encrypt. * * @returns {string} the encrypted ProvingRequest in RFC 4648 standard Base64. */ function encryptProvingRequest(publicKey, provingRequest) { // Zeroize the intermediate plaintext bytes regardless of success or // failure — same pattern as encryptRegistrationRequest. const bytes = provingRequest.toBytesLe(); try { return encryptMessage(publicKey, bytes); } finally { zeroizeBytes(bytes); } } /** * Serialize a ProvingRequest for later encryption with * `encryptSerializedProvingRequest`. * * Useful when the request may have to be encrypted more than once: the * delegated proving service's one-time keys are single-use, so a client that * needs to resend a request (e.g. after the service rejected a spent or * unknown `key_id`) can keep the serialized form and re-encrypt it for a * fresh key instead of rebuilding and re-signing the request. * * The returned buffer contains the plaintext request (including the signed * authorization and its inputs) and is owned by the caller: keep it only as * long as a resend may still be needed, then overwrite it with * `zeroizeBytes(serialized)`. Zeroization in JavaScript is best-effort (see * `zeroizeBytes`), and transient copies made inside the wasm boundary by * `toBytesLe` are outside its reach. * * @param {ProvingRequest} provingRequest the ProvingRequest to serialize. * * @returns {Uint8Array} the serialized ProvingRequest bytes. */ function serializeProvingRequest(provingRequest) { return provingRequest.toBytesLe(); } /** * Encrypt an already-serialized ProvingRequest (as produced by * `serializeProvingRequest`) with a cryptobox X25519 public key * (libsodium-compatible wire format). * * Produces exactly the same ciphertext format as `encryptProvingRequest` — * `encryptSerializedProvingRequest(pk, serializeProvingRequest(req))` and * `encryptProvingRequest(pk, req)` are interchangeable from the proving * service's point of view. * * The input buffer is not mutated and deliberately not zeroized here: the * caller keeps ownership so the same bytes can be re-encrypted for another * one-time key (retry). Call `zeroizeBytes(serializedProvingRequest)` once * no resend can be needed anymore. * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {Uint8Array} serializedProvingRequest the serialized ProvingRequest bytes. * * @returns {string} the encrypted ProvingRequest in RFC 4648 standard Base64. */ function encryptSerializedProvingRequest(publicKey, serializedProvingRequest) { return encryptMessage(publicKey, serializedProvingRequest); } /** * Encrypt a view key with a cryptobox X25519 public key (libsodium-compatible wire format). * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {ViewKey} viewKey the view key to encrypt. * * @returns {string} the encrypted view key in RFC 4648 standard Base64. */ function encryptViewKey(publicKey, viewKey) { // Zeroize the intermediate plaintext bytes regardless of success or // failure — same pattern as encryptRegistrationRequest. const bytes = viewKey.toBytesLe(); try { return encryptMessage(publicKey, bytes); } finally { zeroizeBytes(bytes); } } /** * Encrypt a record scanner registration request. * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {ViewKey} viewKey the view key to encrypt. * @param {number} start the start height of the registration request. * * @returns {string} the encrypted view key in RFC 4648 standard Base64. */ function encryptRegistrationRequest(publicKey, viewKey, start) { // Turn the view key into a Uint8Array. const vk_bytes = viewKey.toBytesLe(); // Create a new array to hold the original bytes and the 4-byte start height. const bytes = new Uint8Array(vk_bytes.length + 4); // Copy existing bytes. bytes.set(vk_bytes, 0); // Write the 4-byte number in LE format at the end of the array. const view = new DataView(bytes.buffer); view.setUint32(vk_bytes.length, start, true); // Encrypt the encoded bytes, ensuring sensitive intermediate // byte arrays are zeroized regardless of success or failure. try { return encryptMessage(publicKey, bytes); } finally { zeroizeBytes(vk_bytes); zeroizeBytes(bytes); } } /** * Best-effort zeroization of a byte array by overwriting all bytes with zeros. * Use this to clear sensitive data (e.g., key bytes) from memory when working * with Uint8Array representations of keys or other secrets. * * This is best-effort in JavaScript — the JIT compiler could theoretically * elide the fill if the array is never read again (though current engines * do not). For deterministic zeroization of key material, use * `Account.destroy()` or call `.free()` on key objects (PrivateKey, ViewKey, * ComputeKey, GraphKey) whose Rust Drop implementations zeroize memory * before deallocation. * * Note: This cannot zeroize JavaScript strings, which are immutable and managed * by the garbage collector. Prefer using byte array representations of sensitive * data over strings whenever possible. * * @param {Uint8Array} bytes The byte array to zeroize * * @example * const keyBytes = privateKey.toBytesLe(); * // ... use keyBytes ... * zeroizeBytes(keyBytes); // Overwrite with zeros when done */ function zeroizeBytes(bytes) { bytes.fill(0); } /** * Encrypt arbitrary bytes with a cryptobox public key using the libsodium * `crypto_box_seal` wire format. * * The implementation is delegated to `@serenity-kit/noble-sodium`, which * composes the primitive steps of `crypto_box_seal` — ephemeral X25519 * keypair, blake2b-24 nonce derivation over `epk || rpk`, HSalsa20 key * derivation from the ECDH shared secret, and XSalsa20-Poly1305 AEAD — on * top of the audited `@noble/*` primitives. Output is byte-identical to * libsodium's `crypto_box_seal` for any given ephemeral key, so ciphertexts * are decryptable by any libsodium-compatible backend (e.g. `sodiumoxide`, * `libsodium-sys`) with no changes. * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {Uint8Array} message the bytes to encrypt. * * @returns {string} the encrypted bytes in RFC 4648 standard Base64. */ function encryptMessage(publicKey, message) { const publicKeyBytes = base64.decode(publicKey); const ciphertext = cryptoBoxSeal({ message, publicKey: publicKeyBytes }); return base64.encode(ciphertext); } /** * Key Management class. Enables the creation of a new Aleo Account, importation of an existing account from * an existing private key or seed, and message signing and verification functionality. An Aleo Account is generated * from a randomly generated seed (number) from which an account private key, view key, and a public account address are * derived. The private key lies at the root of an Aleo account. It is a highly sensitive secret and should be protected * as it allows for creation of Aleo Program executions and arbitrary value transfers. The View Key allows for decryption * of a user's activity on the blockchain. The Address is the public address to which other users of Aleo can send Aleo * credits and other records to. This class should only be used in environments where the safety of the underlying key * material can be assured. * * When an Account is no longer needed, call {@link destroy} to securely zeroize and free all sensitive key material * from WASM memory. Alternatively, use `[Symbol.dispose]()` with the `using` declaration in ES2024+ environments. * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create a new account * const myRandomAccount = new Account(); * * // Create an account from a randomly generated seed * const seed = new Uint8Array([94, 91, 52, 251, 240, 230, 226, 35, 117, 253, 224, 210, 175, 13, 205, 120, 155, 214, 7, 169, 66, 62, 206, 50, 188, 40, 29, 122, 40, 250, 54, 18]); * const mySeededAccount = new Account({seed: seed}); * * // Create an account from an existing private key * const myExistingAccount = new Account({privateKey: process.env.privateKey}); * * // Sign a message * const hello_world = Uint8Array.from([104, 101, 108, 108, 111 119, 111, 114, 108, 100]); * const signature = myRandomAccount.sign(hello_world); * * // Verify a signature * assert(myRandomAccount.verify(hello_world, signature)); * * // Securely destroy the account when done * myRandomAccount.destroy(); */ class Account { _privateKey; _viewKey; _computeKey; _address; _destroyed = false; constructor(params = {}) { try { this._privateKey = this.privateKeyFromParams(params); } catch (e) { logger.error("Wrong parameter", e); throw new Error("Wrong Parameter"); } this._viewKey = ViewKey.from_private_key(this._privateKey); this._computeKey = ComputeKey.from_private_key(this._privateKey); this._address = Address.from_private_key(this._privateKey); } /** * Attempts to create an account from a private key ciphertext * @param {PrivateKeyCiphertext | string} ciphertext The encrypted private key ciphertext or its string representation * @param {string} password The password used to decrypt the private key ciphertext * @returns {Account} A new Account instance created from the decrypted private key * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create an account object from a previously encrypted ciphertext and password. * const account = Account.fromCiphertext(process.env.ciphertext, process.env.password); */ static fromCiphertext(ciphertext, password) { try { ciphertext = (typeof ciphertext === "string") ? PrivateKeyCiphertext.fromString(ciphertext) : ciphertext; const _privateKey = PrivateKey.fromPrivateKeyCiphertext(ciphertext, password); try { return new Account({ privateKey: _privateKey }); } finally { _privateKey.free(); // Zeroize + free the temporary; Account owns its own clone } } catch (e) { throw new Error("Wrong password or invalid ciphertext"); } } /** * Validates whether the given input is a valid Aleo address. * @param {string | Uint8Array} address The address to validate, either as a string or bytes * @returns {boolean} True if the address is valid, false otherwise * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const isValid = Account.isValidAddress("aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px"); * console.log(isValid); // true * * const isInvalid = Account.isValidAddress("invalid_address"); * console.log(isInvalid); // false */ static isValidAddress(address) { return Address.isValid(address); } /** * Creates a PrivateKey from the provided parameters. * @param {AccountParam} params The parameters containing either a private key string, PrivateKey object, or a seed * @returns {PrivateKey} A PrivateKey instance derived from the provided parameters */ privateKeyFromParams(params) { if (params.seed) { return PrivateKey.from_seed_unchecked(params.seed); } if (params.privateKey) { if (typeof params.privateKey === 'string') { return PrivateKey.from_string(params.privateKey); } // Clone the PrivateKey WASM object via byte serialization to avoid // creating an immutable JS string of the private key. const bytes = params.privateKey.toBytesLe(); try { return PrivateKey.fromBytesLe(bytes); } finally { zeroizeBytes(bytes); } } return new PrivateKey(); } /** * Throws an error if this account has been destroyed. */ assertNotDestroyed() { if (this._destroyed) { throw new Error("Account has been destroyed. Create a new Account instance."); } } /** * Returns the PrivateKey associated with the account. * @returns {PrivateKey} The private key of the account * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const privateKey = account.privateKey(); */ privateKey() { this.assertNotDestroyed(); return this._privateKey; } /** * Returns the ViewKey associated with the account. * @returns {ViewKey} The view key of the account * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const viewKey = account.viewKey(); */ viewKey() { this.assertNotDestroyed(); return this._viewKey; } /** * Returns the ComputeKey associated with the account. * @returns {ComputeKey} The compute key of the account * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const computeKey = account.computeKey(); */ computeKey() { this.assertNotDestroyed(); return this._computeKey; } /** * Returns the Aleo address associated with the account. * @returns {Address} The public address of the account * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const address = account.address(); */ address() { this.assertNotDestroyed(); return this._address; } /** * Deep clones the Account via byte serialization of the private key, * avoiding creation of immutable JS string representations of the private key. * @returns {Account} A new Account instance with the same private key * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const clonedAccount = account.clone(); */ clone() { this.assertNotDestroyed(); return new Account({ privateKey: this._privateKey }); } /** * Returns the address of the account in a string representation. * * @returns {string} The string representation of the account address */ toString() { this.assertNotDestroyed(); return this.address().to_string(); } /** * Encrypts the account's private key with a password. * * @param {string} password Password to encrypt the private key. * @returns {PrivateKeyCiphertext} The encrypted private key ciphertext * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const ciphertext = account.encryptAccount("password"); * process.env.ciphertext = ciphertext.toString(); */ encryptAccount(password) { this.assertNotDestroyed(); return this._privateKey.toCiphertext(password); } /** * Decrypts an encrypted record string into a plaintext record object. * * @param {string} ciphertext A string representing the ciphertext of a record. * @returns {RecordPlaintext} The decrypted record plaintext * * @example * // Import the AleoNetworkClient and Account classes * import { AleoNetworkClient, Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const networkClient = new AleoNetworkClient("https://api.provable.com/v2"); * const account = Account.fromCiphertext(process.env.ciphertext!, process.env.password!); * * // Get the record ciphertexts from a transaction. * const transaction = await networkClient.getTransactionObject("at1fjy6s9md2v4rgcn3j3q4qndtfaa2zvg58a4uha0rujvrn4cumu9qfazxdd"); * const records = transaction.records(); * * // Decrypt any records the account owns. * const decryptedRecords = []; * for (const record of records) { * if (account.decryptRecord(record)) { * decryptedRecords.push(record); * } * } */ decryptRecord(ciphertext) { this.assertNotDestroyed(); return this._viewKey.decrypt(ciphertext); } /** * Decrypts an array of Record ciphertext strings into an array of record plaintext objects. * * @param {string[]} ciphertexts An array of strings representing the ciphertexts of records. * @returns {RecordPlaintext[]} An array of decrypted record plaintexts * * @example * // Import the AleoNetworkClient and Account classes * import { AleoNetworkClient, Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const networkClient = new AleoNetworkClient("https://api.provable.com/v2"); * const account = Account.fromCiphertext(process.env.ciphertext!, process.env.password!); * * // Get the record ciphertexts from a transaction. * const transaction = await networkClient.getTransactionObject("at1fjy6s9md2v4rgcn3j3q4qndtfaa2zvg58a4uha0rujvrn4cumu9qfazxdd"); * const records = transaction.records(); * * // Decrypt any records the account owns. If the account owns no records, the array will be empty. * const decryptedRecords = account.decryptRecords(records); */ decryptRecords(ciphertexts) { this.assertNotDestroyed(); return ciphertexts.map((ciphertext) => this._viewKey.decrypt(ciphertext)); } /** * Generates a record view key from the account owner's view key and the record ciphertext. * This key can be used to decrypt the record without revealing the account's view key. * @param {RecordCiphertext | string} recordCiphertext The record ciphertext to generate the view key for * @returns {Field} The record view key * * @example * // Import the Account class * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create an account object from a previously encrypted ciphertext and password. * const account = Account.fromCiphertext(process.env.ciphertext!, process.env.password!); * * // Generate a record view key from the account's view key and a record ciphertext * const recordCiphertext = RecordCiphertext.fromString("your_record_ciphertext_here"); * const recordViewKey = account.generateRecordViewKey(recordCiphertext); */ generateRecordViewKey(recordCiphertext) { this.assertNotDestroyed(); if (typeof recordCiphertext === 'string') { recordCiphertext = RecordCiphertext.fromString(recordCiphertext); } if (!(recordCiphertext.isOwner(this._viewKey))) { throw new Error("The record ciphertext does not belong to this account"); } return EncryptionToolkit.generateRecordViewKey(this._viewKey, recordCiphertext); } /** * Generates a transition view key from the account owner's view key and the transition public key. * This key can be used to decrypt the private inputs and outputs of a the transition without * revealing the account's view key. * @param {string | Group} tpk The transition public key * @returns {Field} The transition view key * * @example * // Import the Account class * import { Account } from "@provablehq/sdk/testnet.js"; * * // Generate a transition view key from the account's view key and a transition public key * const tpk = Group.fromString("your_transition_public_key_here"); * * const transitionViewKey = account.generateTransitionViewKey(tpk); */ generateTransitionViewKey(tpk) { this.assertNotDestroyed(); if (typeof tpk === 'string') { tpk = Group.fromString(tpk); } return EncryptionToolkit.generateTvk(this._viewKey, tpk); } /** * Determines whether the account owns a ciphertext record. * @param {RecordCiphertext | string} ciphertext The record ciphertext to check ownership of * @returns {boolean} True if the account owns the record, false otherwise * * @example * // Import the AleoNetworkClient and Account classes * import { AleoNetworkClient, Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const networkClient = new AleoNetworkClient("https://api.provable.com/v2"); * const account = Account.fromCiphertext(process.env.ciphertext!, process.env.password!); * * // Get the record ciphertexts from a transaction and check ownership of them. * const transaction = await networkClient.getTransactionObject("at1fjy6s9md2v4rgcn3j3q4qndtfaa2zvg58a4uha0rujvrn4cumu9qfazxdd"); * const records = transaction.records(); * * // Check if the account owns any of the record ciphertexts present in the transaction. * const ownedRecords = []; * for (const record of records) { * if (account.ownsRecordCiphertext(record)) { * ownedRecords.push(record); * } * } */ ownsRecordCiphertext(ciphertext) { this.assertNotDestroyed(); if (typeof ciphertext === 'string') { try { const ciphertextObject = RecordCiphertext.fromString(ciphertext); return ciphertextObject.isOwner(this._viewKey); } catch (e) { return false; } } else { return ciphertext.isOwner(this._viewKey); } } /** * Signs a message with the account's private key. * Returns a Signature. * * @param {Uint8Array} message Message to be signed. * @returns {Signature} Signature over the message in bytes. * * @example * // Import the Account class * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const account = Account.fromCiphertext(process.env.ciphertext, process.env.password); * * // Create an account and a message to sign. * const account = new Account(); * const message = Uint8Array.from([104, 101, 108, 108, 111 119, 111, 114, 108, 100]) * const signature = account.sign(message); * * // Verify the signature. * assert(account.verify(message, signature)); */ sign(message) { this.assertNotDestroyed(); return this._privateKey.sign(message); } /** * Verifies the Signature on a message. * * @param {Uint8Array} message Message in bytes to be signed. * @param {Signature} signature Signature to be verified. * @returns {boolean} True if the signature is valid, false otherwise. * * @example * // Import the Account class * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const account = Account.fromCiphertext(process.env.ciphertext, process.env.password); * * // Sign a message. * const message = Uint8Array.from([104, 101, 108, 108, 111 119, 111, 114, 108, 100]) * const signature = account.sign(message); * * // Verify the signature. * assert(account.verify(message, signature)); */ verify(message, signature) { this.assertNotDestroyed(); return this._address.verify(message, signature); } /** * Securely destroys the account by zeroizing and freeing all sensitive key material * from WASM memory. After calling this method, the account object should not be used. * * This triggers the Rust-level zeroizing Drop implementation which overwrites private key, * view key, and compute key bytes with zeros in WASM linear memory before deallocation. * * Note: If destroy() is never called, the FinalizationRegistry (set up by wasm-bindgen) * will eventually trigger cleanup via GC, but the timing is non-deterministic. For security- * sensitive applications, always call destroy() explicitly when the account is no longer needed. * * @example * const account = new Account(); * // ... use account ... * account.destroy(); // Securely cleans up key material */ destroy() { if (this._destroyed) return; this._destroyed = true; // Free sensitive WASM objects (triggers Rust Drop -> zeroization). // try/catch guards against double-free if FinalizationRegistry already ran. try { this._privateKey.free(); } catch (_) { /* already freed */ } try { this._viewKey.free(); } catch (_) { /* already freed */ } try { this._computeKey.free(); } catch (_) { /* already freed */ } // Address is public data but free it for completeness. try { this._address.free(); } catch (_) { /* already freed */ } } /** * Implements the Disposable interface for use with `using` declarations (ES2024+). * Calls {@link destroy} to securely clean up key material. * * @example * { * using account = new Account(); * // ... use account ... * } // account is automatically destroyed here */ [Symbol.dispose]() { this.destroy(); } } function detectBrowser() { const userAgent = navigator.userAgent; if (/chrome|crios|crmo/i.test(userAgent) && !/edge|edg|opr/i.test(userAgent)) { return "chrome"; } else if (/firefox|fxios/i.test(userAgent)) { return "firefox"; } else if (/safari/i.test(userAgent) && !/chrome|crios|crmo|android/i.test(userAgent)) { return "safari"; } else if (/edg/i.test(userAgent)) { return "edge"; } else if (/opr\//i.test(userAgent)) { return "opera"; } else { return "browser"; } } function isNode() { return (typeof process !== "undefined" && process.versions != null && process.versions.node != null); } function environment() { if (typeof process !== "undefined" && process.release?.name === "node") { return "node"; } else if (typeof window !== "undefined") { return detectBrowser(); } else { return "unknown"; } } function logAndThrow(message) { logger.error(message); throw new Error(message); } /* * Per-origin cookie jar used by `cookieAffinityTransport`. * * Some Aleo backends sit behind a gateway (e.g. Kong) that uses cookie-based * session affinity: the server sets a routing cookie on the first response and * expects it back on subsequent requests so per-session state stays on the same * upstream instance. Browsers and iOS NSURLSession persist cookies automatically, * but Node `fetch` and bare React Native do not — without a jar, those runtimes * land on a random upstream per request. * * Only cookies named in `AFFINITY_COOKIE_NAMES` are captured. The jar is * module-scoped — the same process shares routing cookies per origin — but the * whitelist keeps unrelated cookies (auth, session, CSRF) out of it, so other * SDK clients hitting the same origin can't accidentally inherit caller state. * * In a browser this jar is effectively a no-op: `Cookie` is a forbidden request * header, so the manual value set here is dropped by the browser and the * browser's own cookie store takes over. */ const AFFINITY_COOKIE_NAMES = new Set(["route"]); const cookieJar = new Map(); function isRequestLike(value) { return (typeof Request !== "undefined" && value !== null && typeof value === "object" && value instanceof Request); } function isHeadersLike(value) { return (typeof Headers !== "undefined" && value !== null && typeof value === "object" && value instanceof Headers); } function originOf(urlOrReq) { try { const raw = typeof urlOrReq === "string" ? urlOrReq : urlOrReq instanceof URL ? urlOrReq.toString() : isRequestLike(urlOrReq) ? urlOrReq.url : String(urlOrReq); const parsed = new URL(raw); return `${parsed.protocol}//${parsed.host}`; } catch { return null; } } /* * Derives the origin a response's `Set-Cookie` belongs to. `fetch` may follow * redirects across origins (or http→https), in which case the final response * headers belong to `response.url`'s origin, not the request input's. Returns * null when the response has no usable `url` (e.g. the minimal response-like * objects existing SDK tests stub fetch with), so callers can fall