diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..ab1f4164 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/intercom.iml b/.idea/intercom.iml new file mode 100644 index 00000000..d6ebd480 --- /dev/null +++ b/.idea/intercom.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..07115cdf --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..a9807df8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..83067447 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index d00cef36..22a5f1cb 100644 --- a/README.md +++ b/README.md @@ -75,3 +75,117 @@ Intercom is a single long-running Pear process that participates in three distin --- If you plan to build your own app, study the existing contract/protocol and remove example logic as needed (see `SKILL.md`). + +## Competition App: InterSplit (P2P Expense Splitter) +InterSplit is a sidechannel-native shared expense ledger for teams, friends, and co-travelers. + +What it does: +- Tracks shared expenses in any sidechannel room. +- Computes per-member balances (who owes vs who should receive). +- Produces a minimal settlement plan (debtor -> creditor payments). +- Syncs entries peer-to-peer over Intercom sidechannels (no central server). +- Persists room snapshots into contract state for recovery after restarts. +- Exports settlement plans in one shot (`text`, `json`, or `csv`). + +Terminal commands: +- `/expense_add --channel "" --payer "" --amount "" --split "a,b,c" [--note ""]` +- `/expense_list --channel ""` +- `/expense_balance --channel ""` +- `/expense_clear --channel ""` +- `/expense_persist --channel "" [--sim 1]` +- `/expense_restore --channel "" [--confirmed 1|0] [--replace 1]` +- `/expense_export --channel "" [--format text|json|csv]` + +SC-Bridge JSON commands: +- `expense_add` with `channel`, `payer`, `amount`, `split`, optional `note` +- `expense_list` with `channel` +- `expense_balance` with `channel` +- `expense_clear` with `channel` +- `expense_persist` with `channel`, optional `sim` +- `expense_restore` with `channel`, optional `confirmed`, `replace` +- `expense_export` with `channel`, optional `format` + +Web frontend (no terminal command entry needed): +1. Start Intercom with SC-Bridge enabled: + - `pear run . --peer-store-name demo --msb-store-name demo-msb --subnet-channel intersplit-demo --sidechannels trip-nyc --sc-bridge 1 --sc-bridge-token YOUR_TOKEN` +2. Start UI server: + - `npm run ui` +3. Open: + - `http://127.0.0.1:5070` +4. In the UI: + - Enter WS URL (`ws://127.0.0.1:49222`), token, and channel. + - Click `Connect`, then `Join`, then use Chat/Expense controls. + - `Persist` can take 10-60s depending on validator/network latency. + - `Restore` in UI reads local node view first (`confirmed=0`) for faster feedback. + - `Local node view` means files under `stores//...` on your machine, not browser localStorage. + - Assistant prompt accepts simple commands like: + - `add alice 30 split alice,bob note dinner` + - `balance` + - `persist` + - `restore` + - `export text` + +Frontend tutorial (end-to-end): +1. Start Intercom backend in terminal A: +```powershell +cd C:\Users\user\Documents\Emma\intercom +$env:PATH="$env:APPDATA\npm;$env:APPDATA\pear\bin;$env:PATH" +pear run . --peer-store-name demo2 --msb-store-name demo2-msb --subnet-channel intersplit-demo --sidechannels trip-nyc --sc-bridge 1 --sc-bridge-token mysecret123 +``` +2. Start frontend server in terminal B: +```powershell +cd C:\Users\user\Documents\Emma\intercom +npm run ui +``` +3. Open browser at `http://127.0.0.1:5070`. +4. In the UI Connection card: + - WS URL: `ws://127.0.0.1:49222` + - Token: `mysecret123` (or your chosen token) + - Channel: `trip-nyc` + - Click `Connect`, `Join`, `Subscribe`. +5. In the UI, add expense records: + - Form mode: fill payer/amount/split/note and click `Add Expense`. + - Assistant mode: `add alice 30 split alice,bob note dinner` +6. Click `Balance` and verify expected output: + - `alice: +15.00` + - `bob: -15.00` + - settlement `bob -> alice: 15.00` +7. Click `Persist` once and wait until a tx hash appears in Live Feed. +8. Click `Export Text` to generate a copyable settlement summary. +9. Optional restart proof: + - Stop peer with `/exit` in terminal A. + - Start the same command again using the same store names (`demo2`, `demo2-msb`). + - In UI click `Restore` then `Balance`. + - Live Feed will show `source=contract` or `source=local`. + +Optional two-peer chat verification: +1. Start peer A: + - `--peer-store-name demoA --msb-store-name demoA-msb --sc-bridge-port 49222 --sc-bridge-token tokenA` +2. Start peer B: + - `--peer-store-name demoB --msb-store-name demoB-msb --subnet-bootstrap --sc-bridge-port 49223 --sc-bridge-token tokenB` +3. Open two UI tabs: + - Tab A -> `ws://127.0.0.1:49222` / `tokenA` + - Tab B -> `ws://127.0.0.1:49223` / `tokenB` +4. Join + subscribe on both tabs to `trip-nyc`. +5. Send a chat message in Tab A; Tab B should receive `sidechannel_message`. + +Contract persistence keys: +- `expense/room/` stores room snapshots (`events`) and update metadata. +- Local fallback snapshot file per peer store: + - `stores//expense-split.snapshots.json` + - UI/CLI restore falls back to this local file if contract state is not yet confirmed. + +Quick 60-second demo: +1. Peer A and Peer B join the same sidechannel, e.g. `trip-nyc`. +2. Add two expenses: + - `/expense_add --channel "trip-nyc" --payer "alice" --amount "30" --split "alice,bob" --note "dinner"` + - `/expense_add --channel "trip-nyc" --payer "bob" --amount "10" --split "alice,bob" --note "snacks"` +3. View settlement: + - `/expense_balance --channel "trip-nyc"` +4. Persist: + - `/expense_persist --channel "trip-nyc"` +5. Export ready-to-share settlement: + - `/expense_export --channel "trip-nyc" --format text` + +## Trac Address (Payout) +- `trac1j8wqd88yhnssf74uzrpp5kvwmwdr6jnl42yxluldq893mjxvtf3s8hsyrq` diff --git a/contract/contract.js b/contract/contract.js index f661e5fc..7dc954e7 100644 --- a/contract/contract.js +++ b/contract/contract.js @@ -80,6 +80,31 @@ class SampleContract extends Contract { key : { type : "string", min : 1, max: 256 } } }); + this.addSchema('expenseUpsertRoom', { + value : { + $$strict : true, + $$type: "object", + op : { type : "string", min : 1, max: 128 }, + channel : { type : "string", min : 1, max: 128 }, + snapshot : { type : "any" } + } + }); + this.addSchema('expenseDeleteRoom', { + value : { + $$strict : true, + $$type: "object", + op : { type : "string", min : 1, max: 128 }, + channel : { type : "string", min : 1, max: 128 } + } + }); + this.addSchema('expenseReadRoom', { + value : { + $$strict : true, + $$type: "object", + op : { type : "string", min : 1, max: 128 }, + channel : { type : "string", min : 1, max: 128 } + } + }); // now we are registering the timer feature itself (see /features/time/ in package). // note the naming convention for the feature name _feature. @@ -235,6 +260,50 @@ class SampleContract extends Contract { const currentTime = await this.get('currentTime'); console.log('currentTime:', currentTime); } + + _expenseRoomKey(channel){ + return 'expense/room/' + channel; + } + + async expenseUpsertRoom(){ + const channel = String(this.value?.channel || '').trim().toLowerCase(); + if(channel.length === 0) return new Error('Channel is required.'); + + const snapshot = this.protocol.safeClone(this.value?.snapshot); + this.assert(snapshot !== null, new Error('Invalid snapshot payload.')); + + const currentTime = await this.get('currentTime'); + await this.put(this._expenseRoomKey(channel), { + channel, + snapshot, + updatedAt: currentTime ?? null, + updatedBy: this.address ?? null, + version: 1 + }); + } + + async expenseDeleteRoom(){ + const channel = String(this.value?.channel || '').trim().toLowerCase(); + if(channel.length === 0) return new Error('Channel is required.'); + + const currentTime = await this.get('currentTime'); + await this.put(this._expenseRoomKey(channel), { + channel, + snapshot: null, + deletedAt: currentTime ?? null, + deletedBy: this.address ?? null, + deleted: true, + version: 1 + }); + } + + async expenseReadRoom(){ + const channel = String(this.value?.channel || '').trim().toLowerCase(); + if(channel.length === 0) return new Error('Channel is required.'); + + const value = await this.get(this._expenseRoomKey(channel)); + console.log('expense room', channel, value); + } } export default SampleContract; diff --git a/contract/protocol.js b/contract/protocol.js index 7345bdab..fa314db5 100644 --- a/contract/protocol.js +++ b/contract/protocol.js @@ -85,6 +85,92 @@ const parseWelcomeArg = (raw) => { return null; }; +const parseMembersArg = (raw) => { + if (Array.isArray(raw)) { + return raw + .map((value) => String(value || '').trim().toLowerCase()) + .filter((value) => value.length > 0); + } + if (!raw) return []; + return String(raw) + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0); +}; + +const formatCents = (cents) => (Number(cents || 0) / 100).toFixed(2); +const normalizeChannel = (value) => String(value || '').trim().toLowerCase(); +const parseBoolFlag = (value, fallback = false) => { + if (value === undefined || value === null || value === '') return fallback; + return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase()); +}; + +const buildExpenseExport = (summary, format = 'text') => { + const normalizedFormat = String(format || 'text').trim().toLowerCase(); + const generatedAt = new Date().toISOString(); + const balances = Array.isArray(summary?.balances) ? summary.balances : []; + const settlements = Array.isArray(summary?.settlements) ? summary.settlements : []; + + if (normalizedFormat === 'json') { + return { + format: 'json', + data: JSON.stringify({ + app: 'intersplit', + version: 1, + generatedAt, + channel: summary.channel, + eventCount: summary.eventCount ?? 0, + total: formatCents(summary.totalCents ?? 0), + balances: balances.map((entry) => ({ + member: entry.member, + cents: entry.cents, + amount: formatCents(Math.abs(entry.cents ?? 0)), + direction: entry.cents >= 0 ? 'receives' : 'owes' + })), + settlements: settlements.map((entry) => ({ + from: entry.from, + to: entry.to, + cents: entry.amountCents, + amount: formatCents(entry.amountCents) + })) + }, null, 2) + }; + } + + if (normalizedFormat === 'csv') { + const lines = ['from,to,amount']; + for (const row of settlements) { + lines.push(`${row.from},${row.to},${formatCents(row.amountCents)}`); + } + return { format: 'csv', data: lines.join('\n') }; + } + + const lines = []; + lines.push('InterSplit Settlement Export'); + lines.push(`generated_at: ${generatedAt}`); + lines.push(`channel: ${summary.channel}`); + lines.push(`events: ${summary.eventCount ?? 0}`); + lines.push(`total: ${formatCents(summary.totalCents ?? 0)}`); + lines.push('balances:'); + if (balances.length === 0) { + lines.push('- none'); + } else { + for (const entry of balances) { + const sign = entry.cents >= 0 ? '+' : '-'; + lines.push(`- ${entry.member}: ${sign}${formatCents(Math.abs(entry.cents))}`); + } + } + lines.push('settlements:'); + if (settlements.length === 0) { + lines.push('- none'); + } else { + for (const row of settlements) { + lines.push(`- ${row.from} -> ${row.to}: ${formatCents(row.amountCents)}`); + } + } + return { format: 'text', data: lines.join('\n') }; +}; + class SampleProtocol extends Protocol{ /** @@ -198,6 +284,18 @@ class SampleProtocol extends Protocol{ obj.type = 'readTimer'; obj.value = null; return obj; + } else if (json.op !== undefined && json.op === 'expense_upsert_room') { + obj.type = 'expenseUpsertRoom'; + obj.value = json; + return obj; + } else if (json.op !== undefined && json.op === 'expense_delete_room') { + obj.type = 'expenseDeleteRoom'; + obj.value = json; + return obj; + } else if (json.op !== undefined && json.op === 'expense_read_room') { + obj.type = 'expenseReadRoom'; + obj.value = json; + return obj; } } // return null if no case matches. @@ -224,6 +322,13 @@ class SampleProtocol extends Protocol{ console.log('- /sc_invite --channel "" --pubkey "" [--ttl ] [--welcome ] | create a signed invite.'); console.log('- /sc_welcome --channel "" --text "" | create a signed welcome.'); console.log('- /sc_stats | show sidechannel channels + connection count.'); + console.log('- /expense_add --channel "" --payer "" --amount "" --split "a,b,c" [--note ""] | add an expense entry.'); + console.log('- /expense_list --channel "" | print expense events for a room.'); + console.log('- /expense_balance --channel "" | print balances and suggested settlements.'); + console.log('- /expense_clear --channel "" | clear the local room ledger and broadcast reset.'); + console.log('- /expense_persist --channel "" [--sim 1] | persist current room snapshot into contract state.'); + console.log('- /expense_restore --channel "" [--confirmed 1|0] [--replace 1] | load room snapshot from contract state.'); + console.log('- /expense_export --channel "" [--format text|json|csv] | one-shot settlement export.'); // further protocol specific options go here } @@ -589,6 +694,213 @@ class SampleProtocol extends Protocol{ console.log({ channels, connectionCount }); return; } + if (this.input.startsWith("/expense_add")) { + if (!this.peer.expenseSplit) { + console.log('Expense split app not initialized.'); + return; + } + const args = this.parseArgs(input); + const channel = args.channel || args.ch || args.room || this.peer.sidechannel?.entryChannel || '0000intercom'; + const payer = args.payer || args.p; + const amount = args.amount || args.a; + const split = parseMembersArg(args.split || args.members || args.with); + const note = args.note || args.memo || ''; + if (!payer || !amount || split.length === 0) { + console.log('Usage: /expense_add --channel "" --payer "" --amount "" --split "a,b,c" [--note ""]'); + return; + } + const result = this.peer.expenseSplit.addExpense({ channel, payer, amount, split, note }); + if (!result.ok) { + console.log(result.error || 'Failed to add expense.'); + return; + } + console.log( + `[expense:${result.channel}] added ${formatCents(result.event.amountCents)} by ${result.event.payer} split=${result.event.split.join(',')}` + ); + return; + } + if (this.input.startsWith("/expense_list")) { + if (!this.peer.expenseSplit) { + console.log('Expense split app not initialized.'); + return; + } + const args = this.parseArgs(input); + const channel = args.channel || args.ch || args.room || this.peer.sidechannel?.entryChannel || '0000intercom'; + const events = this.peer.expenseSplit.list(channel); + if (events.length === 0) { + console.log(`[expense:${channel}] no expenses yet.`); + return; + } + console.log(`[expense:${channel}] entries=${events.length}`); + for (const event of events) { + console.log( + `- ${new Date(event.ts).toISOString()} | ${event.payer} paid ${formatCents(event.amountCents)} | split=${event.split.join(',')} | note=${event.note || '-'}` + ); + } + return; + } + if (this.input.startsWith("/expense_balance")) { + if (!this.peer.expenseSplit) { + console.log('Expense split app not initialized.'); + return; + } + const args = this.parseArgs(input); + const channel = args.channel || args.ch || args.room || this.peer.sidechannel?.entryChannel || '0000intercom'; + const summary = this.peer.expenseSplit.summary(channel); + console.log(`[expense:${summary.channel}] entries=${summary.eventCount} total=${formatCents(summary.totalCents)}`); + if (summary.balances.length === 0) { + console.log('- no balances yet.'); + return; + } + console.log('- balances:'); + for (const entry of summary.balances) { + const sign = entry.cents >= 0 ? '+' : '-'; + console.log(` ${entry.member}: ${sign}${formatCents(Math.abs(entry.cents))}`); + } + if (summary.settlements.length === 0) { + console.log('- settlements: none'); + return; + } + console.log('- settlements:'); + for (const move of summary.settlements) { + console.log(` ${move.from} -> ${move.to}: ${formatCents(move.amountCents)}`); + } + return; + } + if (this.input.startsWith("/expense_clear")) { + if (!this.peer.expenseSplit) { + console.log('Expense split app not initialized.'); + return; + } + const args = this.parseArgs(input); + const channel = args.channel || args.ch || args.room || this.peer.sidechannel?.entryChannel || '0000intercom'; + const result = this.peer.expenseSplit.clearChannel(channel); + if (!result.ok) { + console.log('Failed to clear ledger.'); + return; + } + console.log(`[expense:${result.channel}] ledger cleared.`); + return; + } + if (this.input.startsWith("/expense_persist")) { + if (!this.peer.expenseSplit) { + console.log('Expense split app not initialized.'); + return; + } + if (this.peer.base?.writable === false) { + console.log('Peer is not writable; cannot persist contract state.'); + return; + } + const args = this.parseArgs(input); + const channelRaw = args.channel || args.ch || args.room || this.peer.sidechannel?.entryChannel || '0000intercom'; + const channel = normalizeChannel(channelRaw); + if (!channel) { + console.log('Usage: /expense_persist --channel "" [--sim 1]'); + return; + } + const snapshot = this.peer.expenseSplit.exportRoom(channel); + const commandObj = { + op: 'expense_upsert_room', + channel, + snapshot + }; + const command = this.safeJsonStringify(commandObj); + if (command === null) { + console.log('Failed to serialize snapshot for tx payload.'); + return; + } + const sim = parseBoolFlag(args.sim, false); + try { + const result = await this.tx({ command }, sim); + const err = this.getError(result); + if (err) { + console.log(`Persist failed: ${err.message}`); + return; + } + if (sim) { + console.log(`[expense:${channel}] persist simulated.`); + console.log(result); + return; + } + if (result?.txo?.tx) { + console.log(`[expense:${channel}] persisted. tx=${result.txo.tx}`); + return; + } + console.log(`[expense:${channel}] persist broadcast submitted.`); + } catch (err) { + console.log(`Persist failed: ${err?.message ?? String(err)}`); + } + return; + } + if (this.input.startsWith("/expense_restore")) { + if (!this.peer.expenseSplit) { + console.log('Expense split app not initialized.'); + return; + } + const args = this.parseArgs(input); + const channelRaw = args.channel || args.ch || args.room || this.peer.sidechannel?.entryChannel || '0000intercom'; + const channel = normalizeChannel(channelRaw); + if (!channel) { + console.log('Usage: /expense_restore --channel "" [--confirmed 1|0] [--replace 1]'); + return; + } + const key = `expense/room/${channel}`; + const confirmed = args.unconfirmed !== undefined ? false : parseBoolFlag(args.confirmed, true); + const replace = parseBoolFlag(args.replace, false); + const value = confirmed ? await this.getSigned(key) : await this.get(key); + if (!value || value.deleted === true || value.snapshot === null) { + const localSnapshot = this.peer.expenseSplit.getLocalSnapshot(channel); + if (!localSnapshot) { + console.log(`[expense:${channel}] no persisted snapshot found.`); + return; + } + const localImported = this.peer.expenseSplit.importRoom(localSnapshot, { replace }); + if (!localImported.ok) { + console.log(localImported.error || 'Failed to import local snapshot.'); + return; + } + console.log( + `[expense:${localImported.channel}] restored from local snapshot added=${localImported.added} total=${localImported.total} replace=${replace ? '1' : '0'}` + ); + return; + } + const snapshot = value?.snapshot && typeof value.snapshot === 'object' ? value.snapshot : value; + const imported = this.peer.expenseSplit.importRoom(snapshot, { replace }); + if (!imported.ok) { + console.log(imported.error || 'Failed to import persisted snapshot.'); + return; + } + console.log( + `[expense:${imported.channel}] restored added=${imported.added} total=${imported.total} source=${confirmed ? 'signed' : 'view'} replace=${replace ? '1' : '0'}` + ); + return; + } + if (this.input.startsWith("/expense_export")) { + if (!this.peer.expenseSplit) { + console.log('Expense split app not initialized.'); + return; + } + const args = this.parseArgs(input); + const channelRaw = args.channel || args.ch || args.room || this.peer.sidechannel?.entryChannel || '0000intercom'; + const channel = normalizeChannel(channelRaw); + if (!channel) { + console.log('Usage: /expense_export --channel "" [--format text|json|csv]'); + return; + } + const format = String(args.format || 'text').trim().toLowerCase(); + const summary = this.peer.expenseSplit.summary(channel); + const out = buildExpenseExport(summary, format); + if (!out || !out.data) { + console.log('Export failed.'); + return; + } + console.log(out.data); + if (out.format === 'json' || out.format === 'text') { + const b64 = b4a.toString(b4a.from(out.data, 'utf8'), 'base64'); + console.log('export_b64:', b64); + } + return; + } if (this.input.startsWith("/print")) { const splitted = this.parseArgs(input); console.log(splitted.text); diff --git a/features/expense-split/index.js b/features/expense-split/index.js new file mode 100644 index 00000000..75941849 --- /dev/null +++ b/features/expense-split/index.js @@ -0,0 +1,401 @@ +import fs from 'fs'; +import path from 'path'; + +const APP_KEY = 'expense_split_v1'; + +const normalizeText = (value) => String(value || '').trim(); + +const normalizeMember = (value) => normalizeText(value).toLowerCase(); + +const parseMembers = (value) => { + const values = Array.isArray(value) + ? value + : typeof value === 'string' + ? value.split(',') + : []; + const dedup = new Set(); + const members = []; + for (const raw of values) { + const member = normalizeMember(raw); + if (!member || dedup.has(member)) continue; + dedup.add(member); + members.push(member); + } + return members; +}; + +const parseAmountToCents = (value) => { + if (value === null || value === undefined || value === '') return null; + const numeric = Number(String(value).replace(/,/g, '')); + if (!Number.isFinite(numeric) || numeric <= 0) return null; + return Math.round(numeric * 100); +}; + +const formatCents = (cents) => (Number(cents || 0) / 100).toFixed(2); + +class ExpenseSplit { + constructor(peer, config = {}) { + this.peer = peer; + this.sidechannel = config.sidechannel || null; + this.defaultChannel = + typeof config.defaultChannel === 'string' && config.defaultChannel.trim() + ? config.defaultChannel.trim() + : '0000intercom'; + this.debug = config.debug === true; + this.persistencePath = + typeof config.persistencePath === 'string' && config.persistencePath.trim() + ? config.persistencePath.trim() + : null; + this.rooms = new Map(); + this._loadLocalSnapshots(); + } + + attachSidechannel(sidechannel) { + this.sidechannel = sidechannel; + } + + resolveChannel(channel) { + const target = normalizeText(channel); + return target || this.defaultChannel; + } + + getRoom(channel) { + const key = this.resolveChannel(channel); + if (!this.rooms.has(key)) { + this.rooms.set(key, { + events: [], + txSeen: new Set(), + }); + } + return this.rooms.get(key); + } + + createTxId() { + const pub = this.peer?.wallet?.publicKey; + const key = + typeof pub === 'string' && pub.length > 0 ? pub.slice(0, 12) : Math.random().toString(16).slice(2, 14); + return `${key}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`; + } + + normalizeEvent(event) { + if (!event || typeof event !== 'object') return null; + const txId = normalizeText(event.txId); + const payer = normalizeMember(event.payer); + const amountCents = Number.parseInt(event.amountCents, 10); + const split = parseMembers(event.split); + if (!txId || !payer || !Number.isSafeInteger(amountCents) || amountCents <= 0 || split.length === 0) { + return null; + } + const note = normalizeText(event.note); + const ts = Number.isFinite(event.ts) ? Number(event.ts) : Date.now(); + const by = event.by ?? null; + return { + txId, + payer, + amountCents, + split, + note, + ts, + by, + }; + } + + addEvent(channel, event) { + const normalized = this.normalizeEvent(event); + if (!normalized) { + return { ok: false, error: 'Invalid expense event payload.' }; + } + const room = this.getRoom(channel); + if (room.txSeen.has(normalized.txId)) { + return { ok: true, duplicate: true, event: normalized }; + } + room.txSeen.add(normalized.txId); + room.events.push(normalized); + this._saveLocalSnapshots(); + if (this.debug) { + console.log( + `[expense-split:${this.resolveChannel(channel)}] +${formatCents(normalized.amountCents)} by ${normalized.payer}` + ); + } + return { ok: true, duplicate: false, event: normalized }; + } + + handleSidechannelMessage(channel, payload) { + const message = payload?.message; + if (!message || typeof message !== 'object' || message.app !== APP_KEY) return false; + + if (message.type === 'expense_add') { + this.addEvent(channel, message.event); + return true; + } + + if (message.type === 'expense_clear') { + const target = this.resolveChannel(channel); + this.rooms.delete(target); + if (this.debug) console.log(`[expense-split:${target}] ledger cleared`); + return true; + } + + return false; + } + + addExpense(input = {}) { + const channel = this.resolveChannel(input.channel); + const payer = normalizeMember(input.payer); + if (!payer) return { ok: false, error: 'Missing payer.' }; + + const amountCents = parseAmountToCents(input.amount); + if (!Number.isSafeInteger(amountCents) || amountCents <= 0) { + return { ok: false, error: 'Invalid amount. Use a positive number.' }; + } + + const split = parseMembers(input.split); + if (split.length === 0) { + return { ok: false, error: 'Missing split members. Use comma-separated names.' }; + } + if (!split.includes(payer)) split.unshift(payer); + + const note = normalizeText(input.note); + const event = { + txId: this.createTxId(), + payer, + amountCents, + split, + note, + ts: Date.now(), + by: this.peer?.wallet?.address ?? null, + }; + + const local = this.addEvent(channel, event); + if (!local.ok) return local; + + let broadcasted = false; + if (this.sidechannel && typeof this.sidechannel.broadcast === 'function') { + try { + if (typeof this.sidechannel.addChannel === 'function') { + this.sidechannel.addChannel(channel).catch(() => {}); + } + broadcasted = this.sidechannel.broadcast(channel, { + app: APP_KEY, + type: 'expense_add', + event, + }); + } catch (_e) { + broadcasted = false; + } + } + + return { ok: true, channel, event, broadcasted }; + } + + clearChannel(channel, options = {}) { + const target = this.resolveChannel(channel); + this.rooms.delete(target); + this._saveLocalSnapshots(); + + const shouldBroadcast = options.broadcast !== false; + if ( + shouldBroadcast && + this.sidechannel && + typeof this.sidechannel.broadcast === 'function' + ) { + try { + this.sidechannel.broadcast(target, { + app: APP_KEY, + type: 'expense_clear', + ts: Date.now(), + by: this.peer?.wallet?.address ?? null, + }); + } catch (_e) {} + } + + return { ok: true, channel: target }; + } + + normalizeSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== 'object') return null; + const channel = this.resolveChannel(snapshot.channel); + const sourceEvents = Array.isArray(snapshot.events) ? snapshot.events : []; + const events = []; + const txSeen = new Set(); + for (const raw of sourceEvents) { + const normalized = this.normalizeEvent(raw); + if (!normalized || txSeen.has(normalized.txId)) continue; + txSeen.add(normalized.txId); + events.push(normalized); + } + events.sort((a, b) => a.ts - b.ts); + return { + channel, + version: Number.isSafeInteger(snapshot.version) ? snapshot.version : 1, + events, + }; + } + + exportRoom(channel) { + const target = this.resolveChannel(channel); + const events = this.list(target); + return { + channel: target, + version: 1, + events: events.map((event) => ({ ...event })), + }; + } + + importRoom(snapshot, options = {}) { + const normalized = this.normalizeSnapshot(snapshot); + if (!normalized) { + return { ok: false, error: 'Invalid snapshot.' }; + } + const replace = options.replace === true; + const room = this.getRoom(normalized.channel); + if (replace) { + room.events = []; + room.txSeen = new Set(); + } + let added = 0; + for (const event of normalized.events) { + if (room.txSeen.has(event.txId)) continue; + room.txSeen.add(event.txId); + room.events.push(event); + added += 1; + } + room.events.sort((a, b) => a.ts - b.ts); + this._saveLocalSnapshots(); + return { + ok: true, + channel: normalized.channel, + added, + total: room.events.length, + }; + } + + list(channel) { + const target = this.resolveChannel(channel); + const room = this.rooms.get(target); + if (!room) return []; + return room.events.slice().sort((a, b) => a.ts - b.ts); + } + + balances(channel) { + const events = this.list(channel); + const map = new Map(); + + for (const event of events) { + const members = Array.isArray(event.split) ? event.split : []; + if (members.length === 0) continue; + const baseShare = Math.floor(event.amountCents / members.length); + const remainder = event.amountCents - baseShare * members.length; + + members.forEach((member, idx) => { + const share = baseShare + (idx < remainder ? 1 : 0); + const prev = map.get(member) || 0; + map.set(member, prev - share); + }); + + const payerPrev = map.get(event.payer) || 0; + map.set(event.payer, payerPrev + event.amountCents); + } + + return Array.from(map.entries()) + .map(([member, cents]) => ({ member, cents })) + .sort((a, b) => a.member.localeCompare(b.member)); + } + + settlements(channel) { + const balances = this.balances(channel); + const creditors = balances + .filter((entry) => entry.cents > 0) + .map((entry) => ({ ...entry })) + .sort((a, b) => b.cents - a.cents); + const debtors = balances + .filter((entry) => entry.cents < 0) + .map((entry) => ({ member: entry.member, cents: Math.abs(entry.cents) })) + .sort((a, b) => b.cents - a.cents); + + const settlements = []; + let i = 0; + let j = 0; + while (i < debtors.length && j < creditors.length) { + const debtor = debtors[i]; + const creditor = creditors[j]; + const amountCents = Math.min(debtor.cents, creditor.cents); + if (amountCents > 0) { + settlements.push({ + from: debtor.member, + to: creditor.member, + amountCents, + }); + } + debtor.cents -= amountCents; + creditor.cents -= amountCents; + if (debtor.cents === 0) i += 1; + if (creditor.cents === 0) j += 1; + } + return settlements; + } + + summary(channel) { + const target = this.resolveChannel(channel); + const events = this.list(target); + const balances = this.balances(target); + const settlements = this.settlements(target); + const totalCents = events.reduce((sum, item) => sum + item.amountCents, 0); + return { + channel: target, + eventCount: events.length, + totalCents, + balances, + settlements, + }; + } + + formatAmount(cents) { + return formatCents(cents); + } + + getLocalSnapshot(channel) { + const target = this.resolveChannel(channel); + const room = this.rooms.get(target); + if (!room || !Array.isArray(room.events) || room.events.length === 0) return null; + return this.exportRoom(target); + } + + _serializeLocalSnapshots() { + const rooms = []; + for (const key of this.rooms.keys()) { + const snapshot = this.exportRoom(key); + if (snapshot.events.length === 0) continue; + rooms.push(snapshot); + } + return { + version: 1, + updatedAt: Date.now(), + rooms, + }; + } + + _saveLocalSnapshots() { + if (!this.persistencePath) return; + try { + fs.mkdirSync(path.dirname(this.persistencePath), { recursive: true }); + fs.writeFileSync(this.persistencePath, `${JSON.stringify(this._serializeLocalSnapshots())}\n`, 'utf8'); + } catch (_e) {} + } + + _loadLocalSnapshots() { + if (!this.persistencePath) return; + try { + if (!fs.existsSync(this.persistencePath)) return; + const raw = fs.readFileSync(this.persistencePath, 'utf8'); + if (!raw) return; + const parsed = JSON.parse(raw); + const rooms = Array.isArray(parsed?.rooms) ? parsed.rooms : []; + for (const snapshot of rooms) { + this.importRoom(snapshot, { replace: true }); + } + } catch (_e) {} + } +} + +export default ExpenseSplit; diff --git a/features/sc-bridge/index.js b/features/sc-bridge/index.js index daa945a7..adb15e83 100644 --- a/features/sc-bridge/index.js +++ b/features/sc-bridge/index.js @@ -55,6 +55,22 @@ const matchesFilter = (filter, text) => { return filter.some((group) => group.every((word) => haystack.includes(word))); }; +const parseMembers = (value) => { + const values = Array.isArray(value) + ? value + : typeof value === 'string' + ? value.split(',') + : []; + return values + .map((entry) => String(entry || '').trim().toLowerCase()) + .filter((entry) => entry.length > 0); +}; + +const parseBoolFlag = (value, fallback = false) => { + if (value === undefined || value === null || value === '') return fallback; + return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase()); +}; + class ScBridge extends Feature { constructor(peer, config = {}) { super(peer, config); @@ -369,6 +385,267 @@ class ScBridge extends Feature { reply({ type: 'open_requested', channel, via: via || null }); return; } + case 'expense_add': { + const app = this.peer?.expenseSplit; + if (!app) { + sendError('Expense split app not initialized.'); + return; + } + const channel = String(message.channel || app.defaultChannel || '').trim(); + const payer = String(message.payer || '').trim(); + const amount = message.amount; + const split = parseMembers(message.split); + const note = message.note; + if (!channel || !payer || amount === undefined || split.length === 0) { + sendError('Missing fields. Required: channel, payer, amount, split.'); + return; + } + const result = app.addExpense({ channel, payer, amount, split, note }); + if (!result.ok) { + sendError(result.error || 'Failed to add expense.'); + return; + } + reply({ + type: 'expense_added', + channel: result.channel, + event: result.event, + broadcasted: result.broadcasted === true, + }); + return; + } + case 'expense_list': { + const app = this.peer?.expenseSplit; + if (!app) { + sendError('Expense split app not initialized.'); + return; + } + const channel = String(message.channel || app.defaultChannel || '').trim(); + if (!channel) { + sendError('Missing channel.'); + return; + } + const events = app.list(channel); + reply({ + type: 'expense_list', + channel, + count: events.length, + events, + }); + return; + } + case 'expense_balance': { + const app = this.peer?.expenseSplit; + if (!app) { + sendError('Expense split app not initialized.'); + return; + } + const channel = String(message.channel || app.defaultChannel || '').trim(); + if (!channel) { + sendError('Missing channel.'); + return; + } + const summary = app.summary(channel); + reply({ + type: 'expense_balance', + summary, + }); + return; + } + case 'expense_clear': { + const app = this.peer?.expenseSplit; + if (!app) { + sendError('Expense split app not initialized.'); + return; + } + const channel = String(message.channel || app.defaultChannel || '').trim(); + if (!channel) { + sendError('Missing channel.'); + return; + } + const result = app.clearChannel(channel); + reply({ + type: 'expense_cleared', + channel: result.channel, + ok: result.ok === true, + }); + return; + } + case 'expense_export': { + const app = this.peer?.expenseSplit; + if (!app) { + sendError('Expense split app not initialized.'); + return; + } + const channel = String(message.channel || app.defaultChannel || '').trim(); + if (!channel) { + sendError('Missing channel.'); + return; + } + const format = String(message.format || 'text').trim().toLowerCase(); + const summary = app.summary(channel); + if (format === 'csv') { + const lines = ['from,to,amount']; + for (const row of summary.settlements || []) { + lines.push(`${row.from},${row.to},${app.formatAmount(row.amountCents)}`); + } + reply({ + type: 'expense_export', + channel: summary.channel, + format: 'csv', + data: lines.join('\n'), + summary, + }); + return; + } + if (format === 'json') { + reply({ + type: 'expense_export', + channel: summary.channel, + format: 'json', + data: JSON.stringify(summary, null, 2), + summary, + }); + return; + } + const lines = []; + lines.push('InterSplit Settlement Export'); + lines.push(`channel: ${summary.channel}`); + lines.push(`events: ${summary.eventCount}`); + lines.push(`total: ${app.formatAmount(summary.totalCents)}`); + lines.push('settlements:'); + if (!summary.settlements || summary.settlements.length === 0) { + lines.push('- none'); + } else { + for (const row of summary.settlements) { + lines.push(`- ${row.from} -> ${row.to}: ${app.formatAmount(row.amountCents)}`); + } + } + reply({ + type: 'expense_export', + channel: summary.channel, + format: 'text', + data: lines.join('\n'), + summary, + }); + return; + } + case 'expense_persist': { + const app = this.peer?.expenseSplit; + const protocol = this.peer?.protocol?.instance; + if (!app) { + sendError('Expense split app not initialized.'); + return; + } + if (!protocol || typeof protocol.tx !== 'function' || typeof protocol.safeJsonStringify !== 'function') { + sendError('Protocol tx interface not available.'); + return; + } + if (this.peer?.base?.writable === false) { + sendError('Peer is not writable.'); + return; + } + const channel = String(message.channel || app.defaultChannel || '').trim().toLowerCase(); + if (!channel) { + sendError('Missing channel.'); + return; + } + const snapshot = app.exportRoom(channel); + const command = protocol.safeJsonStringify({ + op: 'expense_upsert_room', + channel, + snapshot, + }); + if (command === null) { + sendError('Failed to serialize snapshot for tx payload.'); + return; + } + const sim = parseBoolFlag(message.sim, false); + protocol + .tx({ command }, sim) + .then((txResult) => { + const err = typeof protocol.getError === 'function' ? protocol.getError(txResult) : null; + if (err) { + sendError(`Persist failed: ${err.message}`); + return; + } + reply({ + type: 'expense_persisted', + channel, + sim, + tx: txResult?.txo?.tx || null, + result: txResult || null, + }); + }) + .catch((err) => { + sendError(`Persist failed: ${err?.message ?? String(err)}`); + }); + return; + } + case 'expense_restore': { + const app = this.peer?.expenseSplit; + const protocol = this.peer?.protocol?.instance; + if (!app) { + sendError('Expense split app not initialized.'); + return; + } + if (!protocol || (typeof protocol.getSigned !== 'function' && typeof protocol.get !== 'function')) { + sendError('Protocol state reader not available.'); + return; + } + const channel = String(message.channel || app.defaultChannel || '').trim().toLowerCase(); + if (!channel) { + sendError('Missing channel.'); + return; + } + const confirmed = parseBoolFlag(message.confirmed, true); + const replace = parseBoolFlag(message.replace, false); + const key = `expense/room/${channel}`; + const readPromise = confirmed ? protocol.getSigned(key) : protocol.get(key); + Promise.resolve(readPromise) + .then((value) => { + if (!value || value.deleted === true || value.snapshot === null) { + const localSnapshot = typeof app.getLocalSnapshot === 'function' ? app.getLocalSnapshot(channel) : null; + if (!localSnapshot) { + sendError('No persisted snapshot found.'); + return; + } + const localImported = app.importRoom(localSnapshot, { replace }); + if (!localImported.ok) { + sendError(localImported.error || 'Failed to import local snapshot.'); + return; + } + reply({ + type: 'expense_restored', + channel: localImported.channel, + added: localImported.added, + total: localImported.total, + confirmed, + replace, + source: 'local', + }); + return; + } + const snapshot = value?.snapshot && typeof value.snapshot === 'object' ? value.snapshot : value; + const imported = app.importRoom(snapshot, { replace }); + if (!imported.ok) { + sendError(imported.error || 'Failed to import snapshot.'); + return; + } + reply({ + type: 'expense_restored', + channel: imported.channel, + added: imported.added, + total: imported.total, + confirmed, + replace, + source: 'contract', + }); + }) + .catch((err) => { + sendError(`Restore failed: ${err?.message ?? String(err)}`); + }); + return; + } case 'stats': { if (!this.sidechannel) { sendError('Sidechannel not ready.'); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..7b027fae --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,307 @@ + + + + + + InterSplit Control Panel + + + + + + + + + InterSplit Control Panel + disconnected + + + + + Live Feed + + Tip: anyone can type !balance in the room if your bot listens for it. + + + + + Scroll inside this right panel for all sections. + + + Connection + + + WebSocket URL + + + + SC-Bridge Token + + + + Channel + + + Connect + Disconnect + Join + Subscribe + + + Join: not joined + Subscribe: inactive + + + + + Chat + + + Message + + + Send Message + + + + + Expense Actions + + + Payer + + + + Amount + + + + Split Members (comma) + + + + Note + + + Add Expense + Balance + Export Text + Persist + Restore + Clear + + + + + Assistant Input + + + Prompt + + + Run Prompt + + + Supported: add ..., balance, persist, restore, export [text|json|csv], send .... + + + Note: restore fallback uses local node files in stores/<peer-store-name>, not browser storage. + + + + + + + + + diff --git a/frontend/ui.js b/frontend/ui.js new file mode 100644 index 00000000..8e547f40 --- /dev/null +++ b/frontend/ui.js @@ -0,0 +1,373 @@ +const el = (id) => document.getElementById(id); + +const state = { + ws: null, + authed: false, + nextId: 1, + pending: new Map(), +}; + +const timeoutByType = { + expense_persist: 65_000, + expense_restore: 30_000, + expense_export: 20_000, + expense_balance: 20_000, +}; + +const logEl = el('log'); +const statusEl = el('status'); +const joinStateEl = el('joinState'); +const subStateEl = el('subState'); + +const log = (message, kind = 'info') => { + let safeMessage = String(message ?? ''); + if (safeMessage.length > 900) { + safeMessage = `${safeMessage.slice(0, 900)} ...[truncated]`; + } + const line = document.createElement('div'); + const ts = new Date().toISOString().slice(11, 19); + line.textContent = `[${ts}] ${kind.toUpperCase()} ${safeMessage}`; + if (kind === 'error') line.style.color = '#dc2626'; + if (kind === 'ok') line.style.color = '#0f766e'; + logEl.appendChild(line); + logEl.scrollTop = logEl.scrollHeight; +}; + +const setStatus = (text) => { + statusEl.textContent = text; +}; + +const setMiniState = (target, text, kind = '') => { + if (!target) return; + target.textContent = text; + target.classList.remove('ok', 'pending', 'error'); + if (kind) target.classList.add(kind); +}; + +const sendRaw = (payload) => { + if (!state.ws || state.ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not connected.'); + } + state.ws.send(JSON.stringify(payload)); +}; + +const request = (type, payload = {}, options = {}) => + new Promise((resolve, reject) => { + const id = state.nextId++; + const timeoutMs = Number.isFinite(options.timeoutMs) + ? options.timeoutMs + : timeoutByType[type] || 12_000; + const timer = setTimeout(() => { + const p = state.pending.get(id); + if (!p) return; + state.pending.delete(id); + reject(new Error(`Request timeout: ${type}`)); + }, timeoutMs); + state.pending.set(id, { resolve, reject, type, timer }); + try { + sendRaw({ id, type, ...payload }); + } catch (err) { + clearTimeout(timer); + state.pending.delete(id); + reject(err); + } + }); + +const withButtonBusy = async (buttonId, busyLabel, task) => { + const btn = el(buttonId); + if (!btn) return task(); + if (btn.dataset.busy === '1') return; + const prevText = btn.textContent; + btn.dataset.busy = '1'; + btn.disabled = true; + btn.textContent = busyLabel; + try { + return await task(); + } finally { + btn.dataset.busy = '0'; + btn.disabled = false; + btn.textContent = prevText; + } +}; + +const channel = () => String(el('channel').value || '').trim(); + +const connect = () => { + const wsUrl = String(el('wsUrl').value || '').trim(); + const token = String(el('token').value || '').trim(); + if (!wsUrl) { + log('WS URL is required.', 'error'); + return; + } + if (!token) { + log('SC-Bridge token is required.', 'error'); + return; + } + + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.close(); + } + state.ws = new WebSocket(wsUrl); + setStatus('connecting...'); + setMiniState(joinStateEl, 'Join: not joined'); + setMiniState(subStateEl, 'Subscribe: inactive'); + + state.ws.onopen = () => { + setStatus('connected'); + log(`Connected: ${wsUrl}`, 'ok'); + sendRaw({ type: 'auth', token }); + }; + + state.ws.onclose = () => { + state.authed = false; + setStatus('disconnected'); + setMiniState(joinStateEl, 'Join: disconnected'); + setMiniState(subStateEl, 'Subscribe: disconnected'); + log('Disconnected.', 'error'); + }; + + state.ws.onerror = () => { + setStatus('error'); + log('WebSocket error.', 'error'); + }; + + state.ws.onmessage = (ev) => { + let msg = null; + try { + msg = JSON.parse(ev.data); + } catch (_e) { + log(`Non-JSON message: ${String(ev.data)}`, 'error'); + return; + } + + if (Number.isInteger(msg.id) && state.pending.has(msg.id)) { + const p = state.pending.get(msg.id); + state.pending.delete(msg.id); + if (p.timer) clearTimeout(p.timer); + if (msg.type === 'error') p.reject(new Error(msg.error || 'Request failed.')); + else p.resolve(msg); + return; + } + + if (msg.type === 'hello') { + log(`Hello peer=${msg.peer || 'n/a'} requiresAuth=${String(msg.requiresAuth)}`); + return; + } + if (msg.type === 'auth_ok') { + state.authed = true; + setStatus('authenticated'); + log('Authenticated.', 'ok'); + return; + } + if (msg.type === 'error') { + log(msg.error || 'Unknown error', 'error'); + return; + } + if (msg.type === 'sidechannel_message') { + const from = msg.from || 'unknown'; + const m = typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message); + log(`[${msg.channel}] ${from}: ${m}`); + return; + } + + log(JSON.stringify(msg)); + }; +}; + +const run = async (fn) => { + try { + await fn(); + } catch (err) { + log(err?.message || String(err), 'error'); + } +}; + +const mustAuth = () => { + if (!state.authed) throw new Error('Authenticate first (Connect).'); +}; + +const parseAssistant = async () => { + mustAuth(); + const input = String(el('assistantText').value || '').trim(); + if (!input) return; + const c = channel(); + const lower = input.toLowerCase(); + + if (lower.startsWith('balance')) { + const res = await request('expense_balance', { channel: c }); + log(JSON.stringify(res.summary, null, 2), 'ok'); + return; + } + if (lower.startsWith('persist')) { + log('Persisting snapshot... this can take 10-60s.'); + const res = await request('expense_persist', { channel: c }, { timeoutMs: 65_000 }); + log(`Persisted tx=${res.tx || 'n/a'}`, 'ok'); + return; + } + if (lower.startsWith('restore')) { + log('Restoring snapshot from local node store...'); + const res = await request( + 'expense_restore', + { channel: c, confirmed: false, replace: true }, + { timeoutMs: 30_000 } + ); + log(`Restored added=${res.added} total=${res.total} source=${res.source || 'unknown'}`, 'ok'); + return; + } + if (lower.startsWith('export')) { + const m = input.match(/export\s+(text|json|csv)/i); + const format = m ? m[1].toLowerCase() : 'text'; + const res = await request('expense_export', { channel: c, format }); + log(res.data || JSON.stringify(res), 'ok'); + return; + } + if (lower.startsWith('send ')) { + const text = input.slice(5).trim(); + const res = await request('send', { channel: c, message: text }); + log(JSON.stringify(res), 'ok'); + return; + } + if (lower.startsWith('add ')) { + const amount = input.match(/\b(\d+(?:\.\d+)?)\b/); + const payer = input.match(/add\s+([a-z0-9_-]+)/i); + const split = input.match(/split\s+([a-z0-9_,-]+)/i); + const note = input.match(/note\s+(.+)$/i); + if (!amount || !payer || !split) { + throw new Error('Use: add alice 30 split alice,bob note dinner'); + } + const res = await request('expense_add', { + channel: c, + payer: payer[1], + amount: amount[1], + split: split[1], + note: note ? note[1] : '', + }); + log(JSON.stringify(res), 'ok'); + return; + } + + throw new Error('Unknown prompt. Try: add/balance/persist/restore/export/send'); +}; + +el('connectBtn').addEventListener('click', () => run(connect)); +el('disconnectBtn').addEventListener('click', () => { + if (state.ws) state.ws.close(); +}); + +el('joinBtn').addEventListener('click', () => + run(async () => { + mustAuth(); + const ch = channel(); + setMiniState(joinStateEl, `Join: joining ${ch}...`, 'pending'); + try { + const res = await request('join', { channel: ch }); + setMiniState(joinStateEl, `Join: joined ${res.channel || ch}`, 'ok'); + log(JSON.stringify(res), 'ok'); + } catch (err) { + setMiniState(joinStateEl, `Join: failed ${ch}`, 'error'); + throw err; + } + }) +); + +el('subBtn').addEventListener('click', () => + run(async () => { + mustAuth(); + const ch = channel(); + setMiniState(subStateEl, `Subscribe: updating ${ch}...`, 'pending'); + try { + const res = await request('subscribe', { channel: ch }); + const channels = Array.isArray(res.channels) ? res.channels : []; + const label = channels.length > 0 ? channels.join(', ') : 'none'; + setMiniState(subStateEl, `Subscribe: ${label}`, 'ok'); + log(JSON.stringify(res), 'ok'); + } catch (err) { + setMiniState(subStateEl, `Subscribe: failed ${ch}`, 'error'); + throw err; + } + }) +); + +el('sendChatBtn').addEventListener('click', () => + run(async () => { + mustAuth(); + const msg = String(el('chatText').value || '').trim(); + if (!msg) throw new Error('Message is empty.'); + const res = await request('send', { channel: channel(), message: msg }); + log(JSON.stringify(res), 'ok'); + el('chatText').value = ''; + }) +); + +el('addExpenseBtn').addEventListener('click', () => + run(async () => { + mustAuth(); + const res = await request('expense_add', { + channel: channel(), + payer: String(el('payer').value || '').trim(), + amount: String(el('amount').value || '').trim(), + split: String(el('split').value || '').trim(), + note: String(el('note').value || '').trim(), + }); + log(JSON.stringify(res), 'ok'); + }) +); + +el('balanceBtn').addEventListener('click', () => + run(async () => { + mustAuth(); + const res = await request('expense_balance', { channel: channel() }); + log(JSON.stringify(res.summary, null, 2), 'ok'); + }) +); + +el('exportBtn').addEventListener('click', () => + run(async () => { + mustAuth(); + const res = await request('expense_export', { channel: channel(), format: 'text' }); + log(res.data || JSON.stringify(res), 'ok'); + }) +); + +el('persistBtn').addEventListener('click', () => + run(async () => { + mustAuth(); + await withButtonBusy('persistBtn', 'Persisting...', async () => { + log('Persisting snapshot... this can take 10-60s.'); + const res = await request('expense_persist', { channel: channel() }, { timeoutMs: 65_000 }); + log(`Persisted tx=${res.tx || 'n/a'}`, 'ok'); + }); + }) +); + +el('restoreBtn').addEventListener('click', () => + run(async () => { + mustAuth(); + await withButtonBusy('restoreBtn', 'Restoring...', async () => { + log('Restoring snapshot from local node store...'); + const res = await request( + 'expense_restore', + { + channel: channel(), + confirmed: false, + replace: true, + }, + { timeoutMs: 30_000 } + ); + log(`Restored added=${res.added} total=${res.total} source=${res.source || 'unknown'}`, 'ok'); + }); + }) +); + +el('clearBtn').addEventListener('click', () => + run(async () => { + mustAuth(); + const res = await request('expense_clear', { channel: channel() }); + log(JSON.stringify(res), 'ok'); + }) +); + +el('assistantBtn').addEventListener('click', () => run(parseAssistant)); + +setStatus('disconnected'); +log('UI loaded. Connect to SC-Bridge to begin.'); diff --git a/index.js b/index.js index 47bc4ade..c83b47a5 100644 --- a/index.js +++ b/index.js @@ -14,6 +14,7 @@ import SampleContract from './contract/contract.js'; import { Timer } from './features/timer/index.js'; import Sidechannel from './features/sidechannel/index.js'; import ScBridge from './features/sc-bridge/index.js'; +import ExpenseSplit from './features/expense-split/index.js'; const { env, storeLabel, flags } = getPearRuntime(); @@ -353,7 +354,7 @@ const msbConfig = createMsbConfig(MSB_ENV.MAINNET, { storeName: msbStoreName, storesDirectory: msbStoresDirectory, enableInteractiveMode: false, - dhtBootstrap: msbDhtBootstrap || undefined, + ...(msbDhtBootstrap ? { dhtBootstrap: msbDhtBootstrap } : {}), }); const msbBootstrapHex = b4a.toString(msbConfig.bootstrap, 'hex'); @@ -370,7 +371,7 @@ const peerConfig = createPeerConfig(PEER_ENV.MAINNET, { enableBackgroundTasks: true, enableUpdater: true, replicate: true, - dhtBootstrap: peerDhtBootstrap || undefined, + ...(peerDhtBootstrap ? { dhtBootstrap: peerDhtBootstrap } : {}), }); const ensureKeypairFile = async (keyPairPath) => { @@ -481,6 +482,32 @@ if (scBridgeEnabled) { }); } +const expenseSplit = new ExpenseSplit(peer, { + defaultChannel: sidechannelEntry, + debug: sidechannelDebug, + persistencePath: path.join(peerConfig.fullStoresDirectory, 'expense-split.snapshots.json'), +}); +peer.expenseSplit = expenseSplit; + +const onSidechannelMessage = (channel, payload, connection) => { + let handledByApp = false; + try { + handledByApp = expenseSplit.handleSidechannelMessage(channel, payload, connection) === true; + } catch (err) { + console.error('ExpenseSplit message handler error:', err?.message ?? err); + } + + if (scBridgeEnabled && scBridge) { + scBridge.handleSidechannelMessage(channel, payload, connection); + return; + } + + if (sidechannelQuiet || handledByApp) return; + const from = payload?.from ?? 'unknown'; + const msg = payload?.message ?? payload; + console.log(`[sidechannel:${channel}] ${from}:`, msg); +}; + const sidechannel = new Sidechannel(peer, { channels: [sidechannelEntry, ...sidechannelExtras], debug: sidechannelDebug, @@ -502,13 +529,10 @@ const sidechannel = new Sidechannel(peer, { ownerWriteChannels: sidechannelOwnerWriteChannels || undefined, ownerKeys: sidechannelOwnerMap.size > 0 ? sidechannelOwnerMap : undefined, welcomeByChannel: sidechannelWelcomeMap.size > 0 ? sidechannelWelcomeMap : undefined, - onMessage: scBridgeEnabled - ? (channel, payload, connection) => scBridge.handleSidechannelMessage(channel, payload, connection) - : sidechannelQuiet - ? () => {} - : null, + onMessage: onSidechannelMessage, }); peer.sidechannel = sidechannel; +expenseSplit.attachSidechannel(sidechannel); if (scBridge) { scBridge.attachSidechannel(sidechannel); diff --git a/package.json b/package.json index 5961dfd1..465b1d96 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.0.1", "type": "module", "main": "index.js", + "scripts": { + "ui": "node scripts/serve-ui.mjs" + }, "pear": { "name": "contract-test-latest", "type": "terminal" diff --git a/scripts/serve-ui.mjs b/scripts/serve-ui.mjs new file mode 100644 index 00000000..a1c0d414 --- /dev/null +++ b/scripts/serve-ui.mjs @@ -0,0 +1,54 @@ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; + +const host = process.env.UI_HOST || '127.0.0.1'; +const port = Number.parseInt(process.env.UI_PORT || '5070', 10); +const root = path.resolve('frontend'); + +const mime = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', +}; + +const resolvePath = (urlPath) => { + const clean = String(urlPath || '/').split('?')[0].split('#')[0]; + const rel = clean === '/' ? '/index.html' : clean; + const full = path.resolve(root, `.${rel}`); + if (!full.startsWith(root)) return null; + return full; +}; + +const server = http.createServer((req, res) => { + const filePath = resolvePath(req.url || '/'); + if (!filePath) { + res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('Bad request'); + return; + } + + fs.stat(filePath, (statErr, stat) => { + if (statErr || !stat.isFile()) { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + res.writeHead(200, { + 'content-type': mime[ext] || 'application/octet-stream', + 'cache-control': 'no-cache', + }); + fs.createReadStream(filePath).pipe(res); + }); +}); + +server.listen(port, host, () => { + console.log(`InterSplit UI running at http://${host}:${port}`); + console.log('Open this URL in your browser while Intercom runs with --sc-bridge 1.'); +});
!balance
add ...
balance
persist
restore
export [text|json|csv]
send ...
stores/<peer-store-name>