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,89 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { argon2id } from '@noble/hashes/argon2';
|
|
5
|
+
import { CliError, EXIT, usageError } from '../errors.js';
|
|
6
|
+
import { appendLedger, readLedger } from '../ledger.js';
|
|
7
|
+
import { approvalSecretPath } from '../paths.js';
|
|
8
|
+
import { DEFAULT_KDF } from '../wallet/crypto.js';
|
|
9
|
+
function hashSecret(secret, saltHex, params) {
|
|
10
|
+
const pass = new TextEncoder().encode(secret.normalize('NFKD'));
|
|
11
|
+
const salt = Buffer.from(saltHex, 'hex');
|
|
12
|
+
return Buffer.from(argon2id(pass, salt, { ...params, dkLen: 32 }));
|
|
13
|
+
}
|
|
14
|
+
export function approvalSecretConfigured() {
|
|
15
|
+
return fs.existsSync(approvalSecretPath());
|
|
16
|
+
}
|
|
17
|
+
/** Store the argon2id hash of a new approval secret (never the secret). */
|
|
18
|
+
export function storeApprovalSecret(secret) {
|
|
19
|
+
const file = {
|
|
20
|
+
algo: 'argon2id',
|
|
21
|
+
salt: crypto.randomBytes(16).toString('hex'),
|
|
22
|
+
...DEFAULT_KDF,
|
|
23
|
+
hash: '',
|
|
24
|
+
};
|
|
25
|
+
file.hash = hashSecret(secret, file.salt, file).toString('hex');
|
|
26
|
+
fs.mkdirSync(path.dirname(approvalSecretPath()), { recursive: true, mode: 0o700 });
|
|
27
|
+
fs.writeFileSync(approvalSecretPath(), JSON.stringify(file, null, 2) + '\n', { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
export function verifyApprovalSecret(secret) {
|
|
30
|
+
if (!approvalSecretConfigured())
|
|
31
|
+
return false;
|
|
32
|
+
let file;
|
|
33
|
+
try {
|
|
34
|
+
file = JSON.parse(fs.readFileSync(approvalSecretPath(), 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const expected = Buffer.from(file.hash, 'hex');
|
|
40
|
+
const actual = hashSecret(secret, file.salt, file);
|
|
41
|
+
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Pending approvals are a fold over the append-only ledger: queue decisions
|
|
45
|
+
* minus resolutions. No second mutable state file to tamper with.
|
|
46
|
+
*/
|
|
47
|
+
export function listPendingApprovals(network) {
|
|
48
|
+
const resolved = new Set();
|
|
49
|
+
const queued = [];
|
|
50
|
+
for (const entry of readLedger(network)) {
|
|
51
|
+
if (entry.type === 'approval_resolved')
|
|
52
|
+
resolved.add(entry.approval_id);
|
|
53
|
+
if (entry.type === 'policy_decision' && entry.decision === 'queue' && entry.approval_id) {
|
|
54
|
+
queued.push({
|
|
55
|
+
approvalId: entry.approval_id,
|
|
56
|
+
address: entry.address,
|
|
57
|
+
amountSats: entry.amount_sats,
|
|
58
|
+
memo: entry.memo,
|
|
59
|
+
confirmedOnly: entry.confirmed_only,
|
|
60
|
+
queuedAt: entry.timestamp,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return queued.filter((q) => !resolved.has(q.approvalId));
|
|
65
|
+
}
|
|
66
|
+
/** Find a pending approval by full id or unambiguous prefix. */
|
|
67
|
+
export function findPendingApproval(network, id) {
|
|
68
|
+
const pending = listPendingApprovals(network);
|
|
69
|
+
const matches = pending.filter((p) => p.approvalId === id || p.approvalId.startsWith(id));
|
|
70
|
+
if (matches.length === 1)
|
|
71
|
+
return matches[0];
|
|
72
|
+
if (matches.length > 1) {
|
|
73
|
+
throw usageError('ambiguous_approval', `Approval id "${id}" matches ${matches.length} pending approvals. Use more characters.`);
|
|
74
|
+
}
|
|
75
|
+
throw usageError('unknown_approval', `No pending approval matches "${id}". Run "bsv-pay approvals list".`);
|
|
76
|
+
}
|
|
77
|
+
export function resolveApproval(network, approvalId, resolution, txid) {
|
|
78
|
+
appendLedger(network, {
|
|
79
|
+
type: 'approval_resolved',
|
|
80
|
+
approval_id: approvalId,
|
|
81
|
+
resolution,
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
txid,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Thrown when the typed approval secret is wrong. Exit 7 (auth failure). */
|
|
87
|
+
export function badApprovalSecret() {
|
|
88
|
+
return new CliError(EXIT.WALLET_LOCKED, 'bad_approval_secret', 'Wrong approval secret. The wallet passphrase is NOT accepted here; the approval secret is the one set via "bsv-pay approvals set-secret".');
|
|
89
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Network } from '../paths.js';
|
|
2
|
+
export declare function addSessionSpent(network: Network, amountSats: number): void;
|
|
3
|
+
export declare function resetSessionSpentForTests(): void;
|
|
4
|
+
export interface SpendUsage {
|
|
5
|
+
/** Sum of send amounts (pending, confirmed, AND unknown) in the last 24h. */
|
|
6
|
+
dailySpentSats: number;
|
|
7
|
+
sessionSpentSats: number;
|
|
8
|
+
sendsLastMinute: number;
|
|
9
|
+
sendsLastHour: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read current spend usage from the ledger. Amounts are amount_sats only
|
|
13
|
+
* (fees excluded); `unknown` broadcasts count as spent — conservative, the
|
|
14
|
+
* funds may have moved.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readUsage(network: Network, now?: number): SpendUsage;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readLedger } from '../ledger.js';
|
|
2
|
+
const MINUTE_MS = 60_000;
|
|
3
|
+
const HOUR_MS = 3_600_000;
|
|
4
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
5
|
+
/**
|
|
6
|
+
* Session spend is the ONLY in-memory accounting: by definition it covers
|
|
7
|
+
* this process's lifetime (an MCP server session; each CLI invocation is its
|
|
8
|
+
* own session). Everything else below is recomputed from the append-only
|
|
9
|
+
* ledger at every decision, so restarting a process never resets a budget.
|
|
10
|
+
*/
|
|
11
|
+
const sessionSpentByNetwork = new Map();
|
|
12
|
+
export function addSessionSpent(network, amountSats) {
|
|
13
|
+
sessionSpentByNetwork.set(network, (sessionSpentByNetwork.get(network) ?? 0) + amountSats);
|
|
14
|
+
}
|
|
15
|
+
export function resetSessionSpentForTests() {
|
|
16
|
+
sessionSpentByNetwork.clear();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read current spend usage from the ledger. Amounts are amount_sats only
|
|
20
|
+
* (fees excluded); `unknown` broadcasts count as spent — conservative, the
|
|
21
|
+
* funds may have moved.
|
|
22
|
+
*/
|
|
23
|
+
export function readUsage(network, now = Date.now()) {
|
|
24
|
+
let dailySpentSats = 0;
|
|
25
|
+
let sendsLastMinute = 0;
|
|
26
|
+
let sendsLastHour = 0;
|
|
27
|
+
for (const entry of readLedger(network)) {
|
|
28
|
+
if (entry.type !== 'send')
|
|
29
|
+
continue;
|
|
30
|
+
const t = Date.parse(entry.timestamp);
|
|
31
|
+
if (!Number.isFinite(t))
|
|
32
|
+
continue;
|
|
33
|
+
// Future-dated entries (clock skew) count as "just now" — conservative.
|
|
34
|
+
if (now - t < DAY_MS)
|
|
35
|
+
dailySpentSats += entry.amount_sats;
|
|
36
|
+
if (now - t < HOUR_MS)
|
|
37
|
+
sendsLastHour += 1;
|
|
38
|
+
if (now - t < MINUTE_MS)
|
|
39
|
+
sendsLastMinute += 1;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
dailySpentSats,
|
|
43
|
+
sessionSpentSats: sessionSpentByNetwork.get(network) ?? 0,
|
|
44
|
+
sendsLastMinute,
|
|
45
|
+
sendsLastHour,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import type { Network } from '../paths.js';
|
|
3
|
+
import { type SpendUsage } from './budget.js';
|
|
4
|
+
import { type Policy } from './policy.js';
|
|
5
|
+
/**
|
|
6
|
+
* THE policy gate (invariant 2). Every spend path — CLI send/donate, the
|
|
7
|
+
* core library, the MCP server, the 402 client — reaches the network only
|
|
8
|
+
* via core executeSend, and executeSend only accepts plans this module has
|
|
9
|
+
* authorized. No flag, parameter, or tool argument can disable a policy.toml
|
|
10
|
+
* rule; `softLimitConfirmed` satisfies only the legacy confirmable limit
|
|
11
|
+
* that exists when no policy.toml hard limit is set (pre-Phase-2 behavior).
|
|
12
|
+
*/
|
|
13
|
+
export interface PolicyEnv {
|
|
14
|
+
network: Network;
|
|
15
|
+
config: Config;
|
|
16
|
+
}
|
|
17
|
+
export interface SpendRequest {
|
|
18
|
+
to: string;
|
|
19
|
+
amountSats: number;
|
|
20
|
+
memo?: string;
|
|
21
|
+
/** The human confirmed the legacy soft limit (interactive / --allow-large). */
|
|
22
|
+
softLimitConfirmed?: boolean;
|
|
23
|
+
confirmedOnly?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type Verdict = {
|
|
26
|
+
decision: 'allow';
|
|
27
|
+
rule: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
} | {
|
|
30
|
+
decision: 'deny';
|
|
31
|
+
rule: string;
|
|
32
|
+
reason: string;
|
|
33
|
+
errorCode: string;
|
|
34
|
+
data: Record<string, unknown>;
|
|
35
|
+
} | {
|
|
36
|
+
decision: 'queue';
|
|
37
|
+
rule: string;
|
|
38
|
+
reason: string;
|
|
39
|
+
data: Record<string, unknown>;
|
|
40
|
+
};
|
|
41
|
+
/** Pure rule evaluation: deny wins, then queue, then allow. No I/O. */
|
|
42
|
+
export declare function evaluateSpend(policy: Policy, usage: SpendUsage, req: SpendRequest): Verdict;
|
|
43
|
+
/** Proof an allow decision was made for exactly this recipient and amount. */
|
|
44
|
+
export interface SpendAuthorization {
|
|
45
|
+
decisionId: string;
|
|
46
|
+
to: string;
|
|
47
|
+
amountSats: number;
|
|
48
|
+
/** True when authorized in evaluate-only mode (dry runs): never executable for real. */
|
|
49
|
+
evaluateOnly: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface AuthorizeOptions {
|
|
52
|
+
/**
|
|
53
|
+
* enforce: the decision is real — append it to the ledger (invariant 6),
|
|
54
|
+
* queue approvals. evaluate: dry runs and `policy test` — same verdicts and
|
|
55
|
+
* thrown errors, but nothing persisted and nothing queued.
|
|
56
|
+
*/
|
|
57
|
+
mode: 'enforce' | 'evaluate';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Decide a spend. Returns an authorization on allow; throws exit 8 on deny
|
|
61
|
+
* and exit 9 on queue (after recording the decision — and, for queues, the
|
|
62
|
+
* approval — in the ledger when mode is enforce).
|
|
63
|
+
*/
|
|
64
|
+
export declare function authorizeSpend(env: PolicyEnv, req: SpendRequest, opts: AuthorizeOptions): SpendAuthorization;
|
|
65
|
+
/**
|
|
66
|
+
* Decide a previously queued spend in approval context: every rule except
|
|
67
|
+
* the approval threshold still applies at approval time. Called ONLY by the
|
|
68
|
+
* approvals command after the human passed the TTY + approval-secret gate;
|
|
69
|
+
* not exported from the core public surface.
|
|
70
|
+
*/
|
|
71
|
+
export declare function authorizeApprovedSpend(env: PolicyEnv, req: SpendRequest, approvalId: string): SpendAuthorization;
|
|
72
|
+
export declare function registerAuthorizedPlan(plan: object, auth: SpendAuthorization): void;
|
|
73
|
+
export declare function takeAuthorizedPlan(plan: object, expected: {
|
|
74
|
+
to: string;
|
|
75
|
+
amountSats: number;
|
|
76
|
+
}, forBroadcast: boolean): SpendAuthorization;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { CliError, EXIT } from '../errors.js';
|
|
3
|
+
import { appendLedger } from '../ledger.js';
|
|
4
|
+
import { formatSats } from '../units.js';
|
|
5
|
+
import { readUsage } from './budget.js';
|
|
6
|
+
import { loadPolicy } from './policy.js';
|
|
7
|
+
/** Pure rule evaluation: deny wins, then queue, then allow. No I/O. */
|
|
8
|
+
export function evaluateSpend(policy, usage, req) {
|
|
9
|
+
const { to, amountSats } = req;
|
|
10
|
+
if (policy.denylist.includes(to)) {
|
|
11
|
+
return {
|
|
12
|
+
decision: 'deny',
|
|
13
|
+
rule: 'denylist',
|
|
14
|
+
reason: `recipient ${to} is denylisted`,
|
|
15
|
+
errorCode: 'recipient_denied',
|
|
16
|
+
data: { address: to },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (policy.allowlist.length > 0 && !policy.allowlist.includes(to)) {
|
|
20
|
+
return {
|
|
21
|
+
decision: 'deny',
|
|
22
|
+
rule: 'allowlist',
|
|
23
|
+
reason: `recipient ${to} is not on the allowlist`,
|
|
24
|
+
errorCode: 'recipient_not_allowed',
|
|
25
|
+
data: { address: to },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (policy.perTxLimitSats !== undefined && amountSats > policy.perTxLimitSats) {
|
|
29
|
+
return {
|
|
30
|
+
decision: 'deny',
|
|
31
|
+
rule: 'per_tx_limit_sats',
|
|
32
|
+
reason: `amount ${formatSats(amountSats)} exceeds the hard per-transaction limit of ${formatSats(policy.perTxLimitSats)}`,
|
|
33
|
+
errorCode: 'per_tx_limit_exceeded',
|
|
34
|
+
data: { limit_sats: policy.perTxLimitSats, amount_sats: amountSats },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (policy.softPerTxLimitSats !== undefined &&
|
|
38
|
+
amountSats >= policy.softPerTxLimitSats &&
|
|
39
|
+
!req.softLimitConfirmed) {
|
|
40
|
+
// Legacy confirmable limit — byte-compatible with pre-policy behavior.
|
|
41
|
+
return {
|
|
42
|
+
decision: 'deny',
|
|
43
|
+
rule: 'spend_limit',
|
|
44
|
+
reason: `amount ${formatSats(amountSats)} is at/above the ${formatSats(policy.softPerTxLimitSats)} per-transaction limit`,
|
|
45
|
+
errorCode: 'spend_limit_exceeded',
|
|
46
|
+
data: { limit_sats: policy.softPerTxLimitSats, amount_sats: amountSats },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (policy.sessionBudgetSats !== undefined) {
|
|
50
|
+
const remaining = policy.sessionBudgetSats - usage.sessionSpentSats;
|
|
51
|
+
if (amountSats > remaining) {
|
|
52
|
+
return {
|
|
53
|
+
decision: 'deny',
|
|
54
|
+
rule: 'session_budget_sats',
|
|
55
|
+
reason: `amount ${formatSats(amountSats)} exceeds the remaining session budget of ${formatSats(Math.max(0, remaining))}`,
|
|
56
|
+
errorCode: 'session_budget_exceeded',
|
|
57
|
+
data: {
|
|
58
|
+
budget_sats: policy.sessionBudgetSats,
|
|
59
|
+
spent_sats: usage.sessionSpentSats,
|
|
60
|
+
remaining_sats: Math.max(0, remaining),
|
|
61
|
+
amount_sats: amountSats,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (policy.dailyBudgetSats !== undefined) {
|
|
67
|
+
const remaining = policy.dailyBudgetSats - usage.dailySpentSats;
|
|
68
|
+
if (amountSats > remaining) {
|
|
69
|
+
return {
|
|
70
|
+
decision: 'deny',
|
|
71
|
+
rule: 'daily_budget_sats',
|
|
72
|
+
reason: `amount ${formatSats(amountSats)} exceeds the remaining 24h budget of ${formatSats(Math.max(0, remaining))}`,
|
|
73
|
+
errorCode: 'daily_budget_exceeded',
|
|
74
|
+
data: {
|
|
75
|
+
budget_sats: policy.dailyBudgetSats,
|
|
76
|
+
spent_sats: usage.dailySpentSats,
|
|
77
|
+
remaining_sats: Math.max(0, remaining),
|
|
78
|
+
amount_sats: amountSats,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (policy.rateLimitPerMinute !== undefined &&
|
|
84
|
+
usage.sendsLastMinute >= policy.rateLimitPerMinute) {
|
|
85
|
+
return {
|
|
86
|
+
decision: 'deny',
|
|
87
|
+
rule: 'rate_limit_per_minute',
|
|
88
|
+
reason: `rate limit reached: ${usage.sendsLastMinute} payment(s) in the last minute (max ${policy.rateLimitPerMinute})`,
|
|
89
|
+
errorCode: 'rate_limit_exceeded',
|
|
90
|
+
data: { limit: policy.rateLimitPerMinute, window: 'minute', sent: usage.sendsLastMinute },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (policy.rateLimitPerHour !== undefined && usage.sendsLastHour >= policy.rateLimitPerHour) {
|
|
94
|
+
return {
|
|
95
|
+
decision: 'deny',
|
|
96
|
+
rule: 'rate_limit_per_hour',
|
|
97
|
+
reason: `rate limit reached: ${usage.sendsLastHour} payment(s) in the last hour (max ${policy.rateLimitPerHour})`,
|
|
98
|
+
errorCode: 'rate_limit_exceeded',
|
|
99
|
+
data: { limit: policy.rateLimitPerHour, window: 'hour', sent: usage.sendsLastHour },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (policy.approvalThresholdSats !== undefined && amountSats >= policy.approvalThresholdSats) {
|
|
103
|
+
return {
|
|
104
|
+
decision: 'queue',
|
|
105
|
+
rule: 'approval_threshold_sats',
|
|
106
|
+
reason: `amount ${formatSats(amountSats)} is at/above the ${formatSats(policy.approvalThresholdSats)} approval threshold`,
|
|
107
|
+
data: { threshold_sats: policy.approvalThresholdSats, amount_sats: amountSats },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { decision: 'allow', rule: 'default', reason: 'within policy' };
|
|
111
|
+
}
|
|
112
|
+
function throwVerdict(verdict, extra) {
|
|
113
|
+
if (verdict.decision === 'deny') {
|
|
114
|
+
throw new CliError(EXIT.SPEND_LIMIT, verdict.errorCode, `Denied by policy (${verdict.rule}): ${verdict.reason}.`, { rule: verdict.rule, ...verdict.data, ...extra });
|
|
115
|
+
}
|
|
116
|
+
throw new CliError(EXIT.PENDING_APPROVAL, 'pending_approval', `Queued for human approval (${verdict.rule}): ${verdict.reason}. Run "bsv-pay approvals list" to review.`, { rule: verdict.rule, ...verdict.data, ...extra });
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Decide a spend. Returns an authorization on allow; throws exit 8 on deny
|
|
120
|
+
* and exit 9 on queue (after recording the decision — and, for queues, the
|
|
121
|
+
* approval — in the ledger when mode is enforce).
|
|
122
|
+
*/
|
|
123
|
+
export function authorizeSpend(env, req, opts) {
|
|
124
|
+
const policy = loadPolicy(env.network, env.config);
|
|
125
|
+
const verdict = evaluateSpend(policy, readUsage(env.network), req);
|
|
126
|
+
return settle(env, req, verdict, opts.mode);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Decide a previously queued spend in approval context: every rule except
|
|
130
|
+
* the approval threshold still applies at approval time. Called ONLY by the
|
|
131
|
+
* approvals command after the human passed the TTY + approval-secret gate;
|
|
132
|
+
* not exported from the core public surface.
|
|
133
|
+
*/
|
|
134
|
+
export function authorizeApprovedSpend(env, req, approvalId) {
|
|
135
|
+
const policy = loadPolicy(env.network, env.config);
|
|
136
|
+
const withoutThreshold = { ...policy, approvalThresholdSats: undefined };
|
|
137
|
+
const verdict = evaluateSpend(withoutThreshold, readUsage(env.network), req);
|
|
138
|
+
if (verdict.decision === 'allow') {
|
|
139
|
+
return settle(env, req, { decision: 'allow', rule: 'approval', reason: `human-approved ${approvalId}` }, 'enforce');
|
|
140
|
+
}
|
|
141
|
+
return settle(env, req, verdict, 'enforce', approvalId);
|
|
142
|
+
}
|
|
143
|
+
function settle(env, req, verdict, mode, approvalId) {
|
|
144
|
+
const decisionId = crypto.randomUUID();
|
|
145
|
+
if (mode === 'evaluate') {
|
|
146
|
+
if (verdict.decision !== 'allow')
|
|
147
|
+
throwVerdict(verdict);
|
|
148
|
+
return { decisionId, to: req.to, amountSats: req.amountSats, evaluateOnly: true };
|
|
149
|
+
}
|
|
150
|
+
const base = {
|
|
151
|
+
type: 'policy_decision',
|
|
152
|
+
rule: verdict.rule,
|
|
153
|
+
reason: verdict.reason,
|
|
154
|
+
address: req.to,
|
|
155
|
+
amount_sats: req.amountSats,
|
|
156
|
+
memo: req.memo,
|
|
157
|
+
timestamp: new Date().toISOString(),
|
|
158
|
+
decision_id: decisionId,
|
|
159
|
+
};
|
|
160
|
+
if (verdict.decision === 'allow') {
|
|
161
|
+
appendLedger(env.network, { ...base, decision: 'allow' });
|
|
162
|
+
return { decisionId, to: req.to, amountSats: req.amountSats, evaluateOnly: false };
|
|
163
|
+
}
|
|
164
|
+
if (verdict.decision === 'deny') {
|
|
165
|
+
appendLedger(env.network, { ...base, decision: 'deny' });
|
|
166
|
+
throwVerdict(verdict);
|
|
167
|
+
}
|
|
168
|
+
const newApprovalId = approvalId ?? crypto.randomUUID();
|
|
169
|
+
appendLedger(env.network, {
|
|
170
|
+
...base,
|
|
171
|
+
decision: 'queue',
|
|
172
|
+
approval_id: newApprovalId,
|
|
173
|
+
confirmed_only: req.confirmedOnly,
|
|
174
|
+
});
|
|
175
|
+
throwVerdict(verdict, { approval_id: newApprovalId });
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Unforgeable plan registry: planSend records each authorized plan here;
|
|
179
|
+
* executeSend refuses anything missing, mismatched, evaluate-only, or
|
|
180
|
+
* already executed (one authorization = one broadcast). Module-private and
|
|
181
|
+
* NOT exported from bsv-pay/core — a hand-built plan can never pass.
|
|
182
|
+
*/
|
|
183
|
+
const authorizedPlans = new WeakMap();
|
|
184
|
+
export function registerAuthorizedPlan(plan, auth) {
|
|
185
|
+
authorizedPlans.set(plan, auth);
|
|
186
|
+
}
|
|
187
|
+
export function takeAuthorizedPlan(plan, expected, forBroadcast) {
|
|
188
|
+
const auth = authorizedPlans.get(plan);
|
|
189
|
+
if (!auth || auth.to !== expected.to || auth.amountSats !== expected.amountSats) {
|
|
190
|
+
throw new CliError(EXIT.SPEND_LIMIT, 'unauthorized_spend', 'This spend was not authorized by the policy gate. Plans must come from planSend(); they cannot be constructed or altered by hand.');
|
|
191
|
+
}
|
|
192
|
+
if (forBroadcast) {
|
|
193
|
+
if (auth.evaluateOnly) {
|
|
194
|
+
throw new CliError(EXIT.SPEND_LIMIT, 'unauthorized_spend', 'This plan was authorized for a dry run only. Re-plan without dryRun to broadcast.');
|
|
195
|
+
}
|
|
196
|
+
authorizedPlans.delete(plan); // consumed: one authorization, one broadcast
|
|
197
|
+
}
|
|
198
|
+
return auth;
|
|
199
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Config } from '../config.js';
|
|
2
|
+
import { type Network } from '../paths.js';
|
|
3
|
+
/**
|
|
4
|
+
* The active spend policy. With no policy.toml, `source` is "defaults" and
|
|
5
|
+
* the ONLY rule is the legacy soft per-tx limit from config.spendLimitSats
|
|
6
|
+
* (confirmable interactively / --allow-large) — current behavior exactly.
|
|
7
|
+
* When policy.toml exists, per_tx_limit_sats (if set) is a HARD limit no
|
|
8
|
+
* flag can cross; the soft limit applies only when the file omits it.
|
|
9
|
+
*/
|
|
10
|
+
export interface Policy {
|
|
11
|
+
source: 'defaults' | 'file';
|
|
12
|
+
/** Hard per-transaction cap (policy.toml). No override exists. */
|
|
13
|
+
perTxLimitSats?: number;
|
|
14
|
+
/** Legacy confirmable limit from config.toml (pre-policy behavior). */
|
|
15
|
+
softPerTxLimitSats?: number;
|
|
16
|
+
/** Rolling 24h spend cap, computed from the ledger at decision time. */
|
|
17
|
+
dailyBudgetSats?: number;
|
|
18
|
+
/** Per-process spend cap (meaningful for long-running consumers). */
|
|
19
|
+
sessionBudgetSats?: number;
|
|
20
|
+
rateLimitPerMinute?: number;
|
|
21
|
+
rateLimitPerHour?: number;
|
|
22
|
+
/** At/above this, spends queue for human approval instead of sending. */
|
|
23
|
+
approvalThresholdSats?: number;
|
|
24
|
+
/** When non-empty, only these recipients are allowed. */
|
|
25
|
+
allowlist: string[];
|
|
26
|
+
/** Always wins over everything else. */
|
|
27
|
+
denylist: string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function resetPolicyCacheForTests(): void;
|
|
30
|
+
export declare function loadPolicy(network: Network, config: Config): Policy;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { Utils } from '@bsv/sdk';
|
|
3
|
+
import { parse as parseToml } from 'smol-toml';
|
|
4
|
+
import { usageError } from '../errors.js';
|
|
5
|
+
import { baseDir, policyPath } from '../paths.js';
|
|
6
|
+
const RULE_KEYS = [
|
|
7
|
+
'per_tx_limit_sats',
|
|
8
|
+
'daily_budget_sats',
|
|
9
|
+
'session_budget_sats',
|
|
10
|
+
'rate_limit_per_minute',
|
|
11
|
+
'rate_limit_per_hour',
|
|
12
|
+
'approval_threshold_sats',
|
|
13
|
+
'allowlist',
|
|
14
|
+
'denylist',
|
|
15
|
+
];
|
|
16
|
+
function invalidPolicy(message) {
|
|
17
|
+
throw usageError('invalid_policy', `${message} Fix ${policyPath()}.`);
|
|
18
|
+
}
|
|
19
|
+
function asSats(value, key) {
|
|
20
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
21
|
+
invalidPolicy(`Policy key "${key}" must be a non-negative integer of satoshis.`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function asAddressList(value, key) {
|
|
26
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
27
|
+
invalidPolicy(`Policy key "${key}" must be an array of address strings.`);
|
|
28
|
+
}
|
|
29
|
+
const list = value;
|
|
30
|
+
for (const address of list) {
|
|
31
|
+
try {
|
|
32
|
+
Utils.fromBase58Check(address);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// A typo'd denylist entry would silently never match — fail loudly instead.
|
|
36
|
+
invalidPolicy(`Policy ${key} entry "${address}" is not a valid address (checksum failed).`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return list;
|
|
40
|
+
}
|
|
41
|
+
function rejectUnknownKeys(doc, allowed) {
|
|
42
|
+
for (const key of Object.keys(doc)) {
|
|
43
|
+
if (!allowed.includes(key)) {
|
|
44
|
+
// An ignored typo like "daily_budget_stas" would mean NO budget — unsafe.
|
|
45
|
+
invalidPolicy(`Unknown policy key "${key}".`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function applyRules(policy, doc) {
|
|
50
|
+
if ('per_tx_limit_sats' in doc)
|
|
51
|
+
policy.perTxLimitSats = asSats(doc.per_tx_limit_sats, 'per_tx_limit_sats');
|
|
52
|
+
if ('daily_budget_sats' in doc)
|
|
53
|
+
policy.dailyBudgetSats = asSats(doc.daily_budget_sats, 'daily_budget_sats');
|
|
54
|
+
if ('session_budget_sats' in doc)
|
|
55
|
+
policy.sessionBudgetSats = asSats(doc.session_budget_sats, 'session_budget_sats');
|
|
56
|
+
if ('rate_limit_per_minute' in doc)
|
|
57
|
+
policy.rateLimitPerMinute = asSats(doc.rate_limit_per_minute, 'rate_limit_per_minute');
|
|
58
|
+
if ('rate_limit_per_hour' in doc)
|
|
59
|
+
policy.rateLimitPerHour = asSats(doc.rate_limit_per_hour, 'rate_limit_per_hour');
|
|
60
|
+
if ('approval_threshold_sats' in doc)
|
|
61
|
+
policy.approvalThresholdSats = asSats(doc.approval_threshold_sats, 'approval_threshold_sats');
|
|
62
|
+
if ('allowlist' in doc)
|
|
63
|
+
policy.allowlist = asAddressList(doc.allowlist, 'allowlist');
|
|
64
|
+
if ('denylist' in doc)
|
|
65
|
+
policy.denylist = asAddressList(doc.denylist, 'denylist');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Cached per process (key: state dir + network): limits change only when a
|
|
69
|
+
* human edits policy.toml AND the process restarts (invariant 2) — a
|
|
70
|
+
* long-running MCP server does not pick up live edits.
|
|
71
|
+
*/
|
|
72
|
+
const cache = new Map();
|
|
73
|
+
export function resetPolicyCacheForTests() {
|
|
74
|
+
cache.clear();
|
|
75
|
+
}
|
|
76
|
+
export function loadPolicy(network, config) {
|
|
77
|
+
// spendLimitSats participates: the synthesized default policy depends on it.
|
|
78
|
+
const cacheKey = `${baseDir()}::${network}::${config.spendLimitSats}`;
|
|
79
|
+
const cached = cache.get(cacheKey);
|
|
80
|
+
if (cached)
|
|
81
|
+
return cached;
|
|
82
|
+
const file = policyPath();
|
|
83
|
+
let policy;
|
|
84
|
+
if (!fs.existsSync(file)) {
|
|
85
|
+
policy = {
|
|
86
|
+
source: 'defaults',
|
|
87
|
+
softPerTxLimitSats: config.spendLimitSats,
|
|
88
|
+
allowlist: [],
|
|
89
|
+
denylist: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
let doc;
|
|
94
|
+
try {
|
|
95
|
+
doc = parseToml(fs.readFileSync(file, 'utf8'));
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
invalidPolicy(`Cannot parse policy.toml: ${e instanceof Error ? e.message : String(e)}.`);
|
|
99
|
+
}
|
|
100
|
+
rejectUnknownKeys(doc, [...RULE_KEYS, 'network']);
|
|
101
|
+
policy = { source: 'file', allowlist: [], denylist: [] };
|
|
102
|
+
applyRules(policy, doc);
|
|
103
|
+
// Optional [network.main] / [network.test] override tables.
|
|
104
|
+
if ('network' in doc) {
|
|
105
|
+
const networks = doc.network;
|
|
106
|
+
if (typeof networks !== 'object' || networks === null || Array.isArray(networks)) {
|
|
107
|
+
invalidPolicy('Policy [network] must be a table of per-network overrides.');
|
|
108
|
+
}
|
|
109
|
+
rejectUnknownKeys(networks, ['main', 'test']);
|
|
110
|
+
const override = networks[network];
|
|
111
|
+
if (override !== undefined) {
|
|
112
|
+
if (typeof override !== 'object' || override === null || Array.isArray(override)) {
|
|
113
|
+
invalidPolicy(`Policy [network.${network}] must be a table.`);
|
|
114
|
+
}
|
|
115
|
+
rejectUnknownKeys(override, RULE_KEYS);
|
|
116
|
+
applyRules(policy, override);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Keep the legacy config limit working when the file doesn't set a hard one.
|
|
120
|
+
if (policy.perTxLimitSats === undefined) {
|
|
121
|
+
policy.softPerTxLimitSats = config.spendLimitSats;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
cache.set(cacheKey, policy);
|
|
125
|
+
return policy;
|
|
126
|
+
}
|
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All prompts write to stderr so --json stdout stays machine-clean
|
|
3
|
+
* (invariant 2).
|
|
4
|
+
*/
|
|
5
|
+
export declare function isInteractive(): boolean;
|
|
6
|
+
export declare function ask(question: string): Promise<string>;
|
|
7
|
+
/** Prompt without echoing the typed characters (passphrases). */
|
|
8
|
+
export declare function askHidden(question: string): Promise<string>;
|
|
9
|
+
export declare function confirm(question: string, def?: boolean): Promise<boolean>;
|
|
10
|
+
/** Read all of stdin (for piped seed phrases / passphrases in scripts). */
|
|
11
|
+
export declare function readStdinLine(): Promise<string>;
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
/**
|
|
3
|
+
* All prompts write to stderr so --json stdout stays machine-clean
|
|
4
|
+
* (invariant 2).
|
|
5
|
+
*/
|
|
6
|
+
export function isInteractive() {
|
|
7
|
+
return process.stdin.isTTY === true && process.stderr.isTTY === true;
|
|
8
|
+
}
|
|
9
|
+
export function ask(question) {
|
|
10
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
rl.question(question, (answer) => {
|
|
13
|
+
rl.close();
|
|
14
|
+
resolve(answer.trim());
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** Prompt without echoing the typed characters (passphrases). */
|
|
19
|
+
export function askHidden(question) {
|
|
20
|
+
const rl = readline.createInterface({
|
|
21
|
+
input: process.stdin,
|
|
22
|
+
output: process.stderr,
|
|
23
|
+
terminal: true,
|
|
24
|
+
});
|
|
25
|
+
let muted = false;
|
|
26
|
+
// readline has no public mute API; overriding _writeToOutput is the
|
|
27
|
+
// long-standing supported workaround.
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
rl._writeToOutput = (s) => {
|
|
30
|
+
if (!muted)
|
|
31
|
+
process.stderr.write(s);
|
|
32
|
+
};
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
rl.question(question, (answer) => {
|
|
35
|
+
muted = false;
|
|
36
|
+
rl.close();
|
|
37
|
+
process.stderr.write('\n');
|
|
38
|
+
resolve(answer);
|
|
39
|
+
});
|
|
40
|
+
muted = true;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export async function confirm(question, def = false) {
|
|
44
|
+
const suffix = def ? ' (Y/n) ' : ' (y/N) ';
|
|
45
|
+
const answer = (await ask(question + suffix)).toLowerCase();
|
|
46
|
+
if (answer === '')
|
|
47
|
+
return def;
|
|
48
|
+
return answer === 'y' || answer === 'yes';
|
|
49
|
+
}
|
|
50
|
+
/** Read all of stdin (for piped seed phrases / passphrases in scripts). */
|
|
51
|
+
export async function readStdinLine() {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
for await (const chunk of process.stdin)
|
|
54
|
+
chunks.push(chunk);
|
|
55
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
56
|
+
return (text.split('\n')[0] ?? '').trim();
|
|
57
|
+
}
|