diff --git a/CHANGELOG.md b/CHANGELOG.md index a21b11f..068978d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,12 @@ and the project aims to follow [Semantic Versioning](https://semver.org/spec/v2. ## [Unreleased] +## [0.9.0] โ€” 2026-10-10 + ### Added +- **RGB on this phone.** A complete on-chain RGB wallet that runs on the phone, on the official rgb-lib 0.3.0-beta.7: receive, send, issue and see RGB assets without a node, on mainnet or Mutinynet. Receive is clearer about which way to get paid. +- **Prismo.** KaleidoMind has a face: an animated character in the Mind tab, in chat, on the voice screen and on the cards the assistant shows. It looks, listens, thinks, speaks and reacts to what happens (a payment sent, something that needs a look). +- **Agent wallet.** A separate wallet with its own budget that KaleidoMind can pay small services from (paid data APIs over L402 / MPP) without touching your main funds. Set a limit per payment, per day and per month, pause it, and see every payment in its log. Anything above the limits asks you first, and the sheet shows what the limits leave. - **Tell KaleidoMind what to do.** A bar on the Dashboard and the Mind tab takes a typed or dictated request ("send 10โ‚ฌ to Mario", "swap half my BTC to USDT", "receive 50k sats on Lightning", in English or Italian) and answers with a card: amount and fiat value, recipient, route and estimated fee, from the wallet's own prices and quotes. Review opens Send, Receive or Swap filled in, where you confirm as usual. Common requests work without the model; questions like "how much did I spend this week" get a small summary from Activity. - **Explain.** Activity details (the payment, its fee, an RGB transfer's step), a failed swap and Send errors have an Explain button that says what happened in a few plain sentences, on this phone, with a built-in explanation when KaleidoMind is off. - **Insights on the Dashboard.** Small cards for things worth a look: an RGB backup that failed or never ran, little room to receive on an RGB channel, a swap or payment still unconfirmed, a large payment received, low network fees. Each has one action and can be dismissed. @@ -53,6 +58,12 @@ and the project aims to follow [Semantic Versioning](https://semver.org/spec/v2. swap fee, and the wallet claims the payment while Receive is open. ### Fixed +- **The confirm sheet and the payment can no longer disagree.** A payment from chat or voice goes to exactly the destination the sheet showed, even if the contact's address changed in between. An invoice's own amount is the one shown and paid; a different amount from the assistant is refused. An invoice that a Lightning address returns for another amount than you approved is refused. +- **Flashnet swaps show their floor.** The review says the least you can receive ("At least"), and the swap is bound to it: 1% below the quote instead of a hidden 5%. +- **Assistant errors are readable.** The chat says what to do next (clear a long chat, reload the model, check the connection) and no longer prints raw technical errors. +- Activity shows what is known at once and adds each account as it answers, instead of waiting for the slowest one. +- RGB on this phone is no longer reported offline right after it connects. +- KaleidoMind no longer crashes the app when the on-device model can't start. - Payment sounds play again: Send chimes when a payment completes (or fails), and Receive plays the received chime when a payment arrives. - Swap explains when trading pairs are loading, empty or failed to load, with Retry or a link to Settings. - The Nostr private key and NWC connection string are copied with the auto-clearing secure clipboard. @@ -80,6 +91,7 @@ and the project aims to follow [Semantic Versioning](https://semver.org/spec/v2. automatic one. ### Removed +- The `get_swap_quote` assistant tool, which only pointed at the Swap screen; the assistant quotes with the KaleidoSwap tools and swaps after you confirm. - Screens nothing opened (payment success, Nostr setup, duplicate History and assistant routes) and eight unused components. - The pre-payment "confirmation" screen for channel orders: the order's diff --git a/app.json b/app.json index 53fd76a..065a578 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "KaleidoSwap", "slug": "rate", - "version": "0.3.0", + "version": "0.9.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", diff --git a/package.json b/package.json index e9564fb..002627a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kaleidoswap-wallet", - "version": "0.3.0", + "version": "0.9.0", "main": "index.ts", "scripts": { "start": "expo start", diff --git a/screens/AIAssistantScreen.tsx b/screens/AIAssistantScreen.tsx index 257fa36..bc14609 100644 --- a/screens/AIAssistantScreen.tsx +++ b/screens/AIAssistantScreen.tsx @@ -46,6 +46,7 @@ import { shareLightningInvoice } from '../components/InvoiceQRCode'; import ToastService from '../services/ToastService'; import { useQVAC } from '../hooks/useQVAC'; import { getModelById } from '../services/qvacModels'; +import { chatErrorMessage } from '../services/aiErrors'; import type { Message as MindMessage, Skill } from '@kaleidorg/mind'; import { createMindAgent } from '../services/mindAgent'; import { useAiConfirm } from '../hooks/useAiConfirm'; @@ -635,15 +636,11 @@ export default function AIAssistantScreen({ navigation, route }: Props) { console.error('KaleidoMind chat error:', error); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); flashMood('concerned'); - // Wallet-tool errors are written for the user ("Your SPARK wallet isn't - // connected yet.") โ€” show them; fall back to a generic note otherwise. - const raw = error instanceof Error && error.message ? error.message : ''; - // A prompt-too-long overflow is opaque to users โ€” translate it into an action. - const msg = /context window|prompt tokens|exceeds the|context length|too long/i.test(raw) - ? 'This conversation got too long for the on-device model. Clear the chat (๐Ÿ—‘๏ธ in the header) to start fresh, then try again.' - : raw; + // Wallet-tool errors are written for the user and shown as is; known engine + // failures say what to do next; technical errors never reach the bubble. + const msg = chatErrorMessage(error); updateMessage(assistantId, () => ({ - text: msg || "I couldn't process that on-device just now. Please try again. ๐Ÿ”ง", + text: msg, streaming: false, })); } diff --git a/services/agentWallet/agentPay.ts b/services/agentWallet/agentPay.ts index 6790ab0..e662970 100644 --- a/services/agentWallet/agentPay.ts +++ b/services/agentWallet/agentPay.ts @@ -34,6 +34,9 @@ export interface AgentPayConfirmation { invoice: string; reason: string; why: string; + /** What the daily and monthly limits leave, before this payment. */ + leftTodaySats: number; + leftMonthSats: number; } export interface AgentPayRequest { @@ -152,7 +155,11 @@ async function payNow(req: AgentPayRequest, deps: AgentPayDeps): Promise false) + ? await deps.confirm({ + service, amountSats, feeSats, invoice, reason, why: decision.reason, + leftTodaySats: Math.max(0, (policy?.dailySats ?? 0) - totals.todaySats), + leftMonthSats: Math.max(0, (policy?.monthlySats ?? 0) - totals.monthSats), + }).catch(() => false) : false; if (approved !== true) { await store.add({ kind: 'spend', amountSats, feeSats: 0, status: 'cancelled', service, reason, paymentHash: decoded.paymentHash }).catch(() => {}); diff --git a/services/agentWallet/confirm.ts b/services/agentWallet/confirm.ts index d13756c..58f2f8d 100644 --- a/services/agentWallet/confirm.ts +++ b/services/agentWallet/confirm.ts @@ -21,6 +21,8 @@ export function bindAgentConfirm(ask: Ask | undefined): () => void { fee_sats: c.feeSats, invoice: c.invoice, why: c.why, + left_today_sats: c.leftTodaySats, + left_month_sats: c.leftMonthSats, }, }); return decision?.approved === true; diff --git a/services/aiConfirm.test.ts b/services/aiConfirm.test.ts index 362481b..680c7f9 100644 --- a/services/aiConfirm.test.ts +++ b/services/aiConfirm.test.ts @@ -14,6 +14,7 @@ import { previewSendPayment } from './walletTools'; jest.mock('./swapTools', () => ({ describeSwapQuote: jest.fn() })); jest.mock('./walletTools', () => ({ previewSendPayment: jest.fn(), + reconcileInvoiceAmount: (_invoice: string, requested?: number) => requested, lightningRailLabel: jest.fn(() => 'Lightning (Spark wallet)'), })); jest.mock('./SecurityService', () => ({ __esModule: true, default: { getInstance: jest.fn() } })); @@ -48,6 +49,12 @@ describe('swap readback', () => { expect(r.rows[0].value).toBe('20 USDB ยท Spark'); }); + it('shows the floor a Flashnet swap can end at, and nothing for a fixed-terms maker swap', () => { + const flash = swapReadback({ ...QUOTE, venue: 'flashnet', receiveAmount: 25_000, receiveUnit: 'sats', minReceive: 24_750 }); + expect(flash.rows.find((x) => x.label === 'At least')?.value).toBe('24,750 sats'); + expect(swapReadback(QUOTE).rows.find((x) => x.label === 'At least')).toBeUndefined(); + }); + it('execute_swap builds from the cached quote and refuses a missing one', async () => { (describeSwapQuote as jest.Mock).mockReturnValueOnce(QUOTE).mockReturnValueOnce(null); await expect(buildConfirmReadback({ name: 'execute_swap', arguments: { quote_id: 'rfq1' } })).resolves.toMatchObject({ kind: 'swap', quoteId: 'rfq1' }); @@ -139,6 +146,11 @@ describe('agent wallet payment readback', () => { expect(r.rows).toEqual(expect.arrayContaining([{ label: 'Pays from', value: 'Agent wallet' }, { label: 'Fee', value: 'Up to 3 sats' }])); }); + it('shows what the daily and monthly limits leave', async () => { + const r = await buildConfirmReadback({ name: 'fetch_paid_resource', arguments: { agent_wallet: true, service: 'api.example.com', amount_sats: 500, fee_sats: 3, invoice, left_today_sats: 4_500, left_month_sats: 49_500 } }); + expect(r.rows).toEqual(expect.arrayContaining([{ label: 'Budget left', value: '4,500 sats today ยท 49,500 sats this month' }])); + }); + it('refuses when the amount shown would not match the invoice', async () => { await expect(buildConfirmReadback({ name: 'fetch_paid_resource', arguments: { agent_wallet: true, service: 'a.com', amount_sats: 5, invoice } })).rejects.toThrow(/doesn't match/); }); diff --git a/services/aiConfirm.ts b/services/aiConfirm.ts index b970a5e..4c021f8 100644 --- a/services/aiConfirm.ts +++ b/services/aiConfirm.ts @@ -4,7 +4,7 @@ import { decodeBolt11 } from '../utils/decodeInvoice'; import { describeSwapQuote, type SwapQuoteView } from './swapTools'; -import { previewSendPayment, lightningRailLabel, type SendPreview } from './walletTools'; +import { previewSendPayment, reconcileInvoiceAmount, lightningRailLabel, type SendPreview } from './walletTools'; export interface ReadbackRow { label: string; @@ -71,7 +71,7 @@ export function paymentReadback(p: { description?: string; }): ConfirmReadback { const invoice = decodeAmount(p.preview.destination); - const sats = p.amountSats || invoice.sats; + const sats = invoice.sats ?? p.amountSats; const who = p.preview.recipientName ?? (p.preview.kind === 'lightning_address' ? p.preview.destination : undefined); const rows: ReadbackRow[] = []; if (p.preview.recipientName) rows.push({ label: 'Contact', value: p.preview.recipientName }); @@ -104,6 +104,7 @@ export function swapReadback(q: SwapQuoteView, warning?: string): ConfirmReadbac { label: 'Fee', value: q.fee != null && q.feeUnit ? unitAmount(q.fee, q.feeUnit) : 'Included in the price' }, { label: 'Venue', value: venue }, ]; + if (q.minReceive != null) rows.splice(2, 0, { label: 'At least', value: unitAmount(q.minReceive, q.receiveUnit) }); return { kind: 'swap', title: 'Confirm swap', @@ -147,6 +148,11 @@ export function agentPaymentReadback(a: Record): ConfirmReadbac { label: 'Pays from', value: 'Agent wallet' }, { label: 'Fee', value: Number.isFinite(fee) && fee > 0 ? `Up to ${formatSats(fee)}` : 'None expected' }, ]; + const leftToday = Number(a.left_today_sats); + const leftMonth = Number(a.left_month_sats); + if (Number.isFinite(leftToday) && Number.isFinite(leftMonth)) { + rows.push({ label: 'Budget left', value: `${formatSats(leftToday)} today ยท ${formatSats(leftMonth)} this month` }); + } if (a.why) rows.push({ label: 'Why you are asked', value: String(a.why) }); rows.push({ label: 'Lightning invoice', value: invoice, copyable: true }); return { @@ -187,7 +193,9 @@ export async function buildConfirmReadback(call: { name: string; arguments: Reco case 'send_payment': { const preview = await previewSendPayment(a.to); if (preview.kind === 'onchain') throw new Error("On-chain sends from the assistant aren't supported yet โ€” use the Send screen."); - return paymentReadback({ preview, amountSats: Number(a.amount_sats) || undefined, network: lightningRailLabel() }); + const asked = Number(a.amount_sats) || undefined; + const amountSats = preview.kind === 'lightning_invoice' ? reconcileInvoiceAmount(preview.destination, asked) : asked; + return paymentReadback({ preview, amountSats, network: lightningRailLabel() }); } case 'rln_pay_invoice': case 'spark_pay_invoice': { @@ -195,7 +203,7 @@ export async function buildConfirmReadback(call: { name: string; arguments: Reco if (!invoice) throw new Error('An invoice is required.'); return paymentReadback({ preview: { destination: invoice, kind: 'lightning_invoice' }, - amountSats: Number(a.amount_sats) || undefined, + amountSats: reconcileInvoiceAmount(invoice, Number(a.amount_sats) || undefined), network: lightningRailLabel(), }); } diff --git a/services/aiErrors.test.ts b/services/aiErrors.test.ts new file mode 100644 index 0000000..175616a --- /dev/null +++ b/services/aiErrors.test.ts @@ -0,0 +1,21 @@ +import { chatErrorMessage } from './aiErrors'; + +describe('chatErrorMessage', () => { + it('passes wallet errors written for the user through', () => { + expect(chatErrorMessage(new Error("Your SPARK wallet isn't connected yet."))).toBe("Your SPARK wallet isn't connected yet."); + expect(chatErrorMessage(new Error('That invoice is for 1,000 sats, not 5,000. Nothing was sent.'))).toContain('Nothing was sent.'); + }); + + it('turns known engine failures into a next step', () => { + expect(chatErrorMessage(new Error('prompt tokens exceed the context window'))).toMatch(/Clear the chat/); + expect(chatErrorMessage(new Error('Network request failed'))).toMatch(/network/i); + expect(chatErrorMessage(new Error('model not loaded'))).toMatch(/reload/i); + }); + + it('never shows a raw technical error', () => { + for (const raw of ["TypeError: Cannot read property 'x' of undefined", 'ERR_RGB_WALLET_BUSY', '{"code":12}', 'failed at foo.js:12:3']) { + expect(chatErrorMessage(new Error(raw))).toBe("I couldn't do that just now. Please try again."); + } + expect(chatErrorMessage(undefined)).toBe("I couldn't do that just now. Please try again."); + }); +}); diff --git a/services/aiErrors.ts b/services/aiErrors.ts new file mode 100644 index 0000000..00c8f0e --- /dev/null +++ b/services/aiErrors.ts @@ -0,0 +1,31 @@ +// What the chat says when a turn fails: wallet errors are already written for the +// user and pass through; known engine failures become a next step; anything that +// looks technical is replaced by a calm sentence instead of a raw error string. + +const FALLBACK = "I couldn't do that just now. Please try again."; + +const KNOWN: Array<[RegExp, string]> = [ + [ + /context window|prompt tokens|exceeds the|context length|too long/i, + 'This conversation got too long for the on-device model. Clear the chat (๐Ÿ—‘๏ธ in the header) to start fresh, then try again.', + ], + [ + /network request failed|failed to fetch|timeout|timed out|abort/i, + 'I could not reach the network. Check your connection and try again.', + ], + [ + /model (is )?not (ready|loaded)|not loaded|out of memory|\boom\b|worker (crashed|exited)/i, + 'The on-device model is not ready. Close other apps and try again, or reload it from KaleidoMind settings.', + ], +]; + +const TECHNICAL = /\b(TypeError|ReferenceError|SyntaxError|undefined|NaN|ERR_[A-Z_]+|0x[0-9a-f]{4,})\b|\bat \S+:\d+|[{}[\]]|\n/; + +export function chatErrorMessage(error: unknown): string { + const raw = error instanceof Error && error.message ? error.message.trim() : ''; + if (!raw) return FALLBACK; + const known = KNOWN.find(([re]) => re.test(raw)); + if (known) return known[1]; + const readable = /^[A-Z"'โ€œ].*[.!?โ€ฆ"โ€]$/.test(raw) && raw.length <= 240 && !TECHNICAL.test(raw); + return readable ? raw : FALLBACK; +} diff --git a/services/swapTools.test.ts b/services/swapTools.test.ts index ad994ce..61ad0b5 100644 --- a/services/swapTools.test.ts +++ b/services/swapTools.test.ts @@ -13,7 +13,7 @@ // real findPair/getAssetId/getQuoteLayers/validateSwapString helpers. On-device // end-to-end (a funded node executing a real swap) still needs a device test. -import { buildSwapToolSource, describeSwapQuote, refreshSwapQuoteForConfirm, quoteNeedsRefresh, priceMove } from './swapTools'; +import { buildSwapToolSource, describeSwapQuote, FLASHNET_MIN_RECEIVE_FRACTION, refreshSwapQuoteForConfirm, quoteNeedsRefresh, priceMove } from './swapTools'; const inAMinute = () => Math.floor(Date.now() / 1000) + 60; import { protocolManager, kaleidoClientManager, flashnetClientManager } from './protocols'; @@ -283,7 +283,7 @@ describe('swap tools โ€” Flashnet venue', () => { const res: any = await source.execute('execute_swap', { quote_id: q.quote_id }); expect(flash.executeSwap).toHaveBeenCalledWith(expect.objectContaining({ poolId: 'pool1', assetInAddress: 'btc-spark', assetOutAddress: 'usdb-spark', - amountIn: '50000', minAmountOut: String(Math.floor(36_000_000 * 0.95)), + amountIn: '50000', minAmountOut: String(Math.floor(36_000_000 * FLASHNET_MIN_RECEIVE_FRACTION)), })); expect(res.status).toBe('completed'); expect(res.txid).toBe('tx1'); diff --git a/services/swapTools.ts b/services/swapTools.ts index de90ffb..d915a96 100644 --- a/services/swapTools.ts +++ b/services/swapTools.ts @@ -8,7 +8,7 @@ // // Execution replicates rate's tested SwapScreen flows verbatim: // - maker: get_quote โ†’ initSwap โ†’ validateSwapString โ†’ whitelist โ†’ execute -// - flashnet: simulateSwap โ†’ executeSwap (minAmountOut floored at 95%) +// - flashnet: simulateSwap โ†’ executeSwap (minAmountOut floored at the shown amount less the price tolerance) // The execute path is the canonical `execute_swap { quote_id }` from the wallet // contract โ€” a spend, so confirmation-gated โ€” run against the cached quote of // whichever venue produced it. Status comes from `kaleidoswap_atomic_status`. @@ -83,6 +83,8 @@ export interface SwapQuoteView { fromLayer?: string; toLayer?: string; expiresAt: number; + /** Flashnet: the least the user can end up with (the swap fails below it). */ + minReceive?: number; } function toView(quoteId: string, q: CachedQuote): SwapQuoteView { @@ -91,6 +93,7 @@ function toView(quoteId: string, q: CachedQuote): SwapQuoteView { sendAmount: q.sendAmount, receiveAmount: q.receiveAmount, receiveUnit: q.receiveUnit, fee: q.fee, feeUnit: q.feeUnit, fromLayer: q.fromLayer, toLayer: q.toLayer, expiresAt: Math.min(q.expiresAt, q.ts + QUOTE_TTL_MS), + minReceive: q.venue === 'flashnet' ? q.receiveAmount * FLASHNET_MIN_RECEIVE_FRACTION : undefined, }; } @@ -104,6 +107,8 @@ export function describeSwapQuote(quoteId: string): SwapQuoteView | null { export const REQUOTE_MARGIN_MS = 20_000; /** Largest drop in the received amount accepted without asking the user again. */ export const PRICE_TOLERANCE = 0.01; +/** Flashnet floor, as a share of the quoted amount: the same bound the sheet shows. */ +export const FLASHNET_MIN_RECEIVE_FRACTION = 1 - PRICE_TOLERANCE; export function quoteNeedsRefresh(q: Pick, now = Date.now(), marginMs = REQUOTE_MARGIN_MS): boolean { return q.expiresAt - now < marginMs; @@ -384,7 +389,7 @@ const HANDLERS: Record) => Promise ({ protocolManager: { getAdapterIfAvailable: jest.fn() }, @@ -419,3 +421,47 @@ describe('strict contact matching', () => { await expect(previewSendPayment('lnbc10u1xyz')).resolves.toMatchObject({ destination: 'lnbc10u1xyz', kind: 'lightning_invoice' }); }); }); + + +// โ”€โ”€ The sheet and the payment must agree โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const HASH = 'ab'.repeat(32); +const invoiceFor = (sats: number) => makeInvoice({ sats, paymentHash: HASH, timestamp: 1_700_000_000, expiry: 3600 }); + +describe('confirm sheet and execution agree', () => { + it("pays the destination the sheet showed even if the contact's address changed meanwhile", async () => { + const spark = makeAdapter(); + setAdapters({ SPARK: spark }); + setStore({ contacts: [{ name: 'Alice', lightning_address: 'alice@ln.tips' }] }); + mockedResolveLn.mockResolvedValue(invoiceFor(2100)); + + const readback = await buildConfirmReadback({ name: 'send_payment', arguments: { to: 'Alice', amount_sats: 2100 } }); + expect(readback.rows.find((r) => r.value === 'alice@ln.tips')).toBeDefined(); + + setStore({ contacts: [{ name: 'Alice', lightning_address: 'mallory@evil.example' }] }); + await source().execute('send_payment', { to: 'Alice', amount_sats: 2100 }); + + expect(mockedResolveLn).toHaveBeenCalledWith('alice@ln.tips', 2100); + }); + + it('shows the invoice amount and refuses a different amount asked for by the model', async () => { + const invoice = invoiceFor(1000); + const readback = await buildConfirmReadback({ name: 'send_payment', arguments: { to: invoice } }); + expect(readback.amountSats).toBe(1000); + + await expect(buildConfirmReadback({ name: 'send_payment', arguments: { to: invoice, amount_sats: 50_000 } })).rejects.toThrow(/for 1,000 sats, not 50,000/); + await expect(buildConfirmReadback({ name: 'rln_pay_invoice', arguments: { invoice, amount_sats: 50_000 } })).rejects.toThrow(/Nothing was sent/); + + setAdapters({ SPARK: makeAdapter() }); + await expect(source().execute('send_payment', { to: invoice, amount_sats: 50_000 })).rejects.toThrow(/Nothing was sent/); + }); + + it('refuses an invoice from a Lightning address that is for a different amount', async () => { + const spark = makeAdapter(); + setAdapters({ SPARK: spark }); + mockedResolveLn.mockResolvedValue(invoiceFor(999_999)); + + await expect(source().execute('send_payment', { to: 'bob@ln.tips', amount_sats: 2100 })).rejects.toThrow(/different amount/); + expect(spark.sendPayment).not.toHaveBeenCalled(); + }); +}); diff --git a/services/walletTools.ts b/services/walletTools.ts index 5ce4992..0590d7b 100644 --- a/services/walletTools.ts +++ b/services/walletTools.ts @@ -16,6 +16,7 @@ import { import { protocolManager, type ProtocolType } from './protocols'; import { getStore } from '../store/storeProvider'; import { fetchBitcoinPrice } from '../store/slices/walletSlice'; +import { decodeBolt11 } from '../utils/decodeInvoice'; import NostrService from './NostrService'; import { resolveLightningAddressToInvoice as resolveLightningAddress } from '../utils/lnurl'; @@ -147,8 +148,41 @@ export interface SendPreview { kind: 'lightning_address' | 'lightning_invoice' | 'onchain' | 'other'; } +// What the confirm sheet showed for a `to`, kept so the payment that follows goes +// to exactly that destination even if the contact's address changed in between. +const PIN_TTL_MS = 5 * 60_000; +const pinnedPreviews = new Map(); + +function takePinnedPreview(to: unknown): SendPreview | undefined { + const key = String(to ?? '').trim(); + const hit = pinnedPreviews.get(key); + pinnedPreviews.delete(key); + return hit && Date.now() - hit.ts < PIN_TTL_MS ? hit.preview : undefined; +} + +/** + * The amount a BOLT11 invoice is really for. An invoice with its own amount + * always wins; a different amount asked for by the model is an error, so the + * sheet can never show one number while another is paid. + */ +export function reconcileInvoiceAmount(invoice: string, requestedSats?: number): number | undefined { + if (!/^ln(bc|tb|bcrt)/i.test(invoice.trim())) return requestedSats; + const inSats = decodeBolt11(invoice).amountSats; + if (inSats == null) return requestedSats; + if (requestedSats != null && Math.round(requestedSats) !== inSats) { + throw new Error(`That invoice is for ${inSats.toLocaleString('en-US')} sats, not ${Math.round(requestedSats).toLocaleString('en-US')}. Nothing was sent.`); + } + return inSats; +} + /** Resolve send_payment's `to` the same way the handler does, for the confirm sheet. */ export async function previewSendPayment(to: unknown): Promise { + const preview = await resolveSendTarget(to); + pinnedPreviews.set(String(to ?? '').trim(), { preview, ts: Date.now() }); + return preview; +} + +async function resolveSendTarget(to: unknown): Promise { const raw = String(to ?? '').trim(); if (!raw) throw new Error('A destination (invoice, address, or contact) is required.'); let destination = raw; @@ -234,17 +268,6 @@ const HANDLERS: Record = { return normInvoice(r); }, - // Swap quote โ€” venue-aware (Flashnet on Spark ยท KaleidoSwap on RLN). Read-only: - // the live quote + atomic execution happen on the tested Swap screen, so the - // agent quotes + hands off rather than moving funds blind. - get_swap_quote: async ({ from_asset, to_asset, amount }) => { - const venue = adapter('RGB_LN') ? 'KaleidoSwap (RLN)' : adapter('SPARK') ? 'Flashnet (Spark)' : null; - if (!venue) throw new Error('Connect a Spark or RLN wallet to swap.'); - const f = String(from_asset ?? '').toUpperCase(); - const t = String(to_asset ?? '').toUpperCase(); - return { from_asset: f, to_asset: t, amount, venue, note: `Swap ${amount ?? ''} ${f} โ†’ ${t} via ${venue}. Open the Swap screen to see the live quote and confirm.` }; - }, - // โ”€โ”€ Cross-cutting helpers โ”€โ”€ get_price: async ({ fiat }) => { const price = await ensureBtcPrice(); @@ -303,14 +326,22 @@ const HANDLERS: Record = { throw new Error(`To send ${amount ?? ''} ${String(asset).toUpperCase()} to "${to}", ask them for an RGB invoice and paste it here.`); }, send_payment: async ({ to, amount_sats }) => { - const sats = amount_sats != null ? Number(amount_sats) : undefined; - let { destination: target } = await previewSendPayment(to); + const requested = amount_sats != null ? Number(amount_sats) : undefined; + let { destination: target } = takePinnedPreview(to) ?? (await resolveSendTarget(to)); + let sats = requested; // Lightning address (user@domain) โ†’ resolve to a BOLT11 invoice via LNURL-pay. if (isLightningAddress(target)) { if (!sats) throw new Error('I need an amount in sats to pay a Lightning address.'); target = await resolveLightningAddress(target, sats); + // The service picks the invoice; it must be for what the user approved. + const got = decodeBolt11(target).amountSats; + if (got != null && got !== Math.round(sats)) { + throw new Error('The Lightning address returned an invoice for a different amount, so nothing was sent.'); + } } else if (isOnchainAddress(target)) { throw new Error("On-chain sends from the assistant aren't supported yet โ€” use the Send screen."); + } else { + sats = reconcileInvoiceAmount(target, sats); } // Pay the BOLT11 invoice on the Lightning rail (Spark preferred, RLN fallback). return afterSpend(await lightningAdapter().sendPayment({ invoice: target, ...(sats ? { amountSats: sats } : {}) })); @@ -359,7 +390,6 @@ function describeWalletTool(name: string): string { rln_create_ln_invoice: 'Create a Lightning invoice on the RLN wallet.', rln_create_rgb_invoice: 'Create an RGB asset invoice on the RLN wallet.', create_invoice: 'Create a receive invoice for BTC or an RGB asset.', - get_swap_quote: 'Describe where a swap quote can be obtained.', get_price: 'Get the cached or freshly fetched BTC price.', fiat_to_sats: 'Convert a fiat amount to satoshis using the BTC price.', list_contacts: "List the user's saved contacts (local + Nostr) so you can ask which one to pay. Use this when the user wants to send to a friend/contact but hasn't named who, or to confirm available recipients.", @@ -397,12 +427,6 @@ function paramsForWalletTool(name: string): Record { amount: numberProp('Amount to receive.'), layer: stringProp('Optional layer: spark, rln, or arkade.'), }); - case 'get_swap_quote': - return object({ - from_asset: stringProp('Asset being sold.'), - to_asset: stringProp('Asset being bought.'), - amount: numberProp('Amount to quote.'), - }, ['from_asset', 'to_asset', 'amount']); case 'get_price': return object({ fiat: stringProp('Fiat currency, defaults to USD.') }); case 'fiat_to_sats':