@lightninglabs/pi-402 0.0.0-stage → 0.0.2
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/INSTALL.md +270 -0
- package/LICENSE +21 -0
- package/README.md +192 -2
- package/package.json +47 -5
- package/src/CLAUDE.md +57 -0
- package/src/commands.ts +104 -0
- package/src/index.ts +138 -0
- package/src/payer.ts +34 -0
- package/src/policy.ts +209 -0
- package/src/provider.ts +568 -0
- package/src/store.ts +387 -0
- package/src/wallet.ts +1179 -0
- package/wasm/package.json +1 -0
- package/wasm/sqlite-bridge.js +240 -0
- package/wasm/sqlite-node-vfs.js +894 -0
- package/wasm/sqlite-worker.js +850 -0
- package/wasm/sqlite3.js +14318 -0
- package/wasm/sqlite3.wasm +0 -0
- package/wasm/wasm_exec.js +575 -0
- package/wasm/wavewalletdk.wasm +4 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type FileReceiptLog, receiptPaidSat } from "./store.ts";
|
|
3
|
+
import type { WalletService } from "./wallet.ts";
|
|
4
|
+
|
|
5
|
+
// registerWalletCommands wires the /wallet, /deposit, and /balance slash
|
|
6
|
+
// commands. They surface the wallet's funding state to the user, out of band
|
|
7
|
+
// from the inference path — the agent core never sees a boarding address.
|
|
8
|
+
export function registerWalletCommands(pi: ExtensionAPI, wallet: WalletService): void {
|
|
9
|
+
pi.registerCommand("wallet", {
|
|
10
|
+
description: "Show the pi-402 wallet status and balance",
|
|
11
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
12
|
+
const balance = await wallet.confirmedBalanceSat();
|
|
13
|
+
ctx.ui.setStatus("pi-402", `⚡ ${balance} sat`);
|
|
14
|
+
ctx.ui.notify(`pi-402 wallet: ${balance} sat confirmed`, "info");
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
pi.registerCommand("deposit", {
|
|
19
|
+
description: "Show an on-chain boarding address to fund the wallet",
|
|
20
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
21
|
+
const address = await wallet.depositAddress();
|
|
22
|
+
ctx.ui.notify(`Fund your pi-402 wallet by sending on-chain funds to: ${address}`, "info");
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
pi.registerCommand("fund", {
|
|
27
|
+
description: "Show a Lightning invoice to fund the wallet (/fund <sats>)",
|
|
28
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
29
|
+
// An on-chain deposit needs a confirmation plus an Ark round before
|
|
30
|
+
// it is spendable; an invoice receive settles through a swap in
|
|
31
|
+
// seconds, so this is the path worth reaching for when funding
|
|
32
|
+
// interactively.
|
|
33
|
+
const amountSat = parseFundAmount(args);
|
|
34
|
+
if (amountSat === null) {
|
|
35
|
+
ctx.ui.notify("Usage: /fund <sats>, for example /fund 50000", "error");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const invoice = await wallet.receiveInvoice(amountSat);
|
|
40
|
+
ctx.ui.notify(`Pay this invoice to add ${amountSat} sat to your pi-402 wallet:\n\n${invoice}`, "info");
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
pi.registerCommand("balance", {
|
|
45
|
+
description: "Show the confirmed wallet balance",
|
|
46
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
47
|
+
const balance = await wallet.confirmedBalanceSat();
|
|
48
|
+
ctx.ui.notify(`Confirmed balance: ${balance} sat`, "info");
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// parseFundAmount reads the sat amount off a /fund invocation, returning null
|
|
54
|
+
// when it is missing or not a positive whole number. Underscores and commas are
|
|
55
|
+
// tolerated, since a funding amount is exactly the kind of number people group
|
|
56
|
+
// the digits of.
|
|
57
|
+
export function parseFundAmount(args: string): number | null {
|
|
58
|
+
const raw = args.trim().replace(/[_,]/g, "");
|
|
59
|
+
if (!/^\d+$/.test(raw)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const amountSat = Number(raw);
|
|
64
|
+
return Number.isSafeInteger(amountSat) && amountSat > 0 ? amountSat : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// registerReceiptCommands wires the /receipts slash command over the durable
|
|
68
|
+
// payment ledger: today's spend, the all-time total, and the most recent
|
|
69
|
+
// payments with their proof-of-payment hashes.
|
|
70
|
+
export function registerReceiptCommands(pi: ExtensionAPI, receiptLog: FileReceiptLog): void {
|
|
71
|
+
pi.registerCommand("receipts", {
|
|
72
|
+
description: "Show L402 payment receipts and spend totals",
|
|
73
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
74
|
+
const receipts = await receiptLog.readAll();
|
|
75
|
+
if (receipts.length === 0) {
|
|
76
|
+
ctx.ui.notify("No payments recorded yet.", "info");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// One read serves all three views; the day total filters the
|
|
81
|
+
// records already in hand.
|
|
82
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
83
|
+
const totalSat = receipts.reduce((sum, r) => sum + receiptPaidSat(r), 0);
|
|
84
|
+
const todaySat = receipts
|
|
85
|
+
.filter((r) => r.timestamp?.slice(0, 10) === day)
|
|
86
|
+
.reduce((sum, r) => sum + receiptPaidSat(r), 0);
|
|
87
|
+
|
|
88
|
+
const recent = receipts
|
|
89
|
+
.slice(-10)
|
|
90
|
+
.reverse()
|
|
91
|
+
.map((r) => {
|
|
92
|
+
const model = r.model ?? "unknown-model";
|
|
93
|
+
const when = r.timestamp?.replace("T", " ").slice(0, 19) ?? "unknown-time";
|
|
94
|
+
return `${when} ${receiptPaidSat(r)} sat ${model} ${r.paymentHash.slice(0, 12)}…`;
|
|
95
|
+
})
|
|
96
|
+
.join("\n");
|
|
97
|
+
|
|
98
|
+
ctx.ui.notify(
|
|
99
|
+
`pi-402 receipts: ${receipts.length} payment(s), ${totalSat} sat all-time, ${todaySat} sat today\n${recent}`,
|
|
100
|
+
"info",
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { registerReceiptCommands, registerWalletCommands } from "./commands.ts";
|
|
4
|
+
import { MockInvoicePayer, WalletInvoicePayer } from "./payer.ts";
|
|
5
|
+
import { buildBudgetPolicy } from "./policy.ts";
|
|
6
|
+
import { makeStreamSimple } from "./provider.ts";
|
|
7
|
+
import { dataDir, FileCredentialStore, FileReceiptLog, FileTokenStore } from "./store.ts";
|
|
8
|
+
import { WalletService } from "./wallet.ts";
|
|
9
|
+
|
|
10
|
+
// PROVIDER_ID is the model provider this extension registers. Users select it
|
|
11
|
+
// with `/model l402-baseten/<model>`.
|
|
12
|
+
const PROVIDER_ID = "l402-baseten";
|
|
13
|
+
|
|
14
|
+
// DEFAULT_PROXY_URL is the local L402 proxy used when L402_PROXY_URL is unset.
|
|
15
|
+
const DEFAULT_PROXY_URL = "http://localhost:8402";
|
|
16
|
+
|
|
17
|
+
// The extension entry point. Pi calls this with the ExtensionAPI when the
|
|
18
|
+
// extension is loaded. Everything the extension does is registered here; Pi core
|
|
19
|
+
// is never modified.
|
|
20
|
+
export default function (pi: ExtensionAPI): void {
|
|
21
|
+
const proxyUrl = process.env.L402_PROXY_URL ?? DEFAULT_PROXY_URL;
|
|
22
|
+
|
|
23
|
+
// The wallet holds the embedded walletdk instance for the whole session.
|
|
24
|
+
const wallet = new WalletService();
|
|
25
|
+
|
|
26
|
+
// The mock payer exercises the buyer loop end to end against a mock proxy
|
|
27
|
+
// with no Lightning at all; the real payer settles through walletdk.
|
|
28
|
+
const mockMode = process.env.PI402_MOCK_PAYER === "1";
|
|
29
|
+
const payer = mockMode ? new MockInvoicePayer() : new WalletInvoicePayer(wallet);
|
|
30
|
+
|
|
31
|
+
// Durable state: paid tokens and the receipt ledger live under the data
|
|
32
|
+
// dir, so a paid bundle survives a restart instead of being re-purchased
|
|
33
|
+
// and every spend leaves an auditable record. Mock mode stays in-memory,
|
|
34
|
+
// keeping fake spends out of the real ledger.
|
|
35
|
+
const stateDir = dataDir();
|
|
36
|
+
const tokenStore = mockMode ? undefined : new FileTokenStore(join(stateDir, "tokens.json"));
|
|
37
|
+
const credentialStore = mockMode ? undefined : new FileCredentialStore(join(stateDir, "credentials.json"));
|
|
38
|
+
const receiptLog = mockMode ? undefined : new FileReceiptLog(join(stateDir, "receipts.ndjson"));
|
|
39
|
+
|
|
40
|
+
// The daily cap is seeded from the ledger, so a restart mid-day cannot
|
|
41
|
+
// reset the day's spend to zero.
|
|
42
|
+
const policy = buildBudgetPolicy(pi, receiptLog);
|
|
43
|
+
|
|
44
|
+
// Bootstrap the wallet once per session. In mock mode there is no wallet
|
|
45
|
+
// to load, so the session starts without touching the WASM at all.
|
|
46
|
+
if (!mockMode) {
|
|
47
|
+
pi.on("session_start", async () => {
|
|
48
|
+
await wallet.startAndUnlock();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Release the wallet when Pi tears the extension down. Without this
|
|
52
|
+
// the WASM runtime and its SQLite worker thread keep Node's event
|
|
53
|
+
// loop alive, and a non-interactive `pi -p` never exits.
|
|
54
|
+
//
|
|
55
|
+
// Only a quit stops it. Pi also emits session_shutdown when it replaces
|
|
56
|
+
// the session (reload, new, resume, fork) and keeps running, and the
|
|
57
|
+
// next session needs the wallet the SQLite bridge belongs to.
|
|
58
|
+
pi.on("session_shutdown", async (event) => {
|
|
59
|
+
if (event.reason === "quit") {
|
|
60
|
+
await wallet.stop();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Register the paid model provider. Its streamSimple owns the request path,
|
|
66
|
+
// so the paying fetch is injected without any change to Pi core.
|
|
67
|
+
pi.registerProvider(PROVIDER_ID, {
|
|
68
|
+
api: "l402-openai",
|
|
69
|
+
baseUrl: proxyUrl,
|
|
70
|
+
// The proxy holds the real model credential; Pi sends no API key.
|
|
71
|
+
apiKey: "unused",
|
|
72
|
+
models: [
|
|
73
|
+
{
|
|
74
|
+
// Maps to deepseek-ai/DeepSeek-V4-Flash-0731 on the wire.
|
|
75
|
+
// The budget option: its bundle quotes at roughly half a
|
|
76
|
+
// GLM-4.7 bundle, so it is also the cheapest way to
|
|
77
|
+
// exercise the paid path end to end.
|
|
78
|
+
id: "deepseek-v4-flash",
|
|
79
|
+
name: "DeepSeek V4 Flash (L402)",
|
|
80
|
+
input: ["text"],
|
|
81
|
+
reasoning: true,
|
|
82
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
83
|
+
contextWindow: 1_000_000,
|
|
84
|
+
maxTokens: 32_768,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
// The Pi-facing id is slash-free; the provider maps it to
|
|
88
|
+
// the Baseten Model APIs slug (zai-org/GLM-4.7) on the wire.
|
|
89
|
+
id: "glm-4.7",
|
|
90
|
+
name: "GLM 4.7 (L402)",
|
|
91
|
+
input: ["text"],
|
|
92
|
+
reasoning: true,
|
|
93
|
+
// Cost is metered in sats at the proxy, not billed per token here.
|
|
94
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
95
|
+
contextWindow: 200_000,
|
|
96
|
+
maxTokens: 8_192,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
// Maps to zai-org/GLM-5.2 on the wire.
|
|
100
|
+
id: "glm-5.2",
|
|
101
|
+
name: "GLM 5.2 (L402)",
|
|
102
|
+
input: ["text"],
|
|
103
|
+
reasoning: true,
|
|
104
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
105
|
+
contextWindow: 200_000,
|
|
106
|
+
maxTokens: 8_192,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
// Maps to moonshotai/Kimi-K3 on the wire. Natively
|
|
110
|
+
// multimodal with a 1M-token context.
|
|
111
|
+
id: "kimi-k3",
|
|
112
|
+
name: "Kimi K3 (L402)",
|
|
113
|
+
input: ["text", "image"],
|
|
114
|
+
reasoning: true,
|
|
115
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
116
|
+
contextWindow: 1_048_576,
|
|
117
|
+
maxTokens: 32_768,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
// Maps to moonshotai/Kimi-K3.1-Pro on the wire. 1M-token
|
|
121
|
+
// context, priced below Kimi K3 upstream.
|
|
122
|
+
id: "kimi-k3.1-pro",
|
|
123
|
+
name: "Kimi K3.1 Pro (L402)",
|
|
124
|
+
input: ["text"],
|
|
125
|
+
reasoning: true,
|
|
126
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
127
|
+
contextWindow: 1_000_000,
|
|
128
|
+
maxTokens: 32_768,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
streamSimple: makeStreamSimple({ payer, policy, tokenStore, credentialStore, receiptLog }),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
registerWalletCommands(pi, wallet);
|
|
135
|
+
if (receiptLog) {
|
|
136
|
+
registerReceiptCommands(pi, receiptLog);
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/payer.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { InvoicePayer } from "@lightninglabs/aperture-core";
|
|
2
|
+
import type { WalletService } from "./wallet.ts";
|
|
3
|
+
|
|
4
|
+
// PayInvoiceInput is the L402 buyer loop's request to pay a challenge invoice.
|
|
5
|
+
interface PayInvoiceInput {
|
|
6
|
+
invoice: string;
|
|
7
|
+
amountSat: number;
|
|
8
|
+
challengeId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// MockInvoicePayer returns a fixed preimage without paying anything. It exists
|
|
12
|
+
// so the buyer loop can be exercised end to end with no wallet and no
|
|
13
|
+
// Lightning at all.
|
|
14
|
+
export class MockInvoicePayer implements InvoicePayer {
|
|
15
|
+
async payInvoice(input: PayInvoiceInput): Promise<{ preimageHex: string; amountPaidSat: number }> {
|
|
16
|
+
return { preimageHex: "00".repeat(32), amountPaidSat: input.amountSat };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// WalletInvoicePayer pays an L402 invoice through the embedded walletdk wallet.
|
|
21
|
+
// walletdk hides the Ark↔Lightning swap: prepareSend quotes it (surfacing the
|
|
22
|
+
// swap fee for the budget check), sendPrepared dispatches it, and the preimage
|
|
23
|
+
// is read off the settled activity entry once the swap completes.
|
|
24
|
+
export class WalletInvoicePayer implements InvoicePayer {
|
|
25
|
+
private readonly wallet: WalletService;
|
|
26
|
+
|
|
27
|
+
constructor(wallet: WalletService) {
|
|
28
|
+
this.wallet = wallet;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async payInvoice(input: PayInvoiceInput): Promise<{ preimageHex: string; amountPaidSat: number }> {
|
|
32
|
+
return this.wallet.payInvoice(input.invoice, input.amountSat);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { BuyerPolicy } from "@lightninglabs/aperture-core";
|
|
3
|
+
|
|
4
|
+
// DEFAULT_MAX_SAT_PER_REQUEST caps a single L402 payment when
|
|
5
|
+
// PI402_MAX_SAT_PER_REQUEST is unset.
|
|
6
|
+
const DEFAULT_MAX_SAT_PER_REQUEST = 1_000;
|
|
7
|
+
|
|
8
|
+
// DEFAULT_DAILY_CAP_SAT caps cumulative daily spend when PI402_DAILY_CAP_SAT is
|
|
9
|
+
// unset.
|
|
10
|
+
const DEFAULT_DAILY_CAP_SAT = 100_000;
|
|
11
|
+
|
|
12
|
+
// readSatCeiling parses a positive-integer sat ceiling from an environment
|
|
13
|
+
// variable. A missing value yields the default. A present but non-numeric or
|
|
14
|
+
// non-positive value throws, so a typo like "1,000" fails loudly instead of
|
|
15
|
+
// silently disabling the cap (Number("1,000") is NaN, and every comparison with
|
|
16
|
+
// NaN is false, which would wave all spending through).
|
|
17
|
+
export function readSatCeiling(name: string, fallback: number): number {
|
|
18
|
+
const raw = process.env[name];
|
|
19
|
+
if (raw === undefined || raw === "") {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
const value = Number(raw);
|
|
23
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
24
|
+
throw new Error(`${name} must be a positive number, got ${JSON.stringify(raw)}`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// maxSatPerRequest returns the per-request spend ceiling from the environment.
|
|
30
|
+
// The wallet payer reads the same ceiling to assert the quoted total outflow
|
|
31
|
+
// before dispatching a send.
|
|
32
|
+
export function maxSatPerRequest(): number {
|
|
33
|
+
return readSatCeiling("PI402_MAX_SAT_PER_REQUEST", DEFAULT_MAX_SAT_PER_REQUEST);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// PaymentScheme selects which HTTP authentication schemes the buyer will
|
|
37
|
+
// answer a 402 with.
|
|
38
|
+
export type PaymentScheme = "auto" | "l402";
|
|
39
|
+
|
|
40
|
+
// paymentScheme reads the scheme preference from PI402_PAYMENT_SCHEME.
|
|
41
|
+
//
|
|
42
|
+
// The default is "auto": take a Payment (MPP) offer when the seller makes
|
|
43
|
+
// one, and fall back to L402 otherwise. Aperture with --enablempp offers both
|
|
44
|
+
// in a single merged WWW-Authenticate, so "auto" is what exercises MPP.
|
|
45
|
+
// Setting "l402" pins the buyer to the older scheme, which is the escape
|
|
46
|
+
// hatch if a seller's Payment offer turns out to be unsatisfiable.
|
|
47
|
+
//
|
|
48
|
+
// An unrecognized value throws rather than silently falling back. A typo in
|
|
49
|
+
// the scheme name should not quietly change which protocol the money moves
|
|
50
|
+
// over.
|
|
51
|
+
export function paymentScheme(): PaymentScheme {
|
|
52
|
+
const raw = process.env.PI402_PAYMENT_SCHEME;
|
|
53
|
+
if (raw === undefined || raw === "") {
|
|
54
|
+
return "auto";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const value = raw.trim().toLowerCase();
|
|
58
|
+
if (value === "auto" || value === "l402") {
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new Error(`PI402_PAYMENT_SCHEME must be "auto" or "l402", got ${JSON.stringify(raw)}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// mppIntents returns the Payment intents to accept, most wanted first.
|
|
66
|
+
//
|
|
67
|
+
// Under "l402" the list is empty, so no Payment offer matches and the buyer
|
|
68
|
+
// takes the L402 door. Otherwise the default is "charge" alone: a session
|
|
69
|
+
// holds a deposit the seller owes back, and reclaiming it needs a refund
|
|
70
|
+
// invoice the wallet must mint and a close the process must remember to
|
|
71
|
+
// send. Charge settles each request on its own and leaves nothing
|
|
72
|
+
// outstanding, which is the right default for a wallet running unattended
|
|
73
|
+
// inside an editor.
|
|
74
|
+
//
|
|
75
|
+
// PI402_MPP_INTENTS opts into more, as a comma-separated preference list
|
|
76
|
+
// drawn from "session" and "charge".
|
|
77
|
+
//
|
|
78
|
+
// The session intent additionally requires a return-invoice minter. A
|
|
79
|
+
// session's unspent deposit comes back through an amountless refund invoice
|
|
80
|
+
// the buyer must supply at open, and the reference server refuses an open
|
|
81
|
+
// without one, so a buyer with no minter would pay deposits whose opens are
|
|
82
|
+
// guaranteed to be refused. The swap-backed embedded wallet cannot mint
|
|
83
|
+
// amountless invoices (the daemon refuses a zero amount), so the extension
|
|
84
|
+
// has no minter today and sessions stay off there; the dev harness can wire
|
|
85
|
+
// one from a local lnd, which is what makes the regtest session flow
|
|
86
|
+
// testable. A wavelength-side amountless receive is the upstream seam that
|
|
87
|
+
// lifts this for the extension.
|
|
88
|
+
export function mppIntents(options?: { hasReturnInvoiceMinter?: boolean }): readonly string[] {
|
|
89
|
+
if (paymentScheme() === "l402") {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const raw = process.env.PI402_MPP_INTENTS;
|
|
94
|
+
if (raw === undefined || raw === "") {
|
|
95
|
+
return ["charge"];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const intents = raw
|
|
99
|
+
.split(",")
|
|
100
|
+
.map((intent) => intent.trim().toLowerCase())
|
|
101
|
+
.filter((intent) => intent !== "");
|
|
102
|
+
|
|
103
|
+
for (const intent of intents) {
|
|
104
|
+
if (intent !== "charge" && intent !== "session") {
|
|
105
|
+
throw new Error(`PI402_MPP_INTENTS entries must be "charge" or "session", got ${JSON.stringify(intent)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (intents.length === 0) {
|
|
109
|
+
throw new Error("PI402_MPP_INTENTS must name at least one intent when set");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (intents.includes("session") && !options?.hasReturnInvoiceMinter) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"PI402_MPP_INTENTS includes sessions, but no return-invoice minter is available: a session's " +
|
|
115
|
+
"refund needs an amountless invoice the embedded wallet cannot mint, and the server refuses " +
|
|
116
|
+
"an open without one, so the deposit would be paid for a guaranteed refusal",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return intents;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// utcDayKey returns a stable key for the current UTC calendar day, used to reset
|
|
124
|
+
// the daily counter at the day boundary.
|
|
125
|
+
function utcDayKey(now: Date): string {
|
|
126
|
+
return now.toISOString().slice(0, 10);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// SpentTodaySource supplies the sats already spent on the current UTC day,
|
|
130
|
+
// typically summed from the durable receipt ledger.
|
|
131
|
+
export interface SpentTodaySource {
|
|
132
|
+
spentTodaySat(): Promise<number>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// buildBudgetPolicy constructs the buyer policy from environment ceilings. The
|
|
136
|
+
// policy is asserted before any wallet action, on the quoted total outflow
|
|
137
|
+
// (invoice principal plus swap fee), so a runaway session cannot drain the
|
|
138
|
+
// wallet.
|
|
139
|
+
//
|
|
140
|
+
// With a spentToday source, the ledger is read fresh on every assertion and
|
|
141
|
+
// combined with the in-memory counter, so neither a restart nor a concurrent
|
|
142
|
+
// session can reset the day's spend to zero.
|
|
143
|
+
export function buildBudgetPolicy(_pi: ExtensionAPI, spentTodaySource?: SpentTodaySource): BuyerPolicy {
|
|
144
|
+
const perRequestCap = maxSatPerRequest();
|
|
145
|
+
const dailyCapSat = readSatCeiling("PI402_DAILY_CAP_SAT", DEFAULT_DAILY_CAP_SAT);
|
|
146
|
+
|
|
147
|
+
// spentToday tracks cumulative spend for the daily ceiling, reset at the UTC
|
|
148
|
+
// day boundary.
|
|
149
|
+
let spentToday = 0;
|
|
150
|
+
let currentDay = utcDayKey(new Date());
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
async assertAllowed(ctx: { amountSat: number; host: string; url: string; routeId?: string }): Promise<void> {
|
|
154
|
+
// Read the ledger fresh on every assertion: it is the shared
|
|
155
|
+
// ground truth across restarts and concurrent sessions. Payments
|
|
156
|
+
// are rare and the file is small, so the read is cheap. An
|
|
157
|
+
// unreadable ledger degrades to the in-memory counter alone; the
|
|
158
|
+
// degradation is logged because a spend cap silently losing its
|
|
159
|
+
// memory should never be invisible.
|
|
160
|
+
let ledgerToday = 0;
|
|
161
|
+
if (spentTodaySource) {
|
|
162
|
+
try {
|
|
163
|
+
ledgerToday = await spentTodaySource.spentTodaySat();
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.warn("pi-402: receipt ledger unreadable, daily cap using in-memory spend only:", err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Everything below is synchronous, so concurrent assertions in
|
|
170
|
+
// this process cannot interleave between the check and the
|
|
171
|
+
// counter update.
|
|
172
|
+
const day = utcDayKey(new Date());
|
|
173
|
+
if (day !== currentDay) {
|
|
174
|
+
currentDay = day;
|
|
175
|
+
spentToday = 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (ctx.amountSat > perRequestCap) {
|
|
179
|
+
throw new Error(`payment ${ctx.amountSat} sat exceeds per-request cap ${perRequestCap} sat`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The in-memory counter carries in-flight amounts the ledger
|
|
183
|
+
// cannot see yet (and quotes for payments that later failed);
|
|
184
|
+
// the ledger carries settled spends from other sessions this
|
|
185
|
+
// counter never saw. Take the max: it is conservative under
|
|
186
|
+
// either gap. Units differ slightly, quoted face value in the
|
|
187
|
+
// counter vs fee-inclusive paidSat in the ledger, which also
|
|
188
|
+
// errs on the conservative side.
|
|
189
|
+
//
|
|
190
|
+
// The residual overshoot bound across sessions is the sum of
|
|
191
|
+
// all in-flight (asserted, not yet settled) amounts in OTHER
|
|
192
|
+
// processes, each bounded by their per-request cap; unsettled
|
|
193
|
+
// spends are invisible to this process until they reach the
|
|
194
|
+
// ledger.
|
|
195
|
+
const effectiveSpent = Math.max(spentToday, ledgerToday);
|
|
196
|
+
if (effectiveSpent + ctx.amountSat > dailyCapSat) {
|
|
197
|
+
throw new Error(`payment would exceed daily cap ${dailyCapSat} sat`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// NOTE: the counter is incremented here, before the wallet actually
|
|
201
|
+
// pays. The paying fetch calls assertAllowed ahead of settlement and
|
|
202
|
+
// exposes no post-payment callback, so a failed payment is not rolled
|
|
203
|
+
// back off the daily counter. This errs conservative (a failed
|
|
204
|
+
// payment still consumes budget), which is the safe direction for a
|
|
205
|
+
// spend cap; a clean rollback needs a hook the library does not offer.
|
|
206
|
+
spentToday = effectiveSpent + ctx.amountSat;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|