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:
Local Dev 2026-09-22 18:02:20 +02:00
parent fe89d88597
commit c3d33cad85

View file

@ -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 {