taximeter 0.1.2 → 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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Taximeter
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Default to rejecting parsed payments for unknown assets. Set `policy.unknownAsset` to `allow` to opt in and configure exact contract-address budgets for custom tokens.
8
+
9
+ Replace full ledger replay during payment gating with transactional, rebuildable payment projections and exact timestamp-indexed budget sums. Preserve append-only source records, retry and settlement semantics, and precise rolling-window boundaries. Existing databases migrate to schema version 2 when opened; older 0.1.x clients cannot reopen the migrated schema.
10
+
3
11
  ## 0.1.2
4
12
 
5
13
  ### Patch Changes
package/README.md CHANGED
@@ -51,7 +51,7 @@ Open a terminal in this source checkout, with Node 20+ and npm installed.
51
51
  You should now see:
52
52
 
53
53
  ```text
54
- Taximeter 0.1.2
54
+ Taximeter 0.2.0
55
55
  Proxy: http://127.0.0.1:8402
56
56
  Dashboard: http://127.0.0.1:8403
57
57
  Point an HTTP-proxy-aware agent at http://127.0.0.1:8402.
@@ -116,6 +116,8 @@ their amount before forwarding the payment replay.
116
116
  Policy evaluation and reservation share one SQLite transaction, so concurrent
117
117
  payments cannot all consume the same remaining capacity.
118
118
  Append-only payment, outcome, and diagnostic records derive every total with BigInt.
119
+ Payment checks use an incrementally maintained index of exact budget sums, avoiding
120
+ a replay of all prior payments. The index can be rebuilt from the original log.
119
121
  The local dashboard polls that ledger every second and exports the same counted amounts.
120
122
 
121
123
  An initial 402 challenge is an offer, not spend. Reported settlement counts as
@@ -138,6 +140,11 @@ as text to prevent a spreadsheet application from rounding them.
138
140
 
139
141
  ## Configuration
140
142
 
143
+ When upgrading from 0.1.x, upgrade all writers together. Opening an existing
144
+ ledger builds the new budget index once and upgrades its schema; the source log
145
+ is preserved. Older 0.1.x clients cannot reopen that migrated database. Parsed
146
+ unknown assets now require an explicit opt-in as described below.
147
+
141
148
  No file is required. Start from [the example](taximeter.config.example.json) when
142
149
  needed. Precedence, highest first: flags, environment, explicit `--config` file,
143
150
  `taximeter.config.json` in the working directory, `~/.taximeter/config.json`, defaults.
@@ -158,6 +165,7 @@ invalid values are rejected with Zod.
158
165
  | `policy.allowPayTo` | `[]` | Empty allows all recipients; otherwise exact case-insensitive addresses. |
159
166
  | `policy.maxSinglePayment` | `"1000000"` | One USDC per authorization by default; `null` disables the cap. |
160
167
  | `policy.maxSingleAsset` | `USDC` | Asset to which the single-payment cap applies. |
168
+ | `policy.unknownAsset` | `"deny"` | `deny` blocks parsed payments whose network and contract are absent from the offline asset registry; `allow` opts in to those assets. |
161
169
  | `ports.proxy` | `8402` | Loopback HTTP listener; `0` selects an available port. |
162
170
  | `ports.dashboard` | `8403` | Loopback dashboard listener; `0` selects an available port. |
163
171
  | `db` | `~/.taximeter/ledger.db` | SQLite path; relative paths resolve against the working directory. |
@@ -168,9 +176,16 @@ All commands accept `--db` and `--config`. Start also accepts `--proxy-port`,
168
176
  `TAXIMETER_PORT`, and `TAXIMETER_DASHBOARD_PORT`.
169
177
 
170
178
  Missing task/agent labels share an **Unattributed** bucket. Supply `Taximeter-Task`
171
- and `Taximeter-Agent` headers, or SDK options. The local USDC registry recognizes
172
- Base and Base Sepolia; other contracts remain separate atomic-unit balances with
173
- unknown decimals. Default USDC budgets do not cap unknown assets.
179
+ and `Taximeter-Agent` headers, or SDK options. The offline asset registry recognizes
180
+ USDC by its network and contract on Base and Base Sepolia. Parsed payments for
181
+ other assets are denied by default with `unknown_asset`; supplied symbols or
182
+ decimal metadata cannot make a token known. Unsupported or unparseable payment
183
+ formats still pass through with a diagnostic.
184
+
185
+ Custom-token budgets require `policy.unknownAsset: "allow"`. This opts in to parsed
186
+ unknown assets, which remain separate atomic-unit balances with unknown decimals.
187
+ Configure budgets using the exact contract address and, when needed, its network.
188
+ Default USDC budgets do not cap these assets; only matching budgets and caps apply.
174
189
 
175
190
  A denied replay receives HTTP 402 before it reaches the upstream. For a cap of
176
191
  140 atomic units already fully consumed, the response is:
@@ -185,9 +200,10 @@ A denied replay receives HTTP 402 before it reaches the upstream. For a cap of
185
200
  }
186
201
  ```
187
202
 
