Add cloud paper accounts

This commit is contained in:
Theodore Song
2026-07-12 17:16:34 -04:00
parent b4a1cd709e
commit a6d11ae1e1
5 changed files with 247 additions and 17 deletions
+2
View File
@@ -12,7 +12,9 @@ LIVE_TRADING_ENABLED=false
AUTH_PROVIDER= AUTH_PROVIDER=
DATABASE_URL= DATABASE_URL=
SESSION_SECRET= SESSION_SECRET=
ACCOUNT_SESSION_SECRET=
ENCRYPTION_KEY= ENCRYPTION_KEY=
PMA_ACCOUNTS_INDEX_PATH=accounts/index.json
# Eligibility / compliance provider # Eligibility / compliance provider
KYC_PROVIDER= KYC_PROVIDER=
+10 -2
View File
@@ -13,6 +13,10 @@ The site fetches live Polymarket markets, generates agent suggestions, lets you
run hourly paper cycles, and syncs the shared arena state through the Vercel API run hourly paper cycles, and syncs the shared arena state through the Vercel API
when `BLOB_READ_WRITE_TOKEN` is configured. when `BLOB_READ_WRITE_TOKEN` is configured.
Paper accounts created with a password are also saved through the backend, so a
user can log in from another device and see the same paper portfolio, activity,
and value history. Passwordless paper accounts remain local-only.
## Put it online (free) so you can reach it from any device ## Put it online (free) so you can reach it from any device
Pick one — all give you a public URL: Pick one — all give you a public URL:
@@ -36,6 +40,9 @@ Pick one — all give you a public URL:
Use `.env.example` as the setup template. Use `.env.example` as the setup template.
- `BLOB_READ_WRITE_TOKEN` enables cross-device shared state. - `BLOB_READ_WRITE_TOKEN` enables cross-device shared state.
- `ACCOUNT_SESSION_SECRET` signs cloud paper-account sessions. If omitted, the
app falls back to the existing server secret/token, but production should use
a dedicated value.
- `/api/live` reports whether KYC, payments, wallet/deposit-wallet, Polymarket - `/api/live` reports whether KYC, payments, wallet/deposit-wallet, Polymarket
CLOB, authentication, geofencing, sanctions, audit, support, and monitoring CLOB, authentication, geofencing, sanctions, audit, support, and monitoring
providers are configured. providers are configured.
@@ -47,5 +54,6 @@ Use `.env.example` as the setup template.
## Notes ## Notes
- Paper trading only right now — no real money, nothing places real orders. - Paper trading only right now — no real money, nothing places real orders.
- The analysis is a transparent heuristic, **not financial advice**. - The analysis is a transparent heuristic, **not financial advice**.
- The shared arena uses cloud state when configured. Individual paper accounts - The shared arena uses cloud state when configured. Password-backed paper
still use local browser storage unless a backend account database is added. accounts use the backend account API; passwordless paper accounts use local
browser storage.
+6 -2
View File
@@ -9,6 +9,8 @@ interfaces visible without moving funds or placing orders.
- `.env.example` lists the environment variables the production app needs. - `.env.example` lists the environment variables the production app needs.
- `/api/live` reports which live-money providers are configured or missing. - `/api/live` reports which live-money providers are configured or missing.
- `/api/live` accepts order intents only as dry-runs and returns an audit event. - `/api/live` accepts order intents only as dry-runs and returns an audit event.
- `/api/accounts` creates, logs into, and saves password-backed paper accounts
through Vercel Blob storage.
- The Live Money tab shows launch checks, wallet/deposit settings, agent risk - The Live Money tab shows launch checks, wallet/deposit settings, agent risk
limits, a dry-run order console, and an audit preview. limits, a dry-run order console, and an audit preview.
@@ -27,8 +29,10 @@ provider, wallet-signing, reconciliation, and monitoring steps below are done.
version. version.
2. Account and wallet 2. Account and wallet
- Real authentication. - Real authentication for live-money users.
- Production account database. - Production account database. The current backend account API is enough for
paper portfolios, but live money should use a real database with admin,
audit, and compliance tooling.
- Password reset, sign-out, session expiry, and optional MFA path. - Password reset, sign-out, session expiry, and optional MFA path.
- Non-custodial wallet connection or Polymarket deposit-wallet flow. - Non-custodial wallet connection or Polymarket deposit-wallet flow.
- User-controlled permissions for each agent. - User-controlled permissions for each agent.
+148
View File
@@ -0,0 +1,148 @@
import { get, put } from "@vercel/blob";
import crypto from "node:crypto";
const INDEX_PATH = process.env.PMA_ACCOUNTS_INDEX_PATH || "accounts/index.json";
const PBKDF2_ITERATIONS = 210000;
function configured() {
return Boolean(process.env.BLOB_READ_WRITE_TOKEN || process.env.VERCEL_OIDC_TOKEN);
}
async function readJson(path, fallback) {
const blob = await get(path, { access: "private" });
if (!blob || blob.statusCode !== 200 || !blob.stream) return fallback;
const text = await new Response(blob.stream).text();
return text ? JSON.parse(text) : fallback;
}
async function writeJson(path, data) {
await put(path, JSON.stringify(data), {
access: "private",
allowOverwrite: true,
contentType: "application/json",
cacheControlMaxAge: 0,
});
}
function normalizeName(name) {
return String(name || "").trim().replace(/\s+/g, " ").slice(0, 48);
}
function accountKey(name) {
return normalizeName(name).toLowerCase();
}
function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) {
const hash = crypto.pbkdf2Sync(String(password), salt, PBKDF2_ITERATIONS, 32, "sha256").toString("hex");
return { salt, hash };
}
function timingSafeEqualHex(a, b) {
const ab = Buffer.from(String(a || ""), "hex");
const bb = Buffer.from(String(b || ""), "hex");
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
function tokenSecret() {
return process.env.ACCOUNT_SESSION_SECRET || process.env.SESSION_SECRET || process.env.BLOB_READ_WRITE_TOKEN || "local-dev-only";
}
function makeSessionToken(accountId) {
const issued = Date.now();
const payload = `${accountId}.${issued}`;
const sig = crypto.createHmac("sha256", tokenSecret()).update(payload).digest("hex");
return `${payload}.${sig}`;
}
function verifySessionToken(accountId, token) {
const parts = String(token || "").split(".");
if (parts.length !== 3 || parts[0] !== accountId) return false;
const issued = Number(parts[1]);
if (!Number.isFinite(issued) || Date.now() - issued > 1000 * 60 * 60 * 24 * 30) return false;
const payload = `${parts[0]}.${parts[1]}`;
const expected = crypto.createHmac("sha256", tokenSecret()).update(payload).digest("hex");
return timingSafeEqualHex(parts[2], expected);
}
function sanitizeAccount(account) {
const clean = account && typeof account === "object" ? JSON.parse(JSON.stringify(account)) : {};
clean.id = String(clean.id || "");
clean.name = normalizeName(clean.name || "Paper Trader");
clean.cash = Number.isFinite(Number(clean.cash)) ? Number(clean.cash) : 10000;
clean.starting_balance = Number.isFinite(Number(clean.starting_balance)) ? Number(clean.starting_balance) : 10000;
clean.positions = Array.isArray(clean.positions) ? clean.positions.slice(0, 500) : [];
clean.history = Array.isArray(clean.history) ? clean.history.slice(-1000) : [];
clean.snapshots = Array.isArray(clean.snapshots) ? clean.snapshots.slice(-2000) : [];
clean.passwordHash = "cloud";
clean.cloudBacked = true;
return clean;
}
function accountPath(accountId) {
return `accounts/${accountId}.json`;
}
export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store");
try {
if (!configured()) return res.status(503).json({ ok: false, error: "Account storage is not configured" });
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({ ok: false, error: "Method not allowed" });
}
const body = typeof req.body === "string" ? JSON.parse(req.body || "{}") : (req.body || {});
const action = String(body.action || "");
const index = await readJson(INDEX_PATH, { version: 1, accounts: {} });
if (action === "create") {
const name = normalizeName(body.name);
const password = String(body.password || "");
if (name.length < 2) return res.status(400).json({ ok: false, error: "Account name is too short" });
if (password.length < 4) return res.status(400).json({ ok: false, error: "Use at least 4 characters for a cloud paper password" });
const key = accountKey(name);
if (index.accounts[key]) return res.status(409).json({ ok: false, error: "That paper account name already exists" });
const id = `acct_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`;
const passwordRecord = hashPassword(password);
const now = new Date().toISOString();
index.accounts[key] = { id, name, created_at: now, updated_at: now, password: passwordRecord };
const account = sanitizeAccount(body.account || {});
account.id = id;
account.name = name;
account.history = [{ date: now.slice(0, 10), action: "CREATE", detail: "Created cloud-synced paper account." }].concat(account.history || []).slice(-1000);
await writeJson(INDEX_PATH, index);
await writeJson(accountPath(id), { version: 1, updated_at: now, account });
return res.status(200).json({ ok: true, account, session_token: makeSessionToken(id) });
}
if (action === "login") {
const key = accountKey(body.name);
const record = index.accounts[key];
if (!record) return res.status(404).json({ ok: false, error: "Paper account not found" });
const passwordRecord = record.password || {};
const attempt = hashPassword(String(body.password || ""), passwordRecord.salt);
if (!timingSafeEqualHex(attempt.hash, passwordRecord.hash)) return res.status(401).json({ ok: false, error: "Incorrect paper account password" });
const saved = await readJson(accountPath(record.id), null);
const account = sanitizeAccount(saved && saved.account ? saved.account : {});
account.id = record.id;
account.name = record.name;
return res.status(200).json({ ok: true, account, session_token: makeSessionToken(record.id) });
}
if (action === "save") {
const accountId = String(body.account_id || "");
if (!verifySessionToken(accountId, body.session_token)) return res.status(401).json({ ok: false, error: "Cloud paper session expired. Log in again." });
const account = sanitizeAccount(body.account || {});
account.id = accountId;
const now = new Date().toISOString();
await writeJson(accountPath(accountId), { version: 1, updated_at: now, account });
return res.status(200).json({ ok: true, updated_at: now });
}
return res.status(400).json({ ok: false, error: "Unknown account action" });
} catch (err) {
const message = err && err.message ? err.message : "Account request failed";
return res.status(500).json({ ok: false, error: message });
}
}
+81 -13
View File
@@ -475,7 +475,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
<div class="paper-grid"> <div class="paper-grid">
<div> <div>
<div class="card"> <div class="card">
<div class="card-h"><h3>Account</h3><span class="small muted">browser paper money</span></div> <div class="card-h"><h3>Account</h3><span class="small muted">cloud paper money when password-backed</span></div>
<div class="account-row"> <div class="account-row">
<select class="select" id="paperAccountSelect" aria-label="Paper account"></select> <select class="select" id="paperAccountSelect" aria-label="Paper account"></select>
<input class="input" id="paperAccountName" placeholder="New account name" /> <input class="input" id="paperAccountName" placeholder="New account name" />
@@ -501,7 +501,7 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
<input class="input" id="buyAgentAmount" type="number" min="1" step="25" placeholder="Amount" /> <input class="input" id="buyAgentAmount" type="number" min="1" step="25" placeholder="Amount" />
<button class="btn" id="buyAgentBtn">Buy agent shares</button> <button class="btn" id="buyAgentBtn">Buy agent shares</button>
</div> </div>
<p class="small muted" style="margin:12px 0 0">Deposits and withdrawals are paper-cash controls. Copying an agent replaces this paper account with that agent's current cash and open positions. Buying agent shares lets users invest any paper amount in an agent's return stream.</p> <p class="small muted" style="margin:12px 0 0">Deposits and withdrawals are paper-cash controls. Accounts created with a password sync through the backend so the same portfolio can load on another device. Copying an agent replaces this paper account with that agent's current cash and open positions.</p>
</div> </div>
<div class="card"> <div class="card">
<div class="card-h"><h3>Paper account leaderboard</h3><span class="small muted">best returns</span></div> <div class="card-h"><h3>Paper account leaderboard</h3><span class="small muted">best returns</span></div>
@@ -662,6 +662,7 @@ const CHART_RANGE_KEY = "pma_chart_range_v1";
const INVEST_KEY = "pma_invest_allocations_v1"; const INVEST_KEY = "pma_invest_allocations_v1";
const PAPER_KEY = "pma_paper_accounts_v1"; const PAPER_KEY = "pma_paper_accounts_v1";
const LIVE_KEY = "pma_live_readiness_v1"; const LIVE_KEY = "pma_live_readiness_v1";
const PAPER_SESSION_PREFIX = "pma_cloud_session_";
const CATEGORIES = ["All","Politics","Sports","Crypto","Economy","Pop Culture","Other"]; const CATEGORIES = ["All","Politics","Sports","Crypto","Economy","Pop Culture","Other"];
const SYNC_KEYS=[AGENTS_KEY,SUG_KEY,FOCUS_KEY,VIEW_KEY,PF_SORT_KEY,CHART_RANGE_KEY,INVEST_KEY,PAPER_KEY,LIVE_KEY]; const SYNC_KEYS=[AGENTS_KEY,SUG_KEY,FOCUS_KEY,VIEW_KEY,PF_SORT_KEY,CHART_RANGE_KEY,INVEST_KEY,PAPER_KEY,LIVE_KEY];
const CAT_COLORS = {Politics:"#fb7185",Sports:"#34d399",Crypto:"#fbbf24",Economy:"#7c8cff", const CAT_COLORS = {Politics:"#fb7185",Sports:"#34d399",Crypto:"#fbbf24",Economy:"#7c8cff",
@@ -718,6 +719,7 @@ const $ = (id) => document.getElementById(id);
const fmtUSD = (n) => "$" + Number(n).toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:2}); const fmtUSD = (n) => "$" + Number(n).toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:2});
const fmtPct = (n) => (n>=0?"+":"") + Number(n).toFixed(2) + "%"; const fmtPct = (n) => (n>=0?"+":"") + Number(n).toFixed(2) + "%";
const signClass = (n) => (n>0?"pos-val":n<0?"neg-val":""); const signClass = (n) => (n>0?"pos-val":n<0?"neg-val":"");
const paperSessionKey=(id)=>`${PAPER_SESSION_PREFIX}${id}`;
const marketSearchUrl=(q)=>`https://polymarket.com/search?query=${encodeURIComponent(q||"")}`; const marketSearchUrl=(q)=>`https://polymarket.com/search?query=${encodeURIComponent(q||"")}`;
const positionUrl=(pos)=>pos&&pos.url?pos.url:marketSearchUrl(pos&&pos.question); const positionUrl=(pos)=>pos&&pos.url?pos.url:marketSearchUrl(pos&&pos.question);
function partsInLocalTime(d=new Date()){ function partsInLocalTime(d=new Date()){
@@ -1913,11 +1915,51 @@ function loadPaperStore(){
} }
function savePaperStore(store,sync=true){ function savePaperStore(store,sync=true){
localStorage.setItem(PAPER_KEY,JSON.stringify(store)); localStorage.setItem(PAPER_KEY,JSON.stringify(store));
if(sync)pushCloudState(); if(sync){pushCloudState();syncActivePaperAccount(store);}
} }
function activePaperAccount(store=loadPaperStore()){ function activePaperAccount(store=loadPaperStore()){
return store.accounts[store.activeId]||Object.values(store.accounts)[0]; return store.accounts[store.activeId]||Object.values(store.accounts)[0];
} }
async function paperAccountRequest(payload){
const r=await fetch("/api/accounts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)});
const d=await r.json().catch(()=>({ok:false,error:"Account service returned an invalid response"}));
if(!r.ok||!d.ok)throw new Error(d.error||"Paper account request failed");
return d;
}
function installCloudPaperAccount(account,token){
const store=loadPaperStore();
account.cloudBacked=true;account.passwordHash="cloud";
store.accounts[account.id]=account;store.activeId=account.id;
sessionStorage.setItem(unlockKey(account.id),"1");
sessionStorage.setItem(paperSessionKey(account.id),token);
savePaperStore(store,false);
return store;
}
async function createCloudPaperAccount(name,password){
const local=defaultPaperAccount(name,password);
local.passwordHash="cloud";local.cloudBacked=true;
const d=await paperAccountRequest({action:"create",name,password,account:local});
installCloudPaperAccount(d.account,d.session_token);
}
async function loginCloudPaperAccount(name,password){
const d=await paperAccountRequest({action:"login",name,password});
installCloudPaperAccount(d.account,d.session_token);
}
let PAPER_SYNC_TIMER=null;
function syncActivePaperAccount(store=loadPaperStore()){
const acct=activePaperAccount(store);
if(!acct||!acct.cloudBacked)return;
const token=sessionStorage.getItem(paperSessionKey(acct.id));
if(!token)return;
clearTimeout(PAPER_SYNC_TIMER);
PAPER_SYNC_TIMER=setTimeout(async()=>{
try{
await paperAccountRequest({action:"save",account_id:acct.id,session_token:token,account:acct});
}catch(e){
toast(e.message||"Cloud paper save failed.");
}
},500);
}
function paperEquity(acct){ function paperEquity(acct){
return acct.cash+(acct.positions||[]).reduce((s,p)=>s+(p.value||p.shares*p.current_price||0),0); return acct.cash+(acct.positions||[]).reduce((s,p)=>s+(p.value||p.shares*p.current_price||0),0);
} }
@@ -2100,10 +2142,16 @@ function renderPaperTab(){
if(buyAgentSel)buyAgentSel.innerHTML=AGENTS.map(a=>`<option value="${a.id}">${a.emoji} ${esc(a.name)}</option>`).join(""); if(buyAgentSel)buyAgentSel.innerHTML=AGENTS.map(a=>`<option value="${a.id}">${a.emoji} ${esc(a.name)}</option>`).join("");
const lock=$("paperLockPanel"); const lock=$("paperLockPanel");
if(lock)lock.innerHTML=acct.passwordHash&&!isPaperUnlocked(acct) if(lock)lock.innerHTML=acct.passwordHash&&!isPaperUnlocked(acct)
? `<div class="locked-panel"><b>Password protected.</b><div class="account-row" style="margin:8px 0 0"><input class="input" id="paperUnlockInput" type="password" placeholder="Password"><button class="btn" id="paperUnlockBtn">Unlock</button></div></div>` ? `<div class="locked-panel"><b>${acct.cloudBacked?"Cloud paper account locked.":"Password protected."}</b><div class="account-row" style="margin:8px 0 0"><input class="input" id="paperUnlockInput" type="password" placeholder="Password"><button class="btn" id="paperUnlockBtn">${acct.cloudBacked?"Cloud log in":"Unlock"}</button></div></div>`
: acct.passwordHash?`<div class="small muted" style="margin-bottom:10px">Account unlocked for this browser session.</div>`:`<div class="small muted" style="margin-bottom:10px">No password set for this paper account.</div>`; : acct.cloudBacked?`<div class="small muted" style="margin-bottom:10px">Cloud-synced paper account unlocked. Trades and portfolio history save across devices.</div>`
: acct.passwordHash?`<div class="small muted" style="margin-bottom:10px">Local account unlocked for this browser session.</div>`:`<div class="small muted" style="margin-bottom:10px">Local-only paper account. Add a password when creating an account to sync across devices.</div>`;
const unlockBtn=$("paperUnlockBtn"); const unlockBtn=$("paperUnlockBtn");
if(unlockBtn)unlockBtn.onclick=()=>{if(requirePaperUnlock(acct,$("paperUnlockInput").value)){renderPaperTab();toast("Paper account unlocked.");}}; if(unlockBtn)unlockBtn.onclick=async()=>{
if(acct.cloudBacked){
try{await loginCloudPaperAccount(acct.name,$("paperUnlockInput").value);renderPaperTab();toast("Cloud paper account loaded.");}
catch(e){toast(e.message);}
}else if(requirePaperUnlock(acct,$("paperUnlockInput").value)){renderPaperTab();toast("Paper account unlocked.");}
};
const locked=acct.passwordHash&&!isPaperUnlocked(acct); const locked=acct.passwordHash&&!isPaperUnlocked(acct);
if(locked){ if(locked){
$("paperStats").innerHTML=`<div class="locked-panel" style="grid-column:1/-1">Log in to view this paper portfolio.</div>`; $("paperStats").innerHTML=`<div class="locked-panel" style="grid-column:1/-1">Log in to view this paper portfolio.</div>`;
@@ -2376,13 +2424,21 @@ $("resetBtn").addEventListener("click",()=>{
if(!confirm("Reset all ten agents to their $10,000 starting balance?"))return; if(!confirm("Reset all ten agents to their $10,000 starting balance?"))return;
saveState(defaultState());localStorage.removeItem(SUG_KEY);pushCloudState(true);toast("All agents reset.");renderAll(); saveState(defaultState());localStorage.removeItem(SUG_KEY);pushCloudState(true);toast("All agents reset.");renderAll();
}); });
$("createPaperAccountBtn").addEventListener("click",()=>{ $("createPaperAccountBtn").addEventListener("click",async()=>{
const name=($("paperAccountName").value||"Paper Trader").trim().slice(0,40); const name=($("paperAccountName").value||"Paper Trader").trim().slice(0,40);
const password=($("paperAccountPassword").value||"").trim(); const password=($("paperAccountPassword").value||"").trim();
const store=loadPaperStore(),acct=defaultPaperAccount(name||"Paper Trader",password); try{
store.accounts[acct.id]=acct;store.activeId=acct.id; if(password){
$("paperAccountName").value="";$("paperAccountPassword").value=""; await createCloudPaperAccount(name||"Paper Trader",password);
savePaperStore(store);renderPaperTab();toast("Paper account created."); toast("Cloud paper account created.");
}else{
const store=loadPaperStore(),acct=defaultPaperAccount(name||"Paper Trader",password);
store.accounts[acct.id]=acct;store.activeId=acct.id;
savePaperStore(store);toast("Local paper account created.");
}
$("paperAccountName").value="";$("paperAccountPassword").value="";
renderPaperTab();
}catch(e){toast(e.message);}
}); });
$("copyAgentBtn").addEventListener("click",()=>{ $("copyAgentBtn").addEventListener("click",()=>{
const agentId=$("copyAgentSelect").value,book=cloneAgentBook(agentId),cfg=agentById(agentId); const agentId=$("copyAgentSelect").value,book=cloneAgentBook(agentId),cfg=agentById(agentId);
@@ -2400,13 +2456,25 @@ $("paperSearchBtn").addEventListener("click",()=>searchPaperMarkets());
$("paperAllBtn").addEventListener("click",()=>{PAPER_MARKET_QUERY="";PAPER_SEARCH_RESULTS=[];loadPaperMarketPage(true);}); $("paperAllBtn").addEventListener("click",()=>{PAPER_MARKET_QUERY="";PAPER_SEARCH_RESULTS=[];loadPaperMarketPage(true);});
$("paperMoreBtn").addEventListener("click",()=>loadPaperMarketPage(false)); $("paperMoreBtn").addEventListener("click",()=>loadPaperMarketPage(false));
$("paperClearSearchBtn").addEventListener("click",()=>{PAPER_SEARCH_RESULTS=null;PAPER_MARKET_OFFSET=0;PAPER_MARKET_QUERY="";renderPaperTab();}); $("paperClearSearchBtn").addEventListener("click",()=>{PAPER_SEARCH_RESULTS=null;PAPER_MARKET_OFFSET=0;PAPER_MARKET_QUERY="";renderPaperTab();});
$("paperLoginBtn").addEventListener("click",()=>{ $("paperLoginBtn").addEventListener("click",async()=>{
const acct=activePaperAccount(); const acct=activePaperAccount();
if(requirePaperUnlock(acct,$("paperPasswordInput").value)){renderPaperTab();toast("Logged in.");} const cloudName=($("paperAccountName").value||"").trim();
const topPassword=($("paperAccountPassword").value||"").trim();
try{
if(cloudName&&topPassword){
await loginCloudPaperAccount(cloudName,topPassword);
$("paperAccountName").value="";$("paperAccountPassword").value="";
renderPaperTab();toast("Cloud paper account loaded.");
}else if(acct.cloudBacked){
await loginCloudPaperAccount(acct.name,$("paperPasswordInput").value);
renderPaperTab();toast("Cloud paper account loaded.");
}else if(requirePaperUnlock(acct,$("paperPasswordInput").value)){renderPaperTab();toast("Logged in.");}
}catch(e){toast(e.message);}
}); });
$("paperSignOutBtn").addEventListener("click",()=>{ $("paperSignOutBtn").addEventListener("click",()=>{
const acct=activePaperAccount(); const acct=activePaperAccount();
sessionStorage.removeItem(unlockKey(acct.id)); sessionStorage.removeItem(unlockKey(acct.id));
sessionStorage.removeItem(paperSessionKey(acct.id));
renderPaperTab();toast("Signed out of paper account."); renderPaperTab();toast("Signed out of paper account.");
}); });
$("saveLiveProfileBtn").addEventListener("click",()=>{ $("saveLiveProfileBtn").addEventListener("click",()=>{