diff --git a/README.md b/README.md index 9a917fc..d4aaeef 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,14 @@ Launcher never contains private kernel internals. It verifies and installs signe 3. Member channel register/poll/ack/support 4. Governance install token/confirm/status 5. Lease heartbeat + offline renew +6. Injected wallet automation for intent signing and membership tx send + +Wallet automation shortcuts in the shell: + +1. `Connect wallet` fills address from `window.ethereum`. +2. `Sign intent (EIP-712)` signs the current intent payload and fills `walletSignature`. +3. `Sign payer proof` signs distinct-payer ownership proof and fills `payerProof`. +4. `Send membership tx` submits the quote transaction via `eth_sendTransaction` and fills `confirmTxHash`. Run locally: diff --git a/app/app.js b/app/app.js index 73536a0..d321538 100644 --- a/app/app.js +++ b/app/app.js @@ -4,6 +4,8 @@ const $ = (id) => document.getElementById(id); const state = { eventMap: new Map(), + lastIntent: null, + lastQuote: null, }; function nowISO() { @@ -22,6 +24,28 @@ function chainID() { return Number($("chainId").value || 0); } +function normalizedAddress(value) { + return String(value || "").trim().toLowerCase(); +} + +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`; @@ -121,6 +145,154 @@ function renderEvents(events) { } } +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 provider = await requireProvider(); + const accounts = await provider.request({ method: "eth_requestAccounts" }); + if (!accounts || accounts.length === 0) { + throw new Error("wallet provider returned no accounts"); + } + $("walletAddress").value = normalizedAddress(accounts[0]); + 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, + }); + } +} + +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(), @@ -129,6 +301,7 @@ async function onIntent() { 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 || ""; @@ -178,6 +351,7 @@ async function onQuote() { 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 || ""; @@ -325,10 +499,14 @@ function bind(id, handler) { }); } +bind("btnConnectWallet", onConnectWallet); 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); diff --git a/app/index.html b/app/index.html index 8bf4baf..55e204a 100644 --- a/app/index.html +++ b/app/index.html @@ -29,6 +29,13 @@

Wallet Intent

+
+ + + + + +
-
- - -
-
- -

Membership Quote + Confirm

+
+ + + + +
-
- -
-
- -