Contractless-Web3-Wallets/tests/tests.html

1653 lines
59 KiB
HTML
Raw Permalink Normal View History

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contractless Wallet Integration Test</title>
<style>
:root {
color-scheme: dark;
font-family: Arial, Helvetica, sans-serif;
background: #07141d;
color: #f4f8fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #07141d;
}
header {
padding: 32px clamp(20px, 6vw, 72px);
background: #276eaa;
border-bottom: 1px solid #55d9e8;
}
header p,
h1,
h2 {
margin: 0;
}
header p {
margin-top: 8px;
color: #dbefff;
font-size: 16px;
}
main {
width: min(920px, calc(100% - 32px));
margin: 32px auto;
}
.summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border: 1px solid #315466;
background: #0b1e29;
}
.summary div {
min-width: 0;
padding: 18px;
border-right: 1px solid #315466;
}
.summary div:last-child {
border-right: 0;
}
.label {
display: block;
margin-bottom: 7px;
color: #91b4c7;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.value {
display: block;
overflow-wrap: anywhere;
font-size: 16px;
font-weight: 700;
}
.value.pass {
color: #57e4a1;
}
.value.fail {
color: #ff7777;
}
.controls {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
margin-top: 20px;
border: 1px solid #315466;
background: #315466;
}
button {
min-height: 52px;
padding: 12px;
border: 0;
background: #102b3a;
color: #f4f8fb;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:hover,
button:focus-visible {
background: #17445a;
outline: 2px solid #55d9e8;
outline-offset: -2px;
}
button.primary {
background: #276eaa;
}
button:disabled {
color: #718895;
cursor: not-allowed;
}
section {
margin-top: 20px;
border: 1px solid #315466;
background: #0b1e29;
}
section h2 {
padding: 15px 18px;
border-bottom: 1px solid #315466;
font-size: 17px;
}
dl {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
margin: 0;
}
dt,
dd {
margin: 0;
padding: 13px 18px;
border-bottom: 1px solid #203d4c;
}
dt {
color: #91b4c7;
font-weight: 700;
}
dd {
overflow-wrap: anywhere;
}
dt:last-of-type,
dd:last-of-type {
border-bottom: 0;
}
pre {
min-height: 170px;
max-height: 340px;
margin: 0;
padding: 18px;
overflow: auto;
color: #cce8f5;
font: 14px/1.6 Consolas, monospace;
white-space: pre-wrap;
}
.code-actions {
display: flex;
justify-content: flex-end;
border-top: 1px solid #315466;
}
.code-actions button {
min-width: 190px;
}
.transaction-intro {
margin: 0;
padding: 16px 18px;
border-bottom: 1px solid #315466;
color: #b8cfdb;
line-height: 1.55;
}
.transaction-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
padding: 10px;
background: #07141d;
border-bottom: 1px solid #315466;
}
.transaction-tabs button {
min-height: 48px;
border: 1px solid #315466;
background: #102b3a;
}
.transaction-tabs button.active {
border-color: #55d9e8;
background: #276eaa;
color: #ffffff;
}
.transaction-types {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 1px;
background: #315466;
}
.transaction-types button {
min-height: 58px;
font-size: 13px;
}
.transaction-types button.active {
background: #276eaa;
outline: 2px solid #55d9e8;
outline-offset: -2px;
}
.dual-record-tools {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid #315466;
background: #102b3a;
}
.dual-record-tools[hidden] {
display: none;
}
.dual-record-tools label {
font-weight: 700;
}
.dual-record-tools input {
width: 100%;
min-width: 0;
padding: 11px 12px;
border: 1px solid #52778a;
background: #07141d;
color: #ffffff;
font: 13px Consolas, monospace;
}
.dual-record-tools small {
grid-column: 2;
color: #b8cfdb;
}
.example-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1px;
background: #315466;
}
.example-panel {
min-width: 0;
background: #0b1e29;
}
.example-panel h3 {
margin: 0;
padding: 14px 18px;
border-bottom: 1px solid #315466;
font-size: 15px;
}
.example-panel textarea {
width: 100%;
height: 100%;
min-height: 390px;
display: block;
resize: vertical;
border: 0;
padding: 18px;
background: #07141d;
color: #d9f2ff;
font: 13px/1.55 Consolas, monospace;
outline: 0;
}
.example-panel pre {
min-height: 390px;
max-height: 520px;
background: #07141d;
}
.example-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
border-top: 1px solid #315466;
background: #315466;
}
.transaction-result {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
border-top: 1px solid #315466;
}
.transaction-result[hidden] {
display: none;
}
.transaction-result dt,
.transaction-result dd {
padding: 12px 18px;
}
.transaction-result dd {
font-family: Consolas, monospace;
}
.transaction-result .bytes {
max-height: 180px;
overflow: auto;
word-break: break-all;
}
@media (max-width: 680px) {
.summary,
.controls,
.transaction-tabs,
.transaction-types,
.example-grid,
.example-actions {
grid-template-columns: 1fr;
}
.summary div {
border-right: 0;
border-bottom: 1px solid #315466;
}
.summary div:last-child {
border-bottom: 0;
}
dl {
grid-template-columns: 1fr;
}
dt {
padding-bottom: 4px;
border-bottom: 0;
}
dd {
padding-top: 4px;
}
.transaction-result {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
<h1>Contractless Wallet Integration Test</h1>
<p>Website SDK and browser extension connection checks</p>
</header>
<main>
<div class="summary">
<div>
<span class="label">Wallet detected</span>
<span class="value" id="detected">Checking</span>
</div>
<div>
<span class="label">Application session</span>
<span class="value" id="connected">Not connected</span>
</div>
<div>
<span class="label">Address retrieved</span>
<span class="value" id="address-status">Not tested</span>
</div>
</div>
<div class="controls">
<button type="button" id="detect">Detect wallet</button>
<button type="button" class="primary" id="connect">Connect wallet</button>
<button type="button" id="accounts">Get wallet address</button>
<button type="button" id="disconnect">Disconnect</button>
</div>
<section>
<h2>Connected Wallet</h2>
<dl>
<dt>Origin</dt>
<dd id="origin"></dd>
<dt>Application ID</dt>
<dd id="app-id"></dd>
<dt>Address</dt>
<dd id="address">No address received</dd>
<dt>Session expires</dt>
<dd id="expires">No active session</dd>
</dl>
</section>
<section>
<h2>Wallet Connection Code</h2>
<pre id="connection-code" aria-live="polite"></pre>
<div class="code-actions">
<button type="button" id="copy-connection-code">Copy connection code</button>
</div>
</section>
<section>
<h2>Transaction Examples</h2>
<p class="transaction-intro">
Select a transaction type, replace any example-dependent values, then
submit it to the wallet for review, signing, and broadcast.
Dual-signature tests use a small PHP backend to save one JSON file
per transaction hash, allowing two separate computers to sign the
same transaction before it is broadcast.
</p>
<div class="transaction-tabs" role="tablist" aria-label="Transaction categories">
<button type="button" class="active" data-transaction-category="standard">
Standard transactions
</button>
<button type="button" data-transaction-category="data">
Data transactions
</button>
<button type="button" data-transaction-category="dual">
Dual signature transactions
</button>
</div>
<div class="transaction-types" id="transaction-types"></div>
<div class="dual-record-tools" id="dual-record-tools" hidden>
<label for="dual-transaction-hash">Saved transaction hash</label>
<input
id="dual-transaction-hash"
type="text"
maxlength="64"
placeholder="Paste the 64-character hash returned by signer 1"
autocomplete="off"
spellcheck="false"
>
<small id="dual-record-status">Signer 1 creates the saved transaction record.</small>
</div>
<div class="example-grid">
<div class="example-panel">
<h3 id="transaction-json-title">Editable Transaction JSON</h3>
<textarea id="transaction-json" spellcheck="false"></textarea>
</div>
<div class="example-panel">
<h3>Application Code</h3>
<pre id="transaction-code"></pre>
</div>
</div>
<div class="example-actions">
<button type="button" id="copy-transaction-code">Copy application code</button>
<button type="button" class="primary" id="send-transaction" disabled>
Sign and broadcast
</button>
</div>
<dl class="transaction-result" id="transaction-result" hidden>
<dt>Status</dt>
<dd id="transaction-status"></dd>
<dt>Transaction ID</dt>
<dd id="transaction-txid"></dd>
<dt>Signed bytes</dt>
<dd id="transaction-bytes" class="bytes"></dd>
<dt>Byte length</dt>
<dd id="transaction-byte-count"></dd>
<dt>Broadcast reply</dt>
<dd id="transaction-broadcast"></dd>
</dl>
</section>
</main>
<script type="module">
import {
ContractlessWallet,
waitForContractlessWallet
} from "../sdk/contractless.js?v=20260801-1";
const APP_ID = "contractless-wallet-test";
const API_IDENTITY = {
url: "https://api.contractless.dev",
address: "",
publicKey: "",
signature: "",
apiKey: ""
};
const wallet = new ContractlessWallet({ appId: APP_ID, api: API_IDENTITY });
const standardTransactionExamples = [
{
id: "transfer",
label: "Transfer",
create: ({ address, suffix, alternateAddress, now }) => ({
txtype: 2,
time: now,
value: "100000000",
coin: suffix,
nft_series: 0,
sender: address,
receiver: alternateAddress,
txfee: "1000000"
})
},
{
id: "token",
label: "Create Token",
create: ({ address, now }) => ({
txtype: 3,
time: now,
creator: address,
ticker: `web${String(now).slice(-6)}`,
number: "10000000000",
hard_limit: 0,
txfee: "50000000000"
})
},
{
id: "nft",
label: "Create NFT/RWA",
create: ({ address, now }) => ({
txtype: 4,
time: now,
creator: address,
series: 0,
ownership_type: 0,
nft_name: `web${String(now).slice(-6)}`,
item_ipfs: "replace-with-ipfs-metadata-cid",
count: 1,
desc: "Contractless Web3 wallet example",
txfee: "50000000"
})
},
{
id: "marketing",
label: "Marketing",
create: ({ address, now }) => ({
txtype: 5,
time: now,
campaign: String(now),
ad_type: "banner",
keyword: "contractless",
displayed: "https://example.com/placement",
impression: 1,
click: 0,
impression_value: 1,
click_value: 0,
advertiser: address,
txfee: "100000000"
})
},
{
id: "burn",
label: "Burn Asset",
create: ({ address, now }) => ({
txtype: 10,
time: now,
address,
coin: "testtoken",
nft_series: 0,
value: "100000000",
txfee: "10000"
})
},
{
id: "issue",
label: "Issue Token",
create: ({ address, now }) => ({
txtype: 11,
time: now,
creator: address,
ticker: "testtoken",
number: "10000000000",
txfee: "10000000000"
})
},
{
id: "vanity",
label: "Vanity Address",
create: ({ address, suffix, now }) => ({
txtype: 12,
timestamp: now,
address,
vanity_address: `replaceexample.${suffix}`,
txfee: "500000000"
})
}
];
function storageValueExample(id, label, txtype, value) {
return {
id,
label,
create: ({ address, now }) => ({
txtype,
time: now,
storagekey: "0".repeat(64),
key: "example_key",
value,
address,
txfee: "100000000"
})
};
}
const dataTransactionExamples = [
{
id: "storage-key",
label: "Create Storage Key",
create: ({ address, now }) => ({
txtype: 100,
time: now,
address,
txfee: "50000000000"
})
},
storageValueExample("storage-bool", "Bool", 101, true),
storageValueExample("storage-u8", "U8", 102, 10),
storageValueExample("storage-u16", "U16", 103, 1000),
storageValueExample("storage-u32", "U32", 104, 100000),
storageValueExample("storage-u64", "U64", 105, "10000000000"),
storageValueExample("storage-u128", "U128", 106, "100000000000000000000"),
{
id: "storage-string",
label: "String",
create: ({ address, now }) => ({
txtype: 107,
time: now,
storagekey: "0".repeat(64),
key: "example_key",
value: "Contractless data storage example".padEnd(180, " "),
address,
previous: "0".repeat(64),
txfee: "100000000"
})
},
storageValueExample("storage-i8", "I8", 108, -10),
storageValueExample("storage-i16", "I16", 109, -1000),
storageValueExample("storage-i32", "I32", 110, -100000),
storageValueExample("storage-i64", "I64", 111, "-10000000000"),
storageValueExample("storage-i128", "I128", 112, "-100000000000000000000"),
{
id: "delete-storage-key",
label: "Delete Key",
create: ({ address, now }) => ({
txtype: 113,
time: now,
storagekey: "0".repeat(64),
key: "example_key",
address,
txfee: "100000000"
})
}
];
const createSwapTransaction = ({ address, alternateAddress, now }) => ({
txtype: 6,
timestamp: now,
offer_expiration: now + 3600,
ticker1: "cltc",
nft_series1: 0,
value1: "100000000",
ticker2: "testtoken",
nft_series2: 0,
value2: "100000000",
sender1: address,
sender2: alternateAddress,
tip1: "1000000",
tip2: "1000000",
txfee1: "100000000",
txfee2: "100000000"
});
const createLoanTransaction = ({ address, alternateAddress, now }) => ({
txtype: 7,
timestamp: now,
loan_coin: "cltc",
loan_amount: "1000000000",
lender: address,
collateral: "testcollateral",
collateral_amount: "2000000000",
borrower: alternateAddress,
payment_period: "D",
payment_number: 10,
payment_amount: "110000000",
grace_period: 2,
max_late_value: "220000000",
txfee: "300000000"
});
const dualSignatureTransactionExamples = [
{
id: "swap-signer-1",
label: "Swap Signer 1",
dualAction: "signer1",
kind: "swap",
signerSlot: 1,
create: createSwapTransaction
},
{
id: "swap-signer-2",
label: "Swap Signer 2",
dualAction: "signer2",
kind: "swap",
signerSlot: 2,
create: createSwapTransaction
},
{
id: "swap-broadcast",
label: "Broadcast Swap",
dualAction: "broadcast",
kind: "swap",
create: createSwapTransaction
},
{
id: "loan-lender",
label: "Loan Lender",
dualAction: "signer1",
kind: "loan",
signerSlot: 1,
create: createLoanTransaction
},
{
id: "loan-borrower",
label: "Loan Borrower",
dualAction: "signer2",
kind: "loan",
signerSlot: 2,
create: createLoanTransaction
},
{
id: "loan-broadcast",
label: "Broadcast Loan",
dualAction: "broadcast",
kind: "loan",
create: createLoanTransaction
}
];
const transactionGroups = {
standard: standardTransactionExamples,
data: dataTransactionExamples,
dual: dualSignatureTransactionExamples
};
let selectedCategory = "standard";
let selectedExample = transactionGroups[selectedCategory][0];
let dualLoadId = 0;
const elements = {
detected: document.querySelector("#detected"),
connected: document.querySelector("#connected"),
addressStatus: document.querySelector("#address-status"),
address: document.querySelector("#address"),
expires: document.querySelector("#expires"),
connectionCode: document.querySelector("#connection-code"),
accounts: document.querySelector("#accounts"),
disconnect: document.querySelector("#disconnect"),
transactionJson: document.querySelector("#transaction-json"),
transactionJsonTitle: document.querySelector("#transaction-json-title"),
transactionCode: document.querySelector("#transaction-code"),
transactionResult: document.querySelector("#transaction-result"),
sendTransaction: document.querySelector("#send-transaction"),
dualRecordTools: document.querySelector("#dual-record-tools"),
dualTransactionHash: document.querySelector("#dual-transaction-hash"),
dualRecordStatus: document.querySelector("#dual-record-status")
};
document.querySelector("#origin").textContent = location.origin;
document.querySelector("#app-id").textContent = APP_ID;
function setStatus(element, text, passed = null) {
element.textContent = text;
element.classList.toggle("pass", passed === true);
element.classList.toggle("fail", passed === false);
}
const connectionSetupCode = [
"import {",
" ContractlessWallet,",
" waitForContractlessWallet",
"} from './sdk/contractless.js';",
"",
`const APP_ID = "${APP_ID}";`,
"// APP_ID must contain 5 to 32 URL-safe characters and never change.",
"const wallet = new ContractlessWallet({ appId: APP_ID });"
].join("\n");
const connectionExamples = {
detect: [
connectionSetupCode,
"",
"const installed = await waitForContractlessWallet();",
"if (!installed) {",
" throw new Error('Contractless Wallet is not installed.');",
"}",
"console.log('Contractless Wallet detected');"
].join("\n"),
connect: [
connectionSetupCode,
"",
"const connection = await wallet.connect({",
" message: 'Connect to my application'",
"});",
"",
"// connect() stores the address, public key, access key, origin,",
"// application ID, and expiration in sessionStorage automatically.",
"const session = wallet.session();",
"console.log(connection.address, session.sessionExpiresAt);"
].join("\n"),
accounts: [
connectionSetupCode,
"",
"// session() reads and validates the application session in sessionStorage.",
"const session = wallet.session();",
"if (!session) {",
" throw new Error('Connect the wallet before requesting its address.');",
"}",
"",
"const accounts = await wallet.accounts();",
"if (accounts.length === 0) {",
" throw new Error('The wallet returned no active address.');",
"}",
"const address = accounts[0];",
"console.log(address);"
].join("\n"),
disconnect: [
connectionSetupCode,
"",
"await wallet.disconnect();",
"// disconnect() invalidates wallet access and removes the SDK session",
"// from sessionStorage even if the wallet is already unavailable.",
"console.log('Contractless Wallet disconnected');"
].join("\n")
};
function showConnectionCode(example) {
elements.connectionCode.textContent = connectionExamples[example];
}
function showSession(session) {
if (!session) {
setStatus(elements.connected, "Not connected", false);
elements.address.textContent = "No address received";
elements.expires.textContent = "No active session";
elements.accounts.disabled = true;
elements.disconnect.disabled = true;
updateTransactionAction(false);
return;
}
setStatus(elements.connected, "Connected", true);
elements.address.textContent = session.address;
elements.expires.textContent =
new Date(session.sessionExpiresAt).toLocaleString();
elements.accounts.disabled = false;
elements.disconnect.disabled = false;
updateTransactionAction(true);
}
function transactionContext() {
const session = wallet.session();
const address = session?.address
?? "0000000000000000000000000000000000000000.cltc";
const suffix = address.endsWith(".clc") ? "clc" : "cltc";
const firstAlternate = `1439f758f93333734778a6459dc64b8d63a74c06.${suffix}`;
const secondAlternate = `7ba97ce531937388ef8bca4d8a4ca54cefbefb87.${suffix}`;
return {
address,
suffix,
alternateAddress: address === firstAlternate
? secondAlternate
: firstAlternate,
now: Math.floor(Date.now() / 1_000)
};
}
function atomicAmount(value, field) {
const normalized = String(value ?? "").trim();
if (!/^\d+$/.test(normalized)) {
throw new Error(`${field} must be a non-negative atomic-unit value.`);
}
return BigInt(normalized);
}
function lowercaseTransactionAssets(transaction) {
const wrapperNames = [
"unsigned_transfer",
"unsigned_create_token",
"unsigned_create_nft",
"unsigned_swap",
"unsigned_loan_contract",
"unsigned_burn",
"unsigned_issue_token"
];
const wrapper = wrapperNames.find((name) => transaction[name]);
const unsigned = wrapper ? transaction[wrapper] : transaction;
["coin", "ticker", "nft_name", "ticker1", "ticker2", "loan_coin", "collateral"]
.forEach((field) => {
if (typeof unsigned[field] === "string") {
unsigned[field] = unsigned[field].trim().toLowerCase();
}
});
return transaction;
}
function balanceAtomic(balance) {
const decimal = String(balance?.balance ?? "").trim();
const decimalMatch = decimal.match(/^(\d+)(?:\.(\d{1,8}))?$/);
if (decimalMatch) {
return BigInt(decimalMatch[1]) * 100000000n
+ BigInt((decimalMatch[2] ?? "").padEnd(8, "0"));
}
const value = balance?.balance_atomic ?? 0;
if (typeof value === "number") {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error("The API returned a balance outside JavaScript's safe integer range.");
}
return BigInt(value);
}
return atomicAmount(value, "Available balance");
}
function formatAtomicAmount(value) {
const atomic = BigInt(value);
const whole = atomic / 100000000n;
const fraction = (atomic % 100000000n)
.toString()
.padStart(8, "0")
.replace(/0+$/, "");
return fraction ? `${whole}.${fraction}` : whole.toString();
}
function assetParts(asset, nftSeries = 0) {
const normalized = String(asset ?? "").trim().toUpperCase();
const explicitSeries = Number(nftSeries ?? 0);
const numberedAsset = normalized.match(/^(.+)_([1-9]\d*)$/);
if (numberedAsset && explicitSeries === 0) {
return { asset: numberedAsset[1], nftSeries: Number(numberedAsset[2]) };
}
return { asset: normalized, nftSeries: explicitSeries };
}
function transactionFundingRequirements(transaction, connectedAddress) {
const unsigned = transaction.unsigned_swap
?? transaction.unsigned_loan_contract
?? transaction;
const txtype = Number(unsigned.txtype);
const baseAsset = connectedAddress.endsWith(".clc") ? "CLC" : "CLTC";
const requirements = new Map();
const add = (asset, nftSeries, amount, reason) => {
const parsed = assetParts(asset, nftSeries);
if (!parsed.asset) throw new Error(`The ${reason} asset is missing.`);
const atomic = atomicAmount(amount, reason);
const key = `${parsed.asset}:${parsed.nftSeries}`;
const current = requirements.get(key) ?? {
asset: parsed.asset,
nftSeries: parsed.nftSeries,
amount: 0n,
reasons: []
};
current.amount += atomic;
current.reasons.push(reason);
requirements.set(key, current);
};
const addFee = (value, field = "Transaction fee") =>
add(baseAsset, 0, value, field);
switch (txtype) {
case 2:
add(unsigned.coin, unsigned.nft_series, unsigned.value, "Transfer amount");
addFee(unsigned.txfee);
break;
case 6: {
const slot = unsigned.sender1 === connectedAddress
? 1
: unsigned.sender2 === connectedAddress ? 2 : 0;
if (!slot) throw new Error("The connected wallet is not a party to this swap.");
add(
unsigned[`ticker${slot}`],
unsigned[`nft_series${slot}`],
atomicAmount(unsigned[`value${slot}`], "Swap amount")
+ atomicAmount(unsigned[`tip${slot}`], "Swap miner tip"),
"Swap amount and miner tip"
);
addFee(unsigned[`txfee${slot}`], "Swap transaction fee");
break;
}
case 7:
if (unsigned.lender === connectedAddress) {
add(unsigned.loan_coin, 0, unsigned.loan_amount, "Loan amount");
addFee(unsigned.txfee, "Loan transaction fee");
} else if (unsigned.borrower === connectedAddress) {
add(unsigned.collateral, 0, unsigned.collateral_amount, "Loan collateral");
} else {
throw new Error("The connected wallet is not a party to this loan.");
}
break;
case 10:
add(unsigned.coin, unsigned.nft_series, unsigned.value, "Burn amount");
addFee(unsigned.txfee);
break;
default:
if (unsigned.txfee !== undefined) addFee(unsigned.txfee);
break;
}
return [...requirements.values()];
}
async function assertTransactionFunds(walletClient, transaction, connectedAddress) {
const requirements = transactionFundingRequirements(transaction, connectedAddress);
for (const requirement of requirements) {
const balance = await walletClient.getBalance({
asset: requirement.asset,
nftSeries: requirement.nftSeries
});
const available = balanceAtomic(balance);
if (available < requirement.amount) {
const series = requirement.nftSeries > 0 ? ` #${requirement.nftSeries}` : "";
throw new Error(
`Insufficient ${requirement.asset}${series} balance before wallet request: `
+ `${formatAtomicAmount(requirement.amount)} required for `
+ `${requirement.reasons.join(" + ")}, but only `
+ `${formatAtomicAmount(available)} is available.`
);
}
}
return requirements;
}
function balanceCheckApplicationCode(transaction) {
const unsigned = transaction.unsigned_swap
?? transaction.unsigned_loan_contract
?? transaction;
const txtype = Number(unsigned.txtype);
const connectedAddress = wallet.session()?.address ?? "";
const baseAsset = connectedAddress.endsWith(".clc") ? "CLC" : "CLTC";
const feeOnly = () => [
`const feeBalance = await wallet.getBalance({ asset: "${baseAsset}", nftSeries: 0 });`,
"const fee = BigInt(transaction.txfee);",
"if (fee > BigInt(feeBalance.balance_atomic)) {",
` throw new Error("Insufficient ${baseAsset} balance for the transaction fee.");`,
"}"
];
if (txtype === 2 || txtype === 10) {
const asset = String(unsigned.coin).trim().toUpperCase();
const nftSeries = Number(unsigned.nft_series ?? 0);
if (asset === baseAsset && nftSeries === 0) {
return [
`const balance = await wallet.getBalance({ asset: "${baseAsset}", nftSeries: 0 });`,
"const amount = BigInt(transaction.value);",
"const fee = BigInt(transaction.txfee);",
"const transactionTotal = amount + fee;",
"if (transactionTotal > BigInt(balance.balance_atomic)) {",
` throw new Error("Insufficient ${baseAsset} balance for the amount and fee.");`,
"}"
];
}
return [
`const assetBalance = await wallet.getBalance({ asset: "${asset}", nftSeries: ${nftSeries} });`,
`const feeBalance = await wallet.getBalance({ asset: "${baseAsset}", nftSeries: 0 });`,
"if (BigInt(transaction.value) > BigInt(assetBalance.balance_atomic)) {",
` throw new Error("Insufficient ${asset} balance.");`,
"}",
"if (BigInt(transaction.txfee) > BigInt(feeBalance.balance_atomic)) {",
` throw new Error("Insufficient ${baseAsset} balance for the transaction fee.");`,
"}"
];
}
if (txtype === 6) {
return [
"const connectedAddress = wallet.session().address;",
"const signerSlot = transaction.sender1 === connectedAddress",
" ? 1",
" : transaction.sender2 === connectedAddress ? 2 : 0;",
"if (!signerSlot) throw new Error(\"The connected wallet is not part of this swap.\");",
"const offeredAsset = transaction[`ticker${signerSlot}`].trim().toUpperCase();",
"const nftSeries = Number(transaction[`nft_series${signerSlot}`]);",
"const amountAndTip = BigInt(transaction[`value${signerSlot}`])",
" + BigInt(transaction[`tip${signerSlot}`]);",
"const fee = BigInt(transaction[`txfee${signerSlot}`]);",
`const feeBalance = await wallet.getBalance({ asset: "${baseAsset}", nftSeries: 0 });`,
"if (offeredAsset === \"" + baseAsset + "\" && nftSeries === 0) {",
" if (amountAndTip + fee > BigInt(feeBalance.balance_atomic)) {",
` throw new Error("Insufficient ${baseAsset} balance for the swap amount, tip, and fee.");`,
" }",
"} else {",
" const offeredBalance = await wallet.getBalance({ asset: offeredAsset, nftSeries });",
" if (amountAndTip > BigInt(offeredBalance.balance_atomic)) {",
" throw new Error(`Insufficient ${offeredAsset} balance for the swap amount and tip.`);",
" }",
" if (fee > BigInt(feeBalance.balance_atomic)) {",
` throw new Error("Insufficient ${baseAsset} balance for the swap fee.");`,
" }",
"}"
];
}
if (txtype === 7) {
return [
"const connectedAddress = wallet.session().address;",
"const signerSlot = transaction.lender === connectedAddress",
" ? 1",
" : transaction.borrower === connectedAddress ? 2 : 0;",
"if (!signerSlot) throw new Error(\"The connected wallet is not part of this loan.\");",
"if (signerSlot === 1) {",
" const loanAsset = transaction.loan_coin.trim().toUpperCase();",
" const loanBalance = await wallet.getBalance({ asset: loanAsset, nftSeries: 0 });",
` const feeBalance = await wallet.getBalance({ asset: "${baseAsset}", nftSeries: 0 });`,
" const loanAmount = BigInt(transaction.loan_amount);",
" const fee = BigInt(transaction.txfee);",
" const requiredLoanBalance = loanAsset === \"" + baseAsset + "\"",
" ? loanAmount + fee",
" : loanAmount;",
" if (requiredLoanBalance > BigInt(loanBalance.balance_atomic)) {",
" throw new Error(`Insufficient ${loanAsset} balance for the loan.`);",
" }",
" if (loanAsset !== \"" + baseAsset + "\" && fee > BigInt(feeBalance.balance_atomic)) {",
` throw new Error("Insufficient ${baseAsset} balance for the loan fee.");`,
" }",
"} else {",
" const collateralMatch = transaction.collateral.trim().toUpperCase().match(/^(.+)_([1-9]\\d*)$/);",
" const collateralAsset = collateralMatch ? collateralMatch[1] : transaction.collateral.trim().toUpperCase();",
" const collateralSeries = collateralMatch ? Number(collateralMatch[2]) : 0;",
" const collateralBalance = await wallet.getBalance({",
" asset: collateralAsset,",
" nftSeries: collateralSeries",
" });",
" if (BigInt(transaction.collateral_amount) > BigInt(collateralBalance.balance_atomic)) {",
" throw new Error(`Insufficient ${collateralAsset} collateral balance.`);",
" }",
"}"
];
}
return feeOnly();
}
function applicationCode(transaction) {
const balanceCheck = balanceCheckApplicationCode(transaction);
if (selectedExample.dualAction === "signer1") {
return [
"// This example saves signatures in JSON files for clarity.",
"// Any backend language can be used, and a production application can use a database instead.",
`const transaction = ${JSON.stringify(transaction, null, 2)};`,
...balanceCheck,
"",
"const signatureResult = await wallet.signDualTransaction({",
" transaction,",
` signerSlot: ${selectedExample.signerSlot}`,
"});",
"const saveResponse = await fetch('/tests/dual-signature-store.php', {",
" method: 'POST',",
" headers: { 'Content-Type': 'application/json' },",
" body: JSON.stringify({",
" action: 'save_signature',",
" txid: signatureResult.txid,",
` kind: '${selectedExample.kind}',`,
` signer_slot: ${selectedExample.signerSlot},`,
" transaction,",
" signature: signatureResult",
" })",
"});",
"const savedFile = await saveResponse.json();",
"if (!saveResponse.ok || savedFile.success !== true) {",
" throw new Error(savedFile.error ?? 'The signature could not be saved.');",
"}",
"console.log(signatureResult);",
"console.log(savedFile.data.file, savedFile.data.record);"
].join("\n");
}
if (selectedExample.dualAction === "signer2") {
return [
"// The transaction hash comes from signer 1 and identifies the saved JSON record.",
`const txid = "${elements.dualTransactionHash.value || "PASTE_SIGNER_1_TRANSACTION_HASH"}";`,
"const loadResponse = await fetch('/tests/dual-signature-store.php', {",
" method: 'POST',",
" headers: { 'Content-Type': 'application/json' },",
" body: JSON.stringify({ action: 'load', txid })",
"});",
"const savedFile = await loadResponse.json();",
"if (!loadResponse.ok || savedFile.success !== true) {",
" throw new Error(savedFile.error ?? 'The transaction could not be loaded.');",
"}",
"const transaction = savedFile.data.record.transaction;",
...balanceCheck,
"",
"const signatureResult = await wallet.signDualTransaction({",
" transaction,",
` signerSlot: ${selectedExample.signerSlot}`,
"});",
"const saveResponse = await fetch('/tests/dual-signature-store.php', {",
" method: 'POST',",
" headers: { 'Content-Type': 'application/json' },",
" body: JSON.stringify({",
" action: 'save_signature',",
" txid,",
` kind: '${selectedExample.kind}',`,
` signer_slot: ${selectedExample.signerSlot},`,
" transaction,",
" signature: signatureResult",
" })",
"});",
"const updatedFile = await saveResponse.json();",
"if (!saveResponse.ok || updatedFile.success !== true) {",
" throw new Error(updatedFile.error ?? 'The signature could not be saved.');",
"}",
"console.log(signatureResult);",
"console.log(updatedFile.data.file, updatedFile.data.record);"
].join("\n");
}
if (selectedExample.dualAction === "broadcast") {
return [
"import { ContractlessWallet } from './sdk/contractless.js';",
"",
`const APP_ID = "${APP_ID}";`,
"const API_IDENTITY = {",
" url: \"https://api.contractless.dev\",",
" address: \"\",",
" publicKey: \"\",",
" signature: \"\",",
" apiKey: \"\"",
"};",
"// Use a registered wallet as the API identity.",
"// Its public key is available from wallet.connect() or wallet.session().publicKey.",
"// Run ./sign_message \"aced\" with that wallet to create the reusable API handshake signature.",
"const broadcastWallet = new ContractlessWallet({ appId: APP_ID, api: API_IDENTITY });",
"",
`const txid = "${elements.dualTransactionHash.value || "PASTE_COMPLETED_TRANSACTION_HASH"}";`,
"const loadResponse = await fetch('/tests/dual-signature-store.php', {",
" method: 'POST',",
" headers: { 'Content-Type': 'application/json' },",
" body: JSON.stringify({ action: 'load', txid })",
"});",
"const savedFile = await loadResponse.json();",
"if (!loadResponse.ok || savedFile.success !== true) {",
" throw new Error(savedFile.error ?? 'The completed transaction could not be loaded.');",
"}",
"const { transaction, signer1, signer2 } = savedFile.data.record;",
"if (!signer1 || !signer2) throw new Error('Both signatures are required before broadcast.');",
"const complete = await broadcastWallet.completeDualTransaction({",
" transaction,",
" signer1,",
" signer2",
"});",
"const broadcast = await broadcastWallet.broadcastTransaction({",
" bytes: complete.bytes_hex",
"});",
"console.log(complete.hash, complete.bytes_hex, broadcast);"
].join("\n");
}
return [
`const transaction = ${JSON.stringify(transaction, null, 2)};`,
...balanceCheck,
"",
"const result = await wallet.sendTransaction({ transaction });",
"",
"console.log(result.txid);",
"console.log(result.bytes);",
"console.log(result.broadcast);"
].join("\n");
}
function selectTransactionExample(example) {
selectedExample = example;
const transaction = example.create(transactionContext());
const loadsSavedTransaction = example.dualAction === "signer2"
|| example.dualAction === "broadcast";
elements.transactionJson.value = loadsSavedTransaction
? ""
: JSON.stringify(transaction, null, 2);
elements.transactionJson.readOnly = loadsSavedTransaction;
elements.transactionJsonTitle.textContent = loadsSavedTransaction
? "Saved Transaction JSON"
: "Editable Transaction JSON";
elements.transactionCode.textContent = applicationCode(transaction);
elements.transactionResult.hidden = true;
elements.dualRecordTools.hidden = selectedCategory !== "dual";
elements.dualRecordStatus.textContent = example.dualAction === "signer1"
? "Signer 1 creates the saved transaction record."
: "Enter signer 1's transaction hash to load the saved transaction.";
if (loadsSavedTransaction) void loadSelectedDualTransaction();
updateTransactionAction(Boolean(wallet.session()));
document.querySelectorAll("[data-transaction-example]").forEach((button) => {
button.classList.toggle(
"active",
button.dataset.transactionExample === example.id
);
});
}
const transactionTypes = document.querySelector("#transaction-types");
function updateTransactionAction(connected = Boolean(wallet.session())) {
elements.sendTransaction.disabled = !connected;
const labels = {
signer1: selectedExample?.kind === "loan" ? "Sign as lender" : "Sign as swap signer 1",
signer2: selectedExample?.kind === "loan" ? "Sign as borrower" : "Sign as swap signer 2",
broadcast: selectedExample?.kind === "loan" ? "Broadcast loan" : "Broadcast swap"
};
elements.sendTransaction.textContent = labels[selectedExample?.dualAction]
?? "Sign and broadcast";
}
function renderTransactionTypes() {
transactionTypes.replaceChildren();
transactionGroups[selectedCategory].forEach((example) => {
const button = document.createElement("button");
button.type = "button";
button.dataset.transactionExample = example.id;
button.textContent = example.label;
button.addEventListener("click", () => selectTransactionExample(example));
transactionTypes.append(button);
});
selectTransactionExample(transactionGroups[selectedCategory][0]);
}
document.querySelectorAll("[data-transaction-category]").forEach((button) => {
button.addEventListener("click", () => {
selectedCategory = button.dataset.transactionCategory;
document.querySelectorAll("[data-transaction-category]").forEach((tab) => {
tab.classList.toggle("active", tab === button);
});
renderTransactionTypes();
});
});
elements.transactionJson.addEventListener("input", () => {
try {
const transaction = lowercaseTransactionAssets(
JSON.parse(elements.transactionJson.value)
);
elements.transactionCode.textContent = applicationCode(transaction);
} catch {
elements.transactionCode.textContent =
"The transaction JSON must be valid before application code can be generated.";
}
});
elements.dualTransactionHash.addEventListener("input", () => {
const transaction = selectedExample.create(transactionContext());
elements.transactionCode.textContent = applicationCode(transaction);
if (selectedExample.dualAction === "signer2"
|| selectedExample.dualAction === "broadcast") {
void loadSelectedDualTransaction();
}
});
document.querySelector("#copy-transaction-code").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(elements.transactionCode.textContent);
document.querySelector("#copy-transaction-code").textContent = "Code copied";
} catch (error) {
document.querySelector("#copy-transaction-code").textContent =
error instanceof Error ? error.message : String(error);
}
});
async function dualStoreRequest(payload) {
const response = await fetch("/tests/dual-signature-store.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const reply = await response.json().catch(() => ({}));
if (!response.ok || reply.success !== true) {
throw new Error(reply.error ?? `JSON storage failed with HTTP ${response.status}.`);
}
return reply.data;
}
async function loadSelectedDualTransaction() {
const action = selectedExample.dualAction;
if (action !== "signer2" && action !== "broadcast") return null;
const txid = elements.dualTransactionHash.value.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/.test(txid)) {
dualLoadId += 1;
elements.transactionJson.value = "";
elements.dualRecordStatus.textContent =
"Enter the complete 64-character hash returned by signer 1.";
return null;
}
const requestId = ++dualLoadId;
elements.dualRecordStatus.textContent = "Loading saved transaction...";
try {
const savedFile = await dualStoreRequest({ action: "load", txid });
if (requestId !== dualLoadId) return null;
if (savedFile.record.kind !== selectedExample.kind) {
throw new Error(`The saved transaction is not a ${selectedExample.kind}.`);
}
const transaction = savedFile.record.transaction;
elements.transactionJson.value = JSON.stringify(transaction, null, 2);
elements.transactionCode.textContent = applicationCode(transaction);
const signatures = [savedFile.record.signer1, savedFile.record.signer2]
.filter(Boolean).length;
elements.dualRecordStatus.textContent =
`${savedFile.file} loaded with ${signatures} of 2 signatures.`;
return savedFile;
} catch (error) {
if (requestId !== dualLoadId) return null;
elements.transactionJson.value = "";
elements.dualRecordStatus.textContent =
error instanceof Error ? error.message : String(error);
return null;
}
}
function requiredDualTransactionHash() {
const txid = elements.dualTransactionHash.value.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/.test(txid)) {
throw new Error("Enter the 64-character transaction hash returned by signer 1.");
}
return txid;
}
function showDualSignatureResult(status, result, savedFile) {
status.textContent = `Signer ${result.signerSlot} saved to ${savedFile.file}`;
status.className = "pass";
elements.dualTransactionHash.value = result.txid;
elements.dualRecordStatus.textContent =
`${savedFile.file} saved. Use this hash for the next signing stage.`;
document.querySelector("#transaction-txid").textContent = result.txid;
document.querySelector("#transaction-bytes").textContent = result.signature;
document.querySelector("#transaction-byte-count").textContent =
`${result.unsignedBytes.length / 2} unsigned bytes`;
document.querySelector("#transaction-broadcast").textContent =
JSON.stringify(savedFile.record, null, 2);
}
elements.sendTransaction.addEventListener("click", async () => {
const status = document.querySelector("#transaction-status");
try {
elements.transactionResult.hidden = false;
status.className = "";
if (selectedExample.dualAction) {
let transaction;
let txid = "";
let savedFile = null;
if (selectedExample.dualAction === "signer1") {
transaction = lowercaseTransactionAssets(
JSON.parse(elements.transactionJson.value)
);
elements.transactionJson.value = JSON.stringify(transaction, null, 2);
} else {
txid = requiredDualTransactionHash();
status.textContent = "Loading the saved transaction JSON";
savedFile = await dualStoreRequest({ action: "load", txid });
if (savedFile.record.kind !== selectedExample.kind) {
throw new Error(`The saved transaction is not a ${selectedExample.kind}.`);
}
transaction = savedFile.record.transaction;
elements.transactionJson.value = JSON.stringify(transaction, null, 2);
elements.transactionCode.textContent = applicationCode(transaction);
}
if (selectedExample.dualAction === "broadcast") {
if (!savedFile.record.signer1 || !savedFile.record.signer2) {
throw new Error("Both signatures must be saved before this transaction can be broadcast.");
}
if (!API_IDENTITY.address || !API_IDENTITY.publicKey || !API_IDENTITY.signature) {
throw new Error("Configure API_IDENTITY before testing a dual-signature broadcast.");
}
status.textContent = "Verifying both signatures and building the completed transaction";
const complete = await wallet.completeDualTransaction({
transaction,
signer1: savedFile.record.signer1,
signer2: savedFile.record.signer2
});
const broadcast = await wallet.broadcastTransaction({
bytes: complete.bytes_hex,
api: API_IDENTITY
});
status.textContent = "Both signatures verified and transaction broadcast";
status.className = "pass";
document.querySelector("#transaction-txid").textContent = complete.hash;
document.querySelector("#transaction-bytes").textContent = complete.bytes_hex;
document.querySelector("#transaction-byte-count").textContent =
`${complete.bytes_hex.length / 2} bytes`;
document.querySelector("#transaction-broadcast").textContent =
JSON.stringify(broadcast, null, 2);
return;
}
const activeAddress = wallet.session()?.address;
if (!activeAddress) throw new Error("Connect the wallet before signing.");
const unsigned = transaction.unsigned_swap
?? transaction.unsigned_loan_contract
?? transaction;
const expectedAddress = selectedExample.kind === "swap"
? unsigned[`sender${selectedExample.signerSlot}`]
: selectedExample.signerSlot === 1 ? unsigned.lender : unsigned.borrower;
if (String(expectedAddress).toLowerCase() !== activeAddress.toLowerCase()) {
throw new Error(
`The connected wallet is not ${selectedExample.label.toLowerCase()} for this transaction.`
);
}
status.textContent = "Checking available balances";
await assertTransactionFunds(wallet, transaction, activeAddress);
status.textContent = "Balance check passed; waiting for wallet approval";
const result = await wallet.signDualTransaction({
transaction,
signerSlot: selectedExample.signerSlot
});
if (!/^[0-9a-f]{64}$/i.test(String(result.txid ?? ""))) {
throw new Error("The wallet did not return a valid transaction ID.");
}
savedFile = await dualStoreRequest({
action: "save_signature",
txid: result.txid,
kind: selectedExample.kind,
signer_slot: selectedExample.signerSlot,
transaction,
signature: result
});
showDualSignatureResult(status, result, savedFile);
elements.transactionCode.textContent = applicationCode(transaction);
console.log(result);
console.log(savedFile.file, savedFile.record);
return;
}
const transaction = lowercaseTransactionAssets(
JSON.parse(elements.transactionJson.value)
);
elements.transactionJson.value = JSON.stringify(transaction, null, 2);
status.textContent = "Checking available balances";
const activeAddress = wallet.session()?.address;
if (!activeAddress) throw new Error("Connect the wallet before testing a transaction.");
await assertTransactionFunds(wallet, transaction, activeAddress);
status.textContent = "Balance check passed; waiting for wallet approval";
const result = await wallet.sendTransaction({ transaction });
if (!/^[0-9a-f]{64}$/i.test(String(result.txid ?? ""))) {
throw new Error("The wallet did not return a valid transaction ID.");
}
if (
typeof result.bytes !== "string"
|| !/^[0-9a-f]+$/i.test(result.bytes)
|| result.bytes.length % 2 !== 0
) {
throw new Error("The wallet did not return valid signed transaction bytes.");
}
status.textContent = "Signed and broadcast";
status.className = "pass";
document.querySelector("#transaction-txid").textContent = result.txid;
document.querySelector("#transaction-bytes").textContent = result.bytes;
document.querySelector("#transaction-byte-count").textContent =
`${result.bytes.length / 2} bytes`;
document.querySelector("#transaction-broadcast").textContent =
JSON.stringify(result.broadcast ?? null, null, 2);
} catch (error) {
elements.transactionResult.hidden = false;
status.textContent =
error instanceof Error ? error.message : String(error);
status.className = "fail";
}
});
async function detectWallet(showCode = true) {
if (showCode) showConnectionCode("detect");
const installed = await waitForContractlessWallet();
setStatus(
elements.detected,
installed ? "Installed" : "Not installed",
installed
);
return installed;
}
document.querySelector("#detect").addEventListener("click", async () => {
await detectWallet();
});
document.querySelector("#copy-connection-code").addEventListener("click", async (event) => {
const button = event.currentTarget;
try {
await navigator.clipboard.writeText(elements.connectionCode.textContent);
button.textContent = "Code copied";
} catch (error) {
button.textContent = error instanceof Error ? error.message : String(error);
}
});
document.querySelector("#connect").addEventListener("click", async () => {
showConnectionCode("connect");
try {
if (!await detectWallet(false)) return;
const connection = await wallet.connect({
message: "Connect to the Contractless wallet integration test"
});
showSession(connection.session);
selectTransactionExample(selectedExample);
setStatus(elements.addressStatus, "Returned", true);
} catch (error) {
setStatus(elements.connected, "Connection failed", false);
}
});
elements.accounts.addEventListener("click", async () => {
showConnectionCode("accounts");
try {
const accounts = await wallet.accounts();
if (accounts.length === 0) {
throw new Error("The wallet returned no active addresses.");
}
elements.address.textContent = accounts[0];
setStatus(elements.addressStatus, "Verified", true);
} catch (error) {
setStatus(elements.addressStatus, "Failed", false);
showSession(wallet.session());
}
});
elements.disconnect.addEventListener("click", async () => {
showConnectionCode("disconnect");
try {
await wallet.disconnect();
showSession(null);
setStatus(elements.addressStatus, "Not tested", null);
} catch (error) {
setStatus(elements.connected, "Disconnect failed", false);
}
});
window.addEventListener("contractless:sessionExpired", () => {
showSession(null);
setStatus(elements.addressStatus, "Session expired", false);
});
showSession(wallet.session());
renderTransactionTypes();
showConnectionCode("detect");
await detectWallet(false);
</script>
</body>
</html>