@aibtc/mcp-server 1.58.0 → 1.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/bridge/index.d.ts +31 -0
- package/dist/bridge/index.js +285 -0
- package/dist/index.js +28 -5
- package/dist/services/credentials.js +7 -2
- package/dist/services/defi.service.js +23 -11
- package/dist/services/hiro-api.d.ts +10 -0
- package/dist/services/hiro-api.js +30 -8
- package/dist/services/nft.service.js +14 -5
- package/dist/services/pillar-api.service.d.ts +8 -0
- package/dist/services/pillar-api.service.js +13 -1
- package/dist/services/spend-limiter.d.ts +45 -0
- package/dist/services/spend-limiter.js +218 -0
- package/dist/services/wallet-manager.js +5 -0
- package/dist/services/x402.service.js +63 -13
- package/dist/tools/bitcoin.tools.js +19 -0
- package/dist/tools/endpoint.tools.js +3 -1
- package/dist/tools/inbox.tools.js +9 -12
- package/dist/tools/news.tools.js +14 -16
- package/dist/tools/pillar-direct.tools.js +29 -22
- package/dist/tools/sbtc.tools.js +2 -9
- package/dist/tools/scaffold.tools.js +9 -14
- package/dist/tools/stacking-lottery.tools.js +8 -5
- package/dist/tools/yield-dashboard.tools.js +10 -14
- package/dist/transactions/builder.js +5 -0
- package/dist/utils/encryption.js +2 -0
- package/package.json +11 -5
- package/skill/SKILL.md +1 -1
- package/skill/references/genesis-lifecycle.md +105 -92
- package/dist/config/bitcoin-constants.d.ts.map +0 -1
- package/dist/config/bitcoin-constants.js.map +0 -1
- package/dist/config/caip.d.ts.map +0 -1
- package/dist/config/caip.js.map +0 -1
- package/dist/config/competition.d.ts.map +0 -1
- package/dist/config/competition.js.map +0 -1
- package/dist/config/contracts.d.ts.map +0 -1
- package/dist/config/contracts.js.map +0 -1
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/index.js.map +0 -1
- package/dist/config/networks.d.ts.map +0 -1
- package/dist/config/networks.js.map +0 -1
- package/dist/config/pillar.d.ts.map +0 -1
- package/dist/config/pillar.js.map +0 -1
- package/dist/config/sponsor.d.ts.map +0 -1
- package/dist/config/sponsor.js.map +0 -1
- package/dist/endpoints/index.d.ts.map +0 -1
- package/dist/endpoints/index.js.map +0 -1
- package/dist/endpoints/registry.d.ts.map +0 -1
- package/dist/endpoints/registry.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/services/bitflow.service.d.ts.map +0 -1
- package/dist/services/bitflow.service.js.map +0 -1
- package/dist/services/bns.service.d.ts.map +0 -1
- package/dist/services/bns.service.js.map +0 -1
- package/dist/services/credentials.d.ts.map +0 -1
- package/dist/services/credentials.js.map +0 -1
- package/dist/services/defi.service.d.ts.map +0 -1
- package/dist/services/defi.service.js.map +0 -1
- package/dist/services/erc8004.service.d.ts.map +0 -1
- package/dist/services/erc8004.service.js.map +0 -1
- package/dist/services/hiro-api.d.ts.map +0 -1
- package/dist/services/hiro-api.js.map +0 -1
- package/dist/services/inscription-parser.d.ts.map +0 -1
- package/dist/services/inscription-parser.js.map +0 -1
- package/dist/services/lightning/provider.d.ts.map +0 -1
- package/dist/services/lightning/provider.js.map +0 -1
- package/dist/services/lightning/spark-provider.d.ts.map +0 -1
- package/dist/services/lightning/spark-provider.js.map +0 -1
- package/dist/services/lightning-manager.d.ts.map +0 -1
- package/dist/services/lightning-manager.js.map +0 -1
- package/dist/services/mempool-api.d.ts.map +0 -1
- package/dist/services/mempool-api.js.map +0 -1
- package/dist/services/nft.service.d.ts.map +0 -1
- package/dist/services/nft.service.js.map +0 -1
- package/dist/services/nonce-tracker.d.ts.map +0 -1
- package/dist/services/nonce-tracker.js.map +0 -1
- package/dist/services/pillar-api.service.d.ts.map +0 -1
- package/dist/services/pillar-api.service.js.map +0 -1
- package/dist/services/sbtc-deposit.service.d.ts.map +0 -1
- package/dist/services/sbtc-deposit.service.js.map +0 -1
- package/dist/services/sbtc.service.d.ts.map +0 -1
- package/dist/services/sbtc.service.js.map +0 -1
- package/dist/services/scaffold.service.d.ts.map +0 -1
- package/dist/services/scaffold.service.js.map +0 -1
- package/dist/services/signing-key.service.d.ts.map +0 -1
- package/dist/services/signing-key.service.js.map +0 -1
- package/dist/services/stacking.service.d.ts.map +0 -1
- package/dist/services/stacking.service.js.map +0 -1
- package/dist/services/tenero-api.d.ts.map +0 -1
- package/dist/services/tenero-api.js.map +0 -1
- package/dist/services/tokens.service.d.ts.map +0 -1
- package/dist/services/tokens.service.js.map +0 -1
- package/dist/services/unisat-indexer.d.ts.map +0 -1
- package/dist/services/unisat-indexer.js.map +0 -1
- package/dist/services/wallet-manager.d.ts.map +0 -1
- package/dist/services/wallet-manager.js.map +0 -1
- package/dist/services/x402.service.d.ts.map +0 -1
- package/dist/services/x402.service.js.map +0 -1
- package/dist/tools/arxiv-research.tools.d.ts.map +0 -1
- package/dist/tools/arxiv-research.tools.js.map +0 -1
- package/dist/tools/bitcoin.tools.d.ts.map +0 -1
- package/dist/tools/bitcoin.tools.js.map +0 -1
- package/dist/tools/bitflow.tools.d.ts.map +0 -1
- package/dist/tools/bitflow.tools.js.map +0 -1
- package/dist/tools/bns.tools.d.ts.map +0 -1
- package/dist/tools/bns.tools.js.map +0 -1
- package/dist/tools/bounty-scanner.tools.d.ts.map +0 -1
- package/dist/tools/bounty-scanner.tools.js.map +0 -1
- package/dist/tools/child-inscription.tools.d.ts.map +0 -1
- package/dist/tools/child-inscription.tools.js.map +0 -1
- package/dist/tools/competition.tools.d.ts.map +0 -1
- package/dist/tools/competition.tools.js.map +0 -1
- package/dist/tools/contract.tools.d.ts.map +0 -1
- package/dist/tools/contract.tools.js.map +0 -1
- package/dist/tools/credentials.tools.d.ts.map +0 -1
- package/dist/tools/credentials.tools.js.map +0 -1
- package/dist/tools/defi.tools.d.ts.map +0 -1
- package/dist/tools/defi.tools.js.map +0 -1
- package/dist/tools/dual-stacking.tools.d.ts.map +0 -1
- package/dist/tools/dual-stacking.tools.js.map +0 -1
- package/dist/tools/earning.tools.d.ts.map +0 -1
- package/dist/tools/earning.tools.js.map +0 -1
- package/dist/tools/endpoint.tools.d.ts.map +0 -1
- package/dist/tools/endpoint.tools.js.map +0 -1
- package/dist/tools/erc8004.tools.d.ts.map +0 -1
- package/dist/tools/erc8004.tools.js.map +0 -1
- package/dist/tools/identity.tools.d.ts.map +0 -1
- package/dist/tools/identity.tools.js.map +0 -1
- package/dist/tools/inbox-x402.tools.d.ts.map +0 -1
- package/dist/tools/inbox-x402.tools.js.map +0 -1
- package/dist/tools/inbox.tools.d.ts.map +0 -1
- package/dist/tools/inbox.tools.js.map +0 -1
- package/dist/tools/index.d.ts.map +0 -1
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/jingswap.tools.d.ts.map +0 -1
- package/dist/tools/jingswap.tools.js.map +0 -1
- package/dist/tools/lightning.tools.d.ts.map +0 -1
- package/dist/tools/lightning.tools.js.map +0 -1
- package/dist/tools/mempool.tools.d.ts.map +0 -1
- package/dist/tools/mempool.tools.js.map +0 -1
- package/dist/tools/news.tools.d.ts.map +0 -1
- package/dist/tools/news.tools.js.map +0 -1
- package/dist/tools/nft.tools.d.ts.map +0 -1
- package/dist/tools/nft.tools.js.map +0 -1
- package/dist/tools/nonce.tools.d.ts.map +0 -1
- package/dist/tools/nonce.tools.js.map +0 -1
- package/dist/tools/nostr.tools.d.ts.map +0 -1
- package/dist/tools/nostr.tools.js.map +0 -1
- package/dist/tools/openrouter.tools.d.ts.map +0 -1
- package/dist/tools/openrouter.tools.js.map +0 -1
- package/dist/tools/ordinals-marketplace.tools.d.ts.map +0 -1
- package/dist/tools/ordinals-marketplace.tools.js.map +0 -1
- package/dist/tools/ordinals-p2p.tools.d.ts.map +0 -1
- package/dist/tools/ordinals-p2p.tools.js.map +0 -1
- package/dist/tools/ordinals.tools.d.ts.map +0 -1
- package/dist/tools/ordinals.tools.js.map +0 -1
- package/dist/tools/pillar-direct.tools.d.ts.map +0 -1
- package/dist/tools/pillar-direct.tools.js.map +0 -1
- package/dist/tools/pillar.tools.d.ts.map +0 -1
- package/dist/tools/pillar.tools.js.map +0 -1
- package/dist/tools/psbt.helpers.d.ts.map +0 -1
- package/dist/tools/psbt.helpers.js.map +0 -1
- package/dist/tools/psbt.tools.d.ts.map +0 -1
- package/dist/tools/psbt.tools.js.map +0 -1
- package/dist/tools/query.tools.d.ts.map +0 -1
- package/dist/tools/query.tools.js.map +0 -1
- package/dist/tools/relay-diagnostic.tools.d.ts.map +0 -1
- package/dist/tools/relay-diagnostic.tools.js.map +0 -1
- package/dist/tools/reputation.tools.d.ts.map +0 -1
- package/dist/tools/reputation.tools.js.map +0 -1
- package/dist/tools/runes.tools.d.ts.map +0 -1
- package/dist/tools/runes.tools.js.map +0 -1
- package/dist/tools/sbtc.tools.d.ts.map +0 -1
- package/dist/tools/sbtc.tools.js.map +0 -1
- package/dist/tools/scaffold.tools.d.ts.map +0 -1
- package/dist/tools/scaffold.tools.js.map +0 -1
- package/dist/tools/schemas.d.ts.map +0 -1
- package/dist/tools/schemas.js.map +0 -1
- package/dist/tools/settings.tools.d.ts.map +0 -1
- package/dist/tools/settings.tools.js.map +0 -1
- package/dist/tools/signing.tools.d.ts.map +0 -1
- package/dist/tools/signing.tools.js.map +0 -1
- package/dist/tools/skill-mappings.d.ts.map +0 -1
- package/dist/tools/skill-mappings.js.map +0 -1
- package/dist/tools/souldinals.tools.d.ts.map +0 -1
- package/dist/tools/souldinals.tools.js.map +0 -1
- package/dist/tools/stacking-lottery.tools.d.ts.map +0 -1
- package/dist/tools/stacking-lottery.tools.js.map +0 -1
- package/dist/tools/stacking.tools.d.ts.map +0 -1
- package/dist/tools/stacking.tools.js.map +0 -1
- package/dist/tools/stacks-market.tools.d.ts.map +0 -1
- package/dist/tools/stacks-market.tools.js.map +0 -1
- package/dist/tools/styx.tools.d.ts.map +0 -1
- package/dist/tools/styx.tools.js.map +0 -1
- package/dist/tools/taproot-multisig.tools.d.ts.map +0 -1
- package/dist/tools/taproot-multisig.tools.js.map +0 -1
- package/dist/tools/tenero.tools.d.ts.map +0 -1
- package/dist/tools/tenero.tools.js.map +0 -1
- package/dist/tools/tokens.tools.d.ts.map +0 -1
- package/dist/tools/tokens.tools.js.map +0 -1
- package/dist/tools/transfer.tools.d.ts.map +0 -1
- package/dist/tools/transfer.tools.js.map +0 -1
- package/dist/tools/wallet-management.tools.d.ts.map +0 -1
- package/dist/tools/wallet-management.tools.js.map +0 -1
- package/dist/tools/wallet.tools.d.ts.map +0 -1
- package/dist/tools/wallet.tools.js.map +0 -1
- package/dist/tools/yield-dashboard.tools.d.ts.map +0 -1
- package/dist/tools/yield-dashboard.tools.js.map +0 -1
- package/dist/tools/yield-hunter.tools.d.ts.map +0 -1
- package/dist/tools/yield-hunter.tools.js.map +0 -1
- package/dist/transactions/bitcoin-builder.d.ts.map +0 -1
- package/dist/transactions/bitcoin-builder.js.map +0 -1
- package/dist/transactions/builder.d.ts.map +0 -1
- package/dist/transactions/builder.js.map +0 -1
- package/dist/transactions/child-inscription-builder.d.ts.map +0 -1
- package/dist/transactions/child-inscription-builder.js.map +0 -1
- package/dist/transactions/clarity-values.d.ts.map +0 -1
- package/dist/transactions/clarity-values.js.map +0 -1
- package/dist/transactions/inscription-builder.d.ts.map +0 -1
- package/dist/transactions/inscription-builder.js.map +0 -1
- package/dist/transactions/post-conditions.d.ts.map +0 -1
- package/dist/transactions/post-conditions.js.map +0 -1
- package/dist/transactions/rune-transfer-builder.d.ts.map +0 -1
- package/dist/transactions/rune-transfer-builder.js.map +0 -1
- package/dist/transactions/runestone-builder.d.ts.map +0 -1
- package/dist/transactions/runestone-builder.js.map +0 -1
- package/dist/transactions/sponsor-builder.d.ts.map +0 -1
- package/dist/transactions/sponsor-builder.js.map +0 -1
- package/dist/utils/bip322.d.ts.map +0 -1
- package/dist/utils/bip322.js.map +0 -1
- package/dist/utils/bitcoin.d.ts.map +0 -1
- package/dist/utils/bitcoin.js.map +0 -1
- package/dist/utils/encryption.d.ts.map +0 -1
- package/dist/utils/encryption.js.map +0 -1
- package/dist/utils/erc8004-helpers.d.ts.map +0 -1
- package/dist/utils/erc8004-helpers.js.map +0 -1
- package/dist/utils/errors.d.ts.map +0 -1
- package/dist/utils/errors.js.map +0 -1
- package/dist/utils/fee.d.ts.map +0 -1
- package/dist/utils/fee.js.map +0 -1
- package/dist/utils/formatting.d.ts.map +0 -1
- package/dist/utils/formatting.js.map +0 -1
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/l402-protocol.d.ts.map +0 -1
- package/dist/utils/l402-protocol.js.map +0 -1
- package/dist/utils/redact.d.ts.map +0 -1
- package/dist/utils/redact.js.map +0 -1
- package/dist/utils/relay-health.d.ts.map +0 -1
- package/dist/utils/relay-health.js.map +0 -1
- package/dist/utils/storage.d.ts.map +0 -1
- package/dist/utils/storage.js.map +0 -1
- package/dist/utils/tokens.d.ts.map +0 -1
- package/dist/utils/tokens.js.map +0 -1
- package/dist/utils/x402-payment-logging.d.ts.map +0 -1
- package/dist/utils/x402-payment-logging.js.map +0 -1
- package/dist/utils/x402-payment-state.d.ts.map +0 -1
- package/dist/utils/x402-payment-state.js.map +0 -1
- package/dist/utils/x402-protocol.d.ts.map +0 -1
- package/dist/utils/x402-protocol.js.map +0 -1
- package/dist/utils/x402-recovery.d.ts.map +0 -1
- package/dist/utils/x402-recovery.js.map +0 -1
- package/dist/yield-hunter/index.d.ts.map +0 -1
- package/dist/yield-hunter/index.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { uintCV, principalCV } from "@stacks/transactions";
|
|
2
|
-
import { getHiroApi } from "./hiro-api.js";
|
|
2
|
+
import { getHiroApi, HiroApiError } from "./hiro-api.js";
|
|
3
3
|
import { parseContractId } from "../config/index.js";
|
|
4
4
|
import { callContract } from "../transactions/builder.js";
|
|
5
5
|
import { createNftSendPostCondition } from "../transactions/post-conditions.js";
|
|
@@ -51,8 +51,13 @@ export class NftService {
|
|
|
51
51
|
}
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
|
-
catch {
|
|
55
|
-
|
|
54
|
+
catch (error) {
|
|
55
|
+
// 4xx means the contract doesn't implement get-owner (or rejects the
|
|
56
|
+
// call) — that is "no owner". Network/server failures must surface.
|
|
57
|
+
if (error instanceof HiroApiError && error.status < 500) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
/**
|
|
@@ -75,8 +80,12 @@ export class NftService {
|
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
|
-
catch {
|
|
79
|
-
//
|
|
83
|
+
catch (error) {
|
|
84
|
+
// 4xx means the contract doesn't implement get-last-token-id —
|
|
85
|
+
// supply is simply unknown. Network/server failures must surface.
|
|
86
|
+
if (!(error instanceof HiroApiError && error.status < 500)) {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
80
89
|
}
|
|
81
90
|
return {
|
|
82
91
|
contractId,
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP error from the Pillar API, carrying the status code so callers can
|
|
3
|
+
* treat 4xx ("no such wallet / no position") differently from infra failures.
|
|
4
|
+
*/
|
|
5
|
+
export declare class PillarApiError extends Error {
|
|
6
|
+
readonly status: number;
|
|
7
|
+
constructor(message: string, status: number);
|
|
8
|
+
}
|
|
1
9
|
declare class PillarApiService {
|
|
2
10
|
private baseUrl;
|
|
3
11
|
private apiKey;
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { PILLAR_API_URL, PILLAR_API_KEY } from "../config/pillar.js";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP error from the Pillar API, carrying the status code so callers can
|
|
4
|
+
* treat 4xx ("no such wallet / no position") differently from infra failures.
|
|
5
|
+
*/
|
|
6
|
+
export class PillarApiError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.name = "PillarApiError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
2
14
|
class PillarApiService {
|
|
3
15
|
baseUrl;
|
|
4
16
|
apiKey;
|
|
@@ -32,7 +44,7 @@ class PillarApiService {
|
|
|
32
44
|
});
|
|
33
45
|
if (!response.ok) {
|
|
34
46
|
const errorText = await response.text();
|
|
35
|
-
throw new
|
|
47
|
+
throw new PillarApiError(`Pillar API error (${response.status}): ${errorText}`, response.status);
|
|
36
48
|
}
|
|
37
49
|
return response.json();
|
|
38
50
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type SpendUnit = "ustx" | "sats";
|
|
2
|
+
export declare class SpendLimitError extends Error {
|
|
3
|
+
readonly unit: SpendUnit;
|
|
4
|
+
readonly attempted: number;
|
|
5
|
+
readonly remaining: number;
|
|
6
|
+
readonly scope: "session" | "day";
|
|
7
|
+
constructor(message: string, unit: SpendUnit, attempted: number, remaining: number, scope: "session" | "day");
|
|
8
|
+
}
|
|
9
|
+
declare class SpendLimiter {
|
|
10
|
+
private static instance;
|
|
11
|
+
private stateFile;
|
|
12
|
+
private session;
|
|
13
|
+
private writeLock;
|
|
14
|
+
static getInstance(): SpendLimiter;
|
|
15
|
+
/** Test seam: redirect the state file. */
|
|
16
|
+
setStateFile(file: string): void;
|
|
17
|
+
private readState;
|
|
18
|
+
private writeState;
|
|
19
|
+
private getSession;
|
|
20
|
+
/** Reset the in-memory session ledger for a wallet (call on unlock/lock/switch). */
|
|
21
|
+
resetSession(addr: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Throw SpendLimitError if `amount` would push the session OR day total over
|
|
24
|
+
* its cap. Does NOT record — call recordSpend after a successful broadcast.
|
|
25
|
+
*/
|
|
26
|
+
check(unit: SpendUnit, amount: bigint, addr: string): Promise<void>;
|
|
27
|
+
/** Record a completed spend against both the session and day ledgers. */
|
|
28
|
+
record(unit: SpendUnit, amount: bigint, addr: string): Promise<void>;
|
|
29
|
+
/** Remaining session/day budget for status reporting. */
|
|
30
|
+
status(addr: string): Promise<{
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
ustx: {
|
|
33
|
+
sessionRemaining: number;
|
|
34
|
+
dailyRemaining: number;
|
|
35
|
+
};
|
|
36
|
+
sats: {
|
|
37
|
+
sessionRemaining: number;
|
|
38
|
+
dailyRemaining: number;
|
|
39
|
+
};
|
|
40
|
+
}>;
|
|
41
|
+
private message;
|
|
42
|
+
}
|
|
43
|
+
export declare function getSpendLimiter(): SpendLimiter;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=spend-limiter.d.ts.map
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default-on cumulative spending limit for the wallet.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server lets an LLM move real funds with no human click-through, so a
|
|
5
|
+
* single poisoned instruction (or a malicious x402 endpoint) could otherwise
|
|
6
|
+
* drain the wallet. This module is the safety rail: every outbound spend path
|
|
7
|
+
* (native STX transfer, BTC L1 transfer, x402/L402 auto-payments, Lightning
|
|
8
|
+
* pays) routes through it, and the limiter blocks once cumulative outflow would
|
|
9
|
+
* exceed a per-session OR per-day cap.
|
|
10
|
+
*
|
|
11
|
+
* Two independent ledgers are tracked — micro-STX (`ustx`) and satoshis
|
|
12
|
+
* (`sats`); there is no cross-asset USD normalization. Daily totals are
|
|
13
|
+
* persisted to ~/.aibtc/spend-state.json (keyed by wallet address + UTC day) so
|
|
14
|
+
* the cap survives process restarts; session totals live in memory and reset on
|
|
15
|
+
* wallet unlock/lock/switch.
|
|
16
|
+
*
|
|
17
|
+
* Exceeding the cap is the intended human-in-the-loop checkpoint: it blocks and
|
|
18
|
+
* surfaces the remaining budget + the env var to raise. An LLM cannot set env
|
|
19
|
+
* vars mid-session, so it cannot raise its own ceiling.
|
|
20
|
+
*
|
|
21
|
+
* Disable entirely with SPEND_LIMIT_ENABLED=false. Override caps with
|
|
22
|
+
* SPEND_LIMIT_DAILY_USTX / _SESSION_USTX / _DAILY_SATS / _SESSION_SATS.
|
|
23
|
+
*/
|
|
24
|
+
import { promises as fs } from "fs";
|
|
25
|
+
import os from "os";
|
|
26
|
+
import path from "path";
|
|
27
|
+
const STORAGE_DIR = path.join(os.homedir(), ".aibtc");
|
|
28
|
+
const DEFAULT_STATE_FILE = path.join(STORAGE_DIR, "spend-state.json");
|
|
29
|
+
/**
|
|
30
|
+
* Parse a positive-integer env override, falling back (with a warning) on any
|
|
31
|
+
* invalid value — never silently disable a cap. Mirrors parseSatsCap in
|
|
32
|
+
* x402.service.ts.
|
|
33
|
+
*/
|
|
34
|
+
function parseLimit(envName, fallback) {
|
|
35
|
+
const raw = process.env[envName];
|
|
36
|
+
if (raw === undefined || raw === "")
|
|
37
|
+
return fallback;
|
|
38
|
+
const parsed = Number(raw);
|
|
39
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
40
|
+
console.error(`[spend-limit] Invalid ${envName}="${raw}", falling back to ${fallback}`);
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
return Math.floor(parsed);
|
|
44
|
+
}
|
|
45
|
+
function isEnabled() {
|
|
46
|
+
return process.env.SPEND_LIMIT_ENABLED !== "false";
|
|
47
|
+
}
|
|
48
|
+
// Conservative defaults (~$30/day at the time of writing): tight enough to stop
|
|
49
|
+
// a drain, generous for the micro-payment use case (registry endpoints cost
|
|
50
|
+
// <=0.02 STX / 100 sats). Raise per wallet via env.
|
|
51
|
+
const DEFAULTS = {
|
|
52
|
+
dailyUstx: 10_000_000, // 10 STX
|
|
53
|
+
sessionUstx: 10_000_000, // 10 STX
|
|
54
|
+
dailySats: 50_000, // ~$30 BTC
|
|
55
|
+
sessionSats: 50_000,
|
|
56
|
+
};
|
|
57
|
+
function capsFor(unit) {
|
|
58
|
+
if (unit === "ustx") {
|
|
59
|
+
return {
|
|
60
|
+
daily: parseLimit("SPEND_LIMIT_DAILY_USTX", DEFAULTS.dailyUstx),
|
|
61
|
+
session: parseLimit("SPEND_LIMIT_SESSION_USTX", DEFAULTS.sessionUstx),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
daily: parseLimit("SPEND_LIMIT_DAILY_SATS", DEFAULTS.dailySats),
|
|
66
|
+
session: parseLimit("SPEND_LIMIT_SESSION_SATS", DEFAULTS.sessionSats),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** UTC day bucket, e.g. "2026-06-11". */
|
|
70
|
+
function todayKey() {
|
|
71
|
+
return new Date().toISOString().slice(0, 10);
|
|
72
|
+
}
|
|
73
|
+
function unitLabel(unit) {
|
|
74
|
+
return unit === "ustx" ? "uSTX" : "sats";
|
|
75
|
+
}
|
|
76
|
+
export class SpendLimitError extends Error {
|
|
77
|
+
unit;
|
|
78
|
+
attempted;
|
|
79
|
+
remaining;
|
|
80
|
+
scope;
|
|
81
|
+
constructor(message, unit, attempted, remaining, scope) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.unit = unit;
|
|
84
|
+
this.attempted = attempted;
|
|
85
|
+
this.remaining = remaining;
|
|
86
|
+
this.scope = scope;
|
|
87
|
+
this.name = "SpendLimitError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
class SpendLimiter {
|
|
91
|
+
static instance;
|
|
92
|
+
stateFile = DEFAULT_STATE_FILE;
|
|
93
|
+
// In-memory per-session totals, keyed by wallet address.
|
|
94
|
+
session = new Map();
|
|
95
|
+
writeLock = Promise.resolve();
|
|
96
|
+
static getInstance() {
|
|
97
|
+
if (!SpendLimiter.instance)
|
|
98
|
+
SpendLimiter.instance = new SpendLimiter();
|
|
99
|
+
return SpendLimiter.instance;
|
|
100
|
+
}
|
|
101
|
+
/** Test seam: redirect the state file. */
|
|
102
|
+
setStateFile(file) {
|
|
103
|
+
this.stateFile = file;
|
|
104
|
+
}
|
|
105
|
+
async readState() {
|
|
106
|
+
try {
|
|
107
|
+
const content = await fs.readFile(this.stateFile, "utf8");
|
|
108
|
+
return JSON.parse(content);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async writeState(state) {
|
|
115
|
+
const dir = path.dirname(this.stateFile);
|
|
116
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
117
|
+
const tmp = `${this.stateFile}.tmp`;
|
|
118
|
+
await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
119
|
+
await fs.rename(tmp, this.stateFile);
|
|
120
|
+
}
|
|
121
|
+
getSession(addr) {
|
|
122
|
+
let s = this.session.get(addr);
|
|
123
|
+
if (!s) {
|
|
124
|
+
s = { ustx: 0, sats: 0 };
|
|
125
|
+
this.session.set(addr, s);
|
|
126
|
+
}
|
|
127
|
+
return s;
|
|
128
|
+
}
|
|
129
|
+
/** Reset the in-memory session ledger for a wallet (call on unlock/lock/switch). */
|
|
130
|
+
resetSession(addr) {
|
|
131
|
+
this.session.delete(addr);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Throw SpendLimitError if `amount` would push the session OR day total over
|
|
135
|
+
* its cap. Does NOT record — call recordSpend after a successful broadcast.
|
|
136
|
+
*/
|
|
137
|
+
async check(unit, amount, addr) {
|
|
138
|
+
if (!isEnabled())
|
|
139
|
+
return;
|
|
140
|
+
if (amount <= 0n)
|
|
141
|
+
return;
|
|
142
|
+
const amt = Number(amount);
|
|
143
|
+
const { daily, session } = capsFor(unit);
|
|
144
|
+
const sessionSpent = this.getSession(addr)[unit];
|
|
145
|
+
if (sessionSpent + amt > session) {
|
|
146
|
+
const remaining = Math.max(0, session - sessionSpent);
|
|
147
|
+
throw new SpendLimitError(this.message(unit, amt, remaining, "session", session), unit, amt, remaining, "session");
|
|
148
|
+
}
|
|
149
|
+
const state = await this.readState();
|
|
150
|
+
const daySpent = state[addr]?.[todayKey()]?.[unit] ?? 0;
|
|
151
|
+
if (daySpent + amt > daily) {
|
|
152
|
+
const remaining = Math.max(0, daily - daySpent);
|
|
153
|
+
throw new SpendLimitError(this.message(unit, amt, remaining, "day", daily), unit, amt, remaining, "day");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Record a completed spend against both the session and day ledgers. */
|
|
157
|
+
async record(unit, amount, addr) {
|
|
158
|
+
if (!isEnabled())
|
|
159
|
+
return;
|
|
160
|
+
if (amount <= 0n)
|
|
161
|
+
return;
|
|
162
|
+
const amt = Number(amount);
|
|
163
|
+
this.getSession(addr)[unit] += amt;
|
|
164
|
+
// Serialize read-modify-write to avoid lost updates under concurrency.
|
|
165
|
+
this.writeLock = this.writeLock.then(async () => {
|
|
166
|
+
const state = await this.readState();
|
|
167
|
+
const today = todayKey();
|
|
168
|
+
// Prune stale days so the file does not grow unbounded.
|
|
169
|
+
for (const a of Object.keys(state)) {
|
|
170
|
+
for (const day of Object.keys(state[a])) {
|
|
171
|
+
if (day !== today)
|
|
172
|
+
delete state[a][day];
|
|
173
|
+
}
|
|
174
|
+
if (Object.keys(state[a]).length === 0)
|
|
175
|
+
delete state[a];
|
|
176
|
+
}
|
|
177
|
+
if (!state[addr])
|
|
178
|
+
state[addr] = {};
|
|
179
|
+
if (!state[addr][today])
|
|
180
|
+
state[addr][today] = { ustx: 0, sats: 0 };
|
|
181
|
+
state[addr][today][unit] += amt;
|
|
182
|
+
await this.writeState(state);
|
|
183
|
+
});
|
|
184
|
+
await this.writeLock;
|
|
185
|
+
}
|
|
186
|
+
/** Remaining session/day budget for status reporting. */
|
|
187
|
+
async status(addr) {
|
|
188
|
+
const state = await this.readState();
|
|
189
|
+
const mk = (unit) => {
|
|
190
|
+
const { daily, session } = capsFor(unit);
|
|
191
|
+
const sessionSpent = this.session.get(addr)?.[unit] ?? 0;
|
|
192
|
+
const daySpent = state[addr]?.[todayKey()]?.[unit] ?? 0;
|
|
193
|
+
return {
|
|
194
|
+
sessionRemaining: Math.max(0, session - sessionSpent),
|
|
195
|
+
dailyRemaining: Math.max(0, daily - daySpent),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
return { enabled: isEnabled(), ustx: mk("ustx"), sats: mk("sats") };
|
|
199
|
+
}
|
|
200
|
+
message(unit, attempted, remaining, scope, cap) {
|
|
201
|
+
const envVar = unit === "ustx"
|
|
202
|
+
? scope === "day"
|
|
203
|
+
? "SPEND_LIMIT_DAILY_USTX"
|
|
204
|
+
: "SPEND_LIMIT_SESSION_USTX"
|
|
205
|
+
: scope === "day"
|
|
206
|
+
? "SPEND_LIMIT_DAILY_SATS"
|
|
207
|
+
: "SPEND_LIMIT_SESSION_SATS";
|
|
208
|
+
const label = unitLabel(unit);
|
|
209
|
+
return (`Spending limit reached: this ${attempted} ${label} transfer would exceed the ` +
|
|
210
|
+
`per-${scope} cap of ${cap} ${label} (${remaining} ${label} remaining). ` +
|
|
211
|
+
`This is a safety rail against draining the wallet. To allow a larger spend, ` +
|
|
212
|
+
`raise ${envVar} (or set SPEND_LIMIT_ENABLED=false to disable), then retry.`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export function getSpendLimiter() {
|
|
216
|
+
return SpendLimiter.getInstance();
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=spend-limiter.js.map
|
|
@@ -5,6 +5,7 @@ import { encrypt, decrypt, generateWalletId, initializeStorage, readWalletIndex,
|
|
|
5
5
|
import { WalletError, WalletNotFoundError, InvalidPasswordError, InvalidMnemonicError, } from "../utils/errors.js";
|
|
6
6
|
import { NETWORK } from "../config/networks.js";
|
|
7
7
|
import { resetPendingNonce } from "../transactions/builder.js";
|
|
8
|
+
import { getSpendLimiter } from "./spend-limiter.js";
|
|
8
9
|
import { deriveBitcoinAddress, deriveBitcoinKeyPair, deriveTaprootAddress, deriveTaprootKeyPair, deriveNostrKeyPair } from "../utils/bitcoin.js";
|
|
9
10
|
/**
|
|
10
11
|
* Wallet manager singleton - handles wallet creation, encryption, and session management
|
|
@@ -170,6 +171,8 @@ class WalletManager {
|
|
|
170
171
|
await writeAppConfig(config);
|
|
171
172
|
// Reset pending nonce counter so the new session re-syncs with the chain
|
|
172
173
|
await resetPendingNonce(account.address);
|
|
174
|
+
// Fresh unlock starts a fresh per-session spend budget.
|
|
175
|
+
getSpendLimiter().resetSession(account.address);
|
|
173
176
|
return account;
|
|
174
177
|
}
|
|
175
178
|
/**
|
|
@@ -180,6 +183,8 @@ class WalletManager {
|
|
|
180
183
|
// Reset pending nonce counter before clearing session
|
|
181
184
|
if (this.session?.account) {
|
|
182
185
|
await resetPendingNonce(this.session.account.address);
|
|
186
|
+
// Clear the per-session spend budget so the next unlock starts fresh.
|
|
187
|
+
getSpendLimiter().resetSession(this.session.account.address);
|
|
183
188
|
}
|
|
184
189
|
// Zero out sensitive key buffers before dropping references
|
|
185
190
|
if (this.session?.account) {
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
|
-
import { makeSTXTokenTransfer, makeContractCall, uintCV, principalCV, noneCV, PostConditionMode, } from "@stacks/transactions";
|
|
2
|
+
import { makeSTXTokenTransfer, makeContractCall, uintCV, principalCV, noneCV, Pc, PostConditionMode, } from "@stacks/transactions";
|
|
3
3
|
import { decodePaymentRequired, encodePaymentPayload, generatePaymentId, buildPaymentIdentifierExtension, X402_HEADERS, } from "../utils/x402-protocol.js";
|
|
4
4
|
import { generateWallet, getStxAddress } from "@stacks/wallet-sdk";
|
|
5
5
|
import { NETWORK, API_URL, getStacksNetwork } from "../config/networks.js";
|
|
6
6
|
import { getNetworkFromStacksChainId } from "../config/caip.js";
|
|
7
7
|
import { getWalletManager } from "./wallet-manager.js";
|
|
8
|
+
import { getSpendLimiter } from "./spend-limiter.js";
|
|
8
9
|
import { formatStx, formatSbtc } from "../utils/formatting.js";
|
|
9
10
|
import { getSbtcService } from "./sbtc.service.js";
|
|
10
11
|
import { getHiroApi } from "./hiro-api.js";
|
|
11
12
|
import { createHash } from "crypto";
|
|
12
13
|
import { InsufficientBalanceError } from "../utils/errors.js";
|
|
13
14
|
import { getContracts, parseContractId } from "../config/contracts.js";
|
|
15
|
+
import { resolveDefaultFee } from "../utils/fee.js";
|
|
14
16
|
import { classifyCanonicalPaymentStatus, formatCanonicalPaymentStatus, resolveCanonicalPaymentStatus, } from "../utils/x402-payment-state.js";
|
|
15
17
|
import { extractPaymentIdFromPaymentSignature } from "../utils/x402-recovery.js";
|
|
16
18
|
import { emitCanonicalPaymentDecisionLogs, emitCanonicalPaymentPollLogs, emitPaymentLog, } from "../utils/x402-payment-logging.js";
|
|
@@ -42,6 +44,16 @@ function parseSatsCap(envName, fallback) {
|
|
|
42
44
|
return parsed;
|
|
43
45
|
}
|
|
44
46
|
const L402_MAX_SATS_PER_INVOICE = parseSatsCap("L402_MAX_SATS_PER_INVOICE", DEFAULT_L402_MAX_SATS);
|
|
47
|
+
/**
|
|
48
|
+
* Per-payment caps for the Stacks x402 interceptor, mirroring the L402 cap
|
|
49
|
+
* above: a malicious endpoint can demand an arbitrary amount in its 402
|
|
50
|
+
* response and an autoApprove'd call would pay it, bounded only by wallet
|
|
51
|
+
* balance. Known registry endpoints cost at most 0.02 STX / 100 sats, so the
|
|
52
|
+
* defaults leave generous headroom. Overridable via env vars.
|
|
53
|
+
*/
|
|
54
|
+
const X402_MAX_USTX_PER_PAYMENT = parseSatsCap("X402_MAX_USTX_PER_PAYMENT", 1_000_000 // 1 STX
|
|
55
|
+
);
|
|
56
|
+
const X402_MAX_SATS_PER_PAYMENT = parseSatsCap("X402_MAX_SATS_PER_PAYMENT", 10_000);
|
|
45
57
|
// Track payment attempts per client instance (auto-cleanup via WeakMap)
|
|
46
58
|
const paymentAttempts = new WeakMap();
|
|
47
59
|
// Track 429 retry counts per client instance
|
|
@@ -354,6 +366,13 @@ export async function createApiClient(baseUrl, options) {
|
|
|
354
366
|
`of ${L402_MAX_SATS_PER_INVOICE} sats (L402_MAX_SATS_PER_INVOICE). ` +
|
|
355
367
|
`Refusing to pay.`));
|
|
356
368
|
}
|
|
369
|
+
// Cumulative spending limit (sats ledger) — bounds a drain-by-loop of
|
|
370
|
+
// sub-cap L402 invoices. Keyed by the wallet's Stacks address so all sats
|
|
371
|
+
// spends (BTC L1, sBTC, L402) share one ledger.
|
|
372
|
+
const l402Addr = getWalletManager().getActiveAccount()?.address;
|
|
373
|
+
if (l402Addr) {
|
|
374
|
+
await getSpendLimiter().check("sats", BigInt(amountSats), l402Addr);
|
|
375
|
+
}
|
|
357
376
|
// Pay the Lightning invoice.
|
|
358
377
|
let payment;
|
|
359
378
|
try {
|
|
@@ -362,6 +381,9 @@ export async function createApiClient(baseUrl, options) {
|
|
|
362
381
|
catch (payErr) {
|
|
363
382
|
return Promise.reject(new Error(`L402 payment failed: ${payErr instanceof Error ? payErr.message : String(payErr)}`));
|
|
364
383
|
}
|
|
384
|
+
if (l402Addr) {
|
|
385
|
+
await getSpendLimiter().record("sats", BigInt(amountSats), l402Addr);
|
|
386
|
+
}
|
|
365
387
|
cacheL402Auth(method, canonicalUrlKey, challenge.macaroon, payment.preimage);
|
|
366
388
|
const originalRequest = error.config;
|
|
367
389
|
originalRequest.headers = originalRequest.headers || {};
|
|
@@ -457,6 +479,16 @@ export async function createApiClient(baseUrl, options) {
|
|
|
457
479
|
const networks = paymentRequired.accepts.map((a) => a.network).join(", ");
|
|
458
480
|
return Promise.reject(new Error(`No compatible Stacks payment option found. Available networks: ${networks}`));
|
|
459
481
|
}
|
|
482
|
+
const tokenType = detectTokenType(selectedOption.asset);
|
|
483
|
+
const amount = BigInt(selectedOption.amount);
|
|
484
|
+
const isSbtc = tokenType === "sBTC";
|
|
485
|
+
const cap = isSbtc ? X402_MAX_SATS_PER_PAYMENT : X402_MAX_USTX_PER_PAYMENT;
|
|
486
|
+
const unit = isSbtc ? "sats" : "uSTX";
|
|
487
|
+
if (amount > BigInt(Math.floor(cap))) {
|
|
488
|
+
return Promise.reject(new Error(`x402 payment of ${amount} ${unit} exceeds the per-payment cap of ${cap} ${unit}. ` +
|
|
489
|
+
`If this cost is expected, raise the cap via the ` +
|
|
490
|
+
`${isSbtc ? "X402_MAX_SATS_PER_PAYMENT" : "X402_MAX_USTX_PER_PAYMENT"} env var.`));
|
|
491
|
+
}
|
|
460
492
|
// Lazy-load account on first 402 — free endpoints never reach here
|
|
461
493
|
const acct = await ensureAccount();
|
|
462
494
|
// Verify the payment network matches our configured network
|
|
@@ -465,6 +497,10 @@ export async function createApiClient(baseUrl, options) {
|
|
|
465
497
|
return Promise.reject(new Error(`Network mismatch: endpoint requires ${paymentNetwork} but wallet is configured for ${acct.network}. ` +
|
|
466
498
|
`Switch to a ${paymentNetwork} wallet or use a ${acct.network} endpoint.`));
|
|
467
499
|
}
|
|
500
|
+
// Cumulative spending limit: the per-payment cap above bounds a single
|
|
501
|
+
// 402, but a malicious endpoint can loop sub-cap payments. This blocks
|
|
502
|
+
// once the session/day total would be exceeded.
|
|
503
|
+
await getSpendLimiter().check(isSbtc ? "sats" : "ustx", amount, acct.address);
|
|
468
504
|
// Invoke pre-payment callback (e.g. balance check) before signing/broadcasting.
|
|
469
505
|
// If the callback throws, the payment is aborted and the error propagates to the caller.
|
|
470
506
|
if (options?.onBeforePayment) {
|
|
@@ -476,9 +512,11 @@ export async function createApiClient(baseUrl, options) {
|
|
|
476
512
|
account: acct,
|
|
477
513
|
});
|
|
478
514
|
}
|
|
479
|
-
// Build a sponsored signed transaction
|
|
480
|
-
|
|
481
|
-
|
|
515
|
+
// Build a non-sponsored signed transaction: the sender pays its own
|
|
516
|
+
// STX gas (fee + nonce auto-estimated by @stacks), and the x402
|
|
517
|
+
// facilitator simply verifies and broadcasts it. No relay sponsorship
|
|
518
|
+
// — this is the same tx shape the x402-stacks lib produces, and the
|
|
519
|
+
// balance check in onBeforePayment already accounts for the gas.
|
|
482
520
|
const networkName = getStacksNetwork(acct.network);
|
|
483
521
|
let transaction;
|
|
484
522
|
if (tokenType === "sBTC") {
|
|
@@ -496,9 +534,18 @@ export async function createApiClient(baseUrl, options) {
|
|
|
496
534
|
],
|
|
497
535
|
senderKey: acct.privateKey,
|
|
498
536
|
network: networkName,
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
537
|
+
// The payment amount is known at build time, so pin it: a
|
|
538
|
+
// compromised token contract must not be able to pull more.
|
|
539
|
+
postConditionMode: PostConditionMode.Deny,
|
|
540
|
+
postConditions: [
|
|
541
|
+
Pc.principal(acct.address)
|
|
542
|
+
.willSendEq(amount)
|
|
543
|
+
.ft(contracts.SBTC_TOKEN, "sbtc-token"),
|
|
544
|
+
],
|
|
545
|
+
// Clamped medium fee — do NOT let @stacks auto-estimate, since
|
|
546
|
+
// Hiro's contract_call high_priority tier is polluted by outliers
|
|
547
|
+
// (observed >2000 STX). resolveDefaultFee caps it at 0.05 STX.
|
|
548
|
+
fee: await resolveDefaultFee(acct.network, "contract_call"),
|
|
502
549
|
});
|
|
503
550
|
}
|
|
504
551
|
else {
|
|
@@ -508,8 +555,7 @@ export async function createApiClient(baseUrl, options) {
|
|
|
508
555
|
senderKey: acct.privateKey,
|
|
509
556
|
network: networkName,
|
|
510
557
|
memo: "",
|
|
511
|
-
|
|
512
|
-
fee: 0n,
|
|
558
|
+
fee: await resolveDefaultFee(acct.network, "token_transfer"),
|
|
513
559
|
});
|
|
514
560
|
}
|
|
515
561
|
const txHex = "0x" + transaction.serialize();
|
|
@@ -530,6 +576,9 @@ export async function createApiClient(baseUrl, options) {
|
|
|
530
576
|
action: "payment_submitted",
|
|
531
577
|
compatShimUsed: false,
|
|
532
578
|
});
|
|
579
|
+
// Record the spend against the cumulative ledger now that the signed
|
|
580
|
+
// payment tx is submitted to the facilitator.
|
|
581
|
+
await getSpendLimiter().record(isSbtc ? "sats" : "ustx", amount, acct.address);
|
|
533
582
|
// Retry the original request with the payment header
|
|
534
583
|
const originalRequest = error.config;
|
|
535
584
|
originalRequest.headers = originalRequest.headers || {};
|
|
@@ -716,8 +765,9 @@ export async function checkSufficientBalance(account, amount, asset, sponsored =
|
|
|
716
765
|
const hiroApiForSbtc = getHiroApi(account.network);
|
|
717
766
|
const stxInfoForSbtc = await hiroApiForSbtc.getStxBalance(account.address);
|
|
718
767
|
const stxBalanceForSbtc = BigInt(stxInfoForSbtc.balance);
|
|
719
|
-
|
|
720
|
-
|
|
768
|
+
// Same clamped fee the interceptor actually sets on the sBTC contract
|
|
769
|
+
// call — so this check is exact, not a high-priority over-estimate.
|
|
770
|
+
const estimatedSbtcFee = await resolveDefaultFee(account.network, "contract_call");
|
|
721
771
|
if (stxBalanceForSbtc < estimatedSbtcFee) {
|
|
722
772
|
const stxShortfall = estimatedSbtcFee - stxBalanceForSbtc;
|
|
723
773
|
throw new InsufficientBalanceError(`Insufficient STX balance to cover sBTC transfer fee: need ${formatStx(estimatedSbtcFee.toString())} estimated fee, ` +
|
|
@@ -733,8 +783,8 @@ export async function checkSufficientBalance(account, amount, asset, sponsored =
|
|
|
733
783
|
const balance = BigInt(balanceInfo.balance);
|
|
734
784
|
let totalRequired = requiredAmount;
|
|
735
785
|
if (!sponsored) {
|
|
736
|
-
|
|
737
|
-
const estimatedFee =
|
|
786
|
+
// Same clamped fee the interceptor sets on the STX transfer.
|
|
787
|
+
const estimatedFee = await resolveDefaultFee(account.network, "token_transfer");
|
|
738
788
|
totalRequired = requiredAmount + estimatedFee;
|
|
739
789
|
}
|
|
740
790
|
if (balance >= totalRequired)
|
|
@@ -14,6 +14,7 @@ import { z } from "zod";
|
|
|
14
14
|
import { NETWORK } from "../config/networks.js";
|
|
15
15
|
import { createJsonResponse, createErrorResponse } from "../utils/index.js";
|
|
16
16
|
import { getWalletManager } from "../services/wallet-manager.js";
|
|
17
|
+
import { getSpendLimiter } from "../services/spend-limiter.js";
|
|
17
18
|
import { MempoolApi, getMempoolAddressUrl, getMempoolTxUrl, } from "../services/mempool-api.js";
|
|
18
19
|
import { buildAndSignBtcTransaction } from "../transactions/bitcoin-builder.js";
|
|
19
20
|
import { UnisatIndexer } from "../services/unisat-indexer.js";
|
|
@@ -33,6 +34,19 @@ async function getBtcAddress(providedAddress) {
|
|
|
33
34
|
throw new Error("No Bitcoin address provided and wallet is not unlocked. " +
|
|
34
35
|
"Either provide an address or unlock your wallet first.");
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Reject recipient addresses that belong to the other Bitcoin network before
|
|
39
|
+
* any UTXO fetching. The tx builder (@scure/btc-signer) rejects these too, but
|
|
40
|
+
* late and with a cryptic bech32/version-byte error.
|
|
41
|
+
*/
|
|
42
|
+
function validateRecipientNetwork(address) {
|
|
43
|
+
const prefixes = NETWORK === "mainnet" ? ["bc1", "1", "3"] : ["tb1", "m", "n", "2"];
|
|
44
|
+
if (!prefixes.some((p) => address.startsWith(p))) {
|
|
45
|
+
throw new Error(`Recipient "${address}" is not a ${NETWORK} address. ` +
|
|
46
|
+
`Expected a prefix of ${prefixes.join(", ")} — check that the address ` +
|
|
47
|
+
`matches the configured NETWORK.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
36
50
|
/**
|
|
37
51
|
* Format satoshis as BTC string
|
|
38
52
|
*/
|
|
@@ -231,12 +245,16 @@ export function registerBitcoinTools(server) {
|
|
|
231
245
|
},
|
|
232
246
|
}, async ({ recipient, amount, feeRate, includeOrdinals }) => {
|
|
233
247
|
try {
|
|
248
|
+
validateRecipientNetwork(recipient);
|
|
234
249
|
// Get wallet account (requires unlocked wallet)
|
|
235
250
|
const walletManager = getWalletManager();
|
|
236
251
|
const account = walletManager.getActiveAccount();
|
|
237
252
|
if (!account) {
|
|
238
253
|
throw new Error("Wallet is not unlocked. Use wallet_unlock first to enable transactions.");
|
|
239
254
|
}
|
|
255
|
+
// Safety rail: block before signing if this would exceed the wallet's
|
|
256
|
+
// cumulative spending limit (per-session or per-day).
|
|
257
|
+
await getSpendLimiter().check("sats", BigInt(amount), account.address);
|
|
240
258
|
if (!account.btcAddress || !account.btcPrivateKey || !account.btcPublicKey) {
|
|
241
259
|
throw new Error("Bitcoin keys not available. Please unlock your wallet again.");
|
|
242
260
|
}
|
|
@@ -294,6 +312,7 @@ export function registerBitcoinTools(server) {
|
|
|
294
312
|
}, account.btcPrivateKey);
|
|
295
313
|
// Broadcast the transaction
|
|
296
314
|
const txid = await api.broadcastTransaction(txResult.txHex);
|
|
315
|
+
await getSpendLimiter().record("sats", BigInt(amount), account.address);
|
|
297
316
|
const response = {
|
|
298
317
|
success: true,
|
|
299
318
|
txid,
|
|
@@ -276,7 +276,9 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs
|
|
|
276
276
|
const api = await createApiClient(parsed.baseUrl, {
|
|
277
277
|
toolName: "execute_x402_endpoint",
|
|
278
278
|
onBeforePayment: async (requirements) => {
|
|
279
|
-
|
|
279
|
+
// Non-sponsored: sender pays its own gas, so validate STX for the
|
|
280
|
+
// fee too (sponsored=false is the default, passed explicitly here).
|
|
281
|
+
await checkSufficientBalance(requirements.account, requirements.amount, requirements.asset, false);
|
|
280
282
|
},
|
|
281
283
|
});
|
|
282
284
|
const response = await api.request({ method, url: parsed.requestPath, params, data });
|
|
@@ -40,21 +40,18 @@ async function getNextNonce(address) {
|
|
|
40
40
|
const hiroApi = getHiroApi(NETWORK);
|
|
41
41
|
const accountInfo = await hiroApi.getAccountInfo(address);
|
|
42
42
|
const confirmedNonce = accountInfo.nonce;
|
|
43
|
+
// The mempool scan is what makes the nonce safe against pending txs —
|
|
44
|
+
// silently skipping it on failure risks a nonce collision, so let it fail.
|
|
43
45
|
let highestMempoolNonce = -1;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
highestMempoolNonce = tx.nonce;
|
|
52
|
-
}
|
|
46
|
+
const mempool = await hiroApi.getMempoolTransactions({
|
|
47
|
+
sender_address: address,
|
|
48
|
+
limit: 50,
|
|
49
|
+
});
|
|
50
|
+
for (const tx of mempool.results) {
|
|
51
|
+
if (tx.nonce > highestMempoolNonce) {
|
|
52
|
+
highestMempoolNonce = tx.nonce;
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
-
catch {
|
|
56
|
-
// Non-fatal: fall back to confirmed nonce only
|
|
57
|
-
}
|
|
58
55
|
const chainNext = Math.max(confirmedNonce, highestMempoolNonce + 1);
|
|
59
56
|
// 3. Reconcile tracker with chain state
|
|
60
57
|
await reconcileWithChain(address, chainNext);
|