@kaleidorg/mind 0.7.0 → 0.8.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/README.md +96 -8
- package/dist/context/compress.d.ts.map +1 -1
- package/dist/context/compress.js +1 -0
- package/dist/context/compress.js.map +1 -1
- package/dist/context/rgb-units.d.ts +18 -0
- package/dist/context/rgb-units.d.ts.map +1 -0
- package/dist/context/rgb-units.js +86 -0
- package/dist/context/rgb-units.js.map +1 -0
- package/dist/engine.d.ts +53 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +179 -17
- package/dist/engine.js.map +1 -1
- package/dist/funnel.d.ts.map +1 -1
- package/dist/funnel.js +23 -4
- package/dist/funnel.js.map +1 -1
- package/dist/guards.d.ts +63 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +284 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/kaleidoswap/contract.d.ts +3 -4
- package/dist/kaleidoswap/contract.d.ts.map +1 -1
- package/dist/kaleidoswap/contract.js +3 -17
- package/dist/kaleidoswap/contract.js.map +1 -1
- package/dist/providers/openai.d.ts +64 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +233 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/providers/types.d.ts +20 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/qvac/config.d.ts +1 -1
- package/dist/qvac/config.js +1 -1
- package/dist/qvac/index.d.ts +1 -0
- package/dist/qvac/index.d.ts.map +1 -1
- package/dist/qvac/index.js +1 -0
- package/dist/qvac/index.js.map +1 -1
- package/dist/qvac/models.d.ts +31 -0
- package/dist/qvac/models.d.ts.map +1 -0
- package/dist/qvac/models.js +80 -0
- package/dist/qvac/models.js.map +1 -0
- package/dist/qvac/parse.d.ts +11 -0
- package/dist/qvac/parse.d.ts.map +1 -1
- package/dist/qvac/parse.js +29 -7
- package/dist/qvac/parse.js.map +1 -1
- package/dist/qvac/provider.d.ts +5 -4
- package/dist/qvac/provider.d.ts.map +1 -1
- package/dist/qvac/provider.js +22 -8
- package/dist/qvac/provider.js.map +1 -1
- package/dist/qvac/stream.d.ts +3 -5
- package/dist/qvac/stream.d.ts.map +1 -1
- package/dist/qvac/stream.js.map +1 -1
- package/dist/qvac/tools.d.ts +5 -0
- package/dist/qvac/tools.d.ts.map +1 -1
- package/dist/qvac/tools.js +10 -0
- package/dist/qvac/tools.js.map +1 -1
- package/dist/recipe/issue-asset.d.ts.map +1 -1
- package/dist/recipe/issue-asset.js +42 -16
- package/dist/recipe/issue-asset.js.map +1 -1
- package/dist/recipe/runner.d.ts.map +1 -1
- package/dist/recipe/runner.js +1 -0
- package/dist/recipe/runner.js.map +1 -1
- package/dist/recipe/submarine-pay.d.ts +17 -0
- package/dist/recipe/submarine-pay.d.ts.map +1 -0
- package/dist/recipe/submarine-pay.js +79 -0
- package/dist/recipe/submarine-pay.js.map +1 -0
- package/dist/skills/registry.js +1 -1
- package/dist/skills/select.d.ts +20 -0
- package/dist/skills/select.d.ts.map +1 -0
- package/dist/skills/select.js +32 -0
- package/dist/skills/select.js.map +1 -0
- package/dist/submarine/contract.d.ts +42 -0
- package/dist/submarine/contract.d.ts.map +1 -0
- package/dist/submarine/contract.js +73 -0
- package/dist/submarine/contract.js.map +1 -0
- package/dist/testing/mock-wallet.d.ts +24 -0
- package/dist/testing/mock-wallet.d.ts.map +1 -1
- package/dist/testing/mock-wallet.js +75 -6
- package/dist/testing/mock-wallet.js.map +1 -1
- package/dist/wallet/confirm.d.ts.map +1 -1
- package/dist/wallet/confirm.js +27 -9
- package/dist/wallet/confirm.js.map +1 -1
- package/package.json +6 -1
- package/skills/README.md +1 -0
- package/skills/kaleido-trading/SKILL.md +22 -28
- package/skills/kaleido-trading/references/api.md +3 -3
- package/skills/rgb-lightning-node/SKILL.md +32 -12
- package/skills/spark-wallet/SKILL.md +1 -0
- package/skills/submarine-swaps/SKILL.md +48 -0
- package/src/context/compress.ts +1 -0
- package/src/context/rgb-units.test.ts +42 -0
- package/src/context/rgb-units.ts +89 -0
- package/src/engine.test.ts +94 -3
- package/src/engine.ts +234 -19
- package/src/funnel.mind.test.ts +4 -2
- package/src/funnel.ts +23 -4
- package/src/guards.test.ts +399 -0
- package/src/guards.ts +299 -0
- package/src/index.ts +37 -1
- package/src/kaleidoswap/contract.test.ts +8 -16
- package/src/kaleidoswap/contract.ts +4 -32
- package/src/providers/openai.test.ts +127 -0
- package/src/providers/openai.ts +282 -0
- package/src/providers/types.ts +22 -0
- package/src/qvac/config.ts +1 -1
- package/src/qvac/index.ts +10 -0
- package/src/qvac/models.ts +98 -0
- package/src/qvac/parse.test.ts +7 -0
- package/src/qvac/parse.ts +33 -1
- package/src/qvac/provider.test.ts +34 -1
- package/src/qvac/provider.ts +30 -13
- package/src/qvac/stream.ts +3 -5
- package/src/qvac/tools.ts +10 -0
- package/src/recipe/issue-asset.test.ts +39 -0
- package/src/recipe/issue-asset.ts +41 -13
- package/src/recipe/runner.ts +1 -0
- package/src/recipe/submarine-pay.test.ts +87 -0
- package/src/recipe/submarine-pay.ts +78 -0
- package/src/skills/registry.ts +1 -1
- package/src/skills/select.ts +41 -0
- package/src/submarine/contract.ts +112 -0
- package/src/testing/mock-wallet.ts +73 -6
- package/src/wallet/confirm.ts +27 -9
package/src/qvac/stream.ts
CHANGED
|
@@ -28,11 +28,9 @@ export interface StreamHandlers {
|
|
|
28
28
|
/** The model's `<think>` reasoning, streamed separately. */
|
|
29
29
|
onThinking?: (token: string) => void;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* only on/off), so we count thinking tokens and stop the run once they exceed
|
|
35
|
-
* this. Omit for unlimited reasoning.
|
|
31
|
+
* Stop the run once `<think>` reasoning exceeds this many tokens (estimated
|
|
32
|
+
* from characters). A backstop for the SDK's own `reasoning_budget`. Omit for
|
|
33
|
+
* unlimited reasoning.
|
|
36
34
|
*/
|
|
37
35
|
maxThinkingTokens?: number;
|
|
38
36
|
/**
|
package/src/qvac/tools.ts
CHANGED
|
@@ -104,3 +104,13 @@ export function toQvacTools(
|
|
|
104
104
|
}
|
|
105
105
|
return tools.map(toQvacTool);
|
|
106
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* JSON Schema for a tool's parameters: a JSON-Schema def is returned as is
|
|
110
|
+
* (nested objects intact), a Zod schema is converted to a flat object schema.
|
|
111
|
+
*/
|
|
112
|
+
export function toolParametersSchema(t: Pick<ToolDef, 'name' | 'description' | 'parameters'>): Record<string, unknown> {
|
|
113
|
+
const p = t.parameters as Obj | null | undefined;
|
|
114
|
+
if (p && typeof p === 'object' && !isZodLike(p) && ('properties' in p || p.type === 'object')) return p;
|
|
115
|
+
return toQvacTool(t).parameters;
|
|
116
|
+
}
|
|
@@ -35,6 +35,45 @@ describe('extractIssueAsset (deterministic Tier-0)', () => {
|
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
describe('extractIssueAsset — review regressions', () => {
|
|
39
|
+
it('reads thousands separators in both styles, and decimals with k/m', () => {
|
|
40
|
+
expect(extractIssueAsset('crea 1.000 token CAFE chiamati Caffè Club')).toMatchObject({ amount: 1000, ticker: 'CAFE', name: 'Caffè Club' });
|
|
41
|
+
expect(extractIssueAsset('issue 1,000 TICKET tokens')).toMatchObject({ amount: 1000 });
|
|
42
|
+
expect(extractIssueAsset('mint 1,5k PIZZA tokens')).toMatchObject({ amount: 1500 });
|
|
43
|
+
expect(extractIssueAsset('mint 1.5k PIZZA tokens')).toMatchObject({ amount: 1500 });
|
|
44
|
+
});
|
|
45
|
+
it('never takes the supply from a number inside the name', () => {
|
|
46
|
+
expect(extractIssueAsset('mint tokens named Web3 Summit 2026, supply 300')).toMatchObject({ name: 'Web3 Summit 2026', amount: 300 });
|
|
47
|
+
expect(extractIssueAsset('mint tokens named Web3 Summit 2026')).toMatchObject({ name: 'Web3 Summit 2026', amount: undefined });
|
|
48
|
+
expect(issueAssetRecipe.confident!(extractIssueAsset('mint tokens named Web3 Summit 2026')!)).toBe(false);
|
|
49
|
+
expect(extractIssueAsset('launch a token called Moon with ticker MOON and 1m supply')).toMatchObject({ name: 'Moon', ticker: 'MOON', amount: 1_000_000 });
|
|
50
|
+
});
|
|
51
|
+
it('only fires when the request opens with the verb', () => {
|
|
52
|
+
expect(issueAssetRecipe.match!('I have an issue with my USDT tokens')).toBe(false);
|
|
53
|
+
expect(issueAssetRecipe.match!('how do I create a token?')).toBe(false);
|
|
54
|
+
expect(extractIssueAsset('I have an issue with my USDT tokens')).toBeNull();
|
|
55
|
+
expect(issueAssetRecipe.match!('please mint 100 PIZZA tokens')).toBe(true);
|
|
56
|
+
expect(issueAssetRecipe.match!('puoi creare 100 token CAFE?')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
it('apostrophes inside a name are not quotes', () => {
|
|
59
|
+
expect(extractIssueAsset("mint 100 tokens called Joe's Pizza with ticker 'JOE'")).toEqual({ name: "Joe's Pizza", ticker: 'JOE', amount: 100, schema: 'NIA' });
|
|
60
|
+
expect(extractIssueAsset("crea 50 token chiamati Bottega dell'Arte con ticker ARTE")).toMatchObject({ name: "Bottega dell'Arte", ticker: 'ARTE', amount: 50 });
|
|
61
|
+
expect(extractIssueAsset('mint 10 tokens "Pizza Points" ticker PP')).toMatchObject({ name: 'Pizza Points', ticker: 'PP', amount: 10 });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('MockWallet.reset', () => {
|
|
66
|
+
it('restores the initial state, including issued assets and UTXOs', async () => {
|
|
67
|
+
const wallet = new MockWallet({ utxos: 1 });
|
|
68
|
+
await wallet.registry().execute('rln_issue_asset', { name: 'Ticket', ticker: 'TICKET', amount: 10 });
|
|
69
|
+
expect(wallet.utxos).toBe(0);
|
|
70
|
+
wallet.reset();
|
|
71
|
+
expect(wallet.utxos).toBe(1);
|
|
72
|
+
expect(wallet.assets.TICKET).toBeUndefined();
|
|
73
|
+
await expect(wallet.registry().execute('rln_issue_asset', { name: 'Ticket', ticker: 'TICKET', amount: 10 })).resolves.toMatchObject({ issued: true });
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
38
77
|
describe('issue-asset readback', () => {
|
|
39
78
|
it('fungible: supply + ticker + name', () => {
|
|
40
79
|
expect(confirmReadback({ name: 'rln_issue_asset', arguments: { name: 'Hackathon Ticket', ticker: 'TICKET', amount: 1000 } }))
|
|
@@ -13,30 +13,58 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Recipe, RecipeContext } from './types.js';
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// The request must OPEN with the verb ("issue …", "please mint …", "puoi creare …"),
|
|
17
|
+
// so "I have an issue with my tokens" or "how do I create a token?" stay with the model.
|
|
18
|
+
const VERB = /^(?:(?:please|pls|per favore|can you|could you|puoi|potresti)\s+)?(issue|mint|create|launch|emetti|conia|crea|creare|emettere|coniare)\b/i;
|
|
17
19
|
const NOUN = /\b(tokens?|assets?|coins?|nfts?|tickets?|gettoni|badges?)\b/i;
|
|
18
20
|
const NOT_ISSUE = /\b(invoice|fattura|channel|canale|swap|buy|sell|send|pay|compra|vendi|invia|paga)\b/i;
|
|
19
21
|
const NFT = /\b(nfts?|unique|unico|unica)\b/i;
|
|
20
22
|
// Words that look like tickers but are part of the request, not the asset.
|
|
21
23
|
const STOPWORDS = new Set(['RGB', 'NFT', 'NFTS', 'NIA', 'CFA', 'UDA', 'BTC', 'LN', 'AN', 'A']);
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// "1000", "1,000", "1.000" (it), "1.5", "1,5" (it), optionally followed by k/m.
|
|
26
|
+
const NUM = String.raw`(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\s*([km])?`;
|
|
27
|
+
|
|
28
|
+
function toNumber(digits: string, suffix?: string): number | undefined {
|
|
29
|
+
// Groups of exactly three digits are thousands separators in both "1,000" and "1.000";
|
|
30
|
+
// a single separator followed by another digit count is a decimal point.
|
|
31
|
+
const n = /^\d{1,3}(?:[.,]\d{3})+$/.test(digits)
|
|
32
|
+
? Number(digits.replace(/[.,]/g, ''))
|
|
33
|
+
: Number(digits.replace(',', '.'));
|
|
34
|
+
const scaled = suffix ? n * (suffix.toLowerCase() === 'k' ? 1_000 : 1_000_000) : n;
|
|
35
|
+
return Number.isFinite(scaled) && scaled > 0 ? scaled : undefined;
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The supply: an explicit "supply 300" / "1m supply" wins, then a number right before the token
|
|
40
|
+
* noun / ticker ("1000 TICKET tokens", "500 token"). Numbers inside the asset name
|
|
41
|
+
* ("Web3 Summit 2026") are never the supply.
|
|
42
|
+
*/
|
|
43
|
+
function parseAmount(t: string, name?: string): number | undefined {
|
|
44
|
+
const rest = name ? t.replace(name, ' ') : t;
|
|
45
|
+
const labelled = rest.match(new RegExp(String.raw`\b(?:supply|amount|quantit[àa]|totale)\s*(?:of|di|[:=])?\s*` + NUM, 'i'));
|
|
46
|
+
if (labelled) return toNumber(labelled[1]!, labelled[2]);
|
|
47
|
+
const trailing = rest.match(new RegExp(String.raw`(?:^|\s)` + NUM + String.raw`\s+(?:supply|di supply)\b`, 'i'));
|
|
48
|
+
if (trailing) return toNumber(trailing[1]!, trailing[2]);
|
|
49
|
+
const beforeNoun = rest.match(new RegExp(String.raw`(?:^|\s)` + NUM + String.raw`\s+(?:[A-Z][A-Z0-9]{1,7}\s+)?(?:tokens?|coins?|tickets?|gettoni|badges?|assets?)\b`, 'i'));
|
|
50
|
+
if (beforeNoun) return toNumber(beforeNoun[1]!, beforeNoun[2]);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const unquote = (s: string) => s.replace(/^["“'‘]+|["”'’]+$/g, '').trim();
|
|
55
|
+
|
|
31
56
|
function parseName(t: string): string | undefined {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
57
|
+
// "called Joe's Pizza with ticker 'JOE'" → Joe's Pizza (apostrophes inside a name are fine).
|
|
58
|
+
const m = t.match(/\b(?:called|named|chiamat[oaie])\s+(.+?)(?=\s+(?:with|ticker|con|supply)\b|[,;]|\.(?:\s|$)|$)/i);
|
|
59
|
+
if (m?.[1]) return unquote(m[1]) || undefined;
|
|
60
|
+
// Otherwise only a properly paired quote: double quotes, or single quotes that open
|
|
61
|
+
// after a space and close before a space/punctuation (so "Joe's" never opens one).
|
|
62
|
+
const quoted = t.match(/["“]([^"”]{1,40})["”]/)?.[1] ?? t.match(/(?:^|\s)['‘]([^'’]{1,40})['’](?=\s|$|[,.;!?])/)?.[1];
|
|
63
|
+
return quoted?.trim() || undefined;
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
function parseTicker(t: string, name?: string): string | undefined {
|
|
39
|
-
const explicit = t.match(/\bticker\s
|
|
67
|
+
const explicit = t.match(/\bticker\s*[:=]?\s*["'“‘]?([A-Za-z0-9]{1,8})\b/i)?.[1];
|
|
40
68
|
if (explicit) return explicit.toUpperCase();
|
|
41
69
|
// An all-caps word that isn't part of the name ("1000 TICKET tokens").
|
|
42
70
|
const nameWords = new Set((name ?? '').split(/\s+/));
|
|
@@ -57,7 +85,7 @@ export function extractIssueAsset(text: string): Record<string, unknown> | null
|
|
|
57
85
|
const schema = NFT.test(t) ? 'UDA' : 'NIA';
|
|
58
86
|
const name = parseName(t);
|
|
59
87
|
const ticker = parseTicker(t, name) ?? (name ? tickerFromName(name) : undefined);
|
|
60
|
-
const amount = schema === 'UDA' ? 1 : parseAmount(t);
|
|
88
|
+
const amount = schema === 'UDA' ? 1 : parseAmount(t, name);
|
|
61
89
|
if (!ticker && !name) return null;
|
|
62
90
|
return { name: name ?? ticker, ticker, amount, schema };
|
|
63
91
|
}
|
package/src/recipe/runner.ts
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { extractSubmarinePay, submarinePayRecipe } from './submarine-pay.js';
|
|
3
|
+
import { runRecipe } from './runner.js';
|
|
4
|
+
import { Funnel } from '../funnel.js';
|
|
5
|
+
import { paymentsRecipe } from './payments.js';
|
|
6
|
+
import { MockWallet, scriptedProvider } from '../testing/index.js';
|
|
7
|
+
import { SUBMARINE_TOOLS, isSubmarineSpendTool, formatSubmarineAmount } from '../submarine/contract.js';
|
|
8
|
+
|
|
9
|
+
// 10u = 1,000 sats on signet.
|
|
10
|
+
const INVOICE = 'lntbs10u1pnmockinvoice0qqqqqqqqqqqqqqqqqqqqqqqqq';
|
|
11
|
+
|
|
12
|
+
describe('submarine contract', () => {
|
|
13
|
+
it('only fund moves money, and it takes nothing but the swap id', () => {
|
|
14
|
+
expect(SUBMARINE_TOOLS.map((t) => t.name)).toEqual([
|
|
15
|
+
'kaleidoswap_submarine_pairs', 'kaleidoswap_submarine_create', 'kaleidoswap_submarine_fund', 'kaleidoswap_submarine_status',
|
|
16
|
+
]);
|
|
17
|
+
expect(SUBMARINE_TOOLS.filter((t) => t.requiresConfirmation).map((t) => t.name)).toEqual(['kaleidoswap_submarine_fund']);
|
|
18
|
+
expect(isSubmarineSpendTool('kaleidoswap_submarine_create')).toBe(false);
|
|
19
|
+
const fund = SUBMARINE_TOOLS.find((t) => t.name === 'kaleidoswap_submarine_fund')!;
|
|
20
|
+
expect(Object.keys((fund.parameters as any).properties)).toEqual(['swap_id']);
|
|
21
|
+
});
|
|
22
|
+
it('formats source amounts for the readback', () => {
|
|
23
|
+
expect(formatSubmarineAmount('L-USDT', '87500000')).toBe('0.875 L-USDT');
|
|
24
|
+
expect(formatSubmarineAmount('L-BTC', 1005)).toBe('1,005 sats of L-BTC');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('extractSubmarinePay', () => {
|
|
29
|
+
it('needs an invoice and an explicit Liquid asset', () => {
|
|
30
|
+
expect(extractSubmarinePay(`pay ${INVOICE} with L-USDT`)).toEqual({ invoice: INVOICE, from_asset: 'L-USDT' });
|
|
31
|
+
expect(extractSubmarinePay(`paga ${INVOICE} con USDT su Liquid`)).toEqual({ invoice: INVOICE, from_asset: 'L-USDT' });
|
|
32
|
+
expect(extractSubmarinePay(`pay ${INVOICE} using liquid bitcoin`)).toEqual({ invoice: INVOICE, from_asset: 'L-BTC' });
|
|
33
|
+
// Bare USDT is RGB USDT — not this recipe.
|
|
34
|
+
expect(extractSubmarinePay(`pay ${INVOICE} with USDT`)).toBeNull();
|
|
35
|
+
expect(extractSubmarinePay('pay bob 10 L-USDT')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('runRecipe — submarine pay against the mock /v2 maker', () => {
|
|
40
|
+
it('creates, confirms once with the maker amount, then funds', async () => {
|
|
41
|
+
const wallet = new MockWallet({ priceUsd: 80_000 });
|
|
42
|
+
const onConfirm = vi.fn(async () => ({ approved: true }));
|
|
43
|
+
const res = await runRecipe(submarinePayRecipe, `pay ${INVOICE} with L-USDT`, { provider: scriptedProvider(), tools: wallet.registry(), onConfirm });
|
|
44
|
+
expect(res.status).toBe('done');
|
|
45
|
+
expect(res.inferences).toBe(0);
|
|
46
|
+
expect(onConfirm).toHaveBeenCalledOnce();
|
|
47
|
+
// 1,000 sats + 0.5% at $80k = 0.804 USD → 80,400,000 units of L-USDT (8 dp).
|
|
48
|
+
expect((onConfirm.mock.calls[0] as any[])[0].summary).toBe(
|
|
49
|
+
'Pay Lightning invoice lntbs10u1p…qqqqqq with 0.804 L-USDT (fees included) through a KaleidoSwap submarine swap. Confirm?');
|
|
50
|
+
expect(wallet.liquid['L-USDT']).toBe(100 * 1e8 - 80_400_000);
|
|
51
|
+
expect(wallet.sends.map((s) => s.tool)).toEqual(['kaleidoswap_submarine_fund']);
|
|
52
|
+
expect(res.text).toMatch(/^Locked 0\.804 L-USDT in swap sub1/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('declining funds nothing, but the swap was opened (no money moved)', async () => {
|
|
56
|
+
const wallet = new MockWallet();
|
|
57
|
+
const res = await runRecipe(submarinePayRecipe, `pay ${INVOICE} with L-USDT`, {
|
|
58
|
+
provider: scriptedProvider(), tools: wallet.registry(), onConfirm: async () => ({ approved: false }),
|
|
59
|
+
});
|
|
60
|
+
expect(res.status).toBe('cancelled');
|
|
61
|
+
expect(wallet.sends).toEqual([]);
|
|
62
|
+
expect(wallet.submarineSwaps.sub1?.funded).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('surfaces insufficient Liquid balance without locking anything', async () => {
|
|
66
|
+
const wallet = new MockWallet({ liquid: { 'L-USDT': 1000, 'L-BTC': 0 } });
|
|
67
|
+
const res = await runRecipe(submarinePayRecipe, `pay ${INVOICE} with L-USDT`, {
|
|
68
|
+
provider: scriptedProvider(), tools: wallet.registry(), onConfirm: async () => ({ approved: true }),
|
|
69
|
+
});
|
|
70
|
+
expect(res.status).toBe('error');
|
|
71
|
+
expect(res.error ?? res.text).toMatch(/Insufficient L-USDT/);
|
|
72
|
+
expect(wallet.sends).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('Funnel — submarine-pay ahead of payments', () => {
|
|
77
|
+
it('routes "pay <invoice> with L-USDT" to the submarine recipe, plain invoices elsewhere', async () => {
|
|
78
|
+
const wallet = new MockWallet();
|
|
79
|
+
const funnel = new Funnel({ provider: scriptedProvider(), tools: wallet.registry(), recipes: [submarinePayRecipe, paymentsRecipe] });
|
|
80
|
+
const out = await funnel.runTurn(`pay ${INVOICE} with L-USDT`, { onConfirm: async () => ({ approved: true }) });
|
|
81
|
+
expect(out.tier).toBe('recipe');
|
|
82
|
+
expect(out.route).toBe('submarine-pay');
|
|
83
|
+
const status = await wallet.registry().execute('kaleidoswap_submarine_status', { swap_id: 'sub1' }) as { status: string };
|
|
84
|
+
expect(status.status).toBe('transaction.claimed');
|
|
85
|
+
expect(submarinePayRecipe.match!(`pay ${INVOICE}`)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Pay this Lightning invoice with my Liquid USDT" — a submarine swap on the
|
|
3
|
+
* KaleidoSwap /v2 maker:
|
|
4
|
+
*
|
|
5
|
+
* "pay lntbs1… with L-USDT" → kaleidoswap_submarine_create → 🔒 kaleidoswap_submarine_fund
|
|
6
|
+
* "paga lntbs1… con USDT su Liquid" → same
|
|
7
|
+
*
|
|
8
|
+
* One confirmation, shown after `create` so the user sees the exact amount the
|
|
9
|
+
* maker asked for (fees included) before anything is locked. Keys never reach
|
|
10
|
+
* the model; `fund` takes only the swap id. Opt-in: register via
|
|
11
|
+
* `Funnel.recipes` BEFORE the generic payments recipe on hosts that bind the
|
|
12
|
+
* submarine tools.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Recipe, RecipeContext } from './types.js';
|
|
16
|
+
import { formatSubmarineAmount } from '../submarine/contract.js';
|
|
17
|
+
|
|
18
|
+
const INVOICE = /\b(ln(?:bcrt|tbs|bc|tb)[0-9a-z]{20,})\b/i;
|
|
19
|
+
// Only an explicit Liquid asset: a bare "USDT" is RGB USDT (atomic recipe), not L-USDT.
|
|
20
|
+
const L_USDT = /\b(l-?usdt|usdt[- ]?l|liquid[- ]usdt|usdt (?:on|su|in) liquid|usdt liquid)\b/i;
|
|
21
|
+
const L_BTC = /\b(l-?btc|liquid[- ]btc|liquid bitcoin|btc (?:on|su|in) liquid|bitcoin (?:on|su|in) liquid)\b/i;
|
|
22
|
+
|
|
23
|
+
/** Tool results may arrive as JSON text (MCP) or objects (in-process). */
|
|
24
|
+
function obj(v: unknown): Record<string, unknown> {
|
|
25
|
+
if (typeof v === 'string') {
|
|
26
|
+
try { return JSON.parse(v) as Record<string, unknown>; } catch { return {}; }
|
|
27
|
+
}
|
|
28
|
+
return (v ?? {}) as Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shortInvoice(inv: string): string {
|
|
32
|
+
return `${inv.slice(0, 10)}…${inv.slice(-6)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extractSubmarinePay(text: string): Record<string, unknown> | null {
|
|
36
|
+
const invoice = text.match(INVOICE)?.[1];
|
|
37
|
+
if (!invoice) return null;
|
|
38
|
+
const from_asset = L_USDT.test(text) ? 'L-USDT' : L_BTC.test(text) ? 'L-BTC' : undefined;
|
|
39
|
+
if (!from_asset) return null;
|
|
40
|
+
return { invoice, from_asset };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const submarinePayRecipe: Recipe = {
|
|
44
|
+
name: 'submarine-pay',
|
|
45
|
+
description: 'Pay a Lightning invoice from Liquid funds (L-USDT or L-BTC) via a KaleidoSwap submarine swap — one confirmation.',
|
|
46
|
+
match: (t) => INVOICE.test(t) && (L_USDT.test(t) || L_BTC.test(t)),
|
|
47
|
+
triggers: ['l-usdt', 'liquid usdt', 'l-btc', 'submarine'],
|
|
48
|
+
slots: [
|
|
49
|
+
{ name: 'invoice', type: 'string', description: 'BOLT11 Lightning invoice to pay', required: true },
|
|
50
|
+
{ name: 'from_asset', type: 'string', description: 'L-USDT or L-BTC', required: true },
|
|
51
|
+
],
|
|
52
|
+
extract: extractSubmarinePay,
|
|
53
|
+
confident: (s) => typeof s.invoice === 'string' && (s.from_asset === 'L-USDT' || s.from_asset === 'L-BTC'),
|
|
54
|
+
steps: [
|
|
55
|
+
{
|
|
56
|
+
tool: 'kaleidoswap_submarine_create',
|
|
57
|
+
as: 'swap',
|
|
58
|
+
args: (ctx) => ({ invoice: ctx.slots.invoice, from_asset: ctx.slots.from_asset }),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
confirm: (ctx: RecipeContext) => {
|
|
62
|
+
const swap = obj(ctx.results.swap);
|
|
63
|
+
const asset = String(swap.from_asset ?? ctx.slots.from_asset);
|
|
64
|
+
return `Pay Lightning invoice ${shortInvoice(String(ctx.slots.invoice))} with ${formatSubmarineAmount(asset, swap.expected_amount)} (fees included) through a KaleidoSwap submarine swap. Confirm?`;
|
|
65
|
+
},
|
|
66
|
+
final: {
|
|
67
|
+
tool: 'kaleidoswap_submarine_fund',
|
|
68
|
+
args: (ctx) => ({ swap_id: obj(ctx.results.swap).swap_id }),
|
|
69
|
+
},
|
|
70
|
+
summary: (ctx, final) => {
|
|
71
|
+
const swap = obj(ctx.results.swap);
|
|
72
|
+
const funded = obj(final);
|
|
73
|
+
const asset = String(swap.from_asset ?? ctx.slots.from_asset);
|
|
74
|
+
return `Locked ${formatSubmarineAmount(asset, swap.expected_amount)} in swap ${String(swap.swap_id)}` +
|
|
75
|
+
(funded.txid ? ` (tx ${String(funded.txid).slice(0, 12)}…)` : '') +
|
|
76
|
+
'. The maker pays the invoice once the lockup is seen — ask me for the swap status to follow it.';
|
|
77
|
+
},
|
|
78
|
+
};
|
package/src/skills/registry.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const READ_REFERENCE_TOOL = 'read_skill_reference';
|
|
|
20
20
|
* ---
|
|
21
21
|
* name: portfolio-manager
|
|
22
22
|
* description: Rebalance BTC/USDT/XAUT to target allocations.
|
|
23
|
-
* tools: get_balance, kaleidoswap_get_quote,
|
|
23
|
+
* tools: get_balance, kaleidoswap_get_quote, kaleidoswap_atomic_init
|
|
24
24
|
* triggers: rebalance, allocation, portfolio
|
|
25
25
|
* ---
|
|
26
26
|
* <instructions…>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-aware skill selection: pick a skill that can actually act with the tools
|
|
3
|
+
* a host has, before any inference runs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ToolDef } from '../types.js';
|
|
7
|
+
import type { Skill } from './types.js';
|
|
8
|
+
import { READ_REFERENCE_TOOL, SkillRegistry } from './registry.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether a skill can act given the live tool names. A `requires-tools`
|
|
12
|
+
* frontmatter list must be fully live; otherwise at least one of the skill's
|
|
13
|
+
* scoped tools must be.
|
|
14
|
+
*/
|
|
15
|
+
export function skillAvailable(skill: Skill, present: ReadonlySet<string>): boolean {
|
|
16
|
+
const required = skill.metadata?.['requires-tools'];
|
|
17
|
+
if (required) {
|
|
18
|
+
return required.split(',').map((t) => t.trim()).filter(Boolean).every((t) => present.has(t));
|
|
19
|
+
}
|
|
20
|
+
if (!skill.tools?.length) return true;
|
|
21
|
+
return skill.tools.some((t) => t !== READ_REFERENCE_TOOL && present.has(t));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Select the best skill for `query` among those that can act with `liveTools`.
|
|
26
|
+
* Skills whose `requires-tools` are missing are never picked; a skill with zero
|
|
27
|
+
* live tools is only a last resort.
|
|
28
|
+
*/
|
|
29
|
+
export function selectAvailableSkill(
|
|
30
|
+
skills: SkillRegistry | readonly Skill[],
|
|
31
|
+
query: string,
|
|
32
|
+
liveTools: Iterable<string | Pick<ToolDef, 'name'>>,
|
|
33
|
+
): Skill | null {
|
|
34
|
+
const list = skills instanceof SkillRegistry ? skills.list() : [...skills];
|
|
35
|
+
const present = new Set([...liveTools].map((t) => (typeof t === 'string' ? t : t.name)));
|
|
36
|
+
const usable = list.filter((s) => skillAvailable(s, present));
|
|
37
|
+
return (
|
|
38
|
+
new SkillRegistry(usable).select(query) ??
|
|
39
|
+
new SkillRegistry(list.filter((s) => !s.metadata?.['requires-tools'] && !usable.includes(s))).select(query)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical submarine-swap tool contract — pay a Lightning invoice from Liquid
|
|
3
|
+
* funds through the KaleidoSwap Boltz /v2 maker (kaleidoswap-maker-rs).
|
|
4
|
+
*
|
|
5
|
+
* The names match kaleido-mcp, which implements them over @kaleidorg/swap-sdk.
|
|
6
|
+
* Hosts with the SDK in-process (desktop sidecar, React Native) bind the same
|
|
7
|
+
* names with `bindSubmarineTools`.
|
|
8
|
+
*
|
|
9
|
+
* Keys and preimages never cross this contract: the host derives the refund key
|
|
10
|
+
* from its wallet mnemonic and persists the swap. `kaleidoswap_submarine_fund`
|
|
11
|
+
* — the only spend — takes nothing but the swap id, so the model cannot alter
|
|
12
|
+
* the amount, asset or lockup address it pays.
|
|
13
|
+
*
|
|
14
|
+
* Pure data — no deps, no fetch, RN-safe.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ToolDef } from '../types.js';
|
|
18
|
+
import { InProcessToolSource } from '../tools/in-process.js';
|
|
19
|
+
import type { InProcessTool } from '../tools/in-process.js';
|
|
20
|
+
|
|
21
|
+
export interface SubmarineToolDef extends ToolDef {
|
|
22
|
+
/** Moves funds → confirmation-gated. */
|
|
23
|
+
spend?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Props = Record<string, { type: string; description?: string; enum?: string[] }>;
|
|
27
|
+
|
|
28
|
+
function t(name: string, description: string, properties: Props = {}, required: string[] = [], spend = false): SubmarineToolDef {
|
|
29
|
+
return { name, description, spend, requiresConfirmation: spend, parameters: { type: 'object', properties, required } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Assets a submarine swap can be paid from, as the maker names them. */
|
|
33
|
+
export const SUBMARINE_FROM_ASSETS = ['L-USDT', 'L-BTC'] as const;
|
|
34
|
+
export type SubmarineFromAsset = typeof SUBMARINE_FROM_ASSETS[number];
|
|
35
|
+
|
|
36
|
+
/** Smallest-unit decimals of each source asset (L-USDT: 8, L-BTC: sats). */
|
|
37
|
+
export const SUBMARINE_ASSET_DECIMALS: Record<SubmarineFromAsset, number> = { 'L-USDT': 8, 'L-BTC': 8 };
|
|
38
|
+
|
|
39
|
+
const swapId = { type: 'string', description: 'Swap id returned by kaleidoswap_submarine_create' } as const;
|
|
40
|
+
|
|
41
|
+
export const SUBMARINE_TOOLS: SubmarineToolDef[] = [
|
|
42
|
+
t('kaleidoswap_submarine_pairs',
|
|
43
|
+
'List what can pay a Lightning invoice through a submarine swap (e.g. L-USDT on Liquid → Lightning BTC): rate, min/max, fees. No args.'),
|
|
44
|
+
|
|
45
|
+
t('kaleidoswap_submarine_create',
|
|
46
|
+
'Open a submarine swap that pays a Lightning (BOLT11) invoice from Liquid funds. No funds move: returns swap_id and expected_amount (source asset smallest unit). Then confirm with the user and call kaleidoswap_submarine_fund.',
|
|
47
|
+
{
|
|
48
|
+
invoice: { type: 'string', description: 'BOLT11 Lightning invoice to pay (must carry an amount)' },
|
|
49
|
+
from_asset: { type: 'string', enum: [...SUBMARINE_FROM_ASSETS], description: 'Asset to pay with (default L-USDT)' },
|
|
50
|
+
},
|
|
51
|
+
['invoice']),
|
|
52
|
+
|
|
53
|
+
t('kaleidoswap_submarine_fund',
|
|
54
|
+
'SPEND: lock the funds for a swap from kaleidoswap_submarine_create. Takes only the swap id — amount, asset and address come from the stored swap. The maker then pays the invoice.',
|
|
55
|
+
{ swap_id: swapId },
|
|
56
|
+
['swap_id'],
|
|
57
|
+
/* spend */ true),
|
|
58
|
+
|
|
59
|
+
t('kaleidoswap_submarine_status',
|
|
60
|
+
'Status of a submarine swap. "transaction.claimed" = invoice paid, done. A failed funded swap needs a refund.',
|
|
61
|
+
{ swap_id: swapId },
|
|
62
|
+
['swap_id']),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export const SUBMARINE_SPEND_TOOLS: Set<string> = new Set(SUBMARINE_TOOLS.filter((d) => d.spend).map((d) => d.name));
|
|
66
|
+
|
|
67
|
+
export function isSubmarineSpendTool(name: string): boolean {
|
|
68
|
+
return SUBMARINE_SPEND_TOOLS.has(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getSubmarineTool(name: string): SubmarineToolDef | undefined {
|
|
72
|
+
return SUBMARINE_TOOLS.find((d) => d.name === name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Format a smallest-unit amount of a source asset for display ("52.3 L-USDT", "1,200 sats"). */
|
|
76
|
+
export function formatSubmarineAmount(asset: string, smallestUnits: unknown): string {
|
|
77
|
+
const n = Number(smallestUnits);
|
|
78
|
+
if (!Number.isFinite(n)) return `${String(smallestUnits)} ${asset}`;
|
|
79
|
+
if (asset === 'L-BTC') return `${n.toLocaleString('en-US')} sats of L-BTC`;
|
|
80
|
+
const decimals = SUBMARINE_ASSET_DECIMALS[asset as SubmarineFromAsset] ?? 0;
|
|
81
|
+
const value = n / 10 ** decimals;
|
|
82
|
+
return `${value.toLocaleString('en-US', { maximumFractionDigits: decimals })} ${asset}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type SubmarineHandler = (args: Record<string, unknown>) => Promise<unknown>;
|
|
86
|
+
|
|
87
|
+
export interface BindSubmarineOptions {
|
|
88
|
+
/** Skip tools without a handler instead of throwing (default false). */
|
|
89
|
+
allowMissing?: boolean;
|
|
90
|
+
/** ToolSource id for the registry (default 'submarine'). */
|
|
91
|
+
id?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Bind the submarine contract to in-process handlers → an InProcessToolSource. */
|
|
95
|
+
export function bindSubmarineTools(handlers: Record<string, SubmarineHandler>, opts: BindSubmarineOptions = {}): InProcessToolSource {
|
|
96
|
+
const bound: InProcessTool[] = [];
|
|
97
|
+
for (const def of SUBMARINE_TOOLS) {
|
|
98
|
+
const handler = handlers[def.name];
|
|
99
|
+
if (!handler) {
|
|
100
|
+
if (opts.allowMissing) continue;
|
|
101
|
+
throw new Error(`bindSubmarineTools: no handler for "${def.name}"`);
|
|
102
|
+
}
|
|
103
|
+
bound.push({
|
|
104
|
+
name: def.name,
|
|
105
|
+
description: def.description,
|
|
106
|
+
parameters: def.parameters,
|
|
107
|
+
requiresConfirmation: def.requiresConfirmation,
|
|
108
|
+
handler,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return new InProcessToolSource(opts.id ?? 'submarine', bound);
|
|
112
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { bindWalletTools, type WalletHandler } from '../wallet/contract.js';
|
|
13
13
|
import { ToolRegistry } from '../tools/registry.js';
|
|
14
|
+
import { bindSubmarineTools, type SubmarineHandler } from '../submarine/contract.js';
|
|
14
15
|
|
|
15
16
|
export interface SendRecord {
|
|
16
17
|
tool: string;
|
|
@@ -28,6 +29,8 @@ export interface MockWalletOptions {
|
|
|
28
29
|
assets?: Record<string, number>;
|
|
29
30
|
/** Free colored UTXOs on the RLN node (issuance / RGB receive consume one). */
|
|
30
31
|
utxos?: number;
|
|
32
|
+
/** Liquid balances in smallest units (L-USDT: 8 decimals, L-BTC: sats) for submarine swaps. */
|
|
33
|
+
liquid?: { 'L-USDT': number; 'L-BTC': number };
|
|
31
34
|
}
|
|
32
35
|
export interface MockRgbAsset {
|
|
33
36
|
asset_id: string;
|
|
@@ -52,23 +55,31 @@ export class MockWallet {
|
|
|
52
55
|
assets: Record<string, number>;
|
|
53
56
|
contacts: MockContact[];
|
|
54
57
|
utxos: number;
|
|
58
|
+
liquid: { 'L-USDT': number; 'L-BTC': number };
|
|
59
|
+
/** Submarine swaps opened against the mock /v2 maker, by id. */
|
|
60
|
+
submarineSwaps: Record<string, { from: 'L-USDT' | 'L-BTC'; invoice: string; expected: number; funded: boolean }> = {};
|
|
55
61
|
/** RGB assets the node knows about (seeded USDT/XAUT + anything issued). */
|
|
56
62
|
rgbAssets: MockRgbAsset[];
|
|
57
63
|
transfers: MockTransfer[] = [];
|
|
58
64
|
sends: SendRecord[] = [];
|
|
59
65
|
|
|
66
|
+
private readonly opts: MockWalletOptions;
|
|
67
|
+
|
|
60
68
|
constructor(o: MockWalletOptions = {}) {
|
|
69
|
+
this.opts = o;
|
|
61
70
|
this.priceUsd = o.priceUsd ?? 65_000;
|
|
62
71
|
this.failRoute = o.failRoute ?? false;
|
|
63
|
-
|
|
64
|
-
this.
|
|
65
|
-
this.
|
|
72
|
+
// Copies: the wallet mutates its state, and reset() rebuilds from `o`.
|
|
73
|
+
this.balances = { ...(o.balances ?? { spark: 500_000, rln: 300_000, arkade: 200_000 }) };
|
|
74
|
+
this.assets = { ...(o.assets ?? { USDT: 25_000_000, XAUT: 0 }) };
|
|
75
|
+
this.contacts = o.contacts ? [...o.contacts] : [
|
|
66
76
|
{ name: 'bob', ln_address: 'bob@kaleidoswap.com' },
|
|
67
77
|
{ name: 'alice', ln_address: 'alice@kaleidoswap.com' },
|
|
68
78
|
{ name: 'john', ln_address: 'john.smith@kaleidoswap.com' },
|
|
69
79
|
{ name: 'john', ln_address: 'john.doe@kaleidoswap.com' }, // ambiguous on purpose
|
|
70
80
|
];
|
|
71
81
|
this.utxos = o.utxos ?? 5;
|
|
82
|
+
this.liquid = { ...(o.liquid ?? { 'L-USDT': 100 * 1e8, 'L-BTC': 200_000 }) };
|
|
72
83
|
this.rgbAssets = Object.keys(this.assets).map((ticker) => ({
|
|
73
84
|
asset_id: mockAssetId(ticker),
|
|
74
85
|
ticker,
|
|
@@ -112,9 +123,9 @@ export class MockWallet {
|
|
|
112
123
|
totalSats(): number {
|
|
113
124
|
return this.balances.spark + this.balances.rln + this.balances.arkade;
|
|
114
125
|
}
|
|
126
|
+
/** Back to the constructor state: balances, contacts, UTXOs, issued assets, history. */
|
|
115
127
|
reset(): void {
|
|
116
|
-
this
|
|
117
|
-
this.transfers = [];
|
|
128
|
+
Object.assign(this, new MockWallet(this.opts));
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
private send(tool: string, rec: { to: string; amount_sats?: number; asset?: string; amount?: number }) {
|
|
@@ -196,10 +207,56 @@ export class MockWallet {
|
|
|
196
207
|
};
|
|
197
208
|
}
|
|
198
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The KaleidoSwap /v2 submarine contract against a toy maker: 0.5% fee, the
|
|
212
|
+
* invoice amount read from its BOLT11 human-readable part, L-USDT at `priceUsd`.
|
|
213
|
+
*/
|
|
214
|
+
submarineHandlers(): Record<string, SubmarineHandler> {
|
|
215
|
+
return {
|
|
216
|
+
kaleidoswap_submarine_pairs: async () => ({
|
|
217
|
+
pairs: [
|
|
218
|
+
{ from: 'L-USDT', to: 'BTC', fees: { percentage: 0.5 }, limits: { minimal: 10 * 1e8, maximal: 1_000 * 1e8 } },
|
|
219
|
+
{ from: 'L-BTC', to: 'BTC', fees: { percentage: 0.5 }, limits: { minimal: 10_000, maximal: 1_000_000 } },
|
|
220
|
+
],
|
|
221
|
+
}),
|
|
222
|
+
kaleidoswap_submarine_create: async ({ invoice, from_asset }) => {
|
|
223
|
+
const from = (from_asset ?? 'L-USDT') as 'L-USDT' | 'L-BTC';
|
|
224
|
+
if (from !== 'L-USDT' && from !== 'L-BTC') throw new Error(`Unsupported from_asset "${String(from_asset)}".`);
|
|
225
|
+
const sats = bolt11Sats(String(invoice));
|
|
226
|
+
if (sats == null) throw new Error('invalid_invoice: the invoice must be BOLT11 with an amount.');
|
|
227
|
+
const withFee = sats * 1.005;
|
|
228
|
+
const expected = Math.ceil(from === 'L-USDT' ? (withFee / 1e8) * this.priceUsd * 1e8 : withFee);
|
|
229
|
+
const id = `sub${Object.keys(this.submarineSwaps).length + 1}`;
|
|
230
|
+
this.submarineSwaps[id] = { from, invoice: String(invoice), expected, funded: false };
|
|
231
|
+
return { swap_id: id, from_asset: from, expected_amount: String(expected), lockup_address: `tlq1qmock${id}` };
|
|
232
|
+
},
|
|
233
|
+
kaleidoswap_submarine_fund: async ({ swap_id }) => {
|
|
234
|
+
const swap = this.submarineSwaps[String(swap_id)];
|
|
235
|
+
if (!swap) throw new Error(`No submarine swap "${String(swap_id)}" was created by this server.`);
|
|
236
|
+
if (swap.funded) throw new Error(`Swap ${String(swap_id)} is already funded.`);
|
|
237
|
+
if (this.liquid[swap.from] < swap.expected) throw new Error(`Insufficient ${swap.from} balance.`);
|
|
238
|
+
this.liquid[swap.from] -= swap.expected;
|
|
239
|
+
swap.funded = true;
|
|
240
|
+
this.sends.push({ tool: 'kaleidoswap_submarine_fund', to: swap.invoice, asset: swap.from, amount: swap.expected });
|
|
241
|
+
return { funded: true, swap_id, asset: swap.from, amount: String(swap.expected), txid: `mocktx${String(swap_id)}` };
|
|
242
|
+
},
|
|
243
|
+
kaleidoswap_submarine_status: async ({ swap_id }) => {
|
|
244
|
+
const swap = this.submarineSwaps[String(swap_id)];
|
|
245
|
+
if (!swap) throw new Error(`invalid_swap_id: "${String(swap_id)}" is not a swap id`);
|
|
246
|
+
const status = swap.funded ? 'transaction.claimed' : 'swap.created';
|
|
247
|
+
return { swap_id, status, done: swap.funded, failed: false, funded: swap.funded };
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
199
252
|
/** Bind the contract tools to this wallet (optionally overriding some — e.g. to inject). */
|
|
200
253
|
registry(overrides?: Partial<Record<string, WalletHandler>>): ToolRegistry {
|
|
201
254
|
const h = { ...this.handlers(), ...(overrides ?? {}) } as Record<string, WalletHandler>;
|
|
202
|
-
|
|
255
|
+
const sub = { ...this.submarineHandlers(), ...(overrides ?? {}) } as Record<string, SubmarineHandler>;
|
|
256
|
+
return new ToolRegistry([
|
|
257
|
+
bindWalletTools(h, { layers: ['spark', 'rln', 'arkade', 'core'], allowMissing: true }),
|
|
258
|
+
bindSubmarineTools(sub, { allowMissing: true }),
|
|
259
|
+
]);
|
|
203
260
|
}
|
|
204
261
|
}
|
|
205
262
|
|
|
@@ -209,3 +266,13 @@ function mockAssetId(ticker: string): string {
|
|
|
209
266
|
for (const c of ticker) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
|
210
267
|
return `rgb:mock-${ticker.toLowerCase()}-${h.toString(36)}`;
|
|
211
268
|
}
|
|
269
|
+
|
|
270
|
+
/** Amount in sats from a BOLT11 human-readable part ("lntbs10u…" → 1000), or null. */
|
|
271
|
+
function bolt11Sats(invoice: string): number | null {
|
|
272
|
+
const m = invoice.toLowerCase().match(/^ln(?:bcrt|tbs|bc|tb)(\d+)([munp])?1/);
|
|
273
|
+
if (!m) return null;
|
|
274
|
+
const n = Number(m[1]);
|
|
275
|
+
const mult = { m: 1e5, u: 100, n: 0.1, p: 0.0001 }[m[2] ?? ''] ?? 1e8;
|
|
276
|
+
const sats = n * mult;
|
|
277
|
+
return sats >= 1 ? Math.round(sats) : null;
|
|
278
|
+
}
|