@tabai/sdk 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/LICENSE +23 -0
- package/README.md +401 -0
- package/bin/tab.mjs +23 -0
- package/dist/_shared/abi.d.ts +150 -0
- package/dist/_shared/abi.d.ts.map +1 -0
- package/dist/_shared/abi.js +197 -0
- package/dist/_shared/abi.js.map +1 -0
- package/dist/_shared/chains.d.ts +118 -0
- package/dist/_shared/chains.d.ts.map +1 -0
- package/dist/_shared/chains.js +89 -0
- package/dist/_shared/chains.js.map +1 -0
- package/dist/_shared/hex.d.ts +35 -0
- package/dist/_shared/hex.d.ts.map +1 -0
- package/dist/_shared/hex.js +40 -0
- package/dist/_shared/hex.js.map +1 -0
- package/dist/_shared/index.d.ts +14 -0
- package/dist/_shared/index.d.ts.map +1 -0
- package/dist/_shared/index.js +14 -0
- package/dist/_shared/index.js.map +1 -0
- package/dist/_shared/keccak256.d.ts +29 -0
- package/dist/_shared/keccak256.d.ts.map +1 -0
- package/dist/_shared/keccak256.js +145 -0
- package/dist/_shared/keccak256.js.map +1 -0
- package/dist/_shared/result.d.ts +78 -0
- package/dist/_shared/result.d.ts.map +1 -0
- package/dist/_shared/result.js +61 -0
- package/dist/_shared/result.js.map +1 -0
- package/dist/cli/client-config.d.ts +155 -0
- package/dist/cli/client-config.d.ts.map +1 -0
- package/dist/cli/client-config.js +382 -0
- package/dist/cli/client-config.js.map +1 -0
- package/dist/cli/connect.d.ts +76 -0
- package/dist/cli/connect.d.ts.map +1 -0
- package/dist/cli/connect.js +158 -0
- package/dist/cli/connect.js.map +1 -0
- package/dist/cli/doctor.d.ts +57 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +253 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/index.d.ts +13 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +13 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +45 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +371 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +37 -0
- package/dist/errors.js.map +1 -0
- package/dist/http/client-402.d.ts +243 -0
- package/dist/http/client-402.d.ts.map +1 -0
- package/dist/http/client-402.js +515 -0
- package/dist/http/client-402.js.map +1 -0
- package/dist/http/headers.d.ts +173 -0
- package/dist/http/headers.d.ts.map +1 -0
- package/dist/http/headers.js +284 -0
- package/dist/http/headers.js.map +1 -0
- package/dist/http/index.d.ts +15 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +15 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/metering-claim.d.ts +82 -0
- package/dist/http/metering-claim.d.ts.map +1 -0
- package/dist/http/metering-claim.js +99 -0
- package/dist/http/metering-claim.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +51 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +40 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +50 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/assets.d.ts +31 -0
- package/dist/mcp/assets.d.ts.map +1 -0
- package/dist/mcp/assets.js +78 -0
- package/dist/mcp/assets.js.map +1 -0
- package/dist/mcp/index.d.ts +19 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +19 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/json-schema.d.ts +86 -0
- package/dist/mcp/json-schema.d.ts.map +1 -0
- package/dist/mcp/json-schema.js +215 -0
- package/dist/mcp/json-schema.js.map +1 -0
- package/dist/mcp/json.d.ts +43 -0
- package/dist/mcp/json.d.ts.map +1 -0
- package/dist/mcp/json.js +69 -0
- package/dist/mcp/json.js.map +1 -0
- package/dist/mcp/registry-client.d.ts +88 -0
- package/dist/mcp/registry-client.d.ts.map +1 -0
- package/dist/mcp/registry-client.js +158 -0
- package/dist/mcp/registry-client.js.map +1 -0
- package/dist/mcp/schemas.d.ts +82 -0
- package/dist/mcp/schemas.d.ts.map +1 -0
- package/dist/mcp/schemas.js +493 -0
- package/dist/mcp/schemas.js.map +1 -0
- package/dist/mcp/server.d.ts +97 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +285 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/settings.d.ts +90 -0
- package/dist/mcp/settings.d.ts.map +1 -0
- package/dist/mcp/settings.js +160 -0
- package/dist/mcp/settings.js.map +1 -0
- package/dist/mcp/toolset.d.ts +231 -0
- package/dist/mcp/toolset.d.ts.map +1 -0
- package/dist/mcp/toolset.js +760 -0
- package/dist/mcp/toolset.js.map +1 -0
- package/dist/payments/abi.d.ts +9 -0
- package/dist/payments/abi.d.ts.map +1 -0
- package/dist/payments/abi.js +17 -0
- package/dist/payments/abi.js.map +1 -0
- package/dist/payments/config.d.ts +199 -0
- package/dist/payments/config.d.ts.map +1 -0
- package/dist/payments/config.js +259 -0
- package/dist/payments/config.js.map +1 -0
- package/dist/payments/index.d.ts +13 -0
- package/dist/payments/index.d.ts.map +1 -0
- package/dist/payments/index.js +13 -0
- package/dist/payments/index.js.map +1 -0
- package/dist/payments/kuru.d.ts +191 -0
- package/dist/payments/kuru.d.ts.map +1 -0
- package/dist/payments/kuru.js +377 -0
- package/dist/payments/kuru.js.map +1 -0
- package/dist/payments/monad.d.ts +69 -0
- package/dist/payments/monad.d.ts.map +1 -0
- package/dist/payments/monad.js +306 -0
- package/dist/payments/monad.js.map +1 -0
- package/dist/payments/permit2.d.ts +118 -0
- package/dist/payments/permit2.d.ts.map +1 -0
- package/dist/payments/permit2.js +366 -0
- package/dist/payments/permit2.js.map +1 -0
- package/dist/payments/registry.d.ts +119 -0
- package/dist/payments/registry.d.ts.map +1 -0
- package/dist/payments/registry.js +199 -0
- package/dist/payments/registry.js.map +1 -0
- package/dist/payments/strategy.d.ts +80 -0
- package/dist/payments/strategy.d.ts.map +1 -0
- package/dist/payments/strategy.js +103 -0
- package/dist/payments/strategy.js.map +1 -0
- package/dist/proxy/hooks.d.ts +90 -0
- package/dist/proxy/hooks.d.ts.map +1 -0
- package/dist/proxy/hooks.js +35 -0
- package/dist/proxy/hooks.js.map +1 -0
- package/dist/proxy/index.d.ts +9 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +9 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/proxy.d.ts +156 -0
- package/dist/proxy/proxy.d.ts.map +1 -0
- package/dist/proxy/proxy.js +366 -0
- package/dist/proxy/proxy.js.map +1 -0
- package/dist/server/adapters/express.d.ts +89 -0
- package/dist/server/adapters/express.d.ts.map +1 -0
- package/dist/server/adapters/express.js +215 -0
- package/dist/server/adapters/express.js.map +1 -0
- package/dist/server/adapters/hono.d.ts +52 -0
- package/dist/server/adapters/hono.d.ts.map +1 -0
- package/dist/server/adapters/hono.js +61 -0
- package/dist/server/adapters/hono.js.map +1 -0
- package/dist/server/adapters/next.d.ts +52 -0
- package/dist/server/adapters/next.d.ts.map +1 -0
- package/dist/server/adapters/next.js +56 -0
- package/dist/server/adapters/next.js.map +1 -0
- package/dist/server/index.d.ts +30 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +30 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/metering.d.ts +209 -0
- package/dist/server/metering.d.ts.map +1 -0
- package/dist/server/metering.js +365 -0
- package/dist/server/metering.js.map +1 -0
- package/dist/server/post-paid.d.ts +355 -0
- package/dist/server/post-paid.d.ts.map +1 -0
- package/dist/server/post-paid.js +512 -0
- package/dist/server/post-paid.js.map +1 -0
- package/dist/x402/client.d.ts +203 -0
- package/dist/x402/client.d.ts.map +1 -0
- package/dist/x402/client.js +337 -0
- package/dist/x402/client.js.map +1 -0
- package/dist/x402/hub.d.ts +79 -0
- package/dist/x402/hub.d.ts.map +1 -0
- package/dist/x402/hub.js +164 -0
- package/dist/x402/hub.js.map +1 -0
- package/dist/x402/index.d.ts +27 -0
- package/dist/x402/index.d.ts.map +1 -0
- package/dist/x402/index.js +27 -0
- package/dist/x402/index.js.map +1 -0
- package/dist/x402/proxy.d.ts +162 -0
- package/dist/x402/proxy.d.ts.map +1 -0
- package/dist/x402/proxy.js +198 -0
- package/dist/x402/proxy.js.map +1 -0
- package/dist/x402/server.d.ts +162 -0
- package/dist/x402/server.d.ts.map +1 -0
- package/dist/x402/server.js +306 -0
- package/dist/x402/server.js.map +1 -0
- package/dist/x402/wire.d.ts +104 -0
- package/dist/x402/wire.d.ts.map +1 -0
- package/dist/x402/wire.js +265 -0
- package/dist/x402/wire.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four Tab tools, as functions.
|
|
3
|
+
*
|
|
4
|
+
* The MCP protocol wiring lives next door in `server.ts`. This file is the
|
|
5
|
+
* behaviour, and it is separate so the tools can be exercised without a
|
|
6
|
+
* transport: every test in `test/mcp-tools.test.mjs` calls these directly, which
|
|
7
|
+
* is what lets task 16.3 validate every output against its declared schema
|
|
8
|
+
* without standing a server up.
|
|
9
|
+
*
|
|
10
|
+
* ## Nothing here throws, including into the transport
|
|
11
|
+
*
|
|
12
|
+
* A tool that throws over stdio does not produce a bad answer, it produces no
|
|
13
|
+
* answer: the MCP client sees a protocol error, the model sees nothing it can
|
|
14
|
+
* reason about, and a long-running server can lose its transport to one
|
|
15
|
+
* malformed argument. So every entry point below returns a value that validates
|
|
16
|
+
* against the tool's declared output schema, on every path, including the paths
|
|
17
|
+
* where the work failed. A failure is `error: { category, code, message }` in
|
|
18
|
+
* the payload, and for `tab_call` and `tab_settle` it is `ok: false` beside it.
|
|
19
|
+
*
|
|
20
|
+
* `LIMIT_EXCEEDED` is the case that earns the discipline. An Agent out of
|
|
21
|
+
* headroom is not an error in the Agent, the Service, or the call -- it is the
|
|
22
|
+
* credit facility working. The tool answers `ok: false` with
|
|
23
|
+
* `requiredBaseUnits` and `headroomBaseUnits` both filled in, so the model can
|
|
24
|
+
* settle the difference and call again instead of retrying into the same wall.
|
|
25
|
+
* (R21.5)
|
|
26
|
+
*
|
|
27
|
+
* ## Every input is validated against the schema that was published
|
|
28
|
+
*
|
|
29
|
+
* Not a hand-written check that resembles it. {@link applyJsonDefaults} fills in
|
|
30
|
+
* the declared defaults and {@link validateJsonValue} enforces the same document
|
|
31
|
+
* `tools/list` served, so a `limit` of 500 is refused with the same bound the
|
|
32
|
+
* model was shown.
|
|
33
|
+
*
|
|
34
|
+
* Requirements: 21.5, 25.1, 25.2, 25.3, 25.4
|
|
35
|
+
*/
|
|
36
|
+
import { isAddress, ok } from "../_shared/index.js";
|
|
37
|
+
import { decodeBytes32String, encodeBytes32String } from "ethers";
|
|
38
|
+
import { fail, notFoundError, tabError, upstreamError, validationError } from "../errors.js";
|
|
39
|
+
import { createTab402Client } from "../http/client-402.js";
|
|
40
|
+
import { defaultLogger } from "../logger.js";
|
|
41
|
+
import { loadTabConfig } from "../payments/config.js";
|
|
42
|
+
import { moduleStrategyRegistry } from "../payments/registry.js";
|
|
43
|
+
import { assetFacts, assetStringOf, formatAsset, parseAsset } from "./assets.js";
|
|
44
|
+
import { applyJsonDefaults, validateJsonValue } from "./json-schema.js";
|
|
45
|
+
import { asArray, asBoolean, asDigits, asDigitsOrNull, asNumber, asRecord, asString, asStringOrNull, field, isRecord, path as jsonPath, secondsToIso, } from "./json.js";
|
|
46
|
+
import { createRegistryReadClient } from "./registry-client.js";
|
|
47
|
+
import { fetchHubManifest } from "../x402/hub.js";
|
|
48
|
+
import { TAB_CALL_INPUT, TAB_DISCOVER_INPUT, TAB_SETTLE_INPUT, TAB_STATUS_INPUT, tabToolByName, } from "./schemas.js";
|
|
49
|
+
const errorOf = (error) => {
|
|
50
|
+
const details = asRecord(error.details);
|
|
51
|
+
const required = asDigitsOrNull(details["requiredBaseUnits"]);
|
|
52
|
+
const headroom = asDigitsOrNull(details["headroomBaseUnits"]);
|
|
53
|
+
return {
|
|
54
|
+
category: error.category,
|
|
55
|
+
code: error.code,
|
|
56
|
+
message: error.message,
|
|
57
|
+
retryable: error.retryable,
|
|
58
|
+
...(required === null ? {} : { requiredBaseUnits: required }),
|
|
59
|
+
...(headroom === null ? {} : { headroomBaseUnits: headroom }),
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
/** Validates a tool input against the schema that was published for it. */
|
|
63
|
+
function readInput(toolName, raw) {
|
|
64
|
+
const declaration = tabToolByName(toolName);
|
|
65
|
+
if (declaration === undefined) {
|
|
66
|
+
return notFoundError("TOOL_UNKNOWN", `\`${toolName}\` is not a tool this server declares`, {
|
|
67
|
+
details: { tool: toolName },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const supplied = raw === undefined || raw === null ? {} : raw;
|
|
71
|
+
return validateJsonValue(declaration.inputSchema, applyJsonDefaults(declaration.inputSchema, supplied), `${toolName} input`, "INPUT_INVALID");
|
|
72
|
+
}
|
|
73
|
+
/** The ascii a 32-byte word decodes to, or null when it is not padded ascii. */
|
|
74
|
+
function wordToName(word) {
|
|
75
|
+
try {
|
|
76
|
+
const decoded = decodeBytes32String(word);
|
|
77
|
+
return /^[\x20-\x7e]+$/.test(decoded) ? decoded : null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** A tool name as its 32-byte key: a hex word passes through, ascii is padded. */
|
|
84
|
+
function toolKeyOf(tool) {
|
|
85
|
+
if (/^0x[a-fA-F0-9]{64}$/.test(tool))
|
|
86
|
+
return ok(tool.toLowerCase());
|
|
87
|
+
try {
|
|
88
|
+
return ok(encodeBytes32String(tool).toLowerCase());
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return validationError("TOOL_NAME_TOO_LONG", `\`${tool}\` cannot be a tool key: a tool name is stored as 31 bytes of ascii, zero-padded, or given as a 32-byte hex word`, { details: { tool } });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const ZERO_WORD = `0x${"00".repeat(32)}`;
|
|
95
|
+
export function createTabToolset(options) {
|
|
96
|
+
const settings = options.settings;
|
|
97
|
+
const logger = options.logger ?? defaultLogger;
|
|
98
|
+
const now = options.now ?? (() => Date.now());
|
|
99
|
+
const env = options.env ?? process.env;
|
|
100
|
+
const chainId = settings.chainId;
|
|
101
|
+
/**
|
|
102
|
+
* The strategy registry, filled from `tab.config` on first use.
|
|
103
|
+
*
|
|
104
|
+
* Deferred, and this is the one piece of laziness in the package that is not
|
|
105
|
+
* an optimisation. Resolving a strategy entry runs consumer code: a factory in
|
|
106
|
+
* a config file typically builds a signer, and a signer is built from a key.
|
|
107
|
+
* Doing that at startup would mean `tab_discover`, `tab_status` and `tab
|
|
108
|
+
* doctor` -- three surfaces whose whole claim is that they need no key --
|
|
109
|
+
* loading one every time the server starts. So the load happens inside
|
|
110
|
+
* `tab_settle`, the only tool that signs, at the moment it needs a strategy.
|
|
111
|
+
*
|
|
112
|
+
* Memoised, so a second Settlement does not re-import the config, and
|
|
113
|
+
* registration is idempotent by strategy id in any case.
|
|
114
|
+
*/
|
|
115
|
+
let loading;
|
|
116
|
+
const strategiesFor = () => {
|
|
117
|
+
if (options.strategies !== undefined)
|
|
118
|
+
return Promise.resolve(options.strategies);
|
|
119
|
+
loading ??= (async () => {
|
|
120
|
+
const registry = moduleStrategyRegistry(logger);
|
|
121
|
+
if (registry.list().length > 0)
|
|
122
|
+
return registry;
|
|
123
|
+
const loaded = await loadTabConfig({
|
|
124
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
125
|
+
registry,
|
|
126
|
+
logger,
|
|
127
|
+
});
|
|
128
|
+
if (!loaded.ok) {
|
|
129
|
+
logger.warn("tab.config could not be loaded, so no payment strategy is registered", {
|
|
130
|
+
code: loaded.error.code,
|
|
131
|
+
message: loaded.error.message,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return registry;
|
|
135
|
+
})();
|
|
136
|
+
return loading;
|
|
137
|
+
};
|
|
138
|
+
const directory = new Map(settings.services.map((entry) => [entry.serviceId.toLowerCase(), entry]));
|
|
139
|
+
/** The registry client, or the one failure that stands in for it. */
|
|
140
|
+
const registryOf = () => {
|
|
141
|
+
if (options.registry !== undefined)
|
|
142
|
+
return ok(options.registry);
|
|
143
|
+
if (settings.registryUrl === undefined) {
|
|
144
|
+
return upstreamError("REGISTRY_UNCONFIGURED", "no Tab registry read API is configured, so on-chain discovery and status cannot be read; set NEXT_PUBLIC_REGISTRY_API_URL, or registryUrl in tab.config");
|
|
145
|
+
}
|
|
146
|
+
return ok(createRegistryReadClient({
|
|
147
|
+
baseUrl: settings.registryUrl,
|
|
148
|
+
...(options.registryFetch === undefined ? {} : { fetchImpl: options.registryFetch }),
|
|
149
|
+
logger,
|
|
150
|
+
}));
|
|
151
|
+
};
|
|
152
|
+
// ------------------------------------------------------------- tab_discover
|
|
153
|
+
const discover = async (raw) => {
|
|
154
|
+
const input = readInput("tab_discover", raw);
|
|
155
|
+
if (!input.ok)
|
|
156
|
+
return { services: [], error: errorOf(input.error) };
|
|
157
|
+
let wanted;
|
|
158
|
+
if (input.value.asset !== undefined) {
|
|
159
|
+
const parsed = parseAsset(input.value.asset);
|
|
160
|
+
if (!parsed.ok)
|
|
161
|
+
return { services: [], error: errorOf(parsed.error) };
|
|
162
|
+
wanted = parsed.value;
|
|
163
|
+
}
|
|
164
|
+
const registry = registryOf();
|
|
165
|
+
if (!registry.ok)
|
|
166
|
+
return { services: [], error: errorOf(registry.error) };
|
|
167
|
+
// A filtered request has to over-fetch: the read API pages by registration
|
|
168
|
+
// order and filters nothing, so asking for `limit` rows would return `limit`
|
|
169
|
+
// rows before the filter and fewer after it.
|
|
170
|
+
const filtered = wanted !== undefined || input.value.search !== undefined || input.value.tier !== "any";
|
|
171
|
+
const body = await registry.value.services(filtered ? 200 : input.value.limit);
|
|
172
|
+
if (!body.ok)
|
|
173
|
+
return { services: [], error: errorOf(body.error) };
|
|
174
|
+
const services = await Promise.all(asArray(field(body.value, "services"))
|
|
175
|
+
.map((entry) => toDiscoveredService(entry, directory, chainId, env))
|
|
176
|
+
.filter((service) => matches(service, input.value.tier, input.value.search, wanted))
|
|
177
|
+
.slice(0, input.value.limit)
|
|
178
|
+
.map((service) => withHub(service, directory.get(service.serviceId), options.hubFetch)));
|
|
179
|
+
return { services };
|
|
180
|
+
};
|
|
181
|
+
// ---------------------------------------------------------------- tab_call
|
|
182
|
+
const call = async (raw) => {
|
|
183
|
+
const input = readInput("tab_call", raw);
|
|
184
|
+
if (!input.ok)
|
|
185
|
+
return { ok: false, error: errorOf(input.error) };
|
|
186
|
+
if (settings.agent === undefined)
|
|
187
|
+
return { ok: false, error: errorOf(agentUnconfigured()) };
|
|
188
|
+
const serviceId = input.value.serviceId.toLowerCase();
|
|
189
|
+
const entry = directory.get(serviceId);
|
|
190
|
+
if (entry === undefined) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
error: errorOf(tabError("NOT_FOUND", "SERVICE_ENDPOINT_UNKNOWN", `no endpoint is configured for Service ${serviceId}; the chain records no URL, so add it to the \`services\` list in tab.config`, { details: { serviceId, configured: [...directory.keys()].join(", ") } })),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const toolKey = toolKeyOf(input.value.tool);
|
|
197
|
+
if (!toolKey.ok)
|
|
198
|
+
return { ok: false, error: errorOf(toolKey.error) };
|
|
199
|
+
const client = createTab402Client({
|
|
200
|
+
baseUrl: entry.endpoint,
|
|
201
|
+
agent: settings.agent,
|
|
202
|
+
// The credit decision is the Service's, and this tool reports it rather than
|
|
203
|
+
// arguing with it. A repeat would meter the same call twice on the path where
|
|
204
|
+
// the first attempt was declined, so the repeat is off.
|
|
205
|
+
maxRetries: 0,
|
|
206
|
+
logger,
|
|
207
|
+
now,
|
|
208
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
209
|
+
...(settings.strategyId === undefined ? {} : { strategyId: settings.strategyId }),
|
|
210
|
+
// The prepaid fallback, only when tab.config declared a signer factory. The
|
|
211
|
+
// factory is called at the moment a refusal carries an x402 offer and not
|
|
212
|
+
// before, which is what keeps this tool keyless on every other path.
|
|
213
|
+
...(settings.x402 === undefined ? {} : { x402: { signer: settings.x402, chainId: BigInt(chainId) } }),
|
|
214
|
+
});
|
|
215
|
+
// A Service meters its tools under `/meter/<tool>` off its published
|
|
216
|
+
// endpoint, the shape the reference gateway serves and the Dashboard calls,
|
|
217
|
+
// so the endpoint in the directory is the Service's root and `/hub/<prefix>`
|
|
218
|
+
// sits beside the tools rather than under them.
|
|
219
|
+
const url = `${entry.endpoint.replace(/\/+$/, "")}/meter/${encodeURIComponent(input.value.tool)}`;
|
|
220
|
+
const supplied = await serviceHeaders(entry, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
url,
|
|
223
|
+
tool: input.value.tool,
|
|
224
|
+
agent: settings.agent,
|
|
225
|
+
serviceId,
|
|
226
|
+
});
|
|
227
|
+
if (!supplied.ok)
|
|
228
|
+
return { ok: false, error: errorOf(supplied.error) };
|
|
229
|
+
const response = await client.fetch(url, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { "content-type": "application/json", ...supplied.value },
|
|
232
|
+
body: JSON.stringify(input.value.arguments ?? {}),
|
|
233
|
+
signal: timeoutSignal(input.value.timeoutMs),
|
|
234
|
+
});
|
|
235
|
+
const charges = client.charges();
|
|
236
|
+
const charge = charges.at(-1);
|
|
237
|
+
const payment = client.payments().at(-1);
|
|
238
|
+
const x402Block = payment === undefined
|
|
239
|
+
? undefined
|
|
240
|
+
: {
|
|
241
|
+
txHash: payment.txHash,
|
|
242
|
+
network: payment.network,
|
|
243
|
+
amountBaseUnits: payment.amount.toString(10),
|
|
244
|
+
asset: formatAsset(Number(payment.chainId), payment.asset),
|
|
245
|
+
payTo: payment.payTo,
|
|
246
|
+
payer: payment.payer,
|
|
247
|
+
explorerUrl: explorerTx(settings.explorerUrl, payment.txHash),
|
|
248
|
+
};
|
|
249
|
+
const chargeBlock = charge === undefined
|
|
250
|
+
? undefined
|
|
251
|
+
: {
|
|
252
|
+
amountBaseUnits: charge.amount.toString(10),
|
|
253
|
+
asset: formatAsset(charge.asset.chainId, charge.asset.address),
|
|
254
|
+
tool: charge.tool,
|
|
255
|
+
};
|
|
256
|
+
const tabBlock = charge === undefined
|
|
257
|
+
? undefined
|
|
258
|
+
: {
|
|
259
|
+
openTabBaseUnits: charge.openTab.toString(10),
|
|
260
|
+
headroomBaseUnits: charge.headroom.toString(10),
|
|
261
|
+
asset: formatAsset(charge.asset.chainId, charge.asset.address),
|
|
262
|
+
settlementDueIso: null,
|
|
263
|
+
};
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
// A 402 that still stands is the headroom case, and it is the one failure
|
|
266
|
+
// this tool restates rather than passes through: the model needs both
|
|
267
|
+
// figures side by side to decide whether to settle. A standing 402 that
|
|
268
|
+
// an x402 signer tried and failed to pay keeps the payment error instead,
|
|
269
|
+
// because "the facilitator refused the signature" is the actionable fact.
|
|
270
|
+
if (charge !== undefined && charge.outcome === "declined" && response.error.code === "LIMIT_EXCEEDED") {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
...(chargeBlock === undefined ? {} : { charge: chargeBlock }),
|
|
274
|
+
...(tabBlock === undefined ? {} : { tab: tabBlock }),
|
|
275
|
+
error: {
|
|
276
|
+
// `LIMIT` and not `CONFLICT`: the category maps to HTTP 402 everywhere
|
|
277
|
+
// else in Tab, which is exactly what the Service answered.
|
|
278
|
+
category: "LIMIT",
|
|
279
|
+
code: "LIMIT_EXCEEDED",
|
|
280
|
+
message: `this call needs ${charge.amount.toString(10)} base units and the Agent has ${charge.headroom.toString(10)} of headroom for the Asset; settle with tab_settle and call again`,
|
|
281
|
+
retryable: false,
|
|
282
|
+
requiredBaseUnits: charge.amount.toString(10),
|
|
283
|
+
headroomBaseUnits: charge.headroom.toString(10),
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
...(chargeBlock === undefined ? {} : { charge: chargeBlock }),
|
|
290
|
+
...(tabBlock === undefined ? {} : { tab: tabBlock }),
|
|
291
|
+
error: errorOf(response.error),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const status = response.value.status;
|
|
295
|
+
const body = await readBody(response.value);
|
|
296
|
+
if (status < 200 || status >= 300) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
...(chargeBlock === undefined ? {} : { charge: chargeBlock }),
|
|
300
|
+
...(tabBlock === undefined ? {} : { tab: tabBlock }),
|
|
301
|
+
...(x402Block === undefined ? {} : { x402: x402Block }),
|
|
302
|
+
error: serviceRefusal(status, body, input.value.tool),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const result = body;
|
|
306
|
+
return {
|
|
307
|
+
ok: true,
|
|
308
|
+
result,
|
|
309
|
+
// A prepaid call carries the credit refusal it was answered with, so the
|
|
310
|
+
// model sees both what the tab could not cover and what paid for it.
|
|
311
|
+
...(chargeBlock === undefined ? {} : { charge: chargeBlock }),
|
|
312
|
+
...(tabBlock === undefined ? {} : { tab: tabBlock }),
|
|
313
|
+
...(x402Block === undefined ? {} : { x402: x402Block }),
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
// -------------------------------------------------------------- tab_status
|
|
317
|
+
const status = async (raw) => {
|
|
318
|
+
const input = readInput("tab_status", raw);
|
|
319
|
+
const agent = (input.ok ? input.value.agent : undefined) ?? settings.agent;
|
|
320
|
+
const subject = agent ?? "0x0000000000000000000000000000000000000000";
|
|
321
|
+
if (!input.ok)
|
|
322
|
+
return { agent: subject, indexedBlock: null, perAsset: [], error: errorOf(input.error) };
|
|
323
|
+
if (agent === undefined) {
|
|
324
|
+
return { agent: subject, indexedBlock: null, perAsset: [], error: errorOf(agentUnconfigured()) };
|
|
325
|
+
}
|
|
326
|
+
let assetFilter;
|
|
327
|
+
if (input.value.asset !== undefined) {
|
|
328
|
+
const parsed = parseAsset(input.value.asset);
|
|
329
|
+
if (!parsed.ok)
|
|
330
|
+
return { agent: agent.toLowerCase(), indexedBlock: null, perAsset: [], error: errorOf(parsed.error) };
|
|
331
|
+
assetFilter = parsed.value.address.toLowerCase();
|
|
332
|
+
}
|
|
333
|
+
const registry = registryOf();
|
|
334
|
+
if (!registry.ok)
|
|
335
|
+
return { agent: agent.toLowerCase(), indexedBlock: null, perAsset: [], error: errorOf(registry.error) };
|
|
336
|
+
const body = await registry.value.agent(agent);
|
|
337
|
+
if (!body.ok)
|
|
338
|
+
return { agent: agent.toLowerCase(), indexedBlock: null, perAsset: [], error: errorOf(body.error) };
|
|
339
|
+
// The block the index had read to when it answered. Stated, because a
|
|
340
|
+
// Settlement the Agent sent a moment ago sits in a later block and an
|
|
341
|
+
// answer that looked current while omitting it would be the misleading kind.
|
|
342
|
+
const horizon = jsonPath(body.value, "index", "lastBlock");
|
|
343
|
+
const indexedBlock = typeof horizon === "number" && Number.isInteger(horizon) && horizon >= 0 ? horizon : null;
|
|
344
|
+
const perAsset = asArray(field(body.value, "assets"))
|
|
345
|
+
.map((entry) => toPerAsset(entry, input.value.historyLimit, chainId))
|
|
346
|
+
.filter((entry) => entry !== null)
|
|
347
|
+
.filter((entry) => assetFilter === undefined || entry.assetAddress === assetFilter)
|
|
348
|
+
.map(({ assetAddress: _assetAddress, ...rest }) => rest);
|
|
349
|
+
let settlements = [];
|
|
350
|
+
if (input.value.historyLimit > 0) {
|
|
351
|
+
const settled = await registry.value.settlements({
|
|
352
|
+
agent,
|
|
353
|
+
...(assetFilter === undefined ? {} : { asset: assetFilter }),
|
|
354
|
+
limit: input.value.historyLimit,
|
|
355
|
+
});
|
|
356
|
+
if (settled.ok) {
|
|
357
|
+
settlements = asArray(field(settled.value, "settlements")).map((row) => toSettlementRow(row, settings.explorerUrl, chainId));
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
logger.warn("the settlement feed could not be read; status reports credit without history", {
|
|
361
|
+
code: settled.error.code,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return { agent: agent.toLowerCase(), indexedBlock, perAsset, settlements };
|
|
366
|
+
};
|
|
367
|
+
// -------------------------------------------------------------- tab_settle
|
|
368
|
+
const settle = async (raw) => {
|
|
369
|
+
const input = readInput("tab_settle", raw);
|
|
370
|
+
if (!input.ok)
|
|
371
|
+
return { ok: false, dryRun: false, amountBaseUnits: "0", error: errorOf(input.error) };
|
|
372
|
+
const dryRun = input.value.dryRun;
|
|
373
|
+
const amountBaseUnits = input.value.amountBaseUnits;
|
|
374
|
+
const failure = (error) => ({
|
|
375
|
+
ok: false,
|
|
376
|
+
dryRun,
|
|
377
|
+
amountBaseUnits,
|
|
378
|
+
error: errorOf(error),
|
|
379
|
+
});
|
|
380
|
+
if (settings.agent === undefined)
|
|
381
|
+
return failure(agentUnconfigured());
|
|
382
|
+
const asset = parseAsset(input.value.asset, "asset", env);
|
|
383
|
+
if (!asset.ok)
|
|
384
|
+
return failure(asset.error);
|
|
385
|
+
const amount = BigInt(amountBaseUnits);
|
|
386
|
+
if (amount === 0n) {
|
|
387
|
+
return failure(tabError("VALIDATION", "AMOUNT_ZERO", "a Settlement of zero base units moves nothing and proves nothing"));
|
|
388
|
+
}
|
|
389
|
+
// The strategy is resolved before the Service is read, because it is a local
|
|
390
|
+
// lookup and the Service is a network read: an Agent with no strategy for the
|
|
391
|
+
// Asset should be told that, not made to wait for a read it could not use.
|
|
392
|
+
const strategy = resolveStrategy(await strategiesFor(), input.value.strategyId ?? settings.strategyId, asset.value);
|
|
393
|
+
if (!strategy.ok)
|
|
394
|
+
return failure(strategy.error);
|
|
395
|
+
const serviceId = input.value.serviceId.toLowerCase();
|
|
396
|
+
const accepted = await serviceAcceptsAsset(registryOf(), serviceId, asset.value);
|
|
397
|
+
if (!accepted.ok)
|
|
398
|
+
return failure(accepted.error);
|
|
399
|
+
const request = {
|
|
400
|
+
agent: settings.agent,
|
|
401
|
+
serviceId: serviceId,
|
|
402
|
+
asset: asset.value,
|
|
403
|
+
amount,
|
|
404
|
+
};
|
|
405
|
+
if (dryRun) {
|
|
406
|
+
const quote = await strategy.value.quote(request);
|
|
407
|
+
if (!quote.ok)
|
|
408
|
+
return failure(quote.error);
|
|
409
|
+
return {
|
|
410
|
+
ok: true,
|
|
411
|
+
dryRun: true,
|
|
412
|
+
txHash: null,
|
|
413
|
+
chainId: Number(asset.value.chainId),
|
|
414
|
+
amountBaseUnits,
|
|
415
|
+
settlementId: null,
|
|
416
|
+
appliedBaseUnits: null,
|
|
417
|
+
prepaidBaseUnits: null,
|
|
418
|
+
explorerUrl: null,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const receipt = await strategy.value.settle(request);
|
|
422
|
+
if (!receipt.ok)
|
|
423
|
+
return failure(receipt.error);
|
|
424
|
+
return {
|
|
425
|
+
ok: true,
|
|
426
|
+
dryRun: false,
|
|
427
|
+
txHash: receipt.value.txHash,
|
|
428
|
+
chainId: Number(receipt.value.chainId),
|
|
429
|
+
amountBaseUnits: receipt.value.amount.toString(10),
|
|
430
|
+
settlementId: receipt.value.settlementId,
|
|
431
|
+
appliedBaseUnits: receipt.value.applied === null ? null : receipt.value.applied.toString(10),
|
|
432
|
+
prepaidBaseUnits: receipt.value.toPrepaid === null ? null : receipt.value.toPrepaid.toString(10),
|
|
433
|
+
explorerUrl: explorerTx(settings.explorerUrl, receipt.value.txHash),
|
|
434
|
+
};
|
|
435
|
+
};
|
|
436
|
+
// -------------------------------------------------------------- dispatch
|
|
437
|
+
const invoke = async (name, input) => {
|
|
438
|
+
switch (name) {
|
|
439
|
+
case "tab_discover":
|
|
440
|
+
return ok(await discover(input));
|
|
441
|
+
case "tab_call":
|
|
442
|
+
return ok(await call(input));
|
|
443
|
+
case "tab_status":
|
|
444
|
+
return ok(await status(input));
|
|
445
|
+
case "tab_settle":
|
|
446
|
+
return ok(await settle(input));
|
|
447
|
+
default:
|
|
448
|
+
return notFoundError("TOOL_UNKNOWN", `\`${name}\` is not a tool this server declares`, {
|
|
449
|
+
details: { tool: name },
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
return { discover, call, status, settle, invoke };
|
|
454
|
+
}
|
|
455
|
+
// ---------------------------------------------------------------- mapping
|
|
456
|
+
/**
|
|
457
|
+
* The one setting no tool that touches a tab can proceed without.
|
|
458
|
+
*
|
|
459
|
+
* An Agent address, never a key. Deriving the address from a signing key would
|
|
460
|
+
* mean this process holds one, and the whole point of the split is that
|
|
461
|
+
* discovery, calling and status hold none.
|
|
462
|
+
*/
|
|
463
|
+
const agentUnconfigured = () => tabError("VALIDATION", "AGENT_UNCONFIGURED", "no Agent address is configured, so there is no Open Tab to meter against; set `agent` in tab.config, or pass it to this tool where the schema allows it");
|
|
464
|
+
/**
|
|
465
|
+
* Names an Asset the index reports as a bare address.
|
|
466
|
+
*
|
|
467
|
+
* `TabBook` keys an Asset by token address alone, one ledger per address and no
|
|
468
|
+
* chain column, while everything that crosses the MCP boundary is
|
|
469
|
+
* `chainId:address`, because the SDK may be pointed at Mainnet or Testnet and a
|
|
470
|
+
* bare address is ambiguous between the two. The chain id is the one the
|
|
471
|
+
* settings resolved, which is the one the registry this toolset reads indexes.
|
|
472
|
+
*/
|
|
473
|
+
const assetStringFor = (address, chainId) => formatAsset(chainId, address.toLowerCase());
|
|
474
|
+
/** A transaction link on the configured explorer, or null for anything that is not a hash. */
|
|
475
|
+
const explorerTx = (explorerUrl, txHash) => /^0x[a-fA-F0-9]{64}$/.test(txHash) ? `${explorerUrl.replace(/\/+$/, "")}/tx/${txHash}` : null;
|
|
476
|
+
function toDiscoveredService(entry, directory, chainId, env) {
|
|
477
|
+
const serviceId = asString(field(entry, "serviceId"), "").toLowerCase();
|
|
478
|
+
const configured = directory.get(serviceId);
|
|
479
|
+
const assets = [];
|
|
480
|
+
for (const accepted of asArray(field(entry, "acceptedAssets"))) {
|
|
481
|
+
const address = asString(field(accepted, "asset"), "").toLowerCase();
|
|
482
|
+
if (!isAddress(address))
|
|
483
|
+
continue;
|
|
484
|
+
const collection = asStringOrNull(field(accepted, "collection"));
|
|
485
|
+
const facts = assetFacts(chainId, address, env);
|
|
486
|
+
assets.push({
|
|
487
|
+
chainId,
|
|
488
|
+
address,
|
|
489
|
+
symbol: facts.symbol,
|
|
490
|
+
decimals: facts.decimals,
|
|
491
|
+
collectionAddress: collection === null ? null : collection.toLowerCase(),
|
|
492
|
+
curatedAsset: facts.curatedAsset,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const nameAsset = (address) => formatAsset(chainId, address);
|
|
496
|
+
const tools = asArray(field(entry, "prices")).map((price) => {
|
|
497
|
+
const tool = asString(field(price, "tool"), ZERO_WORD).toLowerCase();
|
|
498
|
+
return {
|
|
499
|
+
tool,
|
|
500
|
+
toolName: wordToName(tool),
|
|
501
|
+
asset: nameAsset(asString(field(price, "asset"), "")),
|
|
502
|
+
priceBaseUnits: asDigits(field(price, "baseUnits"), "0"),
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
const bonds = asArray(field(entry, "bond")).map((bond) => ({
|
|
506
|
+
asset: nameAsset(asString(field(bond, "asset"), "")),
|
|
507
|
+
stakedBaseUnits: asDigits(field(bond, "staked"), "0"),
|
|
508
|
+
freeBaseUnits: asDigits(field(bond, "free"), "0"),
|
|
509
|
+
}));
|
|
510
|
+
const pending = asArray(field(entry, "pendingChanges"))[0];
|
|
511
|
+
const pendingChange = pending === undefined
|
|
512
|
+
? null
|
|
513
|
+
: {
|
|
514
|
+
changeId: asString(field(pending, "changeId"), ZERO_WORD).toLowerCase(),
|
|
515
|
+
kind: asStringOrNull(field(pending, "kindName")),
|
|
516
|
+
etaIso: asStringOrNull(field(pending, "etaIso")),
|
|
517
|
+
};
|
|
518
|
+
const tierName = asString(jsonPath(entry, "tier", "name"), "").toLowerCase();
|
|
519
|
+
return {
|
|
520
|
+
serviceId,
|
|
521
|
+
name: configured?.name ?? wordToName(serviceId),
|
|
522
|
+
endpoint: configured?.endpoint ?? null,
|
|
523
|
+
tier: tierName === "curated" ? "curated" : tierName === "permissionless" ? "permissionless" : "unknown",
|
|
524
|
+
settlementWindowSeconds: asNumber(jsonPath(entry, "settlementWindowSeconds", "value"), 0),
|
|
525
|
+
assets,
|
|
526
|
+
tools,
|
|
527
|
+
bonds,
|
|
528
|
+
pendingChange,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Attaches the fronted API Hub provider's endpoints to a discovered Service.
|
|
533
|
+
*
|
|
534
|
+
* A manifest that cannot be read is reported inside the `hub` block rather than
|
|
535
|
+
* failing discovery: the Service is still real and its on-chain tools are still
|
|
536
|
+
* listed, and the Hub's catalogue is an addition the chain does not carry.
|
|
537
|
+
*/
|
|
538
|
+
async function withHub(service, configured, hubFetch) {
|
|
539
|
+
const hub = configured?.hub;
|
|
540
|
+
if (hub === undefined)
|
|
541
|
+
return service;
|
|
542
|
+
const prefix = (hub.prefix ?? hub.provider).replace(/^\/+|\/+$/g, "");
|
|
543
|
+
const manifest = await fetchHubManifest({
|
|
544
|
+
provider: hub.provider,
|
|
545
|
+
...(hub.manifestUrl === undefined ? {} : { manifestUrl: hub.manifestUrl }),
|
|
546
|
+
...(hubFetch === undefined ? {} : { fetchImpl: hubFetch }),
|
|
547
|
+
});
|
|
548
|
+
if (!manifest.ok) {
|
|
549
|
+
return { ...service, hub: { provider: hub.provider, prefix, endpoints: [], total: 0, error: errorOf(manifest.error) } };
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
...service,
|
|
553
|
+
hub: {
|
|
554
|
+
provider: manifest.value.provider,
|
|
555
|
+
prefix,
|
|
556
|
+
endpoints: manifest.value.endpoints.map((endpoint) => ({
|
|
557
|
+
endpoint: endpoint.endpoint,
|
|
558
|
+
name: endpoint.name,
|
|
559
|
+
description: endpoint.description,
|
|
560
|
+
priceType: endpoint.priceType,
|
|
561
|
+
priceUsd: endpoint.priceUsd,
|
|
562
|
+
priceBaseUnits: endpoint.priceBaseUnits,
|
|
563
|
+
networks: endpoint.networks,
|
|
564
|
+
})),
|
|
565
|
+
total: manifest.value.total,
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function matches(service, tier, search, asset) {
|
|
570
|
+
if (tier !== "any" && service.tier !== tier)
|
|
571
|
+
return false;
|
|
572
|
+
if (asset !== undefined) {
|
|
573
|
+
const wanted = assetStringOf(asset);
|
|
574
|
+
if (!service.assets.some((entry) => formatAsset(entry.chainId, entry.address) === wanted))
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
if (search !== undefined) {
|
|
578
|
+
const needle = search.toLowerCase();
|
|
579
|
+
const haystack = `${service.serviceId} ${service.name ?? ""}`.toLowerCase();
|
|
580
|
+
if (!haystack.includes(needle))
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
function toPerAsset(entry, historyLimit, chainId) {
|
|
586
|
+
const address = asString(field(entry, "asset"), "").toLowerCase();
|
|
587
|
+
if (!isAddress(address))
|
|
588
|
+
return null;
|
|
589
|
+
const openTab = asDigitsOrNull(jsonPath(entry, "headroom", "openTab")) ?? asDigits(jsonPath(entry, "openTab", "observed"), "0");
|
|
590
|
+
const tabs = asArray(jsonPath(entry, "openTab", "tabs"))
|
|
591
|
+
.slice(0, historyLimit)
|
|
592
|
+
.map((tab) => {
|
|
593
|
+
// The index keys a tab observation by Agent, Service and Asset, not by the
|
|
594
|
+
// `TabBook` tabId, so the identity is often genuinely absent. Reporting the
|
|
595
|
+
// zero word here would name a tab that does not exist.
|
|
596
|
+
const tabId = asStringOrNull(field(tab, "tabId"));
|
|
597
|
+
return {
|
|
598
|
+
tabId: tabId !== null && /^0x[a-fA-F0-9]{64}$/.test(tabId) ? tabId.toLowerCase() : null,
|
|
599
|
+
serviceId: asString(field(tab, "serviceId"), ZERO_WORD).toLowerCase(),
|
|
600
|
+
openBaseUnits: asDigits(field(tab, "openAfter"), "0"),
|
|
601
|
+
dueIso: null,
|
|
602
|
+
};
|
|
603
|
+
});
|
|
604
|
+
return {
|
|
605
|
+
assetAddress: address,
|
|
606
|
+
asset: assetStringFor(address, chainId),
|
|
607
|
+
creditLimitBaseUnits: asDigitsOrNull(jsonPath(entry, "creditLimit", "value")),
|
|
608
|
+
openTabBaseUnits: openTab,
|
|
609
|
+
prepaidBaseUnits: asDigitsOrNull(jsonPath(entry, "settlements", "prepaidTotal")),
|
|
610
|
+
headroomBaseUnits: asDigitsOrNull(jsonPath(entry, "headroom", "value")),
|
|
611
|
+
delinquent: asBoolean(jsonPath(entry, "delinquency", "delinquent"), false),
|
|
612
|
+
tabs,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function toSettlementRow(row, explorerUrl, chainId) {
|
|
616
|
+
const txHash = asString(jsonPath(row, "monad", "txHash"), "");
|
|
617
|
+
return {
|
|
618
|
+
settlementId: asString(field(row, "settlementId"), ZERO_WORD).toLowerCase(),
|
|
619
|
+
txHash: txHash.toLowerCase(),
|
|
620
|
+
serviceId: asString(field(row, "serviceId"), ZERO_WORD).toLowerCase(),
|
|
621
|
+
asset: formatAsset(chainId, asString(field(row, "asset"), "")),
|
|
622
|
+
amountBaseUnits: asDigits(field(row, "amount"), "0"),
|
|
623
|
+
appliedBaseUnits: asDigits(field(row, "applied"), "0"),
|
|
624
|
+
prepaidBaseUnits: asDigits(field(row, "toPrepaid"), "0"),
|
|
625
|
+
explorerUrl: explorerTx(explorerUrl, txHash),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
const SERVICE_CATEGORIES = [
|
|
629
|
+
"VALIDATION",
|
|
630
|
+
"AUTHORISATION",
|
|
631
|
+
"NOT_FOUND",
|
|
632
|
+
"LIMIT",
|
|
633
|
+
"CHAIN",
|
|
634
|
+
"UPSTREAM",
|
|
635
|
+
"CONFLICT",
|
|
636
|
+
"UNAVAILABLE",
|
|
637
|
+
"INTERNAL",
|
|
638
|
+
];
|
|
639
|
+
/**
|
|
640
|
+
* What a Service's refusal means, in this package's vocabulary.
|
|
641
|
+
*
|
|
642
|
+
* A Service that speaks Tab answers `{ error: { category, code, message } }` in
|
|
643
|
+
* the same vocabulary, and that is passed through unchanged: a model told
|
|
644
|
+
* `METERING_SIGNATURE_ABSENT` can act on it, where a flattened
|
|
645
|
+
* "the Service answered 403" leaves it guessing. A Service that answers
|
|
646
|
+
* something else has its status mapped, which is the most that can be said about
|
|
647
|
+
* a body this package cannot read.
|
|
648
|
+
*/
|
|
649
|
+
function serviceRefusal(status, body, tool) {
|
|
650
|
+
const declared = field(body, "error");
|
|
651
|
+
if (isRecord(declared)) {
|
|
652
|
+
const category = asString(declared["category"], "");
|
|
653
|
+
if (SERVICE_CATEGORIES.includes(category)) {
|
|
654
|
+
return {
|
|
655
|
+
category: category,
|
|
656
|
+
code: asString(declared["code"], "SERVICE_REFUSED"),
|
|
657
|
+
message: asString(declared["message"], `the Service answered ${status} for ${tool}`),
|
|
658
|
+
retryable: asBoolean(declared["retryable"], status >= 500),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const category = status === 401 || status === 403
|
|
663
|
+
? "AUTHORISATION"
|
|
664
|
+
: status === 402
|
|
665
|
+
? "LIMIT"
|
|
666
|
+
: status === 404
|
|
667
|
+
? "NOT_FOUND"
|
|
668
|
+
: status === 409
|
|
669
|
+
? "CONFLICT"
|
|
670
|
+
: status === 408 || status === 429 || status === 503 || status === 504
|
|
671
|
+
? "UNAVAILABLE"
|
|
672
|
+
: status >= 500
|
|
673
|
+
? "UPSTREAM"
|
|
674
|
+
: "VALIDATION";
|
|
675
|
+
return {
|
|
676
|
+
category,
|
|
677
|
+
code: "SERVICE_REFUSED",
|
|
678
|
+
message: `the Service answered ${status} for ${tool}`,
|
|
679
|
+
retryable: status >= 500 || status === 408 || status === 429,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* The headers one Service requires for one call.
|
|
684
|
+
*
|
|
685
|
+
* A provider is consumer code, so it is called inside a `catch`: a config file
|
|
686
|
+
* that throws becomes a failed tool call carrying the reason, not an unhandled
|
|
687
|
+
* rejection that takes the transport with it.
|
|
688
|
+
*/
|
|
689
|
+
async function serviceHeaders(entry, request) {
|
|
690
|
+
const declared = entry.headers;
|
|
691
|
+
if (declared === undefined)
|
|
692
|
+
return ok({});
|
|
693
|
+
if (typeof declared !== "function")
|
|
694
|
+
return ok(declared);
|
|
695
|
+
try {
|
|
696
|
+
return ok(await declared(request));
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
return fail("UPSTREAM", "SERVICE_HEADERS_FAILED", `the header provider configured for Service ${request.serviceId} failed, so the call was not sent`, {
|
|
700
|
+
details: { serviceId: request.serviceId },
|
|
701
|
+
cause: { code: "PROVIDER_THREW", message: error instanceof Error ? error.message : String(error) },
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/** Reads the body of whatever the Service answered, without assuming a `Response`. */
|
|
706
|
+
async function readBody(response) {
|
|
707
|
+
const candidate = response;
|
|
708
|
+
if (typeof candidate.json === "function") {
|
|
709
|
+
try {
|
|
710
|
+
return await candidate.json();
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
// fall through to text
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (typeof candidate.text === "function") {
|
|
717
|
+
try {
|
|
718
|
+
return await candidate.text();
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return candidate.body ?? null;
|
|
725
|
+
}
|
|
726
|
+
/** An abort signal for one call, described structurally because there are no DOM types here. */
|
|
727
|
+
function timeoutSignal(ms) {
|
|
728
|
+
const ctor = globalThis.AbortSignal;
|
|
729
|
+
return typeof ctor?.timeout === "function" ? ctor.timeout(ms) : undefined;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Checks that the Service accepts the Asset before a transaction is built.
|
|
733
|
+
*
|
|
734
|
+
* `TabSettlement` would refuse it anyway, but a revert costs the Agent gas and
|
|
735
|
+
* says less than this does. The registry read is keyless and one request.
|
|
736
|
+
*/
|
|
737
|
+
async function serviceAcceptsAsset(registry, serviceId, asset) {
|
|
738
|
+
if (!registry.ok)
|
|
739
|
+
return registry;
|
|
740
|
+
const body = await registry.value.service(serviceId);
|
|
741
|
+
if (!body.ok)
|
|
742
|
+
return body;
|
|
743
|
+
const wanted = asset.address.toLowerCase();
|
|
744
|
+
for (const accepted of asArray(jsonPath(body.value, "service", "acceptedAssets"))) {
|
|
745
|
+
if (asString(field(accepted, "asset"), "").toLowerCase() === wanted)
|
|
746
|
+
return ok(undefined);
|
|
747
|
+
}
|
|
748
|
+
return notFoundError("ASSET_NOT_ACCEPTED", `Service ${serviceId} does not accept ${assetStringOf(asset)}, so a Settlement in it would be refused`, { details: { serviceId, asset: assetStringOf(asset) } });
|
|
749
|
+
}
|
|
750
|
+
function resolveStrategy(registry, strategyId, asset) {
|
|
751
|
+
const resolved = registry.resolve({
|
|
752
|
+
...(strategyId === undefined ? {} : { strategyId }),
|
|
753
|
+
asset,
|
|
754
|
+
});
|
|
755
|
+
if (resolved.ok)
|
|
756
|
+
return resolved;
|
|
757
|
+
const cause = resolved.error;
|
|
758
|
+
return fail(cause.category, cause.code, `${cause.message}; tab_settle signs with the Agent's own key through a payment strategy, so one must be registered for ${assetStringOf(asset)} before a Settlement can be built`, { details: { asset: assetStringOf(asset), ...(strategyId === undefined ? {} : { strategyId }) } });
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=toolset.js.map
|