@kaleidorg/mind 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/funnel.d.ts +19 -0
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +48 -10
- package/dist/funnel.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +3 -3
- package/dist/kaleidoswap/contract.d.ts.map +1 -1
- package/dist/kaleidoswap/contract.js +16 -4
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/knowledge/bitcoin-copilot.d.ts.map +1 -1
- package/dist/knowledge/bitcoin-copilot.js +102 -0
- package/dist/knowledge/bitcoin-copilot.js.map +1 -1
- package/dist/knowledge/btc-map.d.ts +14 -17
- package/dist/knowledge/btc-map.d.ts.map +1 -1
- package/dist/knowledge/btc-map.js +66 -266
- package/dist/knowledge/btc-map.js.map +1 -1
- package/dist/lsps1/contract.d.ts.map +1 -1
- package/dist/lsps1/contract.js +28 -10
- package/dist/lsps1/contract.js.map +1 -1
- package/dist/recipe/buy-asset-channel.d.ts +26 -0
- package/dist/recipe/buy-asset-channel.d.ts.map +1 -0
- package/dist/recipe/buy-asset-channel.js +112 -0
- package/dist/recipe/buy-asset-channel.js.map +1 -0
- package/dist/recipe/kaleidoswap-atomic.d.ts +26 -18
- package/dist/recipe/kaleidoswap-atomic.d.ts.map +1 -1
- package/dist/recipe/kaleidoswap-atomic.js +101 -63
- package/dist/recipe/kaleidoswap-atomic.js.map +1 -1
- package/dist/recipe/kaleidoswap-channel-order.d.ts +35 -0
- package/dist/recipe/kaleidoswap-channel-order.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-channel-order.js +493 -0
- package/dist/recipe/kaleidoswap-channel-order.js.map +1 -0
- package/dist/recipe/kaleidoswap-price.d.ts +21 -0
- package/dist/recipe/kaleidoswap-price.d.ts.map +1 -0
- package/dist/recipe/kaleidoswap-price.js +57 -0
- package/dist/recipe/kaleidoswap-price.js.map +1 -0
- package/dist/recipe/runner.d.ts +7 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +115 -29
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/swap.d.ts +26 -1
- package/dist/recipe/swap.d.ts.map +1 -1
- package/dist/recipe/swap.js +108 -13
- package/dist/recipe/swap.js.map +1 -1
- package/dist/recipe/types.d.ts +25 -1
- package/dist/recipe/types.d.ts.map +1 -1
- package/dist/skills/registry.d.ts +33 -1
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +45 -1
- package/dist/skills/registry.js.map +1 -1
- package/package.json +1 -1
- package/skills/README.md +3 -0
- package/skills/kaleido-lsps/SKILL.md +101 -43
- package/skills/kaleido-trading/SKILL.md +81 -31
- package/skills/merchant-finder/SKILL.md +96 -66
- package/skills/rgb-lightning-node/SKILL.md +108 -0
- package/skills/wallet-assistant/SKILL.md +32 -21
- package/src/funnel.ts +66 -11
- package/src/index.ts +14 -2
- package/src/kaleidoswap/contract.test.ts +7 -2
- package/src/kaleidoswap/contract.ts +27 -5
- package/src/knowledge/bitcoin-copilot.ts +111 -0
- package/src/knowledge/btc-map.test.ts +53 -96
- package/src/knowledge/btc-map.ts +72 -287
- package/src/lsps1/contract.ts +32 -14
- package/src/recipe/buy-asset-channel.test.ts +148 -0
- package/src/recipe/buy-asset-channel.ts +118 -0
- package/src/recipe/kaleidoswap-atomic.test.ts +134 -61
- package/src/recipe/kaleidoswap-atomic.ts +112 -66
- package/src/recipe/kaleidoswap-channel-order.test.ts +333 -0
- package/src/recipe/kaleidoswap-channel-order.ts +548 -0
- package/src/recipe/kaleidoswap-price.ts +68 -0
- package/src/recipe/recipe.test.ts +61 -5
- package/src/recipe/runner.ts +128 -31
- package/src/recipe/swap.ts +109 -13
- package/src/recipe/types.ts +25 -1
- package/src/skills/registry.ts +52 -1
package/src/lsps1/contract.ts
CHANGED
|
@@ -50,31 +50,49 @@ export const LSPS1_TOOLS: Lsps1ToolDef[] = [
|
|
|
50
50
|
"Get the LSP's Lightning network info: pubkey, host, port, connect URI. Useful to display the counterparty or pre-connect a peer. No args."),
|
|
51
51
|
|
|
52
52
|
t('lsp_estimate_fees',
|
|
53
|
-
"Estimate the
|
|
53
|
+
"Estimate the channel-order fee BEFORE committing. Returns setup_fee, capacity_fee, duration_fee, total_fee (all in sats). Re-estimate rather than reusing a stale value.",
|
|
54
54
|
{
|
|
55
|
-
lsp_balance_sat:
|
|
56
|
-
client_balance_sat:
|
|
57
|
-
channel_expiry_blocks: { type: 'number', description: '
|
|
55
|
+
lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
|
|
56
|
+
client_balance_sat: { type: 'number', description: "Sats the user pre-funds into the channel (push amount). Default 0." },
|
|
57
|
+
channel_expiry_blocks: { type: 'number', description: 'Lease duration in blocks. Default 4320 (~30 days). Maker maximum is typically 20160 (~140 days).' },
|
|
58
|
+
// RGB asset channels (optional):
|
|
59
|
+
asset_id: { type: 'string', description: 'For RGB asset channels — the asset to provision liquidity for.' },
|
|
60
|
+
lsp_asset_amount: { type: 'number', description: 'Asset units the LSP commits (RGB channels only).' },
|
|
61
|
+
client_asset_amount: { type: 'number', description: 'Asset units the user pre-funds (RGB channels only). Requires rfq_id.' },
|
|
62
|
+
rfq_id: { type: 'string', description: 'Quote id from kaleidoswap_get_quote — required when client_asset_amount > 0.' },
|
|
63
|
+
token: { type: 'string', description: 'Optional discount/affiliate token.' },
|
|
58
64
|
},
|
|
59
|
-
['lsp_balance_sat']),
|
|
65
|
+
['lsp_balance_sat', 'client_balance_sat', 'channel_expiry_blocks']),
|
|
60
66
|
|
|
61
67
|
t('lsp_create_order',
|
|
62
|
-
"Create a channel order. SPEND: confirmation-gated. Returns
|
|
68
|
+
"Create a channel order. SPEND: confirmation-gated. Returns order_id + access_token + a payment.bolt11.invoice the user pays to lock the order. The channel opens only after payment.",
|
|
63
69
|
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
client_pubkey: { type: 'string', description: "User's Lightning node pubkey — the LSP opens the channel TO this node. Get it from rln_get_node_info." },
|
|
71
|
+
lsp_balance_sat: { type: 'number', description: "Sats the LSP commits on their side (inbound capacity for the user)." },
|
|
72
|
+
client_balance_sat: { type: 'number', description: "Sats the user pre-funds. Default 0." },
|
|
73
|
+
required_channel_confirmations: { type: 'number', description: 'Number of confs the user wants before considering the channel usable. Default 1.' },
|
|
74
|
+
funding_confirms_within_blocks: { type: 'number', description: 'Max blocks within which funding must confirm. Default 6.' },
|
|
75
|
+
channel_expiry_blocks: { type: 'number', description: 'Lease duration in blocks. Default 4320 (~30 days).' },
|
|
76
|
+
announce_channel: { type: 'boolean', description: 'Public (true) or unannounced (false). Default true.' },
|
|
77
|
+
refund_onchain_address: { type: 'string', description: 'Optional on-chain refund address if the LSP cannot open the channel.' },
|
|
78
|
+
// RGB asset channels (optional):
|
|
79
|
+
asset_id: { type: 'string', description: 'For RGB asset channels.' },
|
|
80
|
+
lsp_asset_amount: { type: 'number', description: 'Asset units the LSP commits.' },
|
|
81
|
+
client_asset_amount: { type: 'number', description: 'Asset units the user pre-funds.' },
|
|
82
|
+
rfq_id: { type: 'string', description: 'Required when client_asset_amount > 0.' },
|
|
83
|
+
token: { type: 'string', description: 'Optional discount/affiliate token.' },
|
|
84
|
+
email: { type: 'string', description: 'Optional contact for order updates.' },
|
|
68
85
|
},
|
|
69
|
-
['lsp_balance_sat'],
|
|
86
|
+
['client_pubkey', 'lsp_balance_sat'],
|
|
70
87
|
/* spend */ true),
|
|
71
88
|
|
|
72
89
|
t('lsp_get_order',
|
|
73
|
-
'Check the status of an LSPS1 order
|
|
90
|
+
'Check the status of an LSPS1 order. order_state progresses CREATED → CHANNEL_OPENING → COMPLETED (or FAILED). ALWAYS pass BOTH the order_id and the access_token from lsp_create_order (the access_token is required for order status).',
|
|
74
91
|
{
|
|
75
|
-
order_id:
|
|
92
|
+
order_id: { type: 'string', description: 'The order id from lsp_create_order.' },
|
|
93
|
+
access_token: { type: 'string', description: 'The per-order access token returned by lsp_create_order. Required for non-admin reads.' },
|
|
76
94
|
},
|
|
77
|
-
['order_id']),
|
|
95
|
+
['order_id', 'access_token']),
|
|
78
96
|
];
|
|
79
97
|
|
|
80
98
|
/** All LSPS1 tool names that move funds (confirmation-gated). */
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
3
|
+
import { InProcessToolSource } from '../tools/in-process.js';
|
|
4
|
+
import type { LLMProvider } from '../providers/types.js';
|
|
5
|
+
import { runRecipe, RecipeRegistry } from './runner.js';
|
|
6
|
+
import { buyAssetChannelRecipe, extractBuyAsset } from './buy-asset-channel.js';
|
|
7
|
+
import { swapRecipe } from './swap.js';
|
|
8
|
+
import { assetSendRecipe } from './asset-send.js';
|
|
9
|
+
|
|
10
|
+
const approve: LLMProvider = { name: 'x', runTurn: async () => ({ text: '', rawContent: '', toolCalls: [] }) };
|
|
11
|
+
|
|
12
|
+
/** Stub the two asset-channel tools the recipe drives. */
|
|
13
|
+
function stubTools(spy?: { create?: (a: any) => void }) {
|
|
14
|
+
return new ToolRegistry([
|
|
15
|
+
new InProcessToolSource('ks', [
|
|
16
|
+
{
|
|
17
|
+
name: 'kaleidoswap_lsp_quote_asset_channel',
|
|
18
|
+
description: '',
|
|
19
|
+
parameters: { type: 'object', properties: {} },
|
|
20
|
+
handler: async (a) => ({
|
|
21
|
+
rfq_id: 'rfq1',
|
|
22
|
+
asset_amount: a.asset_amount,
|
|
23
|
+
btc_amount_sat: 13807,
|
|
24
|
+
channel_fee_sat: 16139,
|
|
25
|
+
total_sat: 29946,
|
|
26
|
+
expires_at: 1234567890,
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'kaleidoswap_lsp_create_asset_channel',
|
|
31
|
+
description: '',
|
|
32
|
+
parameters: { type: 'object', properties: {} },
|
|
33
|
+
requiresConfirmation: true,
|
|
34
|
+
handler: async (a) => {
|
|
35
|
+
spy?.create?.(a);
|
|
36
|
+
return { order_id: 'ord1', total_sat: 29946, payment: { onchain_address: 'bcrt1qexample' } };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
]),
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('extractBuyAsset (deterministic Tier-0)', () => {
|
|
44
|
+
it('parses "buy 100 usdt"', () => {
|
|
45
|
+
expect(extractBuyAsset('buy 100 usdt')).toEqual({ asset: 'USDT', asset_amount: 100 });
|
|
46
|
+
});
|
|
47
|
+
it('parses "get me 50 xaut"', () => {
|
|
48
|
+
expect(extractBuyAsset('get me 50 xaut')).toEqual({ asset: 'XAUT', asset_amount: 50 });
|
|
49
|
+
});
|
|
50
|
+
it('parses "i want 200 usdt" and "purchase 10 xaut"', () => {
|
|
51
|
+
expect(extractBuyAsset('i want 200 usdt')).toEqual({ asset: 'USDT', asset_amount: 200 });
|
|
52
|
+
expect(extractBuyAsset('purchase 10 xaut')).toEqual({ asset: 'XAUT', asset_amount: 10 });
|
|
53
|
+
});
|
|
54
|
+
it('handles comma grouping in the amount', () => {
|
|
55
|
+
expect(extractBuyAsset('buy 1,000 usdt')).toEqual({ asset: 'USDT', asset_amount: 1000 });
|
|
56
|
+
});
|
|
57
|
+
it('null for a swap (a named source asset ⇒ swap owns it)', () => {
|
|
58
|
+
expect(extractBuyAsset('buy 0.001 btc with usdt')).toBeNull();
|
|
59
|
+
expect(extractBuyAsset('swap 10 usdt for btc')).toBeNull();
|
|
60
|
+
expect(extractBuyAsset('buy 100 usdt with my bitcoin')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
it('null for a send (asset-send owns it)', () => {
|
|
63
|
+
expect(extractBuyAsset('send 10 usdt to bob')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
it('null for BTC (BTC is not bought via an asset channel)', () => {
|
|
66
|
+
expect(extractBuyAsset('buy 100000 sats')).toBeNull();
|
|
67
|
+
expect(extractBuyAsset('get 0.01 btc')).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('runRecipe — buy asset channel', () => {
|
|
72
|
+
it('quote → confirm → create order, deterministic (0 inferences)', async () => {
|
|
73
|
+
const created: any[] = [];
|
|
74
|
+
const tools = stubTools({ create: (a) => created.push(a) });
|
|
75
|
+
const onConfirm = vi.fn(async () => ({ approved: true }));
|
|
76
|
+
const res = await runRecipe(buyAssetChannelRecipe, 'buy 100 usdt', { provider: approve, tools, onConfirm });
|
|
77
|
+
|
|
78
|
+
expect(res.status).toBe('done');
|
|
79
|
+
expect(res.inferences).toBe(0);
|
|
80
|
+
expect(onConfirm).toHaveBeenCalledOnce();
|
|
81
|
+
expect(res.results.quote).toMatchObject({ rfq_id: 'rfq1' });
|
|
82
|
+
expect(created[0]).toMatchObject({ asset: 'USDT', asset_amount: 100, rfq_id: 'rfq1' });
|
|
83
|
+
// The quote's cost rides along for the confirm card.
|
|
84
|
+
expect(created[0]).toMatchObject({ total_sat: 29946, btc_amount_sat: 13807, channel_fee_sat: 16139 });
|
|
85
|
+
expect(res.text).toContain('100 USDT');
|
|
86
|
+
expect(res.text).toContain('29,946');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('denied confirmation → cancelled, no order placed', async () => {
|
|
90
|
+
const created: any[] = [];
|
|
91
|
+
const tools = stubTools({ create: (a) => created.push(a) });
|
|
92
|
+
const res = await runRecipe(buyAssetChannelRecipe, 'buy 100 usdt', {
|
|
93
|
+
provider: approve,
|
|
94
|
+
tools,
|
|
95
|
+
onConfirm: async () => ({ approved: false }),
|
|
96
|
+
});
|
|
97
|
+
expect(res.status).toBe('cancelled');
|
|
98
|
+
expect(created).toHaveLength(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('fails closed when no confirm handler is wired (spend never runs)', async () => {
|
|
102
|
+
const created: any[] = [];
|
|
103
|
+
const tools = stubTools({ create: (a) => created.push(a) });
|
|
104
|
+
const res = await runRecipe(buyAssetChannelRecipe, 'buy 100 usdt', { provider: approve, tools });
|
|
105
|
+
expect(res.status).toBe('cancelled');
|
|
106
|
+
expect(created).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('falls back to ONE LLM extraction when the regex misses', async () => {
|
|
110
|
+
const created: any[] = [];
|
|
111
|
+
const tools = stubTools({ create: (a) => created.push(a) });
|
|
112
|
+
const llmOnly = { ...buyAssetChannelRecipe, extract: undefined };
|
|
113
|
+
const provider: LLMProvider = {
|
|
114
|
+
name: 'mock',
|
|
115
|
+
runTurn: vi.fn(async () => ({
|
|
116
|
+
text: '',
|
|
117
|
+
rawContent: '',
|
|
118
|
+
toolCalls: [{ id: '1', name: 'extract_request', arguments: { asset: 'USDT', asset_amount: 100 } }],
|
|
119
|
+
})),
|
|
120
|
+
};
|
|
121
|
+
const res = await runRecipe(llmOnly, 'could you set me up with a hundred tether', {
|
|
122
|
+
provider,
|
|
123
|
+
tools,
|
|
124
|
+
onConfirm: async () => ({ approved: true }),
|
|
125
|
+
});
|
|
126
|
+
expect(res.inferences).toBe(1);
|
|
127
|
+
expect(provider.runTurn).toHaveBeenCalledOnce();
|
|
128
|
+
expect(created[0]).toMatchObject({ asset: 'USDT', asset_amount: 100, rfq_id: 'rfq1' });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('recipe selection / precedence', () => {
|
|
133
|
+
it('selects buy-asset-channel before swap for "buy 100 usdt"', () => {
|
|
134
|
+
const reg = new RecipeRegistry([buyAssetChannelRecipe, swapRecipe]);
|
|
135
|
+
expect(reg.select('buy 100 usdt')?.name).toBe('buy-asset-channel');
|
|
136
|
+
expect(reg.select('get me 50 xaut')?.name).toBe('buy-asset-channel');
|
|
137
|
+
});
|
|
138
|
+
it('does not hijack a swap or an asset send', () => {
|
|
139
|
+
const reg = new RecipeRegistry([buyAssetChannelRecipe, swapRecipe, assetSendRecipe]);
|
|
140
|
+
expect(reg.select('swap 10 usdt for btc')?.name).not.toBe('buy-asset-channel');
|
|
141
|
+
expect(reg.select('send 10 usdt to bob')?.name).not.toBe('buy-asset-channel');
|
|
142
|
+
});
|
|
143
|
+
it('confident only with both asset and a positive amount', () => {
|
|
144
|
+
expect(buyAssetChannelRecipe.confident!({ asset: 'USDT', asset_amount: 100 })).toBe(true);
|
|
145
|
+
expect(buyAssetChannelRecipe.confident!({ asset: 'USDT' })).toBe(false);
|
|
146
|
+
expect(buyAssetChannelRecipe.confident!({ asset: 'USDT', asset_amount: 0 })).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in "buy an asset channel" recipe — the onboarding buy.
|
|
3
|
+
*
|
|
4
|
+
* The user has on-chain BTC but no Lightning channel yet, and wants to HOLD an
|
|
5
|
+
* RGB asset (USDT, XAUT). They can't swap (no channel to swap inside), so they
|
|
6
|
+
* buy a NEW channel from the maker LSP pre-loaded with the asset. One quote,
|
|
7
|
+
* one spend:
|
|
8
|
+
*
|
|
9
|
+
* "buy 100 usdt" / "get me 50 xaut" / "i want 200 usdt"
|
|
10
|
+
* ↓ 1 model inference (slot extraction; 0 when the regex hits)
|
|
11
|
+
* kaleidoswap_lsp_quote_asset_channel ← maker prices the asset + channel
|
|
12
|
+
* kaleidoswap_lsp_create_asset_channel 🔒 ← (final) order it; pay to open
|
|
13
|
+
*
|
|
14
|
+
* Distinct from `swapRecipe`: a swap names a source asset ("swap 10 usdt FOR
|
|
15
|
+
* btc", "buy btc WITH usdt") and needs an existing channel. This is the
|
|
16
|
+
* no-source, no-channel onboarding path — "buy <amount> <asset>" with nothing
|
|
17
|
+
* to spend it from — so it must be SELECTED BEFORE swap for that phrasing.
|
|
18
|
+
*
|
|
19
|
+
* Opt-in: register via `Funnel.recipes` (like `kaleidoswapAtomicRecipe`). The
|
|
20
|
+
* host binds `kaleidoswap_lsp_*` to its transport (maker REST / MCP / WDK).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Recipe } from './types.js';
|
|
24
|
+
|
|
25
|
+
/** RGB assets the maker sells as an asset channel. BTC is never "bought" this way. */
|
|
26
|
+
const RGB_ASSET = /\b(usdt|tether|xaut|gold)\b/i;
|
|
27
|
+
/** A named funding source ⇒ this is a swap, not an onboarding buy. */
|
|
28
|
+
const HAS_SOURCE = /\b(?:with|using|from)\b|\bfor\s+(?:btc|bitcoin|sats?|usdt|xaut|tether|gold)\b/i;
|
|
29
|
+
/** Verbs other intents own (swap / sell / send) — never an onboarding buy. */
|
|
30
|
+
const NOT_BUY = /\b(swap|exchange|convert|trade|sell|send)\b/i;
|
|
31
|
+
/** Acquire verbs that DO mean an onboarding buy. */
|
|
32
|
+
const BUY_VERB = /\b(buy|get|acquire|want|purchase|onboard|need)\b/i;
|
|
33
|
+
|
|
34
|
+
function normAsset(a?: string): string | undefined {
|
|
35
|
+
if (!a) return undefined;
|
|
36
|
+
const x = a.toLowerCase();
|
|
37
|
+
if (/usdt|tether/.test(x)) return 'USDT';
|
|
38
|
+
if (/xaut|gold/.test(x)) return 'XAUT';
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const num = (s?: string): number | undefined => {
|
|
43
|
+
if (!s) return undefined;
|
|
44
|
+
const n = Number(s.replace(/,/g, ''));
|
|
45
|
+
return Number.isFinite(n) ? n : undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Thousands separators, locale-independent (deterministic for tests). */
|
|
49
|
+
const commas = (n: number): string => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
50
|
+
|
|
51
|
+
/** "buy 100 usdt" / "get me 50 xaut" / "i want 200 usdt" / "purchase 10 xaut". */
|
|
52
|
+
export function extractBuyAsset(text: string): Record<string, unknown> | null {
|
|
53
|
+
const t = text.trim();
|
|
54
|
+
if (NOT_BUY.test(t) || HAS_SOURCE.test(t)) return null;
|
|
55
|
+
if (!RGB_ASSET.test(t)) return null;
|
|
56
|
+
// buy/get/want/acquire/purchase [me] <amount> <asset>
|
|
57
|
+
const m = t.match(/\b(?:buy|get|acquire|want|purchase|onboard|need)\b(?:\s+me)?\s+([\d.,]+)\s*([a-z]+)/i);
|
|
58
|
+
if (!m) return null;
|
|
59
|
+
const asset = normAsset(m[2]);
|
|
60
|
+
const amount = num(m[1]);
|
|
61
|
+
if (!asset || amount === undefined) return null;
|
|
62
|
+
return { asset, asset_amount: amount };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const buyAssetChannelRecipe: Recipe = {
|
|
66
|
+
name: 'buy-asset-channel',
|
|
67
|
+
description:
|
|
68
|
+
'Onboarding buy: purchase a new Lightning channel pre-loaded with an RGB asset (USDT, XAUT) from the maker LSP — for a user with on-chain BTC but no channel yet. Quote, then order (with confirmation).',
|
|
69
|
+
// "buy/get/want N <rgb-asset>" with NO named source asset and NO swap/send verb.
|
|
70
|
+
match: (t) => !NOT_BUY.test(t) && !HAS_SOURCE.test(t) && RGB_ASSET.test(t) && BUY_VERB.test(t),
|
|
71
|
+
triggers: ['buy', 'get', 'purchase', 'acquire'],
|
|
72
|
+
slots: [
|
|
73
|
+
{ name: 'asset', type: 'string', description: 'RGB asset to acquire (USDT or XAUT)', required: true },
|
|
74
|
+
{ name: 'asset_amount', type: 'number', description: 'Amount of the asset to load into the channel (display units, e.g. 100)', required: true },
|
|
75
|
+
],
|
|
76
|
+
extract: extractBuyAsset,
|
|
77
|
+
confident: (s) => !!s.asset && s.asset_amount !== undefined && Number(s.asset_amount) > 0,
|
|
78
|
+
steps: [
|
|
79
|
+
// 1. Maker prices the asset + the channel.
|
|
80
|
+
// Returns { rfq_id, btc_amount_sat, channel_fee_sat, total_sat, expires_at }.
|
|
81
|
+
{
|
|
82
|
+
tool: 'kaleidoswap_lsp_quote_asset_channel',
|
|
83
|
+
as: 'quote',
|
|
84
|
+
args: (ctx) => ({ asset: ctx.slots.asset, asset_amount: ctx.slots.asset_amount }),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
// 2. Order the channel with the fresh rfq_id. Spend → confirmation-gated.
|
|
88
|
+
// The quote's cost fields ride along so the host's confirm card can show
|
|
89
|
+
// the price before approval; the create tool treats them as display-only.
|
|
90
|
+
final: {
|
|
91
|
+
tool: 'kaleidoswap_lsp_create_asset_channel',
|
|
92
|
+
args: (ctx) => {
|
|
93
|
+
const q = (ctx.results.quote ?? {}) as {
|
|
94
|
+
rfq_id?: string;
|
|
95
|
+
total_sat?: number;
|
|
96
|
+
btc_amount_sat?: number;
|
|
97
|
+
channel_fee_sat?: number;
|
|
98
|
+
expires_at?: number;
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
asset: ctx.slots.asset,
|
|
102
|
+
asset_amount: ctx.slots.asset_amount,
|
|
103
|
+
rfq_id: q.rfq_id,
|
|
104
|
+
total_sat: q.total_sat,
|
|
105
|
+
btc_amount_sat: q.btc_amount_sat,
|
|
106
|
+
channel_fee_sat: q.channel_fee_sat,
|
|
107
|
+
expires_at: q.expires_at,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
summary: (ctx, finalResult) => {
|
|
112
|
+
const q = ctx.results.quote as { total_sat?: number } | undefined;
|
|
113
|
+
const o = finalResult as { order_id?: string } | undefined;
|
|
114
|
+
const cost = typeof q?.total_sat === 'number' ? ` for ${commas(q.total_sat)} sats` : '';
|
|
115
|
+
const id = o?.order_id ? ` (order ${o.order_id})` : '';
|
|
116
|
+
return `Ordered a Lightning channel with ${ctx.slots.asset_amount} ${ctx.slots.asset}${cost}${id}. Pay the returned invoice/address to open it.`;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
@@ -5,15 +5,19 @@ import type { LLMProvider } from '../providers/types.js';
|
|
|
5
5
|
import { runRecipe } from './runner.js';
|
|
6
6
|
import { kaleidoswapAtomicRecipe } from './kaleidoswap-atomic.js';
|
|
7
7
|
|
|
8
|
-
// LLM provider that should never be called when slots are
|
|
8
|
+
// LLM provider that should never be called when slots are pre-supplied to runRecipe
|
|
9
|
+
// (or when a recipe is not using forceModelExtract).
|
|
9
10
|
const refusingProvider: LLMProvider = {
|
|
10
11
|
name: 'refusing',
|
|
11
12
|
runTurn: async () => {
|
|
12
|
-
throw new Error('provider should NOT be called
|
|
13
|
+
throw new Error('provider should NOT be called (slots pre-supplied or det path)');
|
|
13
14
|
},
|
|
14
15
|
};
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Stub the maker + node tools. The quote echoes full asset specs (asset_id +
|
|
19
|
+
* maker-unit amount) the way the real maker does, so init can source them.
|
|
20
|
+
*/
|
|
17
21
|
function buildStubs(captured: { name: string; args: any }[]) {
|
|
18
22
|
const tool = (name: string, response: any, spend = false) => ({
|
|
19
23
|
name,
|
|
@@ -27,112 +31,181 @@ function buildStubs(captured: { name: string; args: any }[]) {
|
|
|
27
31
|
});
|
|
28
32
|
return new ToolRegistry([
|
|
29
33
|
new InProcessToolSource('kaleidoswap', [
|
|
30
|
-
tool('kaleidoswap_get_quote', {
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
tool('kaleidoswap_get_quote', {
|
|
35
|
+
rfq_id: 'rfq-1',
|
|
36
|
+
from_asset: { asset_id: 'USDT', ticker: 'USDT', amount: 10_000_000 },
|
|
37
|
+
to_asset: { asset_id: 'BTC', ticker: 'BTC', amount: 15_250_000 },
|
|
38
|
+
from_amount_display: '10 USDT',
|
|
39
|
+
to_amount_display: '15,250 sats',
|
|
40
|
+
fee_display: '154 sats',
|
|
41
|
+
}),
|
|
42
|
+
tool('kaleidoswap_atomic_init', { swapstring: 'SWAP/abc/def', payment_hash: 'ph-1' }, /* spend */ true),
|
|
43
|
+
tool('kaleidoswap_atomic_execute', { status: 200, message: 'Swap executed successfully.' }, /* spend */ true),
|
|
33
44
|
]),
|
|
34
45
|
new InProcessToolSource('rln', [
|
|
35
|
-
tool('
|
|
36
|
-
tool('
|
|
37
|
-
tool('rln_pay_invoice', { status: 'SUCCESS', payment_hash: 'h' }, /* spend */ true),
|
|
46
|
+
tool('rln_get_node_info', { pubkey: '03c31dae' }),
|
|
47
|
+
tool('rln_whitelist_swap', { ok: true }, /* spend */ true),
|
|
38
48
|
]),
|
|
39
49
|
]);
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
describe('kaleidoswapAtomicRecipe — selection
|
|
43
|
-
it('triggers on
|
|
44
|
-
expect(kaleidoswapAtomicRecipe.match!('
|
|
45
|
-
expect(kaleidoswapAtomicRecipe.match!('
|
|
46
|
-
expect(kaleidoswapAtomicRecipe.match!('
|
|
52
|
+
describe('kaleidoswapAtomicRecipe — selection', () => {
|
|
53
|
+
it('triggers on swap phrasings', () => {
|
|
54
|
+
expect(kaleidoswapAtomicRecipe.match!('swap 10 usdt to btc')).toBe(true);
|
|
55
|
+
expect(kaleidoswapAtomicRecipe.match!('exchange 100000 sats for usdt')).toBe(true);
|
|
56
|
+
expect(kaleidoswapAtomicRecipe.match!('convert btc to usdt')).toBe(true);
|
|
47
57
|
});
|
|
58
|
+
it('triggers on buy/sell of a crypto asset (the reported bug)', () => {
|
|
59
|
+
expect(kaleidoswapAtomicRecipe.match!('buy one usdt from kaleido')).toBe(true);
|
|
60
|
+
expect(kaleidoswapAtomicRecipe.match!('sell 100 usdt')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('does NOT trigger on buying a gift card (that is commerce, not a swap)', () => {
|
|
63
|
+
expect(kaleidoswapAtomicRecipe.match!('buy a gift card')).toBe(false);
|
|
64
|
+
expect(kaleidoswapAtomicRecipe.match!('buy an amazon voucher')).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
it('does not trigger on a balance question', () => {
|
|
67
|
+
expect(kaleidoswapAtomicRecipe.match!('what is my balance')).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('kaleidoswapAtomicRecipe — forceModelExtract (less deterministic slot parsing)', () => {
|
|
72
|
+
it('always uses 1 LLM inference for slots even when a det extract would succeed (model does the NL understanding)', async () => {
|
|
73
|
+
const captured: { name: string; args: any }[] = [];
|
|
74
|
+
const tools = buildStubs(captured);
|
|
75
|
+
|
|
76
|
+
// Provider that handles the synthetic extract_request tool the runner builds.
|
|
77
|
+
const modelExtractProvider: LLMProvider = {
|
|
78
|
+
name: 'model-extract',
|
|
79
|
+
runTurn: async (input) => {
|
|
80
|
+
// The runner sends a single-turn request with the extract tool.
|
|
81
|
+
const call = input.tools?.find((t) => t.name === 'extract_request');
|
|
82
|
+
if (call && input.messages.some((m) => m.role === 'user' && /usdt/i.test(m.content || ''))) {
|
|
83
|
+
// Simulate the model correctly parsing a natural "buy" phrasing.
|
|
84
|
+
return {
|
|
85
|
+
text: '',
|
|
86
|
+
rawContent: '',
|
|
87
|
+
toolCalls: [{
|
|
88
|
+
id: 'ex1',
|
|
89
|
+
name: 'extract_request',
|
|
90
|
+
arguments: { from_asset: 'BTC', to_asset: 'USDT', amount: 1, amount_side: 'to' },
|
|
91
|
+
}],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { text: '', rawContent: '', toolCalls: [] };
|
|
95
|
+
},
|
|
96
|
+
};
|
|
48
97
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
98
|
+
const res = await runRecipe(kaleidoswapAtomicRecipe, 'buy 1 usdt', {
|
|
99
|
+
provider: modelExtractProvider,
|
|
100
|
+
tools,
|
|
101
|
+
onConfirm: async () => ({ approved: true }),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(res.status).toBe('done');
|
|
105
|
+
expect(res.inferences).toBe(1); // forced through the model
|
|
106
|
+
// The execution still used the model-provided slots (from_asset came from the "model" not regex default).
|
|
107
|
+
// (The stub quote in the test is for USDT→BTC, but the point is the inference count + that it ran.)
|
|
108
|
+
expect(captured[0].name).toBe('kaleidoswap_get_quote');
|
|
52
109
|
});
|
|
53
110
|
});
|
|
54
111
|
|
|
55
|
-
describe('kaleidoswapAtomicRecipe —
|
|
56
|
-
it('runs quote →
|
|
112
|
+
describe('kaleidoswapAtomicRecipe — full chain', () => {
|
|
113
|
+
it('runs quote → init → nodeinfo → whitelist → execute in order (one inference)', async () => {
|
|
57
114
|
const captured: { name: string; args: any }[] = [];
|
|
58
115
|
const tools = buildStubs(captured);
|
|
59
116
|
|
|
60
|
-
const res = await runRecipe(kaleidoswapAtomicRecipe, '
|
|
117
|
+
const res = await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
|
|
61
118
|
provider: refusingProvider,
|
|
62
119
|
tools,
|
|
63
120
|
onConfirm: async () => ({ approved: true }),
|
|
121
|
+
// Pre-supply slots so refusingProvider is not hit. This simulates a
|
|
122
|
+
// successful prior extraction (the normal fast path for most recipes,
|
|
123
|
+
// or the early Funnel heuristic for forceModelExtract recipes).
|
|
124
|
+
slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
|
|
64
125
|
});
|
|
65
126
|
|
|
66
127
|
expect(res.status).toBe('done');
|
|
67
|
-
expect(res.inferences).toBe(0);
|
|
68
|
-
|
|
69
|
-
// The chain: quote → rgb_invoice → atomic_init → pay → atomic_execute (5 calls).
|
|
128
|
+
expect(res.inferences).toBe(0);
|
|
70
129
|
expect(captured.map((c) => c.name)).toEqual([
|
|
71
130
|
'kaleidoswap_get_quote',
|
|
72
|
-
'rln_create_rgb_invoice',
|
|
73
131
|
'kaleidoswap_atomic_init',
|
|
74
|
-
'
|
|
132
|
+
'rln_get_node_info',
|
|
133
|
+
'rln_whitelist_swap',
|
|
75
134
|
'kaleidoswap_atomic_execute',
|
|
76
135
|
]);
|
|
136
|
+
});
|
|
77
137
|
|
|
78
|
-
|
|
138
|
+
it('threads quote → init args (flat asset ids + maker-unit amounts)', async () => {
|
|
139
|
+
const captured: { name: string; args: any }[] = [];
|
|
140
|
+
const tools = buildStubs(captured);
|
|
141
|
+
await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
|
|
142
|
+
provider: refusingProvider, tools, onConfirm: async () => ({ approved: true }),
|
|
143
|
+
slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
|
|
144
|
+
});
|
|
79
145
|
const init = captured.find((c) => c.name === 'kaleidoswap_atomic_init')!;
|
|
80
|
-
expect(init.args).toEqual({
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
146
|
+
expect(init.args).toEqual({
|
|
147
|
+
rfq_id: 'rfq-1',
|
|
148
|
+
from_asset: 'USDT', from_amount: 10_000_000,
|
|
149
|
+
to_asset: 'BTC', to_amount: 15_250_000,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
85
152
|
|
|
86
|
-
|
|
153
|
+
it('threads init.swapstring + node.pubkey + init.payment_hash → execute', async () => {
|
|
154
|
+
const captured: { name: string; args: any }[] = [];
|
|
155
|
+
const tools = buildStubs(captured);
|
|
156
|
+
await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
|
|
157
|
+
provider: refusingProvider, tools, onConfirm: async () => ({ approved: true }),
|
|
158
|
+
slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
|
|
159
|
+
});
|
|
160
|
+
const whitelist = captured.find((c) => c.name === 'rln_whitelist_swap')!;
|
|
161
|
+
expect(whitelist.args).toEqual({ swapstring: 'SWAP/abc/def' });
|
|
87
162
|
const exe = captured.find((c) => c.name === 'kaleidoswap_atomic_execute')!;
|
|
88
|
-
expect(exe.args).toEqual({
|
|
163
|
+
expect(exe.args).toEqual({
|
|
164
|
+
swapstring: 'SWAP/abc/def',
|
|
165
|
+
taker_pubkey: '03c31dae',
|
|
166
|
+
payment_hash: 'ph-1',
|
|
167
|
+
});
|
|
89
168
|
});
|
|
90
169
|
});
|
|
91
170
|
|
|
92
|
-
describe('kaleidoswapAtomicRecipe —
|
|
93
|
-
it('
|
|
171
|
+
describe('kaleidoswapAtomicRecipe — single confirmation', () => {
|
|
172
|
+
it('fires ONE gate (before init), showing the quote summary, then runs ungated', async () => {
|
|
94
173
|
const captured: { name: string; args: any }[] = [];
|
|
95
174
|
const tools = buildStubs(captured);
|
|
175
|
+
const confirms: { name: string; summary?: string }[] = [];
|
|
96
176
|
|
|
97
|
-
const res = await runRecipe(kaleidoswapAtomicRecipe, '
|
|
177
|
+
const res = await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
|
|
98
178
|
provider: refusingProvider,
|
|
99
179
|
tools,
|
|
100
|
-
onConfirm: async () =>
|
|
180
|
+
onConfirm: async (call) => {
|
|
181
|
+
confirms.push({ name: call.name, summary: (call as any).summary });
|
|
182
|
+
return { approved: true };
|
|
183
|
+
},
|
|
184
|
+
slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
|
|
101
185
|
});
|
|
102
186
|
|
|
103
187
|
expect(res.status).toBe('done');
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
]);
|
|
111
|
-
const init = captured.find((c) => c.name === 'kaleidoswap_atomic_init')!;
|
|
112
|
-
expect(init.args.receive_invoice).toBe('lnbc1user');
|
|
188
|
+
// Exactly one confirm, on the first spend step (init), with the rich summary.
|
|
189
|
+
expect(confirms).toHaveLength(1);
|
|
190
|
+
expect(confirms[0]!.name).toBe('kaleidoswap_atomic_init');
|
|
191
|
+
expect(confirms[0]!.summary).toContain('10 USDT');
|
|
192
|
+
expect(confirms[0]!.summary).toContain('15,250 sats');
|
|
193
|
+
expect(confirms[0]!.summary).toContain('154 sats');
|
|
113
194
|
});
|
|
114
|
-
});
|
|
115
195
|
|
|
116
|
-
|
|
117
|
-
it('cancels the chain when the user declines a spend gate', async () => {
|
|
196
|
+
it('declining the single gate cancels the whole chain before any spend', async () => {
|
|
118
197
|
const captured: { name: string; args: any }[] = [];
|
|
119
198
|
const tools = buildStubs(captured);
|
|
120
|
-
let firstSpendSeen = false;
|
|
121
199
|
|
|
122
|
-
const res = await runRecipe(kaleidoswapAtomicRecipe, '
|
|
200
|
+
const res = await runRecipe(kaleidoswapAtomicRecipe, 'swap 10 usdt to btc', {
|
|
123
201
|
provider: refusingProvider,
|
|
124
202
|
tools,
|
|
125
|
-
onConfirm: async () => {
|
|
126
|
-
|
|
127
|
-
firstSpendSeen = true;
|
|
128
|
-
return { approved: false, reason: 'user said no' };
|
|
129
|
-
},
|
|
203
|
+
onConfirm: async () => ({ approved: false, reason: 'user said no' }),
|
|
204
|
+
slots: { from_asset: 'USDT', to_asset: 'BTC', amount: 10, amount_side: 'from' },
|
|
130
205
|
});
|
|
131
206
|
|
|
132
|
-
expect(res.status).
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
expect(captured.some((c) => c.name === 'rln_pay_invoice')).toBe(false);
|
|
136
|
-
expect(captured.some((c) => c.name === 'kaleidoswap_atomic_execute')).toBe(false);
|
|
207
|
+
expect(res.status).toBe('cancelled');
|
|
208
|
+
// Quote ran (read-only), but NOTHING after the declined gate.
|
|
209
|
+
expect(captured.map((c) => c.name)).toEqual(['kaleidoswap_get_quote']);
|
|
137
210
|
});
|
|
138
211
|
});
|