972 lines
29 KiB
JavaScript
972 lines
29 KiB
JavaScript
/* global fetch */
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
const state = {
|
|
eventMap: new Map(),
|
|
lastIntent: null,
|
|
lastQuote: null,
|
|
lastCheckoutQuote: null,
|
|
lastStatus: null,
|
|
channelReady: false,
|
|
walletSessionToken: "",
|
|
walletSessionExpiresAt: "",
|
|
};
|
|
|
|
function nowISO() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function baseURL() {
|
|
return $("apiBase").value.trim().replace(/\/+$/, "");
|
|
}
|
|
|
|
function wallet() {
|
|
return $("walletAddress").value.trim();
|
|
}
|
|
|
|
function chainID() {
|
|
return Number($("chainId").value || 0);
|
|
}
|
|
|
|
function normalizedAddress(value) {
|
|
return String(value || "").trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeAssurance(value) {
|
|
return String(value || "none").trim().toLowerCase() || "none";
|
|
}
|
|
|
|
function assuranceDisplay(value) {
|
|
const assurance = normalizeAssurance(value);
|
|
if (assurance === "onramp_attested") {
|
|
return "onramp_attested";
|
|
}
|
|
if (assurance === "crypto_direct_unattested") {
|
|
return "crypto_direct_unattested";
|
|
}
|
|
if (assurance === "sponsored_unattested") {
|
|
return "sponsored_unattested";
|
|
}
|
|
return "none";
|
|
}
|
|
|
|
function isOnrampAttested(value) {
|
|
return normalizeAssurance(value) === "onramp_attested";
|
|
}
|
|
|
|
function sessionSummary() {
|
|
if (!state.walletSessionToken) {
|
|
return "none";
|
|
}
|
|
if (!state.walletSessionExpiresAt) {
|
|
return "active";
|
|
}
|
|
return `active (exp ${state.walletSessionExpiresAt})`;
|
|
}
|
|
|
|
function injectedProvider() {
|
|
return globalThis.ethereum || null;
|
|
}
|
|
|
|
async function requireProvider() {
|
|
const provider = injectedProvider();
|
|
if (!provider) {
|
|
throw new Error("no injected wallet provider found (expected window.ethereum)");
|
|
}
|
|
return provider;
|
|
}
|
|
|
|
function utf8ToHex(value) {
|
|
return `0x${Array.from(new TextEncoder().encode(String(value)))
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("")}`;
|
|
}
|
|
|
|
function logLine(label, payload) {
|
|
const log = $("log");
|
|
const line = `[${nowISO()}] ${label}\n${JSON.stringify(payload, null, 2)}\n\n`;
|
|
log.textContent = line + log.textContent;
|
|
}
|
|
|
|
function setSummary(id, value) {
|
|
const el = $(id);
|
|
if (!el) return;
|
|
el.textContent = value;
|
|
}
|
|
|
|
function setButtonDisabled(id, disabled) {
|
|
const el = $(id);
|
|
if (!el) return;
|
|
el.disabled = Boolean(disabled);
|
|
}
|
|
|
|
function refreshActionLocks(statusPayload) {
|
|
const effective = statusPayload && typeof statusPayload === "object" ? statusPayload : state.lastStatus;
|
|
const membershipActive = String(effective?.status || "").toLowerCase() === "active";
|
|
const attested = isOnrampAttested(effective?.identity_assurance_level);
|
|
const ownerActionReady = membershipActive && attested;
|
|
|
|
setButtonDisabled("btnSupportTicket", !ownerActionReady);
|
|
setButtonDisabled("btnInstallToken", !ownerActionReady);
|
|
setButtonDisabled("btnQuickInstallStatus", !membershipActive);
|
|
}
|
|
|
|
function refreshOverview(statusPayload) {
|
|
const currentWallet = wallet();
|
|
setSummary("summaryWallet", currentWallet || "not connected");
|
|
setSummary("summarySession", sessionSummary());
|
|
if (statusPayload && typeof statusPayload === "object") {
|
|
setSummary("summaryMembership", statusPayload.status || "unknown");
|
|
setSummary("summaryDesignation", statusPayload.designation_code || "-");
|
|
const assurance = assuranceDisplay(statusPayload.identity_assurance_level);
|
|
setSummary("summaryAssurance", assurance);
|
|
setSummary("summaryAdminPolicy", isOnrampAttested(assurance) ? "ready" : "blocked");
|
|
setSummary("summaryLastSync", nowISO());
|
|
refreshActionLocks(statusPayload);
|
|
return;
|
|
}
|
|
const designation = $("designationCode")?.value?.trim() || "-";
|
|
setSummary("summaryDesignation", designation);
|
|
const assurance = assuranceDisplay(state.lastStatus?.identity_assurance_level);
|
|
setSummary("summaryAssurance", assurance);
|
|
setSummary("summaryAdminPolicy", isOnrampAttested(assurance) ? "ready" : "blocked");
|
|
refreshActionLocks();
|
|
}
|
|
|
|
function setFlowStatus(message) {
|
|
const el = $("flowStatus");
|
|
if (el) {
|
|
el.textContent = message;
|
|
}
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function request(method, path, body) {
|
|
const headers = { "Content-Type": "application/json" };
|
|
if (state.walletSessionToken) {
|
|
headers["X-Edut-Session"] = state.walletSessionToken;
|
|
headers.Authorization = `Bearer ${state.walletSessionToken}`;
|
|
}
|
|
const opts = {
|
|
method,
|
|
headers,
|
|
};
|
|
if (body !== undefined) {
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
const res = await fetch(`${baseURL()}${path}`, opts);
|
|
const text = await res.text();
|
|
let json = {};
|
|
if (text.trim() !== "") {
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
json = { raw: text };
|
|
}
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(`${res.status} ${res.statusText}: ${JSON.stringify(json)}`);
|
|
}
|
|
return json;
|
|
}
|
|
|
|
function requireWallet() {
|
|
const value = wallet();
|
|
if (!value) {
|
|
throw new Error("wallet is required");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function deviceID() {
|
|
return $("deviceId").value.trim();
|
|
}
|
|
|
|
function orgRootID() {
|
|
return $("orgRootId").value.trim();
|
|
}
|
|
|
|
function principalID() {
|
|
return $("principalId").value.trim();
|
|
}
|
|
|
|
function principalRole() {
|
|
return $("principalRole").value.trim();
|
|
}
|
|
|
|
function operationMode() {
|
|
return $("operationMode").value.trim() || "human_manual";
|
|
}
|
|
|
|
function renderEvents(events) {
|
|
const list = $("eventList");
|
|
list.innerHTML = "";
|
|
for (const evt of events) {
|
|
state.eventMap.set(evt.event_id, evt);
|
|
const card = document.createElement("article");
|
|
card.className = "event";
|
|
|
|
const title = document.createElement("h3");
|
|
title.textContent = `${evt.class} · ${evt.event_id}`;
|
|
card.appendChild(title);
|
|
|
|
const body = document.createElement("p");
|
|
body.textContent = evt.body || evt.title || "";
|
|
card.appendChild(body);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "meta";
|
|
meta.textContent = `${evt.created_at} · scope=${evt.visibility_scope} · ack=${evt.requires_ack}`;
|
|
card.appendChild(meta);
|
|
|
|
if (evt.requires_ack) {
|
|
const actions = document.createElement("div");
|
|
actions.className = "actions";
|
|
const button = document.createElement("button");
|
|
button.textContent = "Ack";
|
|
button.addEventListener("click", async () => {
|
|
try {
|
|
const out = await request("POST", `/member/channel/events/${evt.event_id}/ack`, {
|
|
wallet: requireWallet(),
|
|
device_id: deviceID(),
|
|
acknowledged_at: nowISO(),
|
|
});
|
|
logLine(`event ack ${evt.event_id}`, out);
|
|
} catch (err) {
|
|
logLine(`event ack ${evt.event_id} error`, { error: String(err) });
|
|
}
|
|
});
|
|
actions.appendChild(button);
|
|
card.appendChild(actions);
|
|
}
|
|
list.appendChild(card);
|
|
}
|
|
}
|
|
|
|
function buildIntentTypedData(intent, origin) {
|
|
return {
|
|
types: {
|
|
EIP712Domain: [
|
|
{ name: "name", type: "string" },
|
|
{ name: "version", type: "string" },
|
|
{ name: "chainId", type: "uint256" },
|
|
{ name: "verifyingContract", type: "address" },
|
|
],
|
|
DesignationIntent: [
|
|
{ name: "designationCode", type: "string" },
|
|
{ name: "designationToken", type: "string" },
|
|
{ name: "nonce", type: "string" },
|
|
{ name: "issuedAt", type: "string" },
|
|
{ name: "origin", type: "string" },
|
|
],
|
|
},
|
|
primaryType: "DesignationIntent",
|
|
domain: {
|
|
name: intent.domain_name,
|
|
version: "1",
|
|
chainId: Number(intent.chain_id),
|
|
verifyingContract: intent.verifying_contract,
|
|
},
|
|
message: {
|
|
designationCode: intent.designation_code,
|
|
designationToken: intent.display_token,
|
|
nonce: intent.nonce,
|
|
issuedAt: intent.issued_at,
|
|
origin,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function onConnectWallet() {
|
|
const previousWallet = normalizedAddress($("walletAddress").value);
|
|
const provider = await requireProvider();
|
|
const accounts = await provider.request({ method: "eth_requestAccounts" });
|
|
if (!accounts || accounts.length === 0) {
|
|
throw new Error("wallet provider returned no accounts");
|
|
}
|
|
const nextWallet = normalizedAddress(accounts[0]);
|
|
$("walletAddress").value = nextWallet;
|
|
if (previousWallet && previousWallet !== nextWallet) {
|
|
state.walletSessionToken = "";
|
|
state.walletSessionExpiresAt = "";
|
|
logLine("wallet session reset", {
|
|
previous_wallet: previousWallet,
|
|
next_wallet: nextWallet,
|
|
});
|
|
}
|
|
const chainHex = await provider.request({ method: "eth_chainId" });
|
|
const providerChainID = Number.parseInt(chainHex, 16);
|
|
if (Number.isFinite(providerChainID) && providerChainID !== chainID()) {
|
|
logLine("wallet chain mismatch", {
|
|
configured_chain_id: chainID(),
|
|
provider_chain_id: providerChainID,
|
|
provider_chain_hex: chainHex,
|
|
});
|
|
} else {
|
|
logLine("wallet connected", {
|
|
wallet: $("walletAddress").value,
|
|
provider_chain_id: providerChainID,
|
|
});
|
|
}
|
|
refreshOverview();
|
|
}
|
|
|
|
async function onSignIntent() {
|
|
if (!state.lastIntent) {
|
|
throw new Error("create intent before signing");
|
|
}
|
|
const provider = await requireProvider();
|
|
const owner = requireWallet();
|
|
const typedData = buildIntentTypedData(state.lastIntent, $("walletOrigin").value.trim() || "https://edut.ai");
|
|
const typedDataJSON = JSON.stringify(typedData);
|
|
let signature = "";
|
|
try {
|
|
signature = await provider.request({
|
|
method: "eth_signTypedData_v4",
|
|
params: [owner, typedDataJSON],
|
|
});
|
|
} catch (primaryErr) {
|
|
signature = await provider.request({
|
|
method: "eth_signTypedData",
|
|
params: [owner, typedData],
|
|
});
|
|
logLine("wallet sign intent fallback", { warning: String(primaryErr) });
|
|
}
|
|
$("walletSignature").value = signature;
|
|
logLine("wallet sign intent", {
|
|
wallet: owner,
|
|
designation_code: state.lastIntent.designation_code,
|
|
signature,
|
|
});
|
|
}
|
|
|
|
async function onSignPayerProof() {
|
|
const provider = await requireProvider();
|
|
const owner = requireWallet();
|
|
const payer = normalizedAddress($("payerWallet").value);
|
|
if (!payer) {
|
|
throw new Error("payer wallet is required to sign payer proof");
|
|
}
|
|
const designationCode = $("designationCode").value.trim();
|
|
if (!designationCode) {
|
|
throw new Error("designation code is required");
|
|
}
|
|
const message = `EDUT-PAYER-AUTH:${designationCode}:${normalizedAddress(owner)}:${payer}:${chainID()}`;
|
|
const messageHex = utf8ToHex(message);
|
|
let signature = "";
|
|
try {
|
|
signature = await provider.request({
|
|
method: "personal_sign",
|
|
params: [messageHex, owner],
|
|
});
|
|
} catch (primaryErr) {
|
|
signature = await provider.request({
|
|
method: "personal_sign",
|
|
params: [owner, messageHex],
|
|
});
|
|
logLine("wallet payer proof fallback", { warning: String(primaryErr) });
|
|
}
|
|
$("payerProof").value = signature;
|
|
logLine("wallet signed payer proof", {
|
|
wallet: owner,
|
|
payer_wallet: payer,
|
|
designation_code: designationCode,
|
|
signature,
|
|
});
|
|
}
|
|
|
|
async function onSendMembershipTx() {
|
|
if (!state.lastQuote || !state.lastQuote.tx) {
|
|
throw new Error("request quote before sending transaction");
|
|
}
|
|
const provider = await requireProvider();
|
|
const from = normalizedAddress(state.lastQuote.tx.from || requireWallet());
|
|
if (from !== normalizedAddress(requireWallet())) {
|
|
throw new Error(`active wallet ${requireWallet()} does not match quote payer ${from}`);
|
|
}
|
|
const txRequest = {
|
|
from,
|
|
to: state.lastQuote.tx.to,
|
|
data: state.lastQuote.tx.data,
|
|
value: state.lastQuote.tx.value || "0x0",
|
|
};
|
|
const txHash = await provider.request({
|
|
method: "eth_sendTransaction",
|
|
params: [txRequest],
|
|
});
|
|
$("confirmTxHash").value = txHash;
|
|
logLine("membership tx sent", {
|
|
quote_id: state.lastQuote.quote_id,
|
|
tx_hash: txHash,
|
|
payer_wallet: from,
|
|
});
|
|
}
|
|
|
|
async function onIntent() {
|
|
const payload = {
|
|
address: requireWallet(),
|
|
origin: $("walletOrigin").value.trim() || "https://edut.ai",
|
|
locale: $("walletLocale").value.trim() || "en",
|
|
chain_id: chainID(),
|
|
};
|
|
const out = await request("POST", "/secret/wallet/intent", payload);
|
|
state.lastIntent = out;
|
|
$("intentId").value = out.intent_id || "";
|
|
$("designationCode").value = out.designation_code || "";
|
|
$("displayToken").value = out.display_token || "";
|
|
logLine("wallet intent", out);
|
|
refreshOverview();
|
|
}
|
|
|
|
async function onVerify() {
|
|
const out = await request("POST", "/secret/wallet/verify", {
|
|
intent_id: $("intentId").value.trim(),
|
|
address: requireWallet(),
|
|
chain_id: chainID(),
|
|
signature: $("walletSignature").value.trim(),
|
|
});
|
|
if (out.designation_code) {
|
|
$("designationCode").value = out.designation_code;
|
|
}
|
|
state.walletSessionToken = String(out.session_token || "").trim();
|
|
state.walletSessionExpiresAt = String(out.session_expires_at || "").trim();
|
|
logLine("wallet verify", out);
|
|
if (state.walletSessionToken) {
|
|
logLine("wallet session active", {
|
|
wallet: requireWallet(),
|
|
session_expires_at: state.walletSessionExpiresAt || "unknown",
|
|
});
|
|
}
|
|
refreshOverview();
|
|
}
|
|
|
|
async function onStatus() {
|
|
const out = await request(
|
|
"GET",
|
|
`/secret/membership/status?wallet=${encodeURIComponent(requireWallet())}`,
|
|
);
|
|
state.lastStatus = out;
|
|
if (out.designation_code) {
|
|
$("designationCode").value = out.designation_code;
|
|
}
|
|
logLine("membership status", out);
|
|
refreshOverview(out);
|
|
return out;
|
|
}
|
|
|
|
async function onQuote() {
|
|
const payload = {
|
|
designation_code: $("designationCode").value.trim(),
|
|
address: requireWallet(),
|
|
chain_id: chainID(),
|
|
};
|
|
const payerWallet = $("payerWallet").value.trim();
|
|
const payerProof = $("payerProof").value.trim();
|
|
const sponsorOrgRoot = $("sponsorOrgRoot").value.trim();
|
|
if (payerWallet) {
|
|
payload.payer_wallet = payerWallet;
|
|
}
|
|
if (payerProof) {
|
|
payload.payer_proof = payerProof;
|
|
}
|
|
if (sponsorOrgRoot) {
|
|
payload.sponsor_org_root_id = sponsorOrgRoot;
|
|
}
|
|
const out = await request("POST", "/secret/membership/quote", payload);
|
|
state.lastQuote = out;
|
|
$("quoteId").value = out.quote_id || "";
|
|
$("quoteValue").value = out.value || "";
|
|
$("quotePayer").value = out.payer_wallet || "";
|
|
logLine("membership quote", out);
|
|
return out;
|
|
}
|
|
|
|
async function onConfirmMembership() {
|
|
const payload = {
|
|
designation_code: $("designationCode").value.trim(),
|
|
quote_id: $("quoteId").value.trim(),
|
|
tx_hash: $("confirmTxHash").value.trim(),
|
|
address: requireWallet(),
|
|
chain_id: chainID(),
|
|
};
|
|
const assurance = $("membershipIdentityAssurance").value.trim();
|
|
const attestedBy = $("membershipIdentityAttestedBy").value.trim();
|
|
const attestationID = $("membershipIdentityAttestationId").value.trim();
|
|
if (assurance) {
|
|
payload.identity_assurance_level = assurance;
|
|
}
|
|
if (attestedBy) {
|
|
payload.identity_attested_by = attestedBy;
|
|
}
|
|
if (attestationID) {
|
|
payload.identity_attestation_id = attestationID;
|
|
}
|
|
const out = await request("POST", "/secret/membership/confirm", payload);
|
|
state.lastStatus = {
|
|
...(state.lastStatus || {}),
|
|
status: "active",
|
|
wallet: requireWallet(),
|
|
designation_code: out.designation_code || $("designationCode").value.trim(),
|
|
identity_assurance_level: out.identity_assurance_level || state.lastStatus?.identity_assurance_level || "none",
|
|
identity_attested_by: out.identity_attested_by || "",
|
|
identity_attestation_id: out.identity_attestation_id || "",
|
|
};
|
|
logLine("membership confirm", out);
|
|
return out;
|
|
}
|
|
|
|
async function requireMembershipState(actionLabel, opts = {}) {
|
|
const status = await onStatus();
|
|
if (String(status.status || "").toLowerCase() !== "active") {
|
|
throw new Error(`${actionLabel} requires active membership`);
|
|
}
|
|
if (opts.requireOnramp && !isOnrampAttested(status.identity_assurance_level)) {
|
|
throw new Error(`${actionLabel} requires onramp_attested identity assurance`);
|
|
}
|
|
return status;
|
|
}
|
|
|
|
async function waitForTxMined(txHash, timeoutMs = 120000, intervalMs = 3000) {
|
|
const provider = await requireProvider();
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
const receipt = await provider.request({
|
|
method: "eth_getTransactionReceipt",
|
|
params: [txHash],
|
|
});
|
|
if (receipt) {
|
|
const statusHex = String(receipt.status || "").toLowerCase();
|
|
if (statusHex === "0x1" || statusHex === "1") {
|
|
return receipt;
|
|
}
|
|
throw new Error(`transaction reverted: status=${receipt.status}`);
|
|
}
|
|
await sleep(intervalMs);
|
|
}
|
|
throw new Error(`transaction not mined within ${timeoutMs}ms`);
|
|
}
|
|
|
|
async function confirmMembershipWithRetry(maxAttempts = 8, intervalMs = 2500) {
|
|
let lastErr = null;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
try {
|
|
const out = await onConfirmMembership();
|
|
return out;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
const message = String(err || "");
|
|
if (!message.includes("tx verification pending/failed")) {
|
|
throw err;
|
|
}
|
|
setFlowStatus(`confirm pending (${attempt}/${maxAttempts})`);
|
|
await sleep(intervalMs);
|
|
}
|
|
}
|
|
throw lastErr || new Error("membership confirm failed");
|
|
}
|
|
|
|
async function onRunMembershipFlow() {
|
|
setFlowStatus("connecting wallet");
|
|
await onConnectWallet();
|
|
|
|
setFlowStatus("checking membership");
|
|
const status = await onStatus();
|
|
if (String(status.status || "").toLowerCase() === "active") {
|
|
setFlowStatus("membership already active");
|
|
return;
|
|
}
|
|
|
|
setFlowStatus("creating intent");
|
|
await onIntent();
|
|
setFlowStatus("signing intent");
|
|
await onSignIntent();
|
|
setFlowStatus("verifying intent");
|
|
await onVerify();
|
|
setFlowStatus("quoting membership");
|
|
await onQuote();
|
|
setFlowStatus("sending membership transaction");
|
|
await onSendMembershipTx();
|
|
|
|
const txHash = $("confirmTxHash").value.trim();
|
|
if (!txHash) {
|
|
throw new Error("missing tx hash after send");
|
|
}
|
|
setFlowStatus("waiting for chain confirmation");
|
|
await waitForTxMined(txHash);
|
|
setFlowStatus("confirming membership with API");
|
|
await confirmMembershipWithRetry();
|
|
setFlowStatus("refreshing status");
|
|
const refreshed = await onStatus();
|
|
if (isOnrampAttested(refreshed.identity_assurance_level)) {
|
|
setFlowStatus("membership flow complete (attested)");
|
|
} else {
|
|
setFlowStatus("membership active (unattested)");
|
|
}
|
|
}
|
|
|
|
async function onRegisterChannel() {
|
|
const out = await request("POST", "/member/channel/device/register", {
|
|
wallet: requireWallet(),
|
|
chain_id: chainID(),
|
|
device_id: deviceID(),
|
|
platform: $("platform").value.trim(),
|
|
org_root_id: orgRootID(),
|
|
principal_id: principalID(),
|
|
principal_role: principalRole(),
|
|
app_version: $("appVersion").value.trim(),
|
|
push_provider: "none",
|
|
});
|
|
state.channelReady = true;
|
|
logLine("channel register", out);
|
|
return out;
|
|
}
|
|
|
|
async function onUnregisterChannel() {
|
|
const out = await request("POST", "/member/channel/device/unregister", {
|
|
wallet: requireWallet(),
|
|
device_id: deviceID(),
|
|
});
|
|
state.channelReady = false;
|
|
logLine("channel unregister", out);
|
|
return out;
|
|
}
|
|
|
|
async function onPollEvents() {
|
|
const cursor = $("eventCursor").value.trim();
|
|
const limit = $("eventLimit").value.trim() || "25";
|
|
const query = new URLSearchParams({
|
|
wallet: requireWallet(),
|
|
device_id: deviceID(),
|
|
limit,
|
|
});
|
|
if (cursor) {
|
|
query.set("cursor", cursor);
|
|
}
|
|
const out = await request("GET", `/member/channel/events?${query.toString()}`);
|
|
if (out.next_cursor) {
|
|
$("eventCursor").value = out.next_cursor;
|
|
}
|
|
renderEvents(out.events || []);
|
|
logLine("channel poll", out);
|
|
return out;
|
|
}
|
|
|
|
async function onSupportTicket() {
|
|
await requireMembershipState("owner support", { requireOnramp: true });
|
|
const out = await request("POST", "/member/channel/support/ticket", {
|
|
wallet: requireWallet(),
|
|
org_root_id: orgRootID(),
|
|
principal_id: principalID(),
|
|
category: "admin_support",
|
|
summary: $("supportSummary").value.trim(),
|
|
context: {
|
|
source: "launcher-shell",
|
|
requested_at: nowISO(),
|
|
},
|
|
});
|
|
logLine("support ticket", out);
|
|
}
|
|
|
|
async function onInstallToken() {
|
|
await requireMembershipState("governance install token", { requireOnramp: true });
|
|
const out = await request("POST", "/governance/install/token", {
|
|
wallet: requireWallet(),
|
|
org_root_id: orgRootID(),
|
|
principal_id: principalID(),
|
|
principal_role: principalRole(),
|
|
device_id: deviceID(),
|
|
launcher_version: $("appVersion").value.trim(),
|
|
platform: $("platform").value.trim(),
|
|
});
|
|
$("installToken").value = out.install_token || "";
|
|
$("entitlementId").value = out.entitlement_id || "";
|
|
$("runtimeVersion").value = out.package?.runtime_version || "";
|
|
$("packageHash").value = out.package?.package_hash || "";
|
|
logLine("install token", out);
|
|
}
|
|
|
|
async function onInstallConfirm() {
|
|
const out = await request("POST", "/governance/install/confirm", {
|
|
install_token: $("installToken").value.trim(),
|
|
wallet: requireWallet(),
|
|
device_id: deviceID(),
|
|
entitlement_id: $("entitlementId").value.trim(),
|
|
package_hash: $("packageHash").value.trim(),
|
|
runtime_version: $("runtimeVersion").value.trim(),
|
|
operation_mode: operationMode(),
|
|
installed_at: nowISO(),
|
|
launcher_receipt_hash: `receipt-${Date.now()}`,
|
|
});
|
|
logLine("install confirm", out);
|
|
}
|
|
|
|
async function onInstallStatus() {
|
|
const query = new URLSearchParams({
|
|
wallet: requireWallet(),
|
|
device_id: deviceID(),
|
|
});
|
|
const out = await request("GET", `/governance/install/status?${query.toString()}`);
|
|
logLine("install status", out);
|
|
return out;
|
|
}
|
|
|
|
async function onListOffers() {
|
|
const out = await request("GET", "/marketplace/offers");
|
|
logLine("marketplace offers", out);
|
|
return out;
|
|
}
|
|
|
|
async function onCheckoutQuote() {
|
|
const payload = {
|
|
wallet: requireWallet(),
|
|
offer_id: $("checkoutOfferId").value.trim(),
|
|
org_root_id: orgRootID(),
|
|
principal_id: principalID(),
|
|
principal_role: principalRole(),
|
|
include_membership_if_missing: false,
|
|
};
|
|
const payerWallet = $("payerWallet").value.trim();
|
|
const payerProof = $("payerProof").value.trim();
|
|
if (payerWallet) {
|
|
payload.payer_wallet = payerWallet;
|
|
}
|
|
if (payerProof) {
|
|
payload.ownership_proof = payerProof;
|
|
}
|
|
const out = await request("POST", "/marketplace/checkout/quote", payload);
|
|
state.lastCheckoutQuote = out;
|
|
$("checkoutQuoteId").value = out.quote_id || "";
|
|
$("checkoutTotal").value = out.total_amount || "";
|
|
logLine("marketplace checkout quote", out);
|
|
return out;
|
|
}
|
|
|
|
async function onSendCheckoutTx() {
|
|
if (!state.lastCheckoutQuote || !state.lastCheckoutQuote.tx) {
|
|
throw new Error("request checkout quote before sending transaction");
|
|
}
|
|
const provider = await requireProvider();
|
|
const from = normalizedAddress(state.lastCheckoutQuote.tx.from || requireWallet());
|
|
if (from !== normalizedAddress(requireWallet())) {
|
|
throw new Error(`active wallet ${requireWallet()} does not match checkout payer ${from}`);
|
|
}
|
|
const txRequest = {
|
|
from,
|
|
to: state.lastCheckoutQuote.tx.to,
|
|
data: state.lastCheckoutQuote.tx.data,
|
|
value: state.lastCheckoutQuote.tx.value || "0x0",
|
|
};
|
|
const txHash = await provider.request({
|
|
method: "eth_sendTransaction",
|
|
params: [txRequest],
|
|
});
|
|
$("checkoutTxHash").value = txHash;
|
|
logLine("marketplace tx sent", {
|
|
quote_id: state.lastCheckoutQuote.quote_id,
|
|
tx_hash: txHash,
|
|
payer_wallet: from,
|
|
});
|
|
}
|
|
|
|
async function onCheckoutConfirm() {
|
|
const quoteID = $("checkoutQuoteId").value.trim();
|
|
if (!quoteID) {
|
|
throw new Error("checkout quote id is required");
|
|
}
|
|
const txHash = $("checkoutTxHash").value.trim();
|
|
if (!txHash) {
|
|
throw new Error("checkout tx hash is required");
|
|
}
|
|
const offerID = state.lastCheckoutQuote?.offer_id || $("checkoutOfferId").value.trim();
|
|
const out = await request("POST", "/marketplace/checkout/confirm", {
|
|
quote_id: quoteID,
|
|
wallet: requireWallet(),
|
|
offer_id: offerID,
|
|
org_root_id: orgRootID(),
|
|
principal_id: principalID(),
|
|
principal_role: principalRole(),
|
|
tx_hash: txHash,
|
|
chain_id: chainID(),
|
|
});
|
|
$("checkoutEntitlementId").value = out.entitlement_id || "";
|
|
logLine("marketplace checkout confirm", out);
|
|
return out;
|
|
}
|
|
|
|
async function confirmCheckoutWithRetry(maxAttempts = 8, intervalMs = 2500) {
|
|
let lastErr = null;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
try {
|
|
const out = await onCheckoutConfirm();
|
|
return out;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
const message = String(err || "");
|
|
if (!message.includes("tx verification pending/failed") && !message.includes("membership verification pending/failed")) {
|
|
throw err;
|
|
}
|
|
setFlowStatus(`checkout confirm pending (${attempt}/${maxAttempts})`);
|
|
await sleep(intervalMs);
|
|
}
|
|
}
|
|
throw lastErr || new Error("checkout confirm failed");
|
|
}
|
|
|
|
async function onRunCheckoutFlow() {
|
|
setFlowStatus("quoting checkout");
|
|
await onCheckoutQuote();
|
|
setFlowStatus("sending checkout transaction");
|
|
await onSendCheckoutTx();
|
|
|
|
const txHash = $("checkoutTxHash").value.trim();
|
|
if (!txHash) {
|
|
throw new Error("missing checkout tx hash after send");
|
|
}
|
|
setFlowStatus("waiting for checkout chain confirmation");
|
|
await waitForTxMined(txHash);
|
|
setFlowStatus("confirming checkout with API");
|
|
await confirmCheckoutWithRetry();
|
|
setFlowStatus("refreshing entitlements");
|
|
await onListEntitlements();
|
|
setFlowStatus("checkout flow complete");
|
|
}
|
|
|
|
async function onListEntitlements() {
|
|
const out = await request("GET", `/marketplace/entitlements?wallet=${encodeURIComponent(requireWallet())}`);
|
|
logLine("marketplace entitlements", out);
|
|
return out;
|
|
}
|
|
|
|
async function ensureChannelBinding() {
|
|
if (state.channelReady) {
|
|
return;
|
|
}
|
|
await onRegisterChannel();
|
|
}
|
|
|
|
async function onQuickConnect() {
|
|
await onConnectWallet();
|
|
try {
|
|
await onStatus();
|
|
} catch (err) {
|
|
logLine("quick connect status warning", { error: String(err) });
|
|
}
|
|
}
|
|
|
|
async function onQuickActivate() {
|
|
await onRunMembershipFlow();
|
|
try {
|
|
await ensureChannelBinding();
|
|
await onPollEvents();
|
|
} catch (err) {
|
|
logLine("quick activate feed warning", { error: String(err) });
|
|
}
|
|
}
|
|
|
|
async function onQuickRefresh() {
|
|
const status = await onStatus();
|
|
if (String(status.status || "").toLowerCase() !== "active") {
|
|
setFlowStatus("membership inactive");
|
|
return;
|
|
}
|
|
try {
|
|
await ensureChannelBinding();
|
|
await onPollEvents();
|
|
if (isOnrampAttested(status.identity_assurance_level)) {
|
|
setFlowStatus("status synced (admin-ready)");
|
|
} else {
|
|
setFlowStatus("status synced (member mode)");
|
|
}
|
|
} catch (err) {
|
|
setFlowStatus("feed sync warning");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function onQuickInstallStatus() {
|
|
const out = await onInstallStatus();
|
|
const assurance = assuranceDisplay(out.identity_assurance_level);
|
|
if (isOnrampAttested(assurance)) {
|
|
setFlowStatus("governance status synced (attested)");
|
|
} else {
|
|
setFlowStatus("governance status synced (attestation required)");
|
|
}
|
|
}
|
|
|
|
async function onLeaseHeartbeat() {
|
|
const out = await request("POST", "/governance/lease/heartbeat", {
|
|
wallet: requireWallet(),
|
|
org_root_id: orgRootID(),
|
|
principal_id: principalID(),
|
|
device_id: deviceID(),
|
|
});
|
|
logLine("lease heartbeat", out);
|
|
}
|
|
|
|
async function onOfflineRenew() {
|
|
const out = await request("POST", "/governance/lease/offline-renew", {
|
|
wallet: requireWallet(),
|
|
org_root_id: orgRootID(),
|
|
principal_id: principalID(),
|
|
renewal_bundle: {
|
|
bundle_id: `renew-${Date.now()}`,
|
|
source: "launcher-shell",
|
|
},
|
|
});
|
|
logLine("offline renew", out);
|
|
}
|
|
|
|
function bind(id, handler) {
|
|
const el = $(id);
|
|
if (!el) {
|
|
return;
|
|
}
|
|
el.addEventListener("click", async () => {
|
|
try {
|
|
await handler();
|
|
} catch (err) {
|
|
logLine(`${id} error`, { error: String(err) });
|
|
}
|
|
});
|
|
}
|
|
|
|
bind("btnQuickConnect", onQuickConnect);
|
|
bind("btnQuickActivate", onQuickActivate);
|
|
bind("btnQuickRefresh", onQuickRefresh);
|
|
bind("btnQuickInstallStatus", onQuickInstallStatus);
|
|
bind("btnConnectWallet", onConnectWallet);
|
|
bind("btnRunMembershipFlow", onRunMembershipFlow);
|
|
bind("btnIntent", onIntent);
|
|
bind("btnSignIntent", onSignIntent);
|
|
bind("btnVerify", onVerify);
|
|
bind("btnStatus", onStatus);
|
|
bind("btnQuote", onQuote);
|
|
bind("btnSignPayerProof", onSignPayerProof);
|
|
bind("btnSendMembershipTx", onSendMembershipTx);
|
|
bind("btnConfirmMembership", onConfirmMembership);
|
|
bind("btnRegisterChannel", onRegisterChannel);
|
|
bind("btnUnregisterChannel", onUnregisterChannel);
|
|
bind("btnPollEvents", onPollEvents);
|
|
bind("btnSupportTicket", onSupportTicket);
|
|
bind("btnInstallToken", onInstallToken);
|
|
bind("btnInstallConfirm", onInstallConfirm);
|
|
bind("btnInstallStatus", onInstallStatus);
|
|
bind("btnLeaseHeartbeat", onLeaseHeartbeat);
|
|
bind("btnOfflineRenew", onOfflineRenew);
|
|
bind("btnListOffers", onListOffers);
|
|
bind("btnCheckoutQuote", onCheckoutQuote);
|
|
bind("btnSendCheckoutTx", onSendCheckoutTx);
|
|
bind("btnCheckoutConfirm", onCheckoutConfirm);
|
|
bind("btnRunCheckoutFlow", onRunCheckoutFlow);
|
|
bind("btnListEntitlements", onListEntitlements);
|
|
|
|
logLine("launcher shell ready", {
|
|
api_base: baseURL(),
|
|
chain_id: chainID(),
|
|
});
|
|
refreshOverview();
|