1653 lines
59 KiB
HTML
1653 lines
59 KiB
HTML
<!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>
|