Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kaleidoswap-wallet",
"version": "0.3.0",
"version": "0.9.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
Expand Down
13 changes: 5 additions & 8 deletions screens/AIAssistantScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
}));
}
Expand Down
9 changes: 8 additions & 1 deletion services/agentWallet/agentPay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -152,7 +155,11 @@ async function payNow(req: AgentPayRequest, deps: AgentPayDeps): Promise<AgentPa
}
if (decision.kind === 'confirm') {
const approved = deps.confirm
? await deps.confirm({ service, amountSats, feeSats, invoice, reason, why: decision.reason }).catch(() => 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(() => {});
Expand Down
2 changes: 2 additions & 0 deletions services/agentWallet/confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions services/aiConfirm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }));
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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/);
});
Expand Down
16 changes: 12 additions & 4 deletions services/aiConfirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -147,6 +148,11 @@ export function agentPaymentReadback(a: Record<string, unknown>): 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 {
Expand Down Expand Up @@ -187,15 +193,17 @@ 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': {
const invoice = String(a.invoice ?? a.to ?? '').trim();
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(),
});
}
Expand Down
21 changes: 21 additions & 0 deletions services/aiErrors.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
});
31 changes: 31 additions & 0 deletions services/aiErrors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions services/swapTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
9 changes: 7 additions & 2 deletions services/swapTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
};
}

Expand All @@ -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<SwapQuoteView, 'expiresAt'>, now = Date.now(), marginMs = REQUOTE_MARGIN_MS): boolean {
return q.expiresAt - now < marginMs;
Expand Down Expand Up @@ -384,7 +389,7 @@ const HANDLERS: Record<string, (args: Record<string, unknown>) => Promise<unknow
assetInAddress: q.fromAssetId,
assetOutAddress: q.toAssetId,
amountIn: String(q.rawFromAmount),
minAmountOut: String(Math.floor(q.rawToAmount * 0.95)), // bound slippage at 5%
minAmountOut: String(Math.floor(q.rawToAmount * FLASHNET_MIN_RECEIVE_FRACTION)), // same bound the sheet shows
maxSlippageBps: DEFAULT_FLASHNET_SLIPPAGE_BPS,
});
quoteCache.delete(id);
Expand Down
Loading
Loading