bsv-pay-cli 0.2.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/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/dist/address.d.ts +6 -0
- package/dist/address.js +35 -0
- package/dist/chain/provider.d.ts +35 -0
- package/dist/chain/provider.js +1 -0
- package/dist/chain/whatsonchain.d.ts +23 -0
- package/dist/chain/whatsonchain.js +98 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +169 -0
- package/dist/commands/approvals.d.ts +19 -0
- package/dist/commands/approvals.js +112 -0
- package/dist/commands/balance.d.ts +3 -0
- package/dist/commands/balance.js +28 -0
- package/dist/commands/donate.d.ts +8 -0
- package/dist/commands/donate.js +16 -0
- package/dist/commands/fetch.d.ts +13 -0
- package/dist/commands/fetch.js +49 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +188 -0
- package/dist/commands/mcp.d.ts +13 -0
- package/dist/commands/mcp.js +32 -0
- package/dist/commands/policy.d.ts +8 -0
- package/dist/commands/policy.js +101 -0
- package/dist/commands/request.d.ts +9 -0
- package/dist/commands/request.js +85 -0
- package/dist/commands/send.d.ts +11 -0
- package/dist/commands/send.js +125 -0
- package/dist/commands/serve.d.ts +16 -0
- package/dist/commands/serve.js +59 -0
- package/dist/commands/watch.d.ts +10 -0
- package/dist/commands/watch.js +163 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +51 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +12 -0
- package/dist/core/balance.d.ts +24 -0
- package/dist/core/balance.js +34 -0
- package/dist/core/context.d.ts +27 -0
- package/dist/core/context.js +9 -0
- package/dist/core/history.d.ts +18 -0
- package/dist/core/history.js +15 -0
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +17 -0
- package/dist/core/internal.d.ts +22 -0
- package/dist/core/internal.js +19 -0
- package/dist/core/policy-status.d.ts +55 -0
- package/dist/core/policy-status.js +49 -0
- package/dist/core/request.d.ts +43 -0
- package/dist/core/request.js +77 -0
- package/dist/core/send.d.ts +108 -0
- package/dist/core/send.js +277 -0
- package/dist/core/spend-lock.d.ts +2 -0
- package/dist/core/spend-lock.js +25 -0
- package/dist/core/wallet.d.ts +53 -0
- package/dist/core/wallet.js +77 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +39 -0
- package/dist/http402/client.d.ts +32 -0
- package/dist/http402/client.js +85 -0
- package/dist/http402/middleware.d.ts +37 -0
- package/dist/http402/middleware.js +96 -0
- package/dist/http402/protocol.d.ts +50 -0
- package/dist/http402/protocol.js +114 -0
- package/dist/ledger.d.ts +51 -0
- package/dist/ledger.js +27 -0
- package/dist/mcp/server.d.ts +32 -0
- package/dist/mcp/server.js +484 -0
- package/dist/output.d.ts +17 -0
- package/dist/output.js +44 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +24 -0
- package/dist/policy/approvals.d.ts +24 -0
- package/dist/policy/approvals.js +89 -0
- package/dist/policy/budget.d.ts +16 -0
- package/dist/policy/budget.js +47 -0
- package/dist/policy/engine.d.ts +76 -0
- package/dist/policy/engine.js +199 -0
- package/dist/policy/policy.d.ts +30 -0
- package/dist/policy/policy.js +126 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +57 -0
- package/dist/tx.d.ts +29 -0
- package/dist/tx.js +68 -0
- package/dist/units.d.ts +13 -0
- package/dist/units.js +53 -0
- package/dist/wallet/brc100.d.ts +105 -0
- package/dist/wallet/brc100.js +217 -0
- package/dist/wallet/crypto.d.ts +25 -0
- package/dist/wallet/crypto.js +46 -0
- package/dist/wallet/wallet.d.ts +86 -0
- package/dist/wallet/wallet.js +186 -0
- package/docs/AGENTIC-PAYMENTS.md +218 -0
- package/docs/BRC100.md +151 -0
- package/package.json +82 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { Mnemonic, PrivateKey } from '@bsv/sdk';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { CliError, EXIT, usageError } from '../errors.js';
|
|
4
|
+
import { walletPath } from '../paths.js';
|
|
5
|
+
import { ask, askHidden, confirm, isInteractive, readStdinLine } from '../prompt.js';
|
|
6
|
+
import { connectBrc100 } from '../wallet/brc100.js';
|
|
7
|
+
import { buildBrc100WalletFile, buildWalletFile, walletExists, writeWalletFile, } from '../wallet/wallet.js';
|
|
8
|
+
import { Wallet } from '../wallet/wallet.js';
|
|
9
|
+
function stderr(text) {
|
|
10
|
+
process.stderr.write(text + '\n');
|
|
11
|
+
}
|
|
12
|
+
async function obtainNewSeed() {
|
|
13
|
+
const mnemonic = Mnemonic.fromRandom();
|
|
14
|
+
const words = mnemonic.toString().split(' ');
|
|
15
|
+
stderr('');
|
|
16
|
+
stderr(chalk.bold('Your new wallet seed phrase — write it down on paper, in order:'));
|
|
17
|
+
stderr('');
|
|
18
|
+
words.forEach((w, i) => stderr(` ${String(i + 1).padStart(2)}. ${w}`));
|
|
19
|
+
stderr('');
|
|
20
|
+
stderr(chalk.yellow('Anyone with these words can spend your funds. This is the ONLY time they are shown.'));
|
|
21
|
+
stderr('');
|
|
22
|
+
if (isInteractive()) {
|
|
23
|
+
const checkIndex = Math.floor(Math.random() * words.length);
|
|
24
|
+
for (let attempt = 1;; attempt++) {
|
|
25
|
+
const answer = await ask(`Confirm you wrote it down — type word #${checkIndex + 1}: `);
|
|
26
|
+
if (answer.toLowerCase() === words[checkIndex])
|
|
27
|
+
break;
|
|
28
|
+
if (attempt >= 3) {
|
|
29
|
+
throw usageError('seed_confirmation_failed', 'Seed confirmation failed 3 times. No wallet was created; run init again.');
|
|
30
|
+
}
|
|
31
|
+
stderr(chalk.red('That does not match. Check your written copy and try again.'));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
stderr(chalk.yellow('Non-interactive run: seed confirmation skipped. The phrase above was printed to stderr — store it now.'));
|
|
36
|
+
}
|
|
37
|
+
return { type: 'mnemonic', value: mnemonic.toString() };
|
|
38
|
+
}
|
|
39
|
+
async function obtainImportedSeed() {
|
|
40
|
+
const phrase = isInteractive()
|
|
41
|
+
? await ask('Enter your BIP-39 seed phrase: ')
|
|
42
|
+
: await readStdinLine();
|
|
43
|
+
const normalized = phrase.trim().toLowerCase().split(/\s+/).join(' ');
|
|
44
|
+
if (!Mnemonic.isValid(normalized)) {
|
|
45
|
+
throw usageError('invalid_seed_phrase', 'That is not a valid BIP-39 seed phrase (checksum failed). Check the words and their order.');
|
|
46
|
+
}
|
|
47
|
+
return { type: 'mnemonic', value: normalized };
|
|
48
|
+
}
|
|
49
|
+
async function obtainImportedWif() {
|
|
50
|
+
stderr(chalk.yellow('WARNING: a raw WIF key gives a single-address wallet with no HD derivation.'));
|
|
51
|
+
stderr(chalk.yellow('Every payment reuses one address, which is bad for privacy. Prefer a seed phrase.'));
|
|
52
|
+
if (isInteractive() && !(await confirm('Continue with WIF import?'))) {
|
|
53
|
+
throw usageError('aborted', 'WIF import cancelled.');
|
|
54
|
+
}
|
|
55
|
+
const wif = isInteractive() ? await ask('Enter WIF private key: ') : await readStdinLine();
|
|
56
|
+
try {
|
|
57
|
+
PrivateKey.fromWif(wif.trim());
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw usageError('invalid_wif', 'That is not a valid WIF private key (checksum failed).');
|
|
61
|
+
}
|
|
62
|
+
return { type: 'wif', value: wif.trim() };
|
|
63
|
+
}
|
|
64
|
+
/** Resolve the encryption passphrase for a new wallet, or null for opt-in unencrypted mode. */
|
|
65
|
+
async function obtainNewPassphrase(encrypt) {
|
|
66
|
+
if (!encrypt) {
|
|
67
|
+
stderr(chalk.yellow('WARNING: --no-encrypt stores the seed in PLAINTEXT on disk.'));
|
|
68
|
+
if (isInteractive() && !(await confirm('Store the wallet unencrypted?'))) {
|
|
69
|
+
throw usageError('aborted', 'Init cancelled. Re-run without --no-encrypt to use a passphrase.');
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const env = process.env.BSV_PAY_PASSPHRASE;
|
|
74
|
+
if (env !== undefined) {
|
|
75
|
+
if (env === '')
|
|
76
|
+
throw usageError('empty_passphrase', 'BSV_PAY_PASSPHRASE is set but empty. Use a real passphrase or pass --no-encrypt explicitly.');
|
|
77
|
+
return env;
|
|
78
|
+
}
|
|
79
|
+
if (!isInteractive()) {
|
|
80
|
+
throw new CliError(EXIT.WALLET_LOCKED, 'passphrase_required', 'No terminal to prompt for a passphrase. Set BSV_PAY_PASSPHRASE, or pass --no-encrypt to opt out of encryption.');
|
|
81
|
+
}
|
|
82
|
+
for (;;) {
|
|
83
|
+
const first = await askHidden('Choose a wallet passphrase: ');
|
|
84
|
+
if (first === '') {
|
|
85
|
+
stderr(chalk.red('Passphrase cannot be empty (use --no-encrypt to explicitly opt out).'));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const second = await askHidden('Repeat passphrase: ');
|
|
89
|
+
if (first === second)
|
|
90
|
+
return first;
|
|
91
|
+
stderr(chalk.red('Passphrases do not match. Try again.'));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function cmdInit(ctx, opts, brc100Connect) {
|
|
95
|
+
if (opts.brc100) {
|
|
96
|
+
throw usageError('brc100_not_supported', 'BRC-100 custody is EXPERIMENTAL. Re-run with --experimental-brc100 to connect an ' +
|
|
97
|
+
'external wallet app (spending works; receiving still needs the wallet app itself — ' +
|
|
98
|
+
'see the README), or use a local wallet: "bsv-pay init".');
|
|
99
|
+
}
|
|
100
|
+
if (opts.experimentalBrc100 && (opts.importSeed || opts.importWif)) {
|
|
101
|
+
throw usageError('conflicting_flags', '--experimental-brc100 delegates custody to the external wallet; there is no seed or WIF to import.');
|
|
102
|
+
}
|
|
103
|
+
if (opts.importSeed && opts.importWif) {
|
|
104
|
+
throw usageError('conflicting_flags', 'Pass either --import-seed or --import-wif, not both.');
|
|
105
|
+
}
|
|
106
|
+
if (walletExists(ctx.network) && !opts.force) {
|
|
107
|
+
throw usageError('wallet_exists', `A ${ctx.network === 'test' ? 'testnet ' : ''}wallet already exists at ${walletPath(ctx.network)}. ` +
|
|
108
|
+
'Re-run with --force to overwrite it (this DESTROYS the old wallet unless you have its seed).');
|
|
109
|
+
}
|
|
110
|
+
if (opts.experimentalBrc100) {
|
|
111
|
+
await initBrc100(ctx, brc100Connect);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const secret = opts.importSeed
|
|
115
|
+
? await obtainImportedSeed()
|
|
116
|
+
: opts.importWif
|
|
117
|
+
? await obtainImportedWif()
|
|
118
|
+
: await obtainNewSeed();
|
|
119
|
+
const passphrase = await obtainNewPassphrase(opts.encrypt !== false);
|
|
120
|
+
const file = buildWalletFile(ctx.network, secret, passphrase);
|
|
121
|
+
writeWalletFile(ctx.network, file);
|
|
122
|
+
// Derive and record the first receive address. Unlock reads the file we
|
|
123
|
+
// just wrote; passphrase comes from env or the in-memory value.
|
|
124
|
+
let firstAddress;
|
|
125
|
+
if (passphrase !== null && process.env.BSV_PAY_PASSPHRASE === undefined) {
|
|
126
|
+
process.env.BSV_PAY_PASSPHRASE = passphrase; // current process only
|
|
127
|
+
try {
|
|
128
|
+
const wallet = await Wallet.unlock(ctx.network);
|
|
129
|
+
firstAddress = wallet.issueAddress('receive').address;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
delete process.env.BSV_PAY_PASSPHRASE;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
const wallet = await Wallet.unlock(ctx.network);
|
|
137
|
+
firstAddress = wallet.issueAddress('receive').address;
|
|
138
|
+
}
|
|
139
|
+
ctx.out.info('');
|
|
140
|
+
ctx.out.info(chalk.green('Wallet created.'));
|
|
141
|
+
ctx.out.info(` Network: ${ctx.network === 'test' ? 'testnet' : 'mainnet'}`);
|
|
142
|
+
ctx.out.info(` Encrypted: ${passphrase !== null ? 'yes (argon2id + AES-256-GCM)' : chalk.red('NO — plaintext seed')}`);
|
|
143
|
+
ctx.out.info(` First address: ${firstAddress}`);
|
|
144
|
+
ctx.out.info(` State dir: ${walletPath(ctx.network)}`);
|
|
145
|
+
ctx.out.info('');
|
|
146
|
+
ctx.out.info('Next: fund it, then try "bsv-pay balance" or "bsv-pay request 5000sats".');
|
|
147
|
+
ctx.out.result({
|
|
148
|
+
ok: true,
|
|
149
|
+
network: ctx.network,
|
|
150
|
+
encrypted: passphrase !== null,
|
|
151
|
+
type: secret.type,
|
|
152
|
+
address: firstAddress,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* EXPERIMENTAL BRC-100 custody: connect the external wallet app, verify the
|
|
157
|
+
* network, and write a wallet file that records the delegation — no seed, no
|
|
158
|
+
* passphrase, nothing secret stored locally. Spending goes through the same
|
|
159
|
+
* policy gate as every wallet; receiving stays in the wallet app (documented
|
|
160
|
+
* limitation, see README).
|
|
161
|
+
*/
|
|
162
|
+
async function initBrc100(ctx, connectOpts) {
|
|
163
|
+
stderr(chalk.yellow('BRC-100 custody is EXPERIMENTAL. Keys stay in your wallet app;'));
|
|
164
|
+
stderr(chalk.yellow('bsv-pay will ask it to pay, and your policy.toml still governs every spend.'));
|
|
165
|
+
stderr('Connecting to the wallet app... (approve the connection if it prompts)');
|
|
166
|
+
const wallet = await connectBrc100(ctx.network, connectOpts);
|
|
167
|
+
await wallet.waitForAuthentication();
|
|
168
|
+
const identityKey = await wallet.identityKey();
|
|
169
|
+
const version = await wallet.version();
|
|
170
|
+
writeWalletFile(ctx.network, buildBrc100WalletFile(ctx.network, wallet.url));
|
|
171
|
+
ctx.out.info('');
|
|
172
|
+
ctx.out.info(chalk.green('Wallet connected (BRC-100, experimental).'));
|
|
173
|
+
ctx.out.info(` Network: ${ctx.network === 'test' ? 'testnet' : 'mainnet'}`);
|
|
174
|
+
ctx.out.info(` Custody: external wallet app (${version}) at ${wallet.url}`);
|
|
175
|
+
ctx.out.info(` Identity key: ${identityKey}`);
|
|
176
|
+
ctx.out.info(` State dir: ${walletPath(ctx.network)}`);
|
|
177
|
+
ctx.out.info('');
|
|
178
|
+
ctx.out.info('Spending (send/fetch/MCP pay) works now, governed by policy.toml.');
|
|
179
|
+
ctx.out.info('Receiving still happens in the wallet app itself — see the README.');
|
|
180
|
+
ctx.out.result({
|
|
181
|
+
ok: true,
|
|
182
|
+
network: ctx.network,
|
|
183
|
+
backend: 'brc100',
|
|
184
|
+
type: 'brc100',
|
|
185
|
+
identity_key: identityKey,
|
|
186
|
+
wallet_url: wallet.url,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Ctx } from '../context.js';
|
|
2
|
+
/**
|
|
3
|
+
* `bsv-pay mcp` — serve the MCP tools over stdio. stdout belongs to the
|
|
4
|
+
* MCP protocol from here on (the strictest case of invariant 5); every
|
|
5
|
+
* human-facing line goes to stderr.
|
|
6
|
+
*
|
|
7
|
+
* The wallet unlocks ONCE, at startup, before the transport opens: the
|
|
8
|
+
* passphrase comes from BSV_PAY_PASSPHRASE or an interactive TTY prompt,
|
|
9
|
+
* and there is deliberately no tool to unlock, lock, or re-key — the agent
|
|
10
|
+
* connected to this server never holds a secret. With neither env nor TTY
|
|
11
|
+
* the server refuses to start (exit 7) rather than serve a locked wallet.
|
|
12
|
+
*/
|
|
13
|
+
export declare function cmdMcp(ctx: Ctx): Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
2
|
+
import { openWallet } from '../core/wallet.js';
|
|
3
|
+
import { buildMcpServer } from '../mcp/server.js';
|
|
4
|
+
import { obtainPassphrase } from '../wallet/wallet.js';
|
|
5
|
+
/**
|
|
6
|
+
* `bsv-pay mcp` — serve the MCP tools over stdio. stdout belongs to the
|
|
7
|
+
* MCP protocol from here on (the strictest case of invariant 5); every
|
|
8
|
+
* human-facing line goes to stderr.
|
|
9
|
+
*
|
|
10
|
+
* The wallet unlocks ONCE, at startup, before the transport opens: the
|
|
11
|
+
* passphrase comes from BSV_PAY_PASSPHRASE or an interactive TTY prompt,
|
|
12
|
+
* and there is deliberately no tool to unlock, lock, or re-key — the agent
|
|
13
|
+
* connected to this server never holds a secret. With neither env nor TTY
|
|
14
|
+
* the server refuses to start (exit 7) rather than serve a locked wallet.
|
|
15
|
+
*/
|
|
16
|
+
export async function cmdMcp(ctx) {
|
|
17
|
+
const wallet = await openWallet({
|
|
18
|
+
network: ctx.network,
|
|
19
|
+
config: ctx.config,
|
|
20
|
+
passphrase: () => obtainPassphrase(),
|
|
21
|
+
onWarning: (text) => process.stderr.write(text + '\n'),
|
|
22
|
+
});
|
|
23
|
+
const server = buildMcpServer({ network: ctx.network, wallet, config: ctx.config });
|
|
24
|
+
const transport = new StdioServerTransport();
|
|
25
|
+
await server.connect(transport);
|
|
26
|
+
process.stderr.write(`bsv-pay MCP server ready on ${ctx.network === 'test' ? 'testnet' : 'mainnet'} (stdio). ` +
|
|
27
|
+
'Policy is enforced in core; edit policy.toml and restart to change limits.\n');
|
|
28
|
+
// Serve until the client closes the transport (stdin EOF).
|
|
29
|
+
await new Promise((resolve) => {
|
|
30
|
+
server.server.onclose = resolve;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Ctx } from '../context.js';
|
|
2
|
+
export declare function cmdPolicyShow(ctx: Ctx): void;
|
|
3
|
+
/**
|
|
4
|
+
* Dry-run a policy decision: exit 0 = would allow, 8 = would deny,
|
|
5
|
+
* 9 = would queue. Evaluates as an unattended spend (no soft-limit
|
|
6
|
+
* confirmation) and persists nothing — what-ifs are not decisions.
|
|
7
|
+
*/
|
|
8
|
+
export declare function cmdPolicyTest(ctx: Ctx, address: string, amountArg: string): void;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { validateAddress } from '../address.js';
|
|
3
|
+
import { CliError, EXIT } from '../errors.js';
|
|
4
|
+
import { policyPath } from '../paths.js';
|
|
5
|
+
import { approvalSecretConfigured, listPendingApprovals } from '../policy/approvals.js';
|
|
6
|
+
import { readUsage } from '../policy/budget.js';
|
|
7
|
+
import { evaluateSpend } from '../policy/engine.js';
|
|
8
|
+
import { loadPolicy } from '../policy/policy.js';
|
|
9
|
+
import { formatSats, parseAmount } from '../units.js';
|
|
10
|
+
export function cmdPolicyShow(ctx) {
|
|
11
|
+
const policy = loadPolicy(ctx.network, ctx.config);
|
|
12
|
+
const usage = readUsage(ctx.network);
|
|
13
|
+
const pending = listPendingApprovals(ctx.network);
|
|
14
|
+
const secretConfigured = approvalSecretConfigured();
|
|
15
|
+
const fromFile = policy.source === 'file';
|
|
16
|
+
ctx.out.info(chalk.bold(`Policy (${ctx.network === 'test' ? 'testnet' : 'mainnet'}) — ${fromFile ? policyPath() : 'defaults (no policy.toml)'}`));
|
|
17
|
+
if (policy.perTxLimitSats !== undefined) {
|
|
18
|
+
ctx.out.info(` Per-tx limit: ${formatSats(policy.perTxLimitSats)} (hard — no override)`);
|
|
19
|
+
}
|
|
20
|
+
else if (policy.softPerTxLimitSats !== undefined) {
|
|
21
|
+
ctx.out.info(` Per-tx limit: ${formatSats(policy.softPerTxLimitSats)} (soft — interactive confirm / --allow-large)`);
|
|
22
|
+
}
|
|
23
|
+
if (policy.dailyBudgetSats !== undefined) {
|
|
24
|
+
ctx.out.info(` Daily budget: ${formatSats(policy.dailyBudgetSats)} (spent ${formatSats(usage.dailySpentSats)} in 24h, ${formatSats(Math.max(0, policy.dailyBudgetSats - usage.dailySpentSats))} left)`);
|
|
25
|
+
}
|
|
26
|
+
if (policy.sessionBudgetSats !== undefined) {
|
|
27
|
+
ctx.out.info(` Session budget: ${formatSats(policy.sessionBudgetSats)} (this process has spent ${formatSats(usage.sessionSpentSats)})`);
|
|
28
|
+
}
|
|
29
|
+
if (policy.rateLimitPerMinute !== undefined || policy.rateLimitPerHour !== undefined) {
|
|
30
|
+
ctx.out.info(` Rate limit: ${policy.rateLimitPerMinute ?? '∞'}/min, ${policy.rateLimitPerHour ?? '∞'}/hour (sent ${usage.sendsLastMinute} last min, ${usage.sendsLastHour} last hour)`);
|
|
31
|
+
}
|
|
32
|
+
if (policy.approvalThresholdSats !== undefined) {
|
|
33
|
+
ctx.out.info(` Approval threshold: ${formatSats(policy.approvalThresholdSats)} (secret ${secretConfigured ? 'configured' : chalk.red('NOT SET — queued payments cannot be approved')})`);
|
|
34
|
+
}
|
|
35
|
+
if (policy.allowlist.length > 0)
|
|
36
|
+
ctx.out.info(` Allowlist: ${policy.allowlist.length} address(es)`);
|
|
37
|
+
if (policy.denylist.length > 0)
|
|
38
|
+
ctx.out.info(` Denylist: ${policy.denylist.length} address(es)`);
|
|
39
|
+
ctx.out.info(` Pending approvals: ${pending.length}`);
|
|
40
|
+
if (!fromFile) {
|
|
41
|
+
ctx.out.info('');
|
|
42
|
+
ctx.out.info(`No policy.toml — only the legacy spend limit applies. Create ${policyPath()} to govern agents.`);
|
|
43
|
+
}
|
|
44
|
+
if (policy.approvalThresholdSats !== undefined && !secretConfigured) {
|
|
45
|
+
process.stderr.write(chalk.yellow('WARNING: approval_threshold_sats is set but no approval secret exists. Run "bsv-pay approvals set-secret" or queued payments can never be approved.') + '\n');
|
|
46
|
+
}
|
|
47
|
+
ctx.out.result({
|
|
48
|
+
ok: true,
|
|
49
|
+
source: policy.source,
|
|
50
|
+
network: ctx.network,
|
|
51
|
+
rules: {
|
|
52
|
+
...(policy.perTxLimitSats !== undefined && { per_tx_limit_sats: policy.perTxLimitSats }),
|
|
53
|
+
...(policy.softPerTxLimitSats !== undefined && {
|
|
54
|
+
soft_spend_limit_sats: policy.softPerTxLimitSats,
|
|
55
|
+
}),
|
|
56
|
+
...(policy.dailyBudgetSats !== undefined && { daily_budget_sats: policy.dailyBudgetSats }),
|
|
57
|
+
...(policy.sessionBudgetSats !== undefined && {
|
|
58
|
+
session_budget_sats: policy.sessionBudgetSats,
|
|
59
|
+
}),
|
|
60
|
+
...(policy.rateLimitPerMinute !== undefined && {
|
|
61
|
+
rate_limit_per_minute: policy.rateLimitPerMinute,
|
|
62
|
+
}),
|
|
63
|
+
...(policy.rateLimitPerHour !== undefined && {
|
|
64
|
+
rate_limit_per_hour: policy.rateLimitPerHour,
|
|
65
|
+
}),
|
|
66
|
+
...(policy.approvalThresholdSats !== undefined && {
|
|
67
|
+
approval_threshold_sats: policy.approvalThresholdSats,
|
|
68
|
+
}),
|
|
69
|
+
allowlist: policy.allowlist,
|
|
70
|
+
denylist: policy.denylist,
|
|
71
|
+
},
|
|
72
|
+
usage: {
|
|
73
|
+
daily_spent_sats: usage.dailySpentSats,
|
|
74
|
+
session_spent_sats: usage.sessionSpentSats,
|
|
75
|
+
sends_last_minute: usage.sendsLastMinute,
|
|
76
|
+
sends_last_hour: usage.sendsLastHour,
|
|
77
|
+
},
|
|
78
|
+
approval_secret_configured: secretConfigured,
|
|
79
|
+
pending_approvals: pending.length,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Dry-run a policy decision: exit 0 = would allow, 8 = would deny,
|
|
84
|
+
* 9 = would queue. Evaluates as an unattended spend (no soft-limit
|
|
85
|
+
* confirmation) and persists nothing — what-ifs are not decisions.
|
|
86
|
+
*/
|
|
87
|
+
export function cmdPolicyTest(ctx, address, amountArg) {
|
|
88
|
+
validateAddress(address, ctx.network);
|
|
89
|
+
const amountSats = parseAmount(amountArg);
|
|
90
|
+
const policy = loadPolicy(ctx.network, ctx.config);
|
|
91
|
+
const verdict = evaluateSpend(policy, readUsage(ctx.network), { to: address, amountSats });
|
|
92
|
+
if (verdict.decision === 'allow') {
|
|
93
|
+
ctx.out.info(chalk.green(`ALLOW (${verdict.rule}): ${verdict.reason}.`));
|
|
94
|
+
ctx.out.result({ ok: true, decision: 'allow', rule: verdict.rule, reason: verdict.reason });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (verdict.decision === 'deny') {
|
|
98
|
+
throw new CliError(EXIT.SPEND_LIMIT, verdict.errorCode, `Would be denied (${verdict.rule}): ${verdict.reason}.`, { decision: 'deny', rule: verdict.rule, ...verdict.data });
|
|
99
|
+
}
|
|
100
|
+
throw new CliError(EXIT.PENDING_APPROVAL, 'pending_approval', `Would be queued for approval (${verdict.rule}): ${verdict.reason}.`, { decision: 'queue', rule: verdict.rule, ...verdict.data });
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ChainProvider } from '../chain/provider.js';
|
|
2
|
+
import type { Ctx } from '../context.js';
|
|
3
|
+
import { buildPaymentUri } from '../core/request.js';
|
|
4
|
+
export { buildPaymentUri };
|
|
5
|
+
export interface RequestOptions {
|
|
6
|
+
wait?: boolean;
|
|
7
|
+
timeout?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function cmdRequest(ctx: Ctx, amountArg: string, memo: string | undefined, opts: RequestOptions, provider?: ChainProvider): Promise<void>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import qrcode from 'qrcode-terminal';
|
|
3
|
+
import { awaitPayment, buildPaymentUri, createRequest } from '../core/request.js';
|
|
4
|
+
import { openWallet } from '../core/wallet.js';
|
|
5
|
+
import { CliError, EXIT, usageError } from '../errors.js';
|
|
6
|
+
import { formatSats, parseAmount } from '../units.js';
|
|
7
|
+
import { obtainPassphrase } from '../wallet/wallet.js';
|
|
8
|
+
import { explorerTxUrl } from './send.js';
|
|
9
|
+
export { buildPaymentUri };
|
|
10
|
+
function renderQr(uri) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
qrcode.generate(uri, { small: true }, (qr) => resolve(qr));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export async function cmdRequest(ctx, amountArg, memo, opts, provider) {
|
|
16
|
+
const amountSats = parseAmount(amountArg);
|
|
17
|
+
const timeoutSec = Number(opts.timeout ?? '600');
|
|
18
|
+
if (!Number.isInteger(timeoutSec) || timeoutSec <= 0) {
|
|
19
|
+
throw usageError('invalid_timeout', `--timeout must be a positive integer of seconds (got "${opts.timeout}").`);
|
|
20
|
+
}
|
|
21
|
+
const core = { network: ctx.network, config: ctx.config, provider };
|
|
22
|
+
const wallet = await openWallet({
|
|
23
|
+
...core,
|
|
24
|
+
passphrase: () => obtainPassphrase(), // env var, then interactive prompt
|
|
25
|
+
onWarning: (text) => process.stderr.write(text + '\n'),
|
|
26
|
+
});
|
|
27
|
+
const { address, uri } = createRequest(wallet, { amountSats, memo });
|
|
28
|
+
ctx.out.info(chalk.bold('Payment request'));
|
|
29
|
+
ctx.out.info(` Address: ${address}`);
|
|
30
|
+
ctx.out.info(` Amount: ${formatSats(amountSats)}`);
|
|
31
|
+
if (memo)
|
|
32
|
+
ctx.out.info(` Memo: ${memo} (local only)`);
|
|
33
|
+
ctx.out.info(` URI: ${uri}`);
|
|
34
|
+
// QR only on a real terminal, never when piped or in --json mode
|
|
35
|
+
if (!ctx.json && process.stdout.isTTY) {
|
|
36
|
+
ctx.out.info('');
|
|
37
|
+
ctx.out.info(await renderQr(uri));
|
|
38
|
+
}
|
|
39
|
+
const requestObj = {
|
|
40
|
+
ok: true,
|
|
41
|
+
event: 'request_created',
|
|
42
|
+
address,
|
|
43
|
+
amount_sats: amountSats,
|
|
44
|
+
uri,
|
|
45
|
+
...(memo ? { memo } : {}),
|
|
46
|
+
network: ctx.network,
|
|
47
|
+
};
|
|
48
|
+
if (!opts.wait) {
|
|
49
|
+
ctx.out.result(requestObj);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// --wait emits NDJSON in --json mode: the request first, then the outcome
|
|
53
|
+
// (a script needs the address before anyone can pay it). See DECISIONS.md.
|
|
54
|
+
ctx.out.result(requestObj);
|
|
55
|
+
ctx.out.info('');
|
|
56
|
+
ctx.out.info(`Waiting for payment (0-conf), timeout ${timeoutSec}s — Ctrl-C to stop...`);
|
|
57
|
+
let paid;
|
|
58
|
+
try {
|
|
59
|
+
paid = await awaitPayment(core, { address, timeoutMs: timeoutSec * 1000, memo });
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
// keep the CLI's original timeout message and data shape
|
|
63
|
+
if (e instanceof CliError && e.errorCode === 'request_timeout') {
|
|
64
|
+
throw new CliError(EXIT.NETWORK, 'request_timeout', `No payment seen on ${address} within ${timeoutSec}s. The request URI is still valid; re-run with --wait to keep watching.`, { address, amount_sats: amountSats });
|
|
65
|
+
}
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
ctx.out.info(chalk.green('Payment received.'));
|
|
69
|
+
ctx.out.info(` Amount: ${formatSats(paid.receivedSats)}`);
|
|
70
|
+
ctx.out.info(` Txid: ${paid.txid}`);
|
|
71
|
+
ctx.out.info(` Explorer: ${explorerTxUrl(ctx.network, paid.txid)}`);
|
|
72
|
+
if (paid.receivedSats < amountSats) {
|
|
73
|
+
process.stderr.write(chalk.yellow(`Note: received ${formatSats(paid.receivedSats)} is less than the requested ${formatSats(amountSats)}.`) + '\n');
|
|
74
|
+
}
|
|
75
|
+
ctx.out.result({
|
|
76
|
+
ok: true,
|
|
77
|
+
event: 'payment_received',
|
|
78
|
+
address,
|
|
79
|
+
requested_sats: amountSats,
|
|
80
|
+
received_sats: paid.receivedSats,
|
|
81
|
+
txid: paid.txid,
|
|
82
|
+
status: paid.confirmed ? 'confirmed' : 'pending',
|
|
83
|
+
explorer_url: explorerTxUrl(ctx.network, paid.txid),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ChainProvider } from '../chain/provider.js';
|
|
2
|
+
import type { Ctx } from '../context.js';
|
|
3
|
+
import { explorerTxUrl } from '../core/send.js';
|
|
4
|
+
export { explorerTxUrl };
|
|
5
|
+
export interface SendOptions {
|
|
6
|
+
yes?: boolean;
|
|
7
|
+
allowLarge?: boolean;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
confirmedOnly?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function cmdSend(ctx: Ctx, address: string, amountArg: string, memo: string | undefined, opts: SendOptions, provider?: ChainProvider): Promise<void>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { validateAddress } from '../address.js';
|
|
3
|
+
import { executeSend, explorerTxUrl, planSend } from '../core/send.js';
|
|
4
|
+
import { openWallet } from '../core/wallet.js';
|
|
5
|
+
import { CliError, EXIT, usageError } from '../errors.js';
|
|
6
|
+
import { ask, confirm, isInteractive } from '../prompt.js';
|
|
7
|
+
import { formatSats, parseAmount } from '../units.js';
|
|
8
|
+
import { obtainPassphrase } from '../wallet/wallet.js';
|
|
9
|
+
export { explorerTxUrl };
|
|
10
|
+
/**
|
|
11
|
+
* Spend-limit policy (invariant 4): at/above the limit, an explicit
|
|
12
|
+
* interactive confirmation is required; scripts need --yes --allow-large.
|
|
13
|
+
*/
|
|
14
|
+
async function enforceSpendLimit(ctx, amountSats, opts) {
|
|
15
|
+
const limit = ctx.config.spendLimitSats;
|
|
16
|
+
if (amountSats < limit)
|
|
17
|
+
return;
|
|
18
|
+
if (opts.yes) {
|
|
19
|
+
if (opts.allowLarge)
|
|
20
|
+
return;
|
|
21
|
+
throw new CliError(EXIT.SPEND_LIMIT, 'spend_limit_exceeded', `Amount ${formatSats(amountSats)} is at/above your ${formatSats(limit)} per-transaction limit. ` +
|
|
22
|
+
'Add --allow-large alongside --yes, or raise spend_limit_sats in config.toml.', { limit_sats: limit, amount_sats: amountSats });
|
|
23
|
+
}
|
|
24
|
+
if (!isInteractive()) {
|
|
25
|
+
throw new CliError(EXIT.SPEND_LIMIT, 'spend_limit_exceeded', `Amount ${formatSats(amountSats)} is at/above your ${formatSats(limit)} limit and there is no terminal to confirm. Use --yes --allow-large in scripts.`, { limit_sats: limit, amount_sats: amountSats });
|
|
26
|
+
}
|
|
27
|
+
process.stderr.write(chalk.yellow(`This send is at/above your spend limit of ${formatSats(limit)}.`) + '\n');
|
|
28
|
+
const typed = await ask(`Type the amount in sats (${amountSats}) to proceed: `);
|
|
29
|
+
if (typed.trim() !== String(amountSats)) {
|
|
30
|
+
throw new CliError(EXIT.SPEND_LIMIT, 'spend_limit_exceeded', 'Confirmation did not match; nothing was sent.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function cmdSend(ctx, address, amountArg, memo, opts, provider) {
|
|
34
|
+
// 1. Validate everything local BEFORE unlocking or touching the network
|
|
35
|
+
// (invariant 4): address, amount, then the spend limit — a script that
|
|
36
|
+
// is over the limit fails fast with exit 8, not after network calls.
|
|
37
|
+
validateAddress(address, ctx.network);
|
|
38
|
+
const amountSats = parseAmount(amountArg);
|
|
39
|
+
await enforceSpendLimit(ctx, amountSats, opts);
|
|
40
|
+
const core = { network: ctx.network, config: ctx.config, provider };
|
|
41
|
+
const wallet = await openWallet({
|
|
42
|
+
...core,
|
|
43
|
+
passphrase: () => obtainPassphrase(), // env var, then interactive prompt
|
|
44
|
+
onWarning: (text) => process.stderr.write(text + '\n'),
|
|
45
|
+
});
|
|
46
|
+
// 2. Gather funds and plan the transaction. The CLI enforced its limit
|
|
47
|
+
// above (interactively when needed), so the core guard is satisfied.
|
|
48
|
+
const plan = await planSend(wallet, core, {
|
|
49
|
+
to: address,
|
|
50
|
+
amountSats,
|
|
51
|
+
memo,
|
|
52
|
+
confirmedOnly: opts.confirmedOnly,
|
|
53
|
+
allowAboveLimit: true,
|
|
54
|
+
dryRun: opts.dryRun,
|
|
55
|
+
});
|
|
56
|
+
// 3. Per-send confirmation (always shows recipient, amount, fee, and
|
|
57
|
+
// resulting balance before broadcast).
|
|
58
|
+
if (opts.dryRun) {
|
|
59
|
+
process.stderr.write(chalk.bold('Dry run — nothing will be broadcast.') + '\n');
|
|
60
|
+
}
|
|
61
|
+
const summaryLines = [
|
|
62
|
+
` Recipient: ${address}`,
|
|
63
|
+
` Amount: ${formatSats(amountSats)}`,
|
|
64
|
+
plan.external
|
|
65
|
+
? ` Fee: ~${plan.feeSats} sats estimated (the external wallet sets the real fee)`
|
|
66
|
+
: ` Fee: ${plan.feeSats} sats (${ctx.config.feeRateSatsPerKb} sats/KB, ${plan.inputCount} input${plan.inputCount === 1 ? '' : 's'})`,
|
|
67
|
+
` Balance after: ${plan.external ? '~' : ''}${formatSats(plan.balanceAfterSats)}`,
|
|
68
|
+
];
|
|
69
|
+
if (plan.external) {
|
|
70
|
+
summaryLines.push(' Custody: external BRC-100 wallet app (it may ask you to approve)');
|
|
71
|
+
}
|
|
72
|
+
if (memo)
|
|
73
|
+
summaryLines.push(` Memo (local): ${memo}`);
|
|
74
|
+
for (const line of summaryLines)
|
|
75
|
+
process.stderr.write(line + '\n');
|
|
76
|
+
if (!opts.yes && !opts.dryRun) {
|
|
77
|
+
if (!isInteractive()) {
|
|
78
|
+
throw usageError('confirmation_required', 'send needs a confirmation prompt but no terminal is available. Pass --yes in scripts.');
|
|
79
|
+
}
|
|
80
|
+
if (!(await confirm('Send it?'))) {
|
|
81
|
+
throw usageError('aborted', 'Send cancelled; nothing was broadcast.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// 4. Sign, broadcast, and record via core (ledger writes live there).
|
|
85
|
+
const result = await executeSend(wallet, core, plan, { dryRun: opts.dryRun });
|
|
86
|
+
if (result.dryRun) {
|
|
87
|
+
ctx.out.info(chalk.bold('Dry run complete (not broadcast).'));
|
|
88
|
+
if (result.external) {
|
|
89
|
+
ctx.out.info(' Txid (if sent): decided by the external wallet');
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
ctx.out.info(` Txid (if sent): ${result.txid}`);
|
|
93
|
+
ctx.out.info(` Size: ${result.sizeBytes} bytes`);
|
|
94
|
+
}
|
|
95
|
+
ctx.out.result({
|
|
96
|
+
ok: true,
|
|
97
|
+
dry_run: true,
|
|
98
|
+
txid: result.txid,
|
|
99
|
+
recipient: address,
|
|
100
|
+
amount_sats: result.amountSats,
|
|
101
|
+
fee_sats: result.feeSats,
|
|
102
|
+
change_sats: result.changeSats,
|
|
103
|
+
balance_after_sats: result.balanceAfterSats,
|
|
104
|
+
...(result.feeEstimated ? { fee_estimated: true } : {}),
|
|
105
|
+
...(result.external ? { backend: 'brc100' } : {}),
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
ctx.out.info(chalk.green('Sent.'));
|
|
110
|
+
ctx.out.info(` Txid: ${result.txid}`);
|
|
111
|
+
ctx.out.info(` Explorer: ${result.explorerUrl}`);
|
|
112
|
+
ctx.out.info(` New balance: ~${formatSats(result.balanceAfterSats)} (pending confirmation)`);
|
|
113
|
+
ctx.out.result({
|
|
114
|
+
ok: true,
|
|
115
|
+
txid: result.txid,
|
|
116
|
+
recipient: address,
|
|
117
|
+
amount_sats: result.amountSats,
|
|
118
|
+
fee_sats: result.feeSats,
|
|
119
|
+
change_sats: result.changeSats,
|
|
120
|
+
balance_after_sats: result.balanceAfterSats,
|
|
121
|
+
explorer_url: result.explorerUrl,
|
|
122
|
+
...(result.feeEstimated ? { fee_estimated: true } : {}),
|
|
123
|
+
...(result.external ? { backend: 'brc100' } : {}),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ChainProvider } from '../chain/provider.js';
|
|
2
|
+
import type { Ctx } from '../context.js';
|
|
3
|
+
export interface ServeOptions {
|
|
4
|
+
price: string;
|
|
5
|
+
port?: string;
|
|
6
|
+
host?: string;
|
|
7
|
+
body?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `bsv-pay serve` — a demo BRC-105 paywall: every request must pay
|
|
11
|
+
* `--price` into this wallet before it gets the content. Exists for
|
|
12
|
+
* testing, tutorials, and the two-agent demo (M13); the real product is
|
|
13
|
+
* the importable requirePayment() middleware this wraps. Human logging
|
|
14
|
+
* goes to stderr; the HTTP responses are the machine surface.
|
|
15
|
+
*/
|
|
16
|
+
export declare function cmdServe(ctx: Ctx, opts: ServeOptions, provider?: ChainProvider): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { openWallet } from '../core/wallet.js';
|
|
4
|
+
import { usageError } from '../errors.js';
|
|
5
|
+
import { requirePayment } from '../http402/middleware.js';
|
|
6
|
+
import { formatSats, parseAmount } from '../units.js';
|
|
7
|
+
import { obtainPassphrase } from '../wallet/wallet.js';
|
|
8
|
+
/**
|
|
9
|
+
* `bsv-pay serve` — a demo BRC-105 paywall: every request must pay
|
|
10
|
+
* `--price` into this wallet before it gets the content. Exists for
|
|
11
|
+
* testing, tutorials, and the two-agent demo (M13); the real product is
|
|
12
|
+
* the importable requirePayment() middleware this wraps. Human logging
|
|
13
|
+
* goes to stderr; the HTTP responses are the machine surface.
|
|
14
|
+
*/
|
|
15
|
+
export async function cmdServe(ctx, opts, provider) {
|
|
16
|
+
const priceSats = parseAmount(opts.price);
|
|
17
|
+
const port = Number(opts.port ?? '8402');
|
|
18
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
19
|
+
throw usageError('invalid_port', `--port must be 1-65535 (got "${opts.port}").`);
|
|
20
|
+
}
|
|
21
|
+
const core = { network: ctx.network, config: ctx.config, provider };
|
|
22
|
+
const wallet = await openWallet({
|
|
23
|
+
...core,
|
|
24
|
+
passphrase: () => obtainPassphrase(),
|
|
25
|
+
onWarning: (text) => process.stderr.write(text + '\n'),
|
|
26
|
+
});
|
|
27
|
+
const gate = requirePayment({ ...core, wallet, priceSats });
|
|
28
|
+
const server = http.createServer((req, res) => {
|
|
29
|
+
gate(req, res, () => {
|
|
30
|
+
const receipt = req.bsvPayment;
|
|
31
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
32
|
+
res.end(JSON.stringify({
|
|
33
|
+
ok: true,
|
|
34
|
+
message: opts.body ?? 'Paid content served by bsv-pay.',
|
|
35
|
+
amount_sats: receipt.amountSats,
|
|
36
|
+
txid: receipt.txid,
|
|
37
|
+
}));
|
|
38
|
+
process.stderr.write(chalk.green(`sold: ${formatSats(receipt.amountSats)} for ${req.url ?? '/'} (txid ${receipt.txid.slice(0, 12)}…)`) + '\n');
|
|
39
|
+
}).catch((err) => {
|
|
40
|
+
process.stderr.write(chalk.red(`serve error: ${err instanceof Error ? err.message : String(err)}`) + '\n');
|
|
41
|
+
if (!res.headersSent) {
|
|
42
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
43
|
+
res.end(JSON.stringify({ ok: false, error: 'internal_error' }));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
const host = opts.host ?? '127.0.0.1'; // localhost-only unless explicitly exposed
|
|
48
|
+
await new Promise((resolve, reject) => {
|
|
49
|
+
server.once('error', reject);
|
|
50
|
+
server.listen(port, host, () => resolve());
|
|
51
|
+
});
|
|
52
|
+
process.stderr.write(`bsv-pay paywall on http://${host}:${port} — ${formatSats(priceSats)} per request, ` +
|
|
53
|
+
`paid into this ${ctx.network === 'test' ? 'testnet' : 'mainnet'} wallet. Ctrl+C stops.\n`);
|
|
54
|
+
// Serve until interrupted.
|
|
55
|
+
await new Promise((resolve) => {
|
|
56
|
+
process.once('SIGINT', () => server.close(() => resolve()));
|
|
57
|
+
process.once('SIGTERM', () => server.close(() => resolve()));
|
|
58
|
+
});
|
|
59
|
+
}
|