188
- Reasons are `host_denied`, `host_not_allowed`, `recipient_not_allowed`,
203
+ Reasons are `host_denied`, `host_not_allowed`, `recipient_not_allowed`, `unknown_asset`,
189
204
  `max_single_payment`, `per_task_budget`, `per_agent_budget`, and `global_budget`.
190
- Host/recipient denials have `budget: null` and `remaining: null`.
205
+ Host, recipient, and unknown-asset denials have `budget: null`, `spent: "0"`, and
206
+ `remaining: null`.
191
207
 
192
208
  ## What this is not
193
209
 
package/SECURITY.md CHANGED
@@ -7,7 +7,7 @@ on-chain settlement.
7
7
 
8
8
  ## Supported version
9
9
 
10
- Security fixes currently target the `0.1.0` release line. No older release line
10
+ Security fixes currently target the `0.2.x` release line. No older release line
11
11
  is maintained. This policy describes support; it is not a claim that the package
12
12
  has already been published or independently audited.
13
13
 
@@ -32,6 +32,9 @@ against a hostile agent or another process running as the same user.
32
32
  - Unsupported or malformed payment forms pass through with a diagnostic.
33
33
  Supported parsing is narrower than the full x402 protocol; see
34
34
  [protocol notes](https://github.com/Ding808/taximeter/blob/main/SPEC-NOTES.md).
35
+ - Parsed payments outside the offline asset registry are denied by default.
36
+ Setting `policy.unknownAsset` to `allow` permits them; only matching contract
37
+ budgets and caps then apply. Metadata supplied with a payment does not make its asset trusted.
35
38
  - HTTPS CONNECT tunnels encrypted bytes without metering their payments.
36
39
  Taximeter does not install a certificate authority or intercept TLS.
37
40
  - The SDK must wrap the transport inside the payment wrapper. Redirects and
@@ -81,7 +81,8 @@ var policySchema = z2.strictObject({
81
81
  denyHosts: z2.array(labelSchema).default([]),
82
82
  allowPayTo: z2.array(labelSchema).default([]),
83
83
  maxSinglePayment: amountSchema.nullable().default("1000000"),
84
- maxSingleAsset: labelSchema.default("USDC")
84
+ maxSingleAsset: labelSchema.default("USDC"),
85
+ unknownAsset: z2.enum(["allow", "deny"]).default("deny")
85
86
  });
86
87
  var portsSchema = z2.strictObject({
87
88
  proxy: z2.number().int().min(0).max(65535).default(8402),
@@ -109,7 +110,8 @@ var configPatchSchema = z2.strictObject({
109
110
  denyHosts: z2.array(labelSchema).optional(),
110
111
  allowPayTo: z2.array(labelSchema).optional(),
111
112
  maxSinglePayment: amountSchema.nullable().optional(),
112
- maxSingleAsset: labelSchema.optional()
113
+ maxSingleAsset: labelSchema.optional(),
114
+ unknownAsset: z2.enum(["allow", "deny"]).optional()
113
115
  }).optional(),
114
116
  ports: z2.strictObject({
115
117
  proxy: z2.number().int().min(0).max(65535).optional(),
@@ -320,6 +322,23 @@ function spentForBudget(proposed, events, budget, scope, now) {
320
322
  return spent.toString();
321
323
  }
322
324
  function evaluate(proposed, events, config, now) {
325
+ return evaluateWithState(
326
+ proposed,
327
+ {
328
+ reserved: events.find(
329
+ (event) => event.paymentKey === proposed.paymentKey && countsAsSpend(event)
330
+ ),
331
+ spent: {
332
+ perTask: config.budgets.perTask ? spentForBudget(proposed, events, config.budgets.perTask, "perTask", now) : "0",
333
+ perAgent: config.budgets.perAgent ? spentForBudget(proposed, events, config.budgets.perAgent, "perAgent", now) : "0",
334
+ global: config.budgets.global ? spentForBudget(proposed, events, config.budgets.global, "global", now) : "0"
335
+ }
336
+ },
337
+ config,
338
+ now
339
+ );
340
+ }
341
+ function evaluateWithState(proposed, state, config, now) {
323
342
  const { policy } = config;
324
343
  const host = proposed.host.toLowerCase();
325
344
  if (policy.denyHosts.some((value) => value.toLowerCase() === host)) return deny("host_denied");
@@ -327,16 +346,16 @@ function evaluate(proposed, events, config, now) {
327
346
  return deny("host_not_allowed");
328
347
  if (policy.allowPayTo.length > 0 && !policy.allowPayTo.some((value) => value.toLowerCase() === proposed.payTo.toLowerCase()))
329
348
  return deny("recipient_not_allowed");
349
+ if (policy.unknownAsset === "deny" && !assetMetadata(proposed.network, proposed.asset).decimalsKnown)
350
+ return deny("unknown_asset");
330
351
  if (policy.maxSinglePayment !== null && matchesAsset(policy.maxSingleAsset, proposed) && BigInt(proposed.amount) > BigInt(policy.maxSinglePayment))
331
352
  return deny("max_single_payment", policy.maxSinglePayment);
332
- const reserved = events.find(
333
- (event) => event.paymentKey === proposed.paymentKey && countsAsSpend(event)
334
- );
353
+ const reserved = state.reserved && countsAsSpend(state.reserved) ? state.reserved : void 0;
335
354
  for (const scope of ["perTask", "perAgent", "global"]) {
336
355
  const budget = config.budgets[scope];
337
356
  if (!budget || !matchesAsset(budget.asset, proposed) || budget.network && budget.network !== proposed.network)
338
357
  continue;
339
- const spent = spentForBudget(proposed, events, budget, scope, now);
358
+ const spent = state.spent[scope];
340
359
  const reservedTime = reserved ? Date.parse(reserved.attemptedAt ?? reserved.ts) : -Infinity;
341
360
  const inWindow = reservedTime <= now && (!budget.window || reservedTime >= now - windows[budget.window]);
342
361
  const increment = reserved && (reserved.settlementStatus === "confirmed" || inWindow) ? 0n : BigInt(proposed.amount);
@@ -647,8 +666,9 @@ var Meter = class {
647
666
  if (!proposed) return {};
648
667
  try {
649
668
  return this.ledger.transaction(() => {
650
- const events = this.ledger.view();
651
- const decision = evaluate(proposed, events, this.config, Date.now());
669
+ const now = Date.now();
670
+ const state = this.ledger.policyState(proposed, this.config.budgets, now);
671
+ const decision = evaluateWithState(proposed, state, this.config, now);
652
672
  if (!decision.allowed) {
653
673
  this.ledger.append(
654
674
  paymentEventSchema.parse({
@@ -660,9 +680,7 @@ var Meter = class {
660
680
  );
661
681
  return { body: decision.body };
662
682
  }
663
- const previous = events.find(
664
- (event) => event.status === "observed" && event.paymentKey === proposed.paymentKey
665
- );
683
+ const previous = state.reserved;
666
684
  const payment = previous ?? proposed;
667
685
  if (!previous) this.ledger.append(payment);
668
686
  const ts = (/* @__PURE__ */ new Date()).toISOString();
@@ -796,32 +814,324 @@ import { mkdirSync } from "fs";
796
814
  import { dirname as dirname2 } from "path";
797
815
  import Database from "better-sqlite3";
798
816
  import { v7 as v73 } from "uuid";
799
- import { z as z7 } from "zod";
817
+ import { z as z8 } from "zod";
800
818
 
801
819
  // migrations/001.sql
802
820
  var __default = "CREATE TABLE schema_version (version INTEGER PRIMARY KEY);\nINSERT INTO schema_version VALUES (1);\nCREATE TABLE events (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n taskId TEXT,\n agentId TEXT,\n host TEXT NOT NULL,\n status TEXT NOT NULL CHECK (status IN ('observed', 'blocked')),\n paymentKey TEXT NOT NULL,\n payload TEXT NOT NULL CHECK (json_valid(payload))\n);\nCREATE INDEX events_ts ON events(ts);\nCREATE INDEX events_task ON events(taskId);\nCREATE INDEX events_agent ON events(agentId);\nCREATE INDEX events_host ON events(host);\nCREATE UNIQUE INDEX events_payment ON events(paymentKey) WHERE status = 'observed';\nCREATE TABLE outcomes (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n paymentId TEXT NOT NULL REFERENCES events(id),\n payload TEXT NOT NULL CHECK (json_valid(payload))\n);\nCREATE INDEX outcomes_payment ON outcomes(paymentId);\nCREATE TABLE diagnostics (\n id TEXT PRIMARY KEY,\n ts TEXT NOT NULL,\n payload TEXT NOT NULL CHECK (json_valid(payload))\n);\nCREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'append-only events'); END;\nCREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'append-only events'); END;\nCREATE TRIGGER outcomes_no_update BEFORE UPDATE ON outcomes BEGIN SELECT RAISE(ABORT, 'append-only outcomes'); END;\nCREATE TRIGGER outcomes_no_delete BEFORE DELETE ON outcomes BEGIN SELECT RAISE(ABORT, 'append-only outcomes'); END;\nCREATE TRIGGER diagnostics_no_update BEFORE UPDATE ON diagnostics BEGIN SELECT RAISE(ABORT, 'append-only diagnostics'); END;\nCREATE TRIGGER diagnostics_no_delete BEFORE DELETE ON diagnostics BEGIN SELECT RAISE(ABORT, 'append-only diagnostics'); END;\n";
803
821
 
822
+ // migrations/002.sql
823
+ var __default2 = "CREATE TABLE cache_cursors (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n eventRowid INTEGER NOT NULL CHECK (eventRowid >= 0),\n outcomeRowid INTEGER NOT NULL CHECK (outcomeRowid >= 0)\n);\nINSERT INTO cache_cursors VALUES (1, 0, 0);\n\nCREATE TABLE cache_payments (\n paymentId TEXT PRIMARY KEY REFERENCES events(id),\n paymentKey TEXT NOT NULL UNIQUE,\n payload TEXT NOT NULL CHECK (json_valid(payload)),\n maxAttemptedAt TEXT\n);\n\nCREATE TABLE cache_attempts (\n paymentId TEXT NOT NULL REFERENCES cache_payments(paymentId),\n attemptId TEXT NOT NULL,\n firstTs TEXT NOT NULL,\n firstId TEXT NOT NULL,\n winnerTs TEXT NOT NULL,\n winnerId TEXT NOT NULL,\n status TEXT NOT NULL CHECK (status IN ('unknown', 'confirmed', 'failed')),\n payload TEXT NOT NULL CHECK (json_valid(payload)),\n PRIMARY KEY (paymentId, attemptId)\n);\nCREATE INDEX cache_attempt_selection ON cache_attempts (\n paymentId, status, firstTs, firstId COLLATE NOCASE, firstId DESC\n);\n\nCREATE TABLE cache_prefix (\n partitionKey TEXT NOT NULL,\n prefix TEXT NOT NULL,\n amount TEXT NOT NULL CHECK (amount <> '' AND amount NOT GLOB '*[^0-9]*'),\n PRIMARY KEY (partitionKey, prefix)\n) WITHOUT ROWID;\n\nINSERT INTO schema_version VALUES (2);\n";
824
+
825
+ // src/ledger/cache.ts
826
+ import { z as z7 } from "zod";
827
+ var cursorSchema = z7.object({
828
+ eventRowid: z7.number().int().nonnegative(),
829
+ outcomeRowid: z7.number().int().nonnegative()
830
+ });
831
+ var sourceSchema = z7.object({
832
+ sourceRowid: z7.number().int().positive(),
833
+ id: z7.string(),
834
+ payload: z7.string()
835
+ });
836
+ var payloadSchema = z7.object({ payload: z7.string() });
837
+ var paymentRowSchema = z7.object({
838
+ payload: z7.string(),
839
+ originalPayload: z7.string(),
840
+ maxAttemptedAt: z7.iso.datetime().nullable()
841
+ });
842
+ var attemptRowSchema = z7.object({
843
+ firstTs: z7.iso.datetime(),
844
+ firstId: z7.uuidv7(),
845
+ winnerTs: z7.iso.datetime(),
846
+ winnerId: z7.uuidv7(),
847
+ status: z7.enum(["unknown", "confirmed", "failed"])
848
+ });
849
+ var prefixRowSchema = z7.object({
850
+ prefix: z7.string().regex(/^[0-9a-f]{0,16}$/),
851
+ amount: integerStringSchema
852
+ });
853
+ var scopeSchema = z7.enum(["perTask", "perAgent", "global"]);
854
+ var windowSchema = z7.enum(["1h", "24h", "7d", "30d"]);
855
+ var timeSchema = z7.number().int().min(-864e13).max(864e13);
856
+ var windows2 = {
857
+ "1h": 36e5,
858
+ "24h": 864e5,
859
+ "7d": 6048e5,
860
+ "30d": 2592e6
861
+ };
862
+ var digits = "0123456789abcdef";
863
+ var bias = 1n << 63n;
864
+ function timeKey(milliseconds) {
865
+ const value = z7.number().int().parse(milliseconds);
866
+ const encoded = bias + BigInt(value);
867
+ if (encoded < 0n || encoded >= 1n << 64n) throw new Error("Timestamp outside cache index range");
868
+ return encoded.toString(16).padStart(16, "0");
869
+ }
870
+ function partition(payment, scope) {
871
+ const attribution = scope === "perTask" ? payment.taskId : scope === "perAgent" ? payment.agentId : null;
872
+ return JSON.stringify([scope, attribution ?? null, payment.network, payment.asset.toLowerCase()]);
873
+ }
874
+ function order2(left, right) {
875
+ return left.ts.localeCompare(right.ts) || left.id.localeCompare(right.id);
876
+ }
877
+ var LedgerCache = class {
878
+ constructor(database) {
879
+ this.database = database;
880
+ }
881
+ database;
882
+ statements = /* @__PURE__ */ new Map();
883
+ statement(sql) {
884
+ let statement = this.statements.get(sql);
885
+ if (!statement) {
886
+ statement = this.database.prepare(sql);
887
+ this.statements.set(sql, statement);
888
+ }
889
+ return statement;
890
+ }
891
+ /** The caller holds an immediate transaction throughout synchronization and budget gating. */
892
+ synchronize() {
893
+ const cursor = cursorSchema.parse(
894
+ this.statement("SELECT eventRowid, outcomeRowid FROM cache_cursors WHERE id = 1").get()
895
+ );
896
+ let eventRowid = cursor.eventRowid;
897
+ let outcomeRowid = cursor.outcomeRowid;
898
+ const eventQuery = this.statement(
899
+ "SELECT rowid AS sourceRowid, id, payload FROM events WHERE rowid > ? ORDER BY rowid LIMIT 256"
900
+ );
901
+ for (let batch = eventQuery.all(eventRowid); batch.length > 0; batch = eventQuery.all(eventRowid)) {
902
+ for (const input of batch) {
903
+ const row = sourceSchema.parse(input);
904
+ const payment = paymentEventSchema.parse(JSON.parse(row.payload));
905
+ if (payment.id !== row.id)
906
+ throw new Error("Payment row identity does not match its payload");
907
+ if (payment.status === "observed") {
908
+ const derived = paymentEventSchema.parse({
909
+ ...payment,
910
+ settlement_unknown: payment.settlementStatus === "unknown"
911
+ });
912
+ const inserted = this.statement(
913
+ "INSERT OR IGNORE INTO cache_payments (paymentId, paymentKey, payload, maxAttemptedAt) VALUES (?, ?, ?, NULL)"
914
+ ).run(payment.id, payment.paymentKey, JSON.stringify(derived));
915
+ if (inserted.changes > 0) this.changeContribution(void 0, derived);
916
+ }
917
+ eventRowid = row.sourceRowid;
918
+ }
919
+ }
920
+ const outcomeQuery = this.statement(
921
+ "SELECT rowid AS sourceRowid, id, payload FROM outcomes WHERE rowid > ? ORDER BY rowid LIMIT 256"
922
+ );
923
+ for (let batch = outcomeQuery.all(outcomeRowid); batch.length > 0; batch = outcomeQuery.all(outcomeRowid)) {
924
+ for (const input of batch) {
925
+ const row = sourceSchema.parse(input);
926
+ const outcome = outcomeSchema.parse(JSON.parse(row.payload));
927
+ if (outcome.id !== row.id)
928
+ throw new Error("Outcome row identity does not match its payload");
929
+ this.applyOutcome(outcome);
930
+ outcomeRowid = row.sourceRowid;
931
+ }
932
+ }
933
+ if (eventRowid !== cursor.eventRowid || outcomeRowid !== cursor.outcomeRowid) {
934
+ this.statement("UPDATE cache_cursors SET eventRowid = ?, outcomeRowid = ? WHERE id = 1").run(
935
+ eventRowid,
936
+ outcomeRowid
937
+ );
938
+ }
939
+ }
940
+ rebuild() {
941
+ this.database.exec(
942
+ "DELETE FROM cache_prefix; DELETE FROM cache_attempts; DELETE FROM cache_payments; UPDATE cache_cursors SET eventRowid = 0, outcomeRowid = 0 WHERE id = 1"
943
+ );
944
+ this.synchronize();
945
+ }
946
+ reservation(paymentKey) {
947
+ const key = z7.string().min(1).parse(paymentKey);
948
+ const row = payloadSchema.optional().parse(this.statement("SELECT payload FROM cache_payments WHERE paymentKey = ?").get(key));
949
+ return row ? paymentEventSchema.parse(JSON.parse(row.payload)) : void 0;
950
+ }
951
+ spent(proposed, inputScope, inputWindow, now) {
952
+ const payment = paymentEventSchema.parse(proposed);
953
+ const scope = scopeSchema.parse(inputScope);
954
+ const window = windowSchema.optional().parse(inputWindow);
955
+ const end = timeSchema.parse(now);
956
+ const key = partition(payment, scope);
957
+ const upper = this.prefixTotal(key, end);
958
+ const lower = window ? this.prefixTotal(key, end - windows2[window] - 1) : 0n;
959
+ const result = upper - lower;
960
+ if (result < 0n) throw new Error("Cached budget total is inconsistent");
961
+ return result.toString();
962
+ }
963
+ applyOutcome(outcome) {
964
+ const row = paymentRowSchema.optional().parse(
965
+ this.statement(
966
+ "SELECT c.payload, c.maxAttemptedAt, e.payload AS originalPayload FROM cache_payments c JOIN events e ON e.id = c.paymentId WHERE c.paymentId = ?"
967
+ ).get(outcome.paymentId)
968
+ );
969
+ if (!row) return;
970
+ const previous = paymentEventSchema.parse(JSON.parse(row.payload));
971
+ const original = paymentEventSchema.parse(JSON.parse(row.originalPayload));
972
+ const attemptId = outcome.attemptId ?? outcome.paymentId;
973
+ const attempt = attemptRowSchema.optional().parse(
974
+ this.statement(
975
+ "SELECT firstTs, firstId, winnerTs, winnerId, status FROM cache_attempts WHERE paymentId = ? AND attemptId = ?"
976
+ ).get(outcome.paymentId, attemptId)
977
+ );
978
+ if (!attempt) {
979
+ this.statement(
980
+ "INSERT INTO cache_attempts (paymentId, attemptId, firstTs, firstId, winnerTs, winnerId, status, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
981
+ ).run(
982
+ outcome.paymentId,
983
+ attemptId,
984
+ outcome.ts,
985
+ outcome.id,
986
+ outcome.ts,
987
+ outcome.id,
988
+ outcome.status,
989
+ JSON.stringify(outcome)
990
+ );
991
+ } else {
992
+ if (order2(outcome, { ts: attempt.firstTs, id: attempt.firstId }) < 0) {
993
+ this.statement(
994
+ "UPDATE cache_attempts SET firstTs = ?, firstId = ? WHERE paymentId = ? AND attemptId = ?"
995
+ ).run(outcome.ts, outcome.id, outcome.paymentId, attemptId);
996
+ }
997
+ const newer = order2(outcome, { ts: attempt.winnerTs, id: attempt.winnerId }) > 0;
998
+ const wins = attempt.status === "confirmed" ? outcome.status === "confirmed" && newer : outcome.status === "confirmed" || newer;
999
+ if (wins) {
1000
+ this.statement(
1001
+ "UPDATE cache_attempts SET winnerTs = ?, winnerId = ?, status = ?, payload = ? WHERE paymentId = ? AND attemptId = ?"
1002
+ ).run(
1003
+ outcome.ts,
1004
+ outcome.id,
1005
+ outcome.status,
1006
+ JSON.stringify(outcome),
1007
+ outcome.paymentId,
1008
+ attemptId
1009
+ );
1010
+ }
1011
+ }
1012
+ const maxAttemptedAt = outcome.attemptedAt && (!row.maxAttemptedAt || outcome.attemptedAt > row.maxAttemptedAt) ? outcome.attemptedAt : row.maxAttemptedAt;
1013
+ const selected = this.selectedOutcome(outcome.paymentId);
1014
+ const status = original.settlementStatus === "confirmed" ? "confirmed" : selected?.status ?? original.settlementStatus;
1015
+ const current = paymentEventSchema.parse({
1016
+ ...original,
1017
+ ...maxAttemptedAt ? { attemptedAt: maxAttemptedAt } : {},
1018
+ settlementStatus: status,
1019
+ settlement_unknown: status === "unknown",
1020
+ ...selected?.txHash ? { txHash: selected.txHash } : {}
1021
+ });
1022
+ this.changeContribution(previous, current);
1023
+ this.statement(
1024
+ "UPDATE cache_payments SET payload = ?, maxAttemptedAt = ? WHERE paymentId = ?"
1025
+ ).run(JSON.stringify(current), maxAttemptedAt, outcome.paymentId);
1026
+ }
1027
+ selectedOutcome(paymentId) {
1028
+ for (const status of ["confirmed", "unknown"]) {
1029
+ const row2 = payloadSchema.optional().parse(
1030
+ this.statement(
1031
+ "SELECT payload FROM cache_attempts WHERE paymentId = ? AND status = ? ORDER BY firstTs, firstId COLLATE NOCASE, firstId DESC LIMIT 1"
1032
+ ).get(paymentId, status)
1033
+ );
1034
+ if (row2) return outcomeSchema.parse(JSON.parse(row2.payload));
1035
+ }
1036
+ const row = payloadSchema.optional().parse(
1037
+ this.statement(
1038
+ "SELECT payload FROM cache_attempts WHERE paymentId = ? AND status = 'failed' ORDER BY firstTs DESC, firstId COLLATE NOCASE DESC, firstId ASC LIMIT 1"
1039
+ ).get(paymentId)
1040
+ );
1041
+ return row ? outcomeSchema.parse(JSON.parse(row.payload)) : void 0;
1042
+ }
1043
+ changeContribution(previous, current) {
1044
+ const changes = /* @__PURE__ */ new Map();
1045
+ const add = (payment, sign) => {
1046
+ if (!countsAsSpend(payment)) return;
1047
+ const amount = BigInt(payment.amount) * sign;
1048
+ if (amount === 0n) return;
1049
+ const timestamp = timeKey(Date.parse(payment.attemptedAt ?? payment.ts));
1050
+ for (const scope of ["perTask", "perAgent", "global"]) {
1051
+ const key = partition(payment, scope);
1052
+ const nodes = changes.get(key) ?? /* @__PURE__ */ new Map();
1053
+ for (let length = 0; length <= timestamp.length; length += 1) {
1054
+ const prefix = timestamp.slice(0, length);
1055
+ nodes.set(prefix, (nodes.get(prefix) ?? 0n) + amount);
1056
+ }
1057
+ changes.set(key, nodes);
1058
+ }
1059
+ };
1060
+ if (previous) add(previous, -1n);
1061
+ add(current, 1n);
1062
+ for (const [key, nodes] of changes) {
1063
+ const pending = [...nodes].filter(([, amount]) => amount !== 0n);
1064
+ if (pending.length === 0) continue;
1065
+ const existing = /* @__PURE__ */ new Map();
1066
+ for (const input of this.statement(
1067
+ "SELECT prefix, amount FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
1068
+ ).iterate(key, JSON.stringify(pending.map(([prefix]) => prefix)))) {
1069
+ const row = prefixRowSchema.parse(input);
1070
+ existing.set(row.prefix, BigInt(row.amount));
1071
+ }
1072
+ for (const [prefix, delta] of pending) {
1073
+ const next = (existing.get(prefix) ?? 0n) + delta;
1074
+ if (next < 0n) throw new Error("Cached monetary contribution underflow");
1075
+ if (next === 0n) {
1076
+ this.statement("DELETE FROM cache_prefix WHERE partitionKey = ? AND prefix = ?").run(
1077
+ key,
1078
+ prefix
1079
+ );
1080
+ } else {
1081
+ this.statement(
1082
+ "INSERT INTO cache_prefix (partitionKey, prefix, amount) VALUES (?, ?, ?) ON CONFLICT(partitionKey, prefix) DO UPDATE SET amount = excluded.amount"
1083
+ ).run(key, prefix, next.toString());
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ prefixTotal(key, time) {
1089
+ const timestamp = timeKey(time);
1090
+ const prefixes = [];
1091
+ for (let index = 0; index < timestamp.length; index += 1) {
1092
+ const digit = timestamp[index];
1093
+ if (digit === void 0) throw new Error("Missing timestamp digit");
1094
+ const limit = digits.indexOf(digit);
1095
+ for (let child = 0; child < limit; child += 1) {
1096
+ prefixes.push(`${timestamp.slice(0, index)}${digits[child]}`);
1097
+ }
1098
+ }
1099
+ prefixes.push(timestamp);
1100
+ let sum = 0n;
1101
+ for (const input of this.statement(
1102
+ "SELECT prefix, amount FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
1103
+ ).iterate(key, JSON.stringify(prefixes))) {
1104
+ sum += BigInt(prefixRowSchema.parse(input).amount);
1105
+ }
1106
+ return sum;
1107
+ }
1108
+ };
1109
+
804
1110
  // src/ledger/store.ts
805
- var rowSchema = z7.object({ payload: z7.string() });
1111
+ var rowSchema = z8.object({ payload: z8.string() });
806
1112
  var Ledger = class {
807
1113
  database;
1114
+ cache;
808
1115
  constructor(input) {
809
- const path = z7.string().min(1).parse(input);
1116
+ const path = z8.string().min(1).parse(input);
810
1117
  if (path !== ":memory:") mkdirSync(dirname2(path), { recursive: true, mode: 448 });
811
1118
  this.database = new Database(path);
812
1119
  try {
813
1120
  this.database.pragma("journal_mode = WAL");
814
1121
  this.database.pragma("foreign_keys = ON");
815
1122
  this.database.pragma("busy_timeout = 5000");
816
- this.database.transaction(() => {
1123
+ this.cache = this.database.transaction(() => {
817
1124
  const table = this.database.prepare(
818
1125
  "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'"
819
1126
  ).get();
820
1127
  if (!table) this.database.exec(__default);
821
- const version3 = z7.object({ version: z7.literal(1) }).parse(
1128
+ const version3 = z8.object({ version: z8.union([z8.literal(1), z8.literal(2)]) }).parse(
822
1129
  this.database.prepare("SELECT MAX(version) AS version FROM schema_version").get()
823
1130
  );
824
- return version3.version;
1131
+ if (version3.version === 1) this.database.exec(__default2);
1132
+ const cache = new LedgerCache(this.database);
1133
+ cache.synchronize();
1134
+ return cache;
825
1135
  }).immediate();
826
1136
  } catch (error) {
827
1137
  this.database.close();
@@ -833,22 +1143,49 @@ var Ledger = class {
833
1143
  }
834
1144
  append(input) {
835
1145
  const event = paymentEventSchema.parse(input);
836
- return this.database.prepare(
837
- "INSERT OR IGNORE INTO events (id, ts, taskId, agentId, host, status, paymentKey, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
838
- ).run(
839
- event.id,
840
- event.ts,
841
- event.taskId ?? null,
842
- event.agentId ?? null,
843
- event.host,
844
- event.status,
845
- event.paymentKey,
846
- JSON.stringify(event)
847
- ).changes > 0;
1146
+ return this.transaction(() => {
1147
+ const inserted = this.database.prepare(
1148
+ "INSERT OR IGNORE INTO events (id, ts, taskId, agentId, host, status, paymentKey, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
1149
+ ).run(
1150
+ event.id,
1151
+ event.ts,
1152
+ event.taskId ?? null,
1153
+ event.agentId ?? null,
1154
+ event.host,
1155
+ event.status,
1156
+ event.paymentKey,
1157
+ JSON.stringify(event)
1158
+ ).changes > 0;
1159
+ this.cache.synchronize();
1160
+ return inserted;
1161
+ });
848
1162
  }
849
1163
  appendOutcome(input) {
850
1164
  const outcome = outcomeSchema.parse(input);
851
- return this.database.prepare("INSERT OR IGNORE INTO outcomes (id, ts, paymentId, payload) VALUES (?, ?, ?, ?)").run(outcome.id, outcome.ts, outcome.paymentId, JSON.stringify(outcome)).changes > 0;
1165
+ return this.transaction(() => {
1166
+ const inserted = this.database.prepare(
1167
+ "INSERT OR IGNORE INTO outcomes (id, ts, paymentId, payload) VALUES (?, ?, ?, ?)"
1168
+ ).run(outcome.id, outcome.ts, outcome.paymentId, JSON.stringify(outcome)).changes > 0;
1169
+ this.cache.synchronize();
1170
+ return inserted;
1171
+ });
1172
+ }
1173
+ policyState(proposed, budgets, now) {
1174
+ return this.transaction(() => {
1175
+ this.cache.synchronize();
1176
+ return {
1177
+ reserved: this.cache.reservation(proposed.paymentKey),
1178
+ spent: {
1179
+ perTask: budgets.perTask ? this.cache.spent(proposed, "perTask", budgets.perTask.window, now) : "0",
1180
+ perAgent: budgets.perAgent ? this.cache.spent(proposed, "perAgent", budgets.perAgent.window, now) : "0",
1181
+ global: budgets.global ? this.cache.spent(proposed, "global", budgets.global.window, now) : "0"
1182
+ }
1183
+ };
1184
+ });
1185
+ }
1186
+ /** Rebuild disposable projections without modifying the append-only audit log. */
1187
+ rebuildCache() {
1188
+ this.transaction(() => this.cache.rebuild());
852
1189
  }
853
1190
  events() {
854
1191
  return this.database.prepare("SELECT payload FROM events ORDER BY ts, id").all().map((row) => paymentEventSchema.parse(JSON.parse(rowSchema.parse(row).payload)));
@@ -885,10 +1222,10 @@ import {
885
1222
  } from "http";
886
1223
  import { request as httpsRequest } from "https";
887
1224
  import { connect, Socket } from "net";
888
- import { z as z8 } from "zod";
889
- var incomingHeadersSchema = z8.record(
890
- z8.string(),
891
- z8.union([z8.string(), z8.array(z8.string()), z8.undefined()])
1225
+ import { z as z9 } from "zod";
1226
+ var incomingHeadersSchema = z9.record(
1227
+ z9.string(),
1228
+ z9.union([z9.string(), z9.array(z9.string()), z9.undefined()])
892
1229
  );
893
1230
  var sockets = /* @__PURE__ */ new WeakMap();
894
1231
  function normalizedHeaders(input) {
@@ -922,7 +1259,7 @@ function forwardingHeaders(message, host, upgrade = false) {
922
1259
  return result;
923
1260
  }
924
1261
  function targetFor(request, config) {
925
- const reference = z8.string().min(1).parse(request.url);
1262
+ const reference = z9.string().min(1).parse(request.url);
926
1263
  const absolute = /^https?:\/\//i.test(reference);
927
1264
  const url = new URL(
928
1265
  httpUrlSchema.parse(
@@ -931,13 +1268,13 @@ function targetFor(request, config) {
931
1268
  );
932
1269
  const suffix = absolute ? reference.replace(/^https?:\/\/[^/?#]*/i, "") : reference.startsWith("/") ? reference : `${url.pathname}${url.search}`;
933
1270
  const withoutFragment = suffix.split("#", 1)[0] ?? "";
934
- const path = z8.string().min(1).parse(withoutFragment.startsWith("/") ? withoutFragment : `/${withoutFragment}`);
1271
+ const path = z9.string().min(1).parse(withoutFragment.startsWith("/") ? withoutFragment : `/${withoutFragment}`);
935
1272
  return { url, path, resource: httpUrlSchema.parse(`${url.origin}${path}`) };
936
1273
  }
937
1274
  function wire(request, target) {
938
1275
  return {
939
1276
  url: target.resource,
940
- method: z8.string().parse(request.method),
1277
+ method: z9.string().parse(request.method),
941
1278
  headers: normalizedHeaders(request.headers)
942
1279
  };
943
1280
  }
@@ -986,7 +1323,7 @@ function createProxy(options) {
986
1323
  if (status === 402 && !needsBody) meter.observe(input, upstream);
987
1324
  if (needsBody)
988
1325
  reply.on("data", (chunk) => {
989
- const bytes = z8.instanceof(Buffer).parse(chunk);
1326
+ const bytes = z9.instanceof(Buffer).parse(chunk);
990
1327
  length += bytes.length;
991
1328
  if (length <= 65536) chunks.push(bytes);
992
1329
  else chunks.length = 0;
@@ -1027,9 +1364,9 @@ function createProxy(options) {
1027
1364
  });
1028
1365
  server.on("connect", (request, client, head) => {
1029
1366
  try {
1030
- const address = z8.string().regex(/^(?:\[[0-9a-fA-F:]+\]|[^\s/:?#@]+):[0-9]+$/).parse(request.url);
1367
+ const address = z9.string().regex(/^(?:\[[0-9a-fA-F:]+\]|[^\s/:?#@]+):[0-9]+$/).parse(request.url);
1031
1368
  const target = new URL(`http://${address}`);
1032
- const port = z8.coerce.number().int().min(1).max(65535).parse(target.port || "80");
1369
+ const port = z9.coerce.number().int().min(1).max(65535).parse(target.port || "80");
1033
1370
  const host = target.hostname.replace(/^\[|\]$/g, "");
1034
1371
  meter.diagnose(
1035
1372
  "tls_unmetered",
@@ -1137,13 +1474,13 @@ async function closeProxy(server) {
1137
1474
  }
1138
1475
 
1139
1476
  // src/version.ts
1140
- import { z as z9 } from "zod";
1477
+ import { z as z10 } from "zod";
1141
1478
 
1142
1479
  // package.json
1143
- var version = "0.1.2";
1480
+ var version = "0.2.0";
1144
1481
 
1145
1482
  // src/version.ts
1146
- var version2 = z9.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/).parse(version);
1483
+ var version2 = z10.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/).parse(version);
1147
1484
 
1148
1485
  export {
1149
1486
  integerStringSchema,