@provablehq/sdk
Version:
A Software Development Kit (SDK) for Zero-Knowledge Transactions
1,221 lines (1,212 loc) • 439 kB
JavaScript
'use strict';
require('core-js/proposals/json-parse-with-source.js');
var mainnet_js = require('@provablehq/wasm/mainnet.js');
var nobleSodium = require('@serenity-kit/noble-sodium');
var base = require('@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;
Promise.resolve().then(function () { return require('./wasm.cjs'); })
.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 mainnet_js.ProvingKey.fromBytes(bytes);
}
async getVerifyingKey(locator) {
const bytes = await this.getKeyBytes(locator);
if (!bytes)
return null;
return mainnet_js.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 = base.base64.decode(publicKey);
const ciphertext = nobleSodium.cryptoBoxSeal({ message, publicKey: publicKeyBytes });
return base.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 = mainnet_js.ViewKey.from_private_key(this._privateKey);
this._computeKey = mainnet_js.ComputeKey.from_private_key(this._privateKey);
this._address = mainnet_js.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") ? mainnet_js.PrivateKeyCiphertext.fromString(ciphertext) : ciphertext;
const _privateKey = mainnet_js.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 mainnet_js.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 mainnet_js.PrivateKey.from_seed_unchecked(params.seed);
}
if (params.privateKey) {
if (typeof params.privateKey === 'string') {
return mainnet_js.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 mainnet_js.PrivateKey.fromBytesLe(bytes);
}
finally {
zeroizeBytes(bytes);
}
}
return new mainnet_js.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 = mainnet_js.RecordCiphertext.fromString(recordCiphertext);
}
if (!(recordCiphertext.isOwner(this._viewKey))) {
throw new Error("The record ciphertext does not belong to this account");
}
return mainnet_js.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 = mainnet_js.Group.fromString(tpk);
}
return mainnet_js.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 = mainnet_js.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 back to the
* request-derived origin.
*/
function responseOriginOf(response) {
if (!response || typeof response !== "object")
return null;
const url = response.url;
if (typeof url !== "string" || url.length === 0)
return null;
return originOf(url);
}
function cookieHeaderFor(origin) {
if (!origin)
return null;
const jar = cookieJar.get(origin);
if (!jar || jar.size === 0)
return null;
const parts = [];
for (const [name, value] of jar)
parts.push(`${name}=${value}`);
return parts.join("; ");
}
/*
* Reads Set-Cookie values from a fetch-style response in a defensive way.
* Existing test mocks return minimal response-like objects without a
* `headers` field, and Node's fetch surfaces multiple Set-Cookie headers via
* `getSetCookie()` (not `get('set-cookie')`, which returns null or a
* comma-joined string depending on the implementation).
*/
function readSetCookies