feat(potidaea): cross-device tx history via chipnet chain-scan
My Transactions was localStorage-only, so a swap made on mobile never appeared on desktop with the same WIF. Fixed by scanning the chain on wallet attach (and at boot resume): blockchain.address.get_history → per-tx verbose fetches → classify → merge into TX_LOG_KEY with txid-dedupe. Architecture note (user asked): no dedicated database / indexer needed. The chain IS the database. Fulcrum's address history + verbose-tx RPCs give us every historical swap in a bounded number of calls (bounded here by HYDRATE_MAX_TXS = 100 most-recent per address). Adding a server-side indexer would violate the lean-endpoint stance (wiki/infra/lean-utxo-endpoint.md), introduce a custody-adjacent surface, and buy ~100 ms of speed for something we do once per session. If a wallet ever accumulates thousands of swaps we can paginate get_history and cache verbose fetches per txid, but that's a knob-turn not a re-architecture. Wiring: - hydrateTxLogFromChain(signer) fires 300 ms after onSignerChanged (attach / switch / disconnect+reattach) and 500 ms after bootLocalSigner picks up a resumed wallet. Idempotent — hydrateInFlight guard + txid dedupe means duplicate calls are no-ops. - Parallel worker pool (HYDRATE_CONCURRENCY = 4) so a 100-tx address hydrates in ~2-3 s on chipnet. Classification (v1, claims-only): - Match the user's PKH against each verbose output's scriptPubKey to find outputs going TO the user. - If an output carries token_data.category matching a KNOWN_MBAS entry → it's a CLAIM: pick the largest FT delta as the primary swap, derive BCH gross from units × rate, log with fromSym=BCH, toSym=<ticker>, chain block time as ts. - REDEEM detection deferred — burn-in-place txs don't touch the covenant address; distinguishing "I redeemed" from "I received a BCH payment" without input-scan is fuzzy. v2 task queued. Storage: hydrateInsert() writes directly to TX_LOG_KEY with the chain block time (not Date.now()) so the log stays ordered by real chain time, then re-sorts newest-first and repaints odTxLog. Verified with wallet D on chipnet: 17 claim entries hydrated across ASM / SHR745 / ASM2 tickers, first row is a 2026-09-21 claim of 550 ASM for 0.00055 BCH (txid 79084691e78e…). Panel repaints on completion. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fe89d88597
commit
c3d33cad85
1 changed files with 169 additions and 0 deletions
|
|
@ -939,6 +939,12 @@ async function bootLocalSigner(): Promise<void> {
|
|||
console.log('[signer]', localSigner.mode, '· cashaddr', localSigner.cashaddr);
|
||||
renderChip();
|
||||
void refreshBalance();
|
||||
// Pull cross-device tx history for the resumed signer. Deferred so the
|
||||
// balance fetch grabs its RPC slot first; hydrate is idempotent (txids
|
||||
// are deduped) so a duplicate call from a later signer-change is a
|
||||
// no-op.
|
||||
const s = localSigner;
|
||||
setTimeout(() => void hydrateTxLogFromChain(s), 500);
|
||||
}
|
||||
|
||||
function boot(): void {
|
||||
|
|
@ -2996,6 +3002,159 @@ function runResetLocalAssets(): void {
|
|||
* the Balances view before it lands, leaving the page looking empty
|
||||
* until they hit the manual refresh button. Schedule follow-up
|
||||
* refreshes so the balances populate without user action. */
|
||||
// ── Cross-device tx-log hydration ─────────────────────────────────────
|
||||
// My-Transactions is stored in localStorage, so a swap made on mobile
|
||||
// won't appear on desktop with the same WIF. Fix by scanning the chain
|
||||
// on wallet attach: `blockchain.address.get_history(cashaddr)` returns
|
||||
// every tx affecting the address, and per-tx verbose fetches let us
|
||||
// classify each as a claim / redeem. Results merge into TX_LOG_KEY with
|
||||
// txid-dedupe, so local overlay (pending / optimistic) and chain
|
||||
// history coexist without duplicates.
|
||||
//
|
||||
// v1 scope: detect CLAIMS (BCH → known MBA) by finding an FT output of
|
||||
// a KNOWN_MBAS category at the user's tokenaddr. Redeems are burn-in-
|
||||
// place and harder to classify without input-scan (no covenant address
|
||||
// touched); a v2 pass will handle them. Genesis / publish stay local-
|
||||
// only for now — they're rare and the local overlay covers them.
|
||||
const HYDRATE_MAX_TXS = 100; // walk at most this many recent entries
|
||||
const HYDRATE_CONCURRENCY = 4; // parallel verbose fetches
|
||||
let hydrateInFlight = false;
|
||||
|
||||
async function hydrateTxLogFromChain(signer: Signer): Promise<void> {
|
||||
if (hydrateInFlight) return; // guard against double-fire from stacked setTimeouts
|
||||
hydrateInFlight = true;
|
||||
try {
|
||||
if (!currentClient) currentClient = await ElectrumClient.connectAny();
|
||||
const client = currentClient;
|
||||
const rawHist = await client.request<Array<{ tx_hash: string; height: number }>>(
|
||||
'blockchain.address.get_history', [signer.cashaddr],
|
||||
);
|
||||
const hist = (rawHist ?? []).slice(-HYDRATE_MAX_TXS).reverse(); // newest first
|
||||
|
||||
// Decode user's PKH so we can match outputs going TO the user.
|
||||
let userPkhHex = '';
|
||||
try {
|
||||
const dec = decodeCashAddress(signer.cashaddr);
|
||||
if (typeof dec !== 'string' && dec.payload) userPkhHex = bytesToHex(dec.payload).toLowerCase();
|
||||
} catch { /* ignore — will skip classification if we can't identify user */ }
|
||||
if (!userPkhHex) return;
|
||||
|
||||
// Existing local txids for dedupe.
|
||||
const existing = new Set(readTxLog().map((e) => e.txid));
|
||||
const toFetch = hist.filter((h) => !existing.has(h.tx_hash));
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
// Build a categoryId → { ticker, rateSats } lookup.
|
||||
const catToMba: Record<string, { ticker: string; rateSats: bigint }> = {};
|
||||
for (const [ticker, m] of Object.entries(KNOWN_MBAS)) {
|
||||
catToMba[m.categoryId.toLowerCase()] = { ticker, rateSats: m.rateSats };
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (cursor < toFetch.length) {
|
||||
const h = toFetch[cursor++];
|
||||
try {
|
||||
const verbose = await client.request<FulcrumVerboseTx>('blockchain.transaction.get', [h.tx_hash, true]);
|
||||
const classified = classifyChainTx(verbose, userPkhHex, catToMba, h.tx_hash, h.height);
|
||||
if (classified) hydrateInsert(classified);
|
||||
} catch (e) {
|
||||
console.warn('[hydrate] verbose fetch failed for', h.tx_hash.slice(0, 10), e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
};
|
||||
const workers = Array.from({ length: HYDRATE_CONCURRENCY }, () => worker());
|
||||
await Promise.all(workers);
|
||||
} catch (e) {
|
||||
console.warn('[hydrate] chain-scan failed:', e instanceof Error ? e.message : e);
|
||||
} finally {
|
||||
hydrateInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a verbose tx as a claim / redeem for the user, or return
|
||||
* null if we can't tell. v1: claim-only (FT of known category received
|
||||
* at user's tokenaddr). Later: redeem via input-scan. */
|
||||
function classifyChainTx(
|
||||
verbose: FulcrumVerboseTx,
|
||||
userPkhHex: string,
|
||||
catToMba: Record<string, { ticker: string; rateSats: bigint }>,
|
||||
txid: string,
|
||||
_height: number,
|
||||
): Omit<TxLogEntry, 'ts'> | null {
|
||||
const outs = verbose.vout ?? [];
|
||||
// Find FT outputs at user's PKH matching a known category. We match by
|
||||
// "does the scriptPubKey.hex include the user's PKH" — that catches both
|
||||
// plain P2PKH (76a914<pkh>88ac) and the p2pkh-with-tokens variant, which
|
||||
// share the same 20-byte pkh.
|
||||
const ftReceived = new Map<string, bigint>(); // categoryId → amount received by user
|
||||
let bchReceivedToUser = 0n;
|
||||
for (const v of outs) {
|
||||
const spk = (v.scriptPubKey?.hex ?? '').toLowerCase();
|
||||
if (!spk.includes(userPkhHex)) continue; // not going to user
|
||||
const td = v.token_data ?? v.tokenData;
|
||||
if (td && td.category) {
|
||||
const cat = td.category.toLowerCase();
|
||||
if (catToMba[cat]) {
|
||||
const prev = ftReceived.get(cat) ?? 0n;
|
||||
try { ftReceived.set(cat, prev + BigInt(td.amount ?? '0')); } catch { /* noop */ }
|
||||
}
|
||||
} else {
|
||||
try { bchReceivedToUser += BigInt(Math.floor((v.value ?? 0))); } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
// CLAIM: user received FT of a known category. Build the log entry.
|
||||
if (ftReceived.size > 0) {
|
||||
// Take the largest FT delta as the primary claim (a single swap
|
||||
// touches one category at a time).
|
||||
let bestCat = '', bestAmount = 0n;
|
||||
for (const [c, a] of ftReceived) if (a > bestAmount) { bestAmount = a; bestCat = c; }
|
||||
const m = catToMba[bestCat];
|
||||
const bchIn = (bestAmount * m.rateSats);
|
||||
const bchStr = (Number(bchIn) / 1e8).toFixed(8).replace(/0+$/, '').replace(/\.$/, '');
|
||||
return {
|
||||
kind: 'claim',
|
||||
ticker: m.ticker,
|
||||
fromSym: 'BCH',
|
||||
toSym: m.ticker,
|
||||
fromAmount: bchStr + ' BCH',
|
||||
toAmount: String(bestAmount) + ' ' + m.ticker,
|
||||
amount: String(bestAmount) + ' ' + m.ticker + ' received',
|
||||
txid,
|
||||
// Use block time when the tx has one — Fulcrum returns it as `time`
|
||||
// or `blocktime` depending on version. Falls back to now (surfaces
|
||||
// as "just now" in the log). logTx replaces ts inside, so we bypass
|
||||
// it here by casting; the shipped entry gets a real ts from the
|
||||
// block header if available.
|
||||
note: verbose.time
|
||||
? 'chain-hydrated ts=' + verbose.time
|
||||
: (verbose.blocktime ? 'chain-hydrated ts=' + verbose.blocktime : 'chain-hydrated'),
|
||||
} as Omit<TxLogEntry, 'ts'>;
|
||||
}
|
||||
|
||||
// REDEEM detection is v2 — needs input-scan (fetch each vin's prev tx
|
||||
// to see if the user was spending a known-category FT). Skip for now.
|
||||
void bchReceivedToUser;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Insert a chain-hydrated entry directly into localStorage, using the
|
||||
* block time from the tx (not Date.now()) so the log stays ordered by
|
||||
* real chain time. Dedupes by txid: if the row exists (e.g. from an
|
||||
* interactive broadcast) we leave it alone. Repaints the panel. */
|
||||
function hydrateInsert(entry: Omit<TxLogEntry, 'ts'>): void {
|
||||
const list = readTxLog();
|
||||
if (list.some((e) => e.txid === entry.txid)) return; // dedupe
|
||||
const m = entry.note && /chain-hydrated\s+ts=(\d+)/i.exec(entry.note);
|
||||
const chainTs = m ? Number(m[1]) * 1000 : Date.now();
|
||||
const ts = Number.isFinite(chainTs) && chainTs > 0 ? chainTs : Date.now();
|
||||
list.push({ ts, ...entry });
|
||||
list.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
||||
try { localStorage.setItem(TX_LOG_KEY, JSON.stringify(list.slice(0, TX_LOG_MAX))); } catch { /* noop */ }
|
||||
paintOdTxLog();
|
||||
}
|
||||
|
||||
function onSignerChanged(): void {
|
||||
// Drop any pending-swap overlays — they were keyed to the previous
|
||||
// wallet's UTXO snapshot and would mis-apply here.
|
||||
|
|
@ -3012,6 +3171,16 @@ function onSignerChanged(): void {
|
|||
// popped the only wallet — nothing to trade with now).
|
||||
try { paintWelcomeOverlay(); } catch { /* noop */ }
|
||||
try { paintMyAssets(); } catch { /* noop */ }
|
||||
// Cross-device tx-log hydration — pulls historical swaps from chipnet
|
||||
// so a swap made on mobile appears on desktop with the same WIF.
|
||||
// Delayed 300ms so the balance refresh grabs the RPC slot first
|
||||
// (users care more about seeing their balance than the tx-log
|
||||
// instantly). Failures are logged and silently ignored — the local
|
||||
// overlay + interactive broadcasts still populate the log normally.
|
||||
if (localSigner) {
|
||||
const s = localSigner;
|
||||
setTimeout(() => void hydrateTxLogFromChain(s), 300);
|
||||
}
|
||||
}
|
||||
|
||||
function doSwitchToWallet(cashaddr: string): void {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue