const APP_ID_PATTERN = /^[A-Za-z0-9_-]{5,32}$/; const SESSION_PREFIX = "contractless:web3:"; export class ContractlessWalletNotInstalledError extends Error { constructor() { super( "Contractless Wallet is not installed. Install the browser wallet before continuing." ); this.name = "ContractlessWalletNotInstalledError"; this.code = "CONTRACTLESS_WALLET_NOT_INSTALLED"; } } function randomHex(byteLength) { const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function provider() { return window.contractless; } function websiteMessage(value, label) { const message = String(value ?? "").trim(); if (!message || new TextEncoder().encode(message).length > 75) { throw new TypeError(`${label} must contain 1 to 75 bytes.`); } return message; } export function isContractlessWalletInstalled() { return Boolean(provider()?.isContractless && typeof provider()?.request === "function"); } export async function waitForContractlessWallet(timeoutMs = 1_500) { const expiresAt = Date.now() + timeoutMs; while (Date.now() < expiresAt) { if (isContractlessWalletInstalled()) return true; await new Promise((resolve) => setTimeout(resolve, 25)); } return isContractlessWalletInstalled(); } export class ContractlessWallet { constructor({ appId, api = null }) { if (!APP_ID_PATTERN.test(String(appId ?? ""))) { throw new TypeError( "appId must contain 5 to 32 URL-safe characters and must never change for the application." ); } this.appId = appId; this.api = api; this.storageKey = `${SESSION_PREFIX}${location.origin}:${appId}`; this.expirationTimer = undefined; this.scheduleStoredSessionExpiration(); } isInstalled() { return isContractlessWalletInstalled(); } session() { let stored; try { stored = sessionStorage.getItem(this.storageKey); } catch { return null; } if (!stored) return null; try { const session = JSON.parse(stored); if ( session.origin !== location.origin || session.appId !== this.appId || typeof session.accessKey !== "string" || session.accessKey.length !== 64 || !Number.isFinite(Number(session.sessionExpiresAt)) || Number(session.sessionExpiresAt) <= Date.now() ) { this.clearSession({ expired: true }); return null; } this.scheduleSessionExpiration(session); return session; } catch { this.clearSession(); return null; } } async connect({ message, timestamp, nonce } = {}) { await this.requireProvider(); const connectionMessage = websiteMessage( message ?? `Connect to ${location.origin}`, "Connection message" ); const response = await provider().request({ method: "contractless_connect", params: { origin: location.origin, appId: this.appId, message: connectionMessage, timestamp: Number(timestamp ?? Math.floor(Date.now() / 1_000)), nonce: String(nonce ?? randomHex(32)) } }); const session = { origin: location.origin, appId: this.appId, address: response.address, publicKey: response.publicKey, accessKey: response.accessKey, sessionExpiresAt: response.sessionExpiresAt }; sessionStorage.setItem(this.storageKey, JSON.stringify(session)); this.scheduleSessionExpiration(session); return { ...response, session }; } async accounts() { const response = await this.authorizedRequest("contractless_accounts"); const accounts = Array.isArray(response) ? response : []; if (accounts.length === 0) this.clearSession(); return accounts; } async balances() { const response = await this.authorizedRequest("contractless_balances"); return Array.isArray(response?.balances) ? response.balances : []; } async getBalance({ asset, nftSeries = 0 } = {}) { const requestedAsset = String(asset ?? "").trim().toUpperCase(); const requestedSeries = Number(nftSeries); if (!requestedAsset) throw new TypeError("An asset name is required."); if (!Number.isInteger(requestedSeries) || requestedSeries < 0) { throw new TypeError("nftSeries must be a non-negative integer."); } const balance = (await this.balances()).find((entry) => String(entry.asset ?? "").trim().toUpperCase() === requestedAsset && Number(entry.nft_series ?? 0) === requestedSeries ); return balance ?? { asset: requestedAsset, nft_series: requestedSeries, balance_atomic: 0, balance: "0.00000000" }; } async signMessage({ message, nonce, expiresAt } = {}) { const signedMessage = websiteMessage(message, "Signed message"); const now = Math.floor(Date.now() / 1_000); return this.authorizedRequest("contractless_signMessage", { message: signedMessage, nonce: String(nonce ?? randomHex(32)), expires_at: Number(expiresAt ?? now + 300) }); } async sendTransaction({ transaction } = {}) { if (!transaction || typeof transaction !== "object") { throw new TypeError("A complete transaction object is required."); } const txtype = Number(transaction.txtype); if (txtype === 6 || txtype === 7) { throw new TypeError( "Swaps and loan creation require a staged signing workflow." ); } return this.authorizedRequest("contractless_sendTransaction", { transaction }); } async signDualTransaction({ transaction, signerSlot } = {}) { if (!transaction || typeof transaction !== "object") { throw new TypeError("A complete or partially signed transaction object is required."); } const txtype = Number(transaction.txtype ?? transaction.unsigned_swap?.txtype ?? transaction.unsigned_loan_contract?.txtype); if (txtype !== 6 && txtype !== 7) { throw new TypeError("Staged signing is only available for swaps and loan creation."); } if (signerSlot !== 1 && signerSlot !== 2) { throw new TypeError("signerSlot must be 1 or 2."); } return this.authorizedRequest("contractless_signDualTransaction", { transaction, signerSlot }); } async completeDualTransaction({ transaction, signer1, signer2 } = {}) { if (!transaction || typeof transaction !== "object") { throw new TypeError("The original unsigned transaction is required."); } for (const [name, signer, slot] of [ ["signer1", signer1, 1], ["signer2", signer2, 2] ]) { if (!signer || Number(signer.signerSlot) !== slot) { throw new TypeError(`${name} must contain the result for signer slot ${slot}.`); } } if (signer1.txid !== signer2.txid) { throw new Error("The two wallets signed different transaction hashes."); } if (signer1.unsignedBytes !== signer2.unsignedBytes) { throw new Error("The two wallets serialized different unsigned transaction bytes."); } return this.authorizedRequest("contractless_completeDualTransaction", { transaction, signature1: signer1.signature, publicKey1: signer1.signerPublicKey, signature2: signer2.signature, publicKey2: signer2.signerPublicKey }); } async broadcastTransaction({ bytes, api = this.api } = {}) { if (typeof bytes !== "string" || !/^[0-9a-f]+$/i.test(bytes) || bytes.length % 2) { throw new TypeError("Complete hexadecimal transaction bytes are required."); } return this.apiRequest("/api/v1/transactions/broadcast", { method: "POST", body: { transaction_hex: bytes }, api }); } async apiRequest(path, { method = "GET", query = null, body = null, api = this.api } = {}) { const url = String(api?.url ?? "").replace(/\/+$/, ""); const address = String(api?.address ?? "").trim().toLowerCase(); const publicKey = String(api?.publicKey ?? "").trim().toLowerCase(); const signature = String(api?.signature ?? "").trim().toLowerCase(); if (!url || !address || !publicKey || !signature) { throw new TypeError( "API url, address, publicKey, and the precomputed 'aced' signature are required." ); } const route = String(path ?? ""); if (!route.startsWith("/api/v1")) { throw new TypeError("Contractless API routes must begin with /api/v1."); } const requestUrl = new URL(`${url}${route}`); if (query && typeof query === "object") { for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null) { requestUrl.searchParams.set(key, String(value)); } } } const headers = { "X-Contractless-Address": address, "X-Contractless-Public-Key": publicKey, "X-Contractless-Signature": signature }; if (api.apiKey) headers["X-API-Key"] = String(api.apiKey); if (body !== null) headers["Content-Type"] = "application/json"; const response = await fetch(requestUrl, { method: String(method).toUpperCase(), headers, body: body === null ? undefined : JSON.stringify(body) }); const reply = await response.json().catch(() => undefined); if (!response.ok || reply?.success !== true) { throw new Error(reply?.error ?? `Contractless API request failed with HTTP ${response.status}.`); } return reply.data; } async disconnect() { const active = this.session(); if (!active) return { disconnected: true }; try { return await this.authorizedRequest( "contractless_disconnect", {}, { reconnect: false } ); } finally { this.clearSession(); } } clearSession({ expired = false } = {}) { if (this.expirationTimer !== undefined) { clearTimeout(this.expirationTimer); this.expirationTimer = undefined; } try { sessionStorage.removeItem(this.storageKey); } catch { // The extension session still expires when the wallet locks. } if (expired) { window.dispatchEvent(new CustomEvent("contractless:sessionExpired", { detail: { origin: location.origin, appId: this.appId } })); } } scheduleStoredSessionExpiration() { let stored; try { stored = sessionStorage.getItem(this.storageKey); } catch { return; } if (!stored) return; try { this.scheduleSessionExpiration(JSON.parse(stored)); } catch { this.clearSession(); } } scheduleSessionExpiration(session) { if (this.expirationTimer !== undefined) { clearTimeout(this.expirationTimer); this.expirationTimer = undefined; } const expiresAt = Number(session?.sessionExpiresAt); if (!Number.isFinite(expiresAt)) return; const remaining = expiresAt - Date.now(); if (remaining <= 0) { this.clearSession({ expired: true }); return; } this.expirationTimer = setTimeout(() => { this.clearSession({ expired: true }); }, remaining); } async requireProvider() { if (!await waitForContractlessWallet()) { throw new ContractlessWalletNotInstalledError(); } return provider(); } async authorizedRequest(method, params = {}, { reconnect = true } = {}) { await this.requireProvider(); let session = this.session(); if (!session && reconnect) { const connection = await this.connect({ message: "Connect this application to continue" }); session = connection.session; } if (!session) { throw new Error( "No active Contractless wallet session exists. Call connect() first." ); } try { return await this.sendAuthorizedRequest(method, params, session); } catch (error) { if (/session is invalid|session.*expired|wallet is locked/i.test(String(error))) { this.clearSession(); if (reconnect) { const connection = await this.connect({ message: "Reconnect this application to continue" }); return this.sendAuthorizedRequest(method, params, connection.session); } } throw error; } } async sendAuthorizedRequest(method, params, session) { return provider().request({ method, params: { ...params, origin: location.origin, appId: this.appId, accessKey: session.accessKey } }); } }