@aibtc/mcp-server 1.68.0 → 1.70.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/dist/config/at-stake.d.ts +120 -0
- package/dist/config/at-stake.js +183 -0
- package/dist/services/at-stake.service.d.ts +144 -0
- package/dist/services/at-stake.service.js +272 -0
- package/dist/services/spend-limiter.d.ts +53 -0
- package/dist/services/spend-limiter.js +97 -39
- package/dist/services/state-lock.d.ts +73 -0
- package/dist/services/state-lock.js +229 -0
- package/dist/tools/at-stake-legion.tools.d.ts +31 -0
- package/dist/tools/at-stake-legion.tools.js +776 -0
- package/dist/tools/at-stake.tools.d.ts +29 -0
- package/dist/tools/at-stake.tools.js +684 -0
- package/dist/tools/competition.tools.d.ts +12 -11
- package/dist/tools/competition.tools.js +3 -115
- package/dist/tools/index.js +6 -3
- package/dist/tools/legion.tools.d.ts +4 -3
- package/dist/tools/legion.tools.js +9 -12
- package/dist/tools/ordinals.tools.js +28 -2
- package/dist/transactions/builder.js +14 -1
- package/package.json +1 -1
- package/skill/SKILL.md +37 -1
- package/skill/references/at-stake.md +214 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chain reads for the At Stake market and its two side legions.
|
|
3
|
+
*
|
|
4
|
+
* Every read here goes to the chain the contracts live on, not the chain the
|
|
5
|
+
* server is configured for — see the note in `config/at-stake.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Clarity values come back through `cvToJSON`, whose shape is nested and
|
|
8
|
+
* stringly typed. The unwrapping lives here so the tool layer reads as the
|
|
9
|
+
* market's own vocabulary rather than as tuple spelunking.
|
|
10
|
+
*/
|
|
11
|
+
import { cvToJSON, deserializeCV, getAddressFromPrivateKey, principalCV, uintCV, } from "@stacks/transactions";
|
|
12
|
+
import { getHiroApi } from "./hiro-api.js";
|
|
13
|
+
import { getAccount } from "./x402.service.js";
|
|
14
|
+
import { AT_STAKE_NETWORK, CLOSE_HEIGHT, LEGION_ERRORS, MARKET_ADDRESS, MARKET_CONTRACT, MARKET_ERRORS, MARKET_STATUS, PROPOSAL_STATUS, STATUS_OPEN, } from "../config/at-stake.js";
|
|
15
|
+
/**
|
|
16
|
+
* The wallet, re-derived for the legion's chain.
|
|
17
|
+
*
|
|
18
|
+
* A testnet-configured server still holds the same private key; only the
|
|
19
|
+
* c32 encoding of the address differs. Re-deriving means a mainnet call is
|
|
20
|
+
* signed by the mainnet form of the same wallet rather than failing, or worse,
|
|
21
|
+
* quoting one address while signing as another.
|
|
22
|
+
*/
|
|
23
|
+
export async function getAtStakeAccount() {
|
|
24
|
+
const base = await getAccount();
|
|
25
|
+
if (base.network === AT_STAKE_NETWORK)
|
|
26
|
+
return base;
|
|
27
|
+
return {
|
|
28
|
+
...base,
|
|
29
|
+
address: getAddressFromPrivateKey(base.privateKey, AT_STAKE_NETWORK),
|
|
30
|
+
network: AT_STAKE_NETWORK,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Call a read-only function on either contract and return its JSON form. */
|
|
34
|
+
export async function readContract(contractId, functionName, args = []) {
|
|
35
|
+
const hiro = getHiroApi(AT_STAKE_NETWORK);
|
|
36
|
+
const result = await hiro.callReadOnlyFunction(contractId, functionName, args, MARKET_ADDRESS);
|
|
37
|
+
if (!result.okay) {
|
|
38
|
+
throw new Error(`${contractId} ${functionName} failed: ${result.cause ?? "unknown error"}`);
|
|
39
|
+
}
|
|
40
|
+
if (!result.result)
|
|
41
|
+
return null;
|
|
42
|
+
const hex = result.result.startsWith("0x")
|
|
43
|
+
? result.result.slice(2)
|
|
44
|
+
: result.result;
|
|
45
|
+
return cvToJSON(deserializeCV(Buffer.from(hex, "hex")));
|
|
46
|
+
}
|
|
47
|
+
/** Read a market function. */
|
|
48
|
+
export function readMarket(functionName, args = []) {
|
|
49
|
+
return readContract(MARKET_CONTRACT, functionName, args);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Flatten one layer of `cvToJSON`'s `{type, value}` nesting.
|
|
53
|
+
*
|
|
54
|
+
* A Clarity tuple arrives as `{type, value: {field: {type, value}}}`, and an
|
|
55
|
+
* optional wraps that again. Callers want the field, not the envelope.
|
|
56
|
+
*/
|
|
57
|
+
function unwrap(cv) {
|
|
58
|
+
if (cv && typeof cv === "object" && "value" in cv) {
|
|
59
|
+
return cv.value;
|
|
60
|
+
}
|
|
61
|
+
return cv;
|
|
62
|
+
}
|
|
63
|
+
/** Read a uint out of a `cvToJSON` node. */
|
|
64
|
+
export function num(cv) {
|
|
65
|
+
const v = unwrap(cv);
|
|
66
|
+
if (v === null || v === undefined)
|
|
67
|
+
return 0;
|
|
68
|
+
return Number(v);
|
|
69
|
+
}
|
|
70
|
+
/** Read a bool out of a `cvToJSON` node. */
|
|
71
|
+
function bool(cv) {
|
|
72
|
+
return unwrap(cv) === true || unwrap(cv) === "true";
|
|
73
|
+
}
|
|
74
|
+
/** Read a string out of a `cvToJSON` node. */
|
|
75
|
+
function str(cv) {
|
|
76
|
+
const v = unwrap(cv);
|
|
77
|
+
return v === null || v === undefined ? "" : String(v);
|
|
78
|
+
}
|
|
79
|
+
/** Fields of a tuple node, or `null` for a `none`. */
|
|
80
|
+
function fields(cv) {
|
|
81
|
+
const v = unwrap(cv);
|
|
82
|
+
if (v === null || v === undefined)
|
|
83
|
+
return null;
|
|
84
|
+
// An optional's payload is itself a {type, value} node.
|
|
85
|
+
if (typeof v === "object" && "value" in v && "type" in v) {
|
|
86
|
+
return fields(v);
|
|
87
|
+
}
|
|
88
|
+
return v;
|
|
89
|
+
}
|
|
90
|
+
export async function getMarket() {
|
|
91
|
+
const f = fields(await readMarket("get-market"));
|
|
92
|
+
if (!f)
|
|
93
|
+
throw new Error("get-market returned nothing.");
|
|
94
|
+
const status = num(f["status"]);
|
|
95
|
+
return {
|
|
96
|
+
title: str(f["title"]),
|
|
97
|
+
opened: bool(f["opened"]),
|
|
98
|
+
status,
|
|
99
|
+
statusLabel: MARKET_STATUS[status] ?? `unknown(${status})`,
|
|
100
|
+
closeHeight: num(f["close-height"]),
|
|
101
|
+
vault: num(f["vault"]),
|
|
102
|
+
idleCirc: num(f["idle-circ"]),
|
|
103
|
+
bondedCirc: num(f["bonded-circ"]),
|
|
104
|
+
createdAt: num(f["created-at"]),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export async function getPosition(who) {
|
|
108
|
+
const f = fields(await readMarket("get-position", [principal(who)]));
|
|
109
|
+
if (!f)
|
|
110
|
+
return { idle: 0, bonded: 0 };
|
|
111
|
+
return { idle: num(f["idle"]), bonded: num(f["bonded"]) };
|
|
112
|
+
}
|
|
113
|
+
export async function getBid(who, side) {
|
|
114
|
+
const f = fields(await readMarket("get-bid", [principal(who), uint(side.side)]));
|
|
115
|
+
if (!f)
|
|
116
|
+
return null;
|
|
117
|
+
return { amount: num(f["amount"]), escrow: num(f["escrow"]) };
|
|
118
|
+
}
|
|
119
|
+
/** Current burn height — the clock every window in both contracts uses. */
|
|
120
|
+
export async function getBurnHeight() {
|
|
121
|
+
const info = await getHiroApi(AT_STAKE_NETWORK).getCoreApiInfo();
|
|
122
|
+
return info.burn_block_height;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether the market still accepts trades.
|
|
126
|
+
*
|
|
127
|
+
* Mirrors the contract's own `assert-tradeable`: open status AND the close
|
|
128
|
+
* height not yet passed. Derived rather than read so a caller can be told
|
|
129
|
+
* which of the two conditions failed.
|
|
130
|
+
*/
|
|
131
|
+
export function isTradeable(market, burnHeight) {
|
|
132
|
+
return market.status === STATUS_OPEN && burnHeight <= CLOSE_HEIGHT;
|
|
133
|
+
}
|
|
134
|
+
export async function getLegionParams(side) {
|
|
135
|
+
const f = fields(await readContract(side.legion, "get-params"));
|
|
136
|
+
if (!f)
|
|
137
|
+
throw new Error(`${side.legion} get-params returned nothing.`);
|
|
138
|
+
return {
|
|
139
|
+
minPosition: num(f["minPosition"]),
|
|
140
|
+
payout: num(f["payout"]),
|
|
141
|
+
minVoters: num(f["minVoters"]),
|
|
142
|
+
proposerCooldown: num(f["proposerCooldown"]),
|
|
143
|
+
votingThreshold: num(f["votingThreshold"]),
|
|
144
|
+
voteDelay: num(f["voteDelay"]),
|
|
145
|
+
voteWindow: num(f["voteWindow"]),
|
|
146
|
+
concludeWindow: num(f["concludeWindow"]),
|
|
147
|
+
globalProposeInterval: num(f["globalProposeInterval"]),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export async function getProposeStatus(side, who) {
|
|
151
|
+
const f = fields(await readContract(side.legion, "propose-status", [principal(who)]));
|
|
152
|
+
if (!f)
|
|
153
|
+
throw new Error(`${side.legion} propose-status returned nothing.`);
|
|
154
|
+
return {
|
|
155
|
+
canPropose: bool(f["canPropose"]),
|
|
156
|
+
eligible: bool(f["eligible"]),
|
|
157
|
+
slotOpen: bool(f["slotOpen"]),
|
|
158
|
+
cooledDown: bool(f["cooledDown"]),
|
|
159
|
+
noLiveProposal: bool(f["noLiveProposal"]),
|
|
160
|
+
potOk: bool(f["potOk"]),
|
|
161
|
+
marketTradeable: bool(f["marketTradeable"]),
|
|
162
|
+
weight: num(f["weight"]),
|
|
163
|
+
vault: num(f["vault"]),
|
|
164
|
+
winsLeft: num(f["winsLeft"]),
|
|
165
|
+
payout: num(f["payout"]),
|
|
166
|
+
votable: num(f["votable"]),
|
|
167
|
+
nextProposeHeight: num(f["nextProposeHeight"]),
|
|
168
|
+
proposerNextHeight: num(f["proposerNextHeight"]),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
export async function getProposal(side, proposalId) {
|
|
172
|
+
const f = fields(await readContract(side.legion, "get-proposal", [uint(proposalId)]));
|
|
173
|
+
if (!f)
|
|
174
|
+
return null;
|
|
175
|
+
const status = num(f["status"]);
|
|
176
|
+
return {
|
|
177
|
+
proposer: str(f["proposer"]),
|
|
178
|
+
payout: num(f["payout"]),
|
|
179
|
+
createdAt: num(f["createdAt"]),
|
|
180
|
+
voteEnd: num(f["voteEnd"]),
|
|
181
|
+
votableAtOpen: num(f["votableAtOpen"]),
|
|
182
|
+
yesWeight: num(f["yesWeight"]),
|
|
183
|
+
noWeight: num(f["noWeight"]),
|
|
184
|
+
voterCount: num(f["voterCount"]),
|
|
185
|
+
yesVoterCount: num(f["yesVoterCount"]),
|
|
186
|
+
status,
|
|
187
|
+
statusLabel: PROPOSAL_STATUS[status] ?? `unknown(${status})`,
|
|
188
|
+
reason: str(f["reason"]),
|
|
189
|
+
paidInShares: bool(f["paidInShares"]),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export async function getProposalMeta(side, proposalId) {
|
|
193
|
+
const f = fields(await readContract(side.legion, "get-proposal-meta", [uint(proposalId)]));
|
|
194
|
+
if (!f)
|
|
195
|
+
return null;
|
|
196
|
+
return {
|
|
197
|
+
title: str(f["title"]),
|
|
198
|
+
description: str(f["description"]),
|
|
199
|
+
link: str(f["link"]),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** The contract's own phase word for a proposal. */
|
|
203
|
+
export async function getPhase(side, proposalId) {
|
|
204
|
+
return str(await readContract(side.legion, "get-phase", [uint(proposalId)]));
|
|
205
|
+
}
|
|
206
|
+
export async function getSettlement(side) {
|
|
207
|
+
const f = fields(await readContract(side.legion, "get-settlement"));
|
|
208
|
+
if (!f)
|
|
209
|
+
throw new Error(`${side.legion} get-settlement returned nothing.`);
|
|
210
|
+
return {
|
|
211
|
+
redeemed: bool(f["redeemed"]),
|
|
212
|
+
redeemedSats: num(f["redeemedSats"]),
|
|
213
|
+
paidSats: num(f["paidSats"]),
|
|
214
|
+
unpaidSats: num(f["unpaidSats"]),
|
|
215
|
+
totalCredits: num(f["totalCredits"]),
|
|
216
|
+
won: bool(f["won"]),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Predict what `conclude` will decide, in the contract's own decision order.
|
|
221
|
+
*
|
|
222
|
+
* The chain decides for real at the block that mines the transaction; this is
|
|
223
|
+
* the tally as of now, so a caller can see that they are about to burn gas on
|
|
224
|
+
* a proposal that fails for want of one more voter.
|
|
225
|
+
*/
|
|
226
|
+
export function predictOutcome(proposal, params, proposerStillHolding, vault, totalCredits, tradeable) {
|
|
227
|
+
const cast = proposal.yesWeight + proposal.noWeight;
|
|
228
|
+
if (proposal.yesVoterCount < params.minVoters) {
|
|
229
|
+
return { outcome: "failed", reason: "no-voters", payout: 0 };
|
|
230
|
+
}
|
|
231
|
+
if (cast === 0 || (proposal.yesWeight * 100) / cast < params.votingThreshold) {
|
|
232
|
+
return { outcome: "failed", reason: "voted-down", payout: 0 };
|
|
233
|
+
}
|
|
234
|
+
if (!proposerStillHolding) {
|
|
235
|
+
return { outcome: "failed", reason: "not-holding", payout: 0 };
|
|
236
|
+
}
|
|
237
|
+
if (vault < totalCredits + params.payout) {
|
|
238
|
+
return { outcome: "failed", reason: "pot-short", payout: 0 };
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
outcome: "passed",
|
|
242
|
+
reason: tradeable ? "paid-shares" : "credited",
|
|
243
|
+
payout: params.payout,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Clarity argument helpers and error translation
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
function principal(address) {
|
|
250
|
+
return principalCV(address);
|
|
251
|
+
}
|
|
252
|
+
function uint(value) {
|
|
253
|
+
return uintCV(value);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Turn a contract abort into the sentence behind its code.
|
|
257
|
+
*
|
|
258
|
+
* The two contracts use disjoint ranges — the market is 100-313, the legions
|
|
259
|
+
* 401-450 — so one lookup across both tables cannot collide.
|
|
260
|
+
*/
|
|
261
|
+
export function explainAbort(error) {
|
|
262
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
263
|
+
const match = message.match(/\bu(\d+)\b/);
|
|
264
|
+
if (!match)
|
|
265
|
+
return error instanceof Error ? error : new Error(message);
|
|
266
|
+
const code = Number(match[1]);
|
|
267
|
+
const explanation = MARKET_ERRORS[code] ?? LEGION_ERRORS[code];
|
|
268
|
+
if (!explanation)
|
|
269
|
+
return error instanceof Error ? error : new Error(message);
|
|
270
|
+
return new Error(`${explanation} (u${code})\n\nRaw: ${message}`);
|
|
271
|
+
}
|
|
272
|
+
//# sourceMappingURL=at-stake.service.js.map
|
|
@@ -1,4 +1,57 @@
|
|
|
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, and contract calls carrying a bounded caller-owned post condition)
|
|
9
|
+
* routes through it, and the limiter blocks once cumulative outflow would
|
|
10
|
+
* exceed a per-session OR per-day cap.
|
|
11
|
+
*
|
|
12
|
+
* Two independent ledgers are tracked — micro-STX (`ustx`) and satoshis
|
|
13
|
+
* (`sats`); there is no cross-asset USD normalization. Daily totals are
|
|
14
|
+
* persisted to ~/.aibtc/spend-state.json (keyed by wallet address + UTC day) so
|
|
15
|
+
* the cap survives process restarts; session totals live in memory and reset on
|
|
16
|
+
* wallet unlock/lock/switch.
|
|
17
|
+
*
|
|
18
|
+
* Exceeding the cap is the intended human-in-the-loop checkpoint: it blocks and
|
|
19
|
+
* surfaces the remaining budget + the env var to raise. An LLM cannot set env
|
|
20
|
+
* vars mid-session, so it cannot raise its own ceiling.
|
|
21
|
+
*
|
|
22
|
+
* Disable entirely with SPEND_LIMIT_ENABLED=false. Override caps with
|
|
23
|
+
* SPEND_LIMIT_DAILY_USTX / _SESSION_USTX / _DAILY_SATS / _SESSION_SATS.
|
|
24
|
+
*/
|
|
25
|
+
import type { PostCondition } from "@stacks/transactions";
|
|
1
26
|
export type SpendUnit = "ustx" | "sats";
|
|
27
|
+
/**
|
|
28
|
+
* Read spend ceilings out of a contract call's post conditions.
|
|
29
|
+
*
|
|
30
|
+
* A post condition already says "at most N of asset A leaves principal P", in
|
|
31
|
+
* native units, enforced by the chain — which is exactly the bound this rail
|
|
32
|
+
* wants, without taking a price-oracle dependency inside a safety rail.
|
|
33
|
+
*
|
|
34
|
+
* Only conditions that BOUND THE SPEND FROM ABOVE count: `eq`, `lt` and `lte`.
|
|
35
|
+
* A `gt`/`gte` condition is a floor, not a cap, so metering it would invent a
|
|
36
|
+
* ceiling that does not exist. Conditions on another principal are somebody
|
|
37
|
+
* else's funds, and an FT that is not sBTC has no ledger here — both are
|
|
38
|
+
* skipped rather than guessed at.
|
|
39
|
+
*
|
|
40
|
+
* `lt` is metered at its face value (one micro-unit above the true ceiling).
|
|
41
|
+
* Over-metering by one is the safe direction for a cap.
|
|
42
|
+
*/
|
|
43
|
+
export declare function boundedPostConditionSpends(postConditions: PostCondition[] | undefined, accountAddress: string): Array<{
|
|
44
|
+
unit: SpendUnit;
|
|
45
|
+
amount: bigint;
|
|
46
|
+
}>;
|
|
47
|
+
/**
|
|
48
|
+
* Collapse per-unit so one contract call is metered once per ledger, not once
|
|
49
|
+
* per post condition.
|
|
50
|
+
*/
|
|
51
|
+
export declare function totalBoundedSpends(postConditions: PostCondition[] | undefined, accountAddress: string): Array<{
|
|
52
|
+
unit: SpendUnit;
|
|
53
|
+
amount: bigint;
|
|
54
|
+
}>;
|
|
2
55
|
export declare class SpendLimitError extends Error {
|
|
3
56
|
readonly unit: SpendUnit;
|
|
4
57
|
readonly attempted: number;
|
|
@@ -1,29 +1,64 @@
|
|
|
1
|
+
import { promises as fs } from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { withSharedStateLock } from "./state-lock.js";
|
|
5
|
+
import { MAINNET_CONTRACTS, TESTNET_CONTRACTS } from "../config/contracts.js";
|
|
6
|
+
/** Asset ids this rail can meter. Any other FT has no ledger to bill. */
|
|
7
|
+
const SBTC_ASSETS = new Set([
|
|
8
|
+
`${MAINNET_CONTRACTS.SBTC_TOKEN}::sbtc-token`,
|
|
9
|
+
`${TESTNET_CONTRACTS.SBTC_TOKEN}::sbtc-token`,
|
|
10
|
+
]);
|
|
1
11
|
/**
|
|
2
|
-
*
|
|
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.
|
|
12
|
+
* Read spend ceilings out of a contract call's post conditions.
|
|
10
13
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the cap survives process restarts; session totals live in memory and reset on
|
|
15
|
-
* wallet unlock/lock/switch.
|
|
14
|
+
* A post condition already says "at most N of asset A leaves principal P", in
|
|
15
|
+
* native units, enforced by the chain — which is exactly the bound this rail
|
|
16
|
+
* wants, without taking a price-oracle dependency inside a safety rail.
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Only conditions that BOUND THE SPEND FROM ABOVE count: `eq`, `lt` and `lte`.
|
|
19
|
+
* A `gt`/`gte` condition is a floor, not a cap, so metering it would invent a
|
|
20
|
+
* ceiling that does not exist. Conditions on another principal are somebody
|
|
21
|
+
* else's funds, and an FT that is not sBTC has no ledger here — both are
|
|
22
|
+
* skipped rather than guessed at.
|
|
20
23
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
24
|
+
* `lt` is metered at its face value (one micro-unit above the true ceiling).
|
|
25
|
+
* Over-metering by one is the safe direction for a cap.
|
|
23
26
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
export function boundedPostConditionSpends(postConditions, accountAddress) {
|
|
28
|
+
const spends = [];
|
|
29
|
+
for (const condition of postConditions ?? []) {
|
|
30
|
+
const pc = condition;
|
|
31
|
+
if (pc.address !== accountAddress)
|
|
32
|
+
continue;
|
|
33
|
+
if (pc.condition !== "eq" && pc.condition !== "lt" && pc.condition !== "lte")
|
|
34
|
+
continue;
|
|
35
|
+
if (pc.amount === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
const amount = BigInt(pc.amount);
|
|
38
|
+
if (amount < 0n)
|
|
39
|
+
continue;
|
|
40
|
+
if (pc.type === "stx-postcondition") {
|
|
41
|
+
spends.push({ unit: "ustx", amount });
|
|
42
|
+
}
|
|
43
|
+
else if (pc.type === "ft-postcondition" &&
|
|
44
|
+
pc.asset !== undefined &&
|
|
45
|
+
SBTC_ASSETS.has(pc.asset)) {
|
|
46
|
+
spends.push({ unit: "sats", amount });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return spends;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Collapse per-unit so one contract call is metered once per ledger, not once
|
|
53
|
+
* per post condition.
|
|
54
|
+
*/
|
|
55
|
+
export function totalBoundedSpends(postConditions, accountAddress) {
|
|
56
|
+
const totals = new Map();
|
|
57
|
+
for (const { unit, amount } of boundedPostConditionSpends(postConditions, accountAddress)) {
|
|
58
|
+
totals.set(unit, (totals.get(unit) ?? 0n) + amount);
|
|
59
|
+
}
|
|
60
|
+
return [...totals].map(([unit, amount]) => ({ unit, amount }));
|
|
61
|
+
}
|
|
27
62
|
const STORAGE_DIR = path.join(os.homedir(), ".aibtc");
|
|
28
63
|
const DEFAULT_STATE_FILE = path.join(STORAGE_DIR, "spend-state.json");
|
|
29
64
|
/**
|
|
@@ -161,27 +196,50 @@ class SpendLimiter {
|
|
|
161
196
|
return;
|
|
162
197
|
const amt = Number(amount);
|
|
163
198
|
this.getSession(addr)[unit] += amt;
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
199
|
+
// Two locks, for two different races.
|
|
200
|
+
//
|
|
201
|
+
// The promise chain serializes concurrent record() calls INSIDE this
|
|
202
|
+
// process. The file lease excludes the OTHER process writing this file:
|
|
203
|
+
// the skills engine's direct x402 path shares spend-state.json so one
|
|
204
|
+
// wallet has one daily cap. Without the lease, its writes and ours
|
|
205
|
+
// overwrite each other and the day total under-counts, which is the
|
|
206
|
+
// direction that permits spending past the cap.
|
|
207
|
+
//
|
|
208
|
+
// Note the prune below walks EVERY address, so an unlocked concurrent
|
|
209
|
+
// write can lose another wallet's day too, not just this one's.
|
|
210
|
+
const write = this.writeLock.then(async () => {
|
|
211
|
+
const run = await withSharedStateLock(this.stateFile, async () => {
|
|
212
|
+
const state = await this.readState();
|
|
213
|
+
const today = todayKey();
|
|
214
|
+
// Prune stale days so the file does not grow unbounded.
|
|
215
|
+
for (const a of Object.keys(state)) {
|
|
216
|
+
for (const day of Object.keys(state[a])) {
|
|
217
|
+
if (day !== today)
|
|
218
|
+
delete state[a][day];
|
|
219
|
+
}
|
|
220
|
+
if (Object.keys(state[a]).length === 0)
|
|
221
|
+
delete state[a];
|
|
173
222
|
}
|
|
174
|
-
if (
|
|
175
|
-
|
|
223
|
+
if (!state[addr])
|
|
224
|
+
state[addr] = {};
|
|
225
|
+
if (!state[addr][today])
|
|
226
|
+
state[addr][today] = { ustx: 0, sats: 0 };
|
|
227
|
+
state[addr][today][unit] += amt;
|
|
228
|
+
await this.writeState(state);
|
|
229
|
+
});
|
|
230
|
+
// The spend already happened on chain by the time record() is called, so
|
|
231
|
+
// an unavailable lease must not throw: that would turn a broadcast
|
|
232
|
+
// transaction into an unrecorded one. Write anyway and say so, because a
|
|
233
|
+
// silent under-count is the failure this whole lease exists to prevent.
|
|
234
|
+
if (!run.held) {
|
|
235
|
+
console.error(`[spend-limit] Recorded ${amt} ${unit} for ${addr} WITHOUT the shared lock: ` +
|
|
236
|
+
`${run.unavailable} The daily total may under-count if another tool wrote ` +
|
|
237
|
+
`concurrently.`);
|
|
176
238
|
}
|
|
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
239
|
});
|
|
184
|
-
|
|
240
|
+
// A failed write must not poison the chain for every later record().
|
|
241
|
+
this.writeLock = write.catch(() => { });
|
|
242
|
+
await write;
|
|
185
243
|
}
|
|
186
244
|
/** Remaining session/day budget for status reporting. */
|
|
187
245
|
async status(addr) {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process lock for the shared `~/.aibtc` state files.
|
|
3
|
+
*
|
|
4
|
+
* `spend-state.json` has TWO writers: this server, and the aibtc skills engine's
|
|
5
|
+
* direct x402 payment path (aibtcdev/skills#423), which reads and writes the
|
|
6
|
+
* same file in the same shape so one wallet has one daily cap no matter which
|
|
7
|
+
* tool spends from it. Sharing the file is the point; sharing it without a lock
|
|
8
|
+
* is a lost update:
|
|
9
|
+
*
|
|
10
|
+
* 1. this server reads the day total and sees 100
|
|
11
|
+
* 2. the other tool takes its lock, reads 100, writes 150, releases
|
|
12
|
+
* 3. this server writes 130 from the copy it read in step 1
|
|
13
|
+
*
|
|
14
|
+
* The other tool's 50 is gone and the daily cap now UNDER-counts, which is the
|
|
15
|
+
* direction that lets a wallet spend past the cap it was supposed to share. An
|
|
16
|
+
* in-process promise chain cannot fix this: the other writer is another process.
|
|
17
|
+
*
|
|
18
|
+
* THE PROTOCOL HERE IS NOT OURS TO CHANGE UNILATERALLY. It is byte-compatible
|
|
19
|
+
* with `x402-guards.ts` in the skills engine so the two tools' leases are
|
|
20
|
+
* interchangeable — same lock directory name, same `owner` file format, same
|
|
21
|
+
* mtime heartbeat, same staleness window. A lock only excludes writers that
|
|
22
|
+
* agree on where it lives, so "x402-guards.lock" is deliberately kept even
|
|
23
|
+
* though nothing here is x402: renaming it on this side would silently return
|
|
24
|
+
* both tools to the race above while each believed it held a lock.
|
|
25
|
+
*
|
|
26
|
+
* A STALE LEASE IS NEVER RECLAIMED AUTOMATICALLY. Deleting a lock this process
|
|
27
|
+
* did not create needs an atomic "remove only if unchanged since I looked",
|
|
28
|
+
* which the filesystem does not offer; guessing wrong there is precisely the
|
|
29
|
+
* concurrent write the lock exists to prevent. A dead holder is reported with
|
|
30
|
+
* its pid and left for an operator, who can see whether it is really dead.
|
|
31
|
+
*/
|
|
32
|
+
/** Mutable so tests do not have to wait real seconds. */
|
|
33
|
+
declare const timing: {
|
|
34
|
+
maxWaitMs: number;
|
|
35
|
+
staleMs: number;
|
|
36
|
+
heartbeatMs: number;
|
|
37
|
+
retryMs: number;
|
|
38
|
+
};
|
|
39
|
+
/** Test seam. Not part of the public contract. */
|
|
40
|
+
export declare const _stateLockTesting: {
|
|
41
|
+
set(overrides: Partial<typeof timing>): void;
|
|
42
|
+
reset(): void;
|
|
43
|
+
lockDirFor: typeof lockDirFor;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The lease guarding a state file, derived from its directory so a test that
|
|
47
|
+
* redirects the state file redirects the lock with it.
|
|
48
|
+
*/
|
|
49
|
+
declare function lockDirFor(stateFile: string): string;
|
|
50
|
+
export interface LockedRun<T> {
|
|
51
|
+
/** False when the body ran WITHOUT the lease. */
|
|
52
|
+
held: boolean;
|
|
53
|
+
/** Set when `held` is false: why the lease could not be taken. */
|
|
54
|
+
unavailable?: string;
|
|
55
|
+
value: T;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Run `fn` under the shared lease for `stateFile`.
|
|
59
|
+
*
|
|
60
|
+
* `fn` RUNS EITHER WAY. This guards a read-modify-write that records something
|
|
61
|
+
* which has ALREADY HAPPENED on chain, so refusing to run it would turn a
|
|
62
|
+
* broadcast transaction into an unrecorded one — strictly worse than the race
|
|
63
|
+
* it is trying to avoid. The caller is told which case it got and is expected
|
|
64
|
+
* to say so loudly rather than let an under-count pass silently.
|
|
65
|
+
*
|
|
66
|
+
* That is the deliberate difference from the skills engine, which takes this
|
|
67
|
+
* same lease and DOES refuse: there the lock guards a payment not yet signed,
|
|
68
|
+
* so refusing costs nothing but a retry. Authorising a future spend and
|
|
69
|
+
* recording a past one fail in opposite directions.
|
|
70
|
+
*/
|
|
71
|
+
export declare function withSharedStateLock<T>(stateFile: string, fn: () => Promise<T>): Promise<LockedRun<T>>;
|
|
72
|
+
export {};
|
|
73
|
+
//# sourceMappingURL=state-lock.d.ts.map
|