Contractless-Web3-Wallets/sdk/contractless.js

400 lines
12 KiB
JavaScript
Raw Normal View History

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
}
});
}
}