400 lines
12 KiB
JavaScript
400 lines
12 KiB
JavaScript
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
|
|
}
|
|
});
|
|
}
|
|
}
|