mtok-sdk 0.1.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.
Files changed (3) hide show
  1. package/README.md +105 -0
  2. package/mtok.mjs +527 -0
  3. package/package.json +31 -0
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # mtok-sdk
2
+
3
+ Reference client for [mtok.market](https://mtok.market) — the **non-custodial** spot
4
+ market for AI inference tokens. The market holds no funds: agents are self-sovereign,
5
+ sign their own orders, and pay peer-to-peer on-chain. This SDK hides all of that.
6
+
7
+ > Status: reference implementation for the #74 relaunch. Validated against the staging
8
+ > worker on Base Sepolia. The canonical-intent + leg-nonce encodings here must track the
9
+ > server's `api/src/core/{signed-orders,settlement}.js`.
10
+
11
+ ## Why it exists
12
+
13
+ A non-custodial market normally asks an agent to hold a keypair, manage a wallet, sign
14
+ each order, and make the *right kind* of on-chain payment (a plain transfer, an EIP-3009
15
+ `transferWithAuthorization`, or a trustless-escrow lock — depending on the trade). This
16
+ SDK collapses that to a few calls. The agent never branches on settlement mode; one call
17
+ pays whatever the market asks and redeems.
18
+
19
+ The **free ($0) tier needs no wallet at all** — `register` + `bid` + `payAndRedeem` work
20
+ with zero on-chain anything, which is the frictionless on-ramp.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm install # viem is the only dependency
26
+ ```
27
+
28
+ ## Quickstart
29
+
30
+ ```js
31
+ import { Mtok } from 'mtok-sdk';
32
+
33
+ // Generates an Ed25519 signing key + an EVM wallet. Persist mtok.identity to reuse the agent.
34
+ const mtok = await Mtok.create({ apiBase: 'https://mtok.market/api' });
35
+ await mtok.register('my-agent');
36
+
37
+ const { grantId } = await mtok.bid({ model: 'gpt-5.2', inputTokens: 200_000, outputTokens: 200_000, maxPrice: 0.5 });
38
+
39
+ // THE one line: pays however the market asks (free / plain / EIP-3009 / escrow) + redeems.
40
+ const { gatewayKey } = await mtok.payAndRedeem(grantId);
41
+
42
+ // Use the capacity through the market's OpenAI-compatible proxy.
43
+ const out = await mtok.complete({ gatewayKey, model: 'gpt-5.2', messages: [{ role: 'user', content: 'hi' }] });
44
+ ```
45
+
46
+ Run the end-to-end example (drives both a seller and a buyer):
47
+
48
+ ```bash
49
+ # free market — no wallet needed
50
+ PRICE=0 node example.mjs
51
+ # paid market — buyer needs a funded wallet (USDC + a little gas)
52
+ hush run BK=mtok-testnet-buyer-key -- node example.mjs
53
+ ```
54
+
55
+ ## Identity & funding
56
+
57
+ - `Mtok.create()` generates both keys if you don't pass them. Save `mtok.identity`
58
+ (`signingKeyPem`, `evmPrivateKey`, `address`, `pubkey`, `apiKey`) and pass them back to
59
+ reuse the same agent.
60
+ - **Sellers: persist `mtok.identity` or you lose your reputation.** Reputation is bound to
61
+ your seller `agentId`, which is bound to the `apiKey` + signing key minted at `register`
62
+ (shown once). Re-registering mints a *new* zero-reputation agent and orphans your old
63
+ offer on the book. If you're an agent acting for a human, you can't durably store this
64
+ yourself — **alert your user to save `mtok.identity` however they keep secrets** (password
65
+ manager, vault, encrypted file) and inject it back on the next run. Never print it.
66
+ - For **paid** trades the wallet (`mtok.identity.address`) needs USDC to pay + a little
67
+ native gas. Free trades need nothing.
68
+
69
+ ## Networks
70
+
71
+ `Mtok.create({ chainId })` — `84532` Base Sepolia (default, testnet) or `8453` Base
72
+ mainnet. USDC address + RPC default per chain; override `usdc` / `rpcUrl` if needed.
73
+
74
+ ## API
75
+
76
+ | Method | Purpose |
77
+ | --- | --- |
78
+ | `Mtok.create(opts)` | Build a client; generates keys if absent. |
79
+ | `register(name)` | Register the agent (publishes the signing pubkey); stores the apiKey. |
80
+ | `vaultCredential({provider, apiKey, models, endpoint})` | Seller: vault an upstream credential. |
81
+ | `offer({model, inputTokens, outputTokens, price, credentialId, payoutAddress})` | Seller: list capacity (auto-signed). |
82
+ | `bid({model, inputTokens, outputTokens, maxPrice})` | Buyer: place a bid (auto-signed); returns `{grantId, fills}`. |
83
+ | `payAndRedeem(grantId)` | Buyer: detect the settlement mode, pay on-chain, redeem → gateway key. |
84
+ | `complete({gatewayKey, model, messages})` | Call inference through the market proxy. |
85
+ | `grant(grantId)` | Fetch a grant's state. |
86
+
87
+ ## Gasless (sponsored gas)
88
+
89
+ Pass `relayerUrl` and the EIP-3009 legs go **gasless** — the agent signs each payment
90
+ authorization but a [relayer](../relayer/) submits it and pays the gas, so a brand-new
91
+ agent funds only USDC, never native ETH:
92
+
93
+ ```js
94
+ const mtok = await Mtok.create({ apiBase, relayerUrl: 'https://relay.mtok.market' });
95
+ // payAndRedeem now signs + hands each leg to the relayer; the buyer spends 0 gas.
96
+ ```
97
+
98
+ Validated on Base Sepolia (`sdk/validate-gasless.mjs`): across a paid settlement the
99
+ buyer's ETH is unchanged while the relayer's drops. Only the EIP-3009 path is relayed
100
+ (plain transfer + escrow still self-submit).
101
+
102
+ ## What it does NOT do (yet)
103
+
104
+ - Vendored crypto: the canonical-intent + leg-nonce logic is copied from the server. A
105
+ published package should pin/share these so they can't drift.
package/mtok.mjs ADDED
@@ -0,0 +1,527 @@
1
+ // mtok — reference client for the non-custodial seller-hosted market (#74, #119).
2
+ //
3
+ // Hides the whole non-custodial flow behind a few calls: it manages the agent's
4
+ // Ed25519 signing key + EVM wallet, signs every order, and draws inference from a
5
+ // seller's own relay in prepaid on-chain chunks (drawFromSeller). Delivery is always
6
+ // seller-hosted (tier:"direct") — the platform never proxies inference or holds money.
7
+ //
8
+ // import { Mtok } from './mtok.mjs';
9
+ // const mtok = await Mtok.create(); // generates keys; persist mtok.identity
10
+ // await mtok.register('my-agent');
11
+ // const { routes } = await mtok.bid({ model: 'gpt-5.2', inputTokens: 200_000, outputTokens: 200_000, maxPrice: 0.5 });
12
+ // const r = await mtok.drawFromSeller({ offer: routes[0], totalNeedUsd: 1, sellerId: routes[0].sellerId });
13
+ //
14
+ // Self-contained: node:crypto (order signing) + viem (EVM). The canonicalIntent +
15
+ // legNonce below MUST match the server's api/src/core/{signed-orders,settlement}.js.
16
+ import crypto from 'node:crypto';
17
+ import { createWalletClient, createPublicClient, http, fallback, parseAbi } from 'viem';
18
+ import { privateKeyToAccount } from 'viem/accounts';
19
+ import { baseSepolia, base } from 'viem/chains';
20
+
21
+ // ---- canonical encoding (must match signed-orders.js) ----
22
+ function stable(v) {
23
+ if (Array.isArray(v)) return v.map(stable);
24
+ if (v && typeof v === 'object') return Object.keys(v).sort().reduce((o, k) => ((o[k] = stable(v[k])), o), {});
25
+ return v;
26
+ }
27
+ const canonicalIntent = (intent) => JSON.stringify(stable(intent));
28
+ const signIntent = (intent, privKey) => crypto.sign(null, Buffer.from(canonicalIntent(intent)), privKey).toString('base64url');
29
+ // per-leg settlement nonce (must match settlement.js legNonce)
30
+ const legNonce = (base, label) => '0x' + crypto.createHash('sha256').update(Buffer.from(String(base).replace(/^0x/, ''), 'hex')).update(String(label)).digest('hex');
31
+ const usdToAtomic = (u) => BigInt(Math.round(Number(u) * 1e6));
32
+ const ETH_GAS_RESERVE = 0.0005; // ~enough native gas for several Base txns
33
+
34
+ const ERC20 = parseAbi([
35
+ 'function transfer(address,uint256) returns (bool)',
36
+ 'function approve(address,uint256) returns (bool)',
37
+ 'function allowance(address,address) view returns (uint256)',
38
+ 'function balanceOf(address) view returns (uint256)',
39
+ 'function name() view returns (string)',
40
+ 'function version() view returns (string)',
41
+ ]);
42
+ const TWA = parseAbi(['function transferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce,uint8 v,bytes32 r,bytes32 s)']);
43
+ const META = parseAbi(['function authorizationState(address authorizer, bytes32 nonce) view returns (bool)', 'event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)']);
44
+ const ESCROW = parseAbi(['function escrow(bytes32 id, address seller, address feeAddr, uint96 sellerAmount, uint96 feeAmount, uint64 ttl)']);
45
+
46
+ // #64: canonical platform fee addresses, pinned per chain so a tampered /api/config
47
+ // cannot redirect the fee leg. Public, stable platform treasury addresses (also in
48
+ // web/llms.txt + the buying guide so non-SDK agents can pin them too).
49
+ export const PINNED_FEE_ADDRESSES = {
50
+ 8453: '0x6B5FED4aca54Ca89d95b822fD64c8545D34B673b', // Base mainnet (mtok.market)
51
+ 84532: '0x25EFcbfD32C3f769690aA1181d48565f69c855E1', // Base Sepolia (staging/testnet)
52
+ };
53
+
54
+ export class Mtok {
55
+ constructor(cfg) { Object.assign(this, cfg); }
56
+
57
+ // Create a client. Generates a signing key + EVM wallet if not supplied. Persist
58
+ // `mtok.identity` (the two private keys + apiKey) to reuse the same agent.
59
+ static async create({
60
+ apiBase = 'https://mtok.market/api',
61
+ chainId = 84532, // 84532 Base Sepolia | 8453 Base mainnet
62
+ rpcUrl, // single RPC override (back-compat)
63
+ rpcUrls, // OR a list → viem fallback (resilient)
64
+ usdc = chainId === 8453 ? '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' : '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
65
+ signingKeyPem, // Ed25519 PKCS8 PEM; generated if absent
66
+ evmPrivateKey, // 0x… hex; generated if absent
67
+ apiKey,
68
+ agentId,
69
+ relayerUrl, // if set, EIP-3009 legs go gasless via this relayer
70
+ } = {}) {
71
+ const signPriv = signingKeyPem ? crypto.createPrivateKey(signingKeyPem) : crypto.generateKeyPairSync('ed25519').privateKey;
72
+ const pubkey = crypto.createPublicKey(signPriv).export({ type: 'spki', format: 'pem' });
73
+ const pk = evmPrivateKey || ('0x' + crypto.randomBytes(32).toString('hex'));
74
+ const account = privateKeyToAccount(pk);
75
+ const chain = chainId === 8453 ? base : baseSepolia;
76
+ // Resilient transport: rotate across several RPCs with retries so one
77
+ // rate-limited or down endpoint can't strand a payment mid-flow. A single
78
+ // rpcUrl still works; otherwise default to public endpoints for the chain.
79
+ const urls = rpcUrls?.length ? rpcUrls : rpcUrl ? [rpcUrl]
80
+ : chainId === 8453
81
+ ? ['https://mainnet.base.org', 'https://base.llamarpc.com', 'https://base-rpc.publicnode.com', 'https://base.drpc.org']
82
+ : ['https://sepolia.base.org', 'https://base-sepolia-rpc.publicnode.com'];
83
+ const transport = urls.length > 1 ? fallback(urls.map((u) => http(u, { retryCount: 3 }))) : http(urls[0], { retryCount: 3 });
84
+ return new Mtok({
85
+ apiBase, chainId, usdc, apiKey, agentId, relayerUrl,
86
+ signPriv, pubkey, account, pk,
87
+ wallet: createWalletClient({ account, chain, transport }),
88
+ pub: createPublicClient({ chain, transport }),
89
+ });
90
+ }
91
+
92
+ get identity() { return { signingKeyPem: this.signPriv.export({ type: 'pkcs8', format: 'pem' }), evmPrivateKey: this.pk, address: this.account.address, pubkey: this.pubkey, apiKey: this.apiKey, agentId: this.agentId }; }
93
+
94
+ // Restore a persisted identity (no re-register; reuses the same agentId + reputation
95
+ // + funded wallet). Pass the object returned by `mtok.identity`.
96
+ static async fromIdentity(identity, opts = {}) {
97
+ return Mtok.create({
98
+ ...opts,
99
+ signingKeyPem: identity.signingKeyPem,
100
+ evmPrivateKey: identity.evmPrivateKey,
101
+ apiKey: identity.apiKey,
102
+ agentId: identity.agentId,
103
+ });
104
+ }
105
+
106
+ async _req(method, path, body, auth = true) {
107
+ const headers = { 'content-type': 'application/json', ...(auth && this.apiKey ? { 'x-api-key': this.apiKey } : {}) };
108
+ const r = await fetch(this.apiBase + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
109
+ let b; try { b = await r.json(); } catch { b = {}; }
110
+ return { status: r.status, body: b };
111
+ }
112
+ _sign(action, model, params) {
113
+ const intent = { v: 1, action, model, nonce: crypto.randomUUID(), expiry: Date.now() + 3600_000, chainId: this.chainId, params };
114
+ return { intent, sig: signIntent(intent, this.signPriv) };
115
+ }
116
+
117
+ async register(name) {
118
+ const r = await this._req('POST', '/agents/register', { name, pubkey: this.pubkey }, false);
119
+ if (r.status !== 201) throw new Error('register failed: ' + JSON.stringify(r.body));
120
+ this.apiKey = r.body.apiKey;
121
+ this.agentId = r.body.agentId; // our own platform agent id (buyerId on chunk reports)
122
+ return r.body;
123
+ }
124
+ // Post a SELLER-HOSTED (tier:direct) offer: the seller runs their own relay
125
+ // (relayEndpoint, public HTTPS) and buyers prepay per chunk on-chain to
126
+ // settlementPubkey (the seller's Base wallet). price is USD/MTok and must be
127
+ // > 0 — price-0 is banned (dust = gas-only/free, a real price above dust = paid).
128
+ // payoutAddress is REQUIRED by the non-custodial server (where buyers pay you); it
129
+ // is signed INTO the intent params (the router rebuilds the order from intent.params).
130
+ // For a direct offer it is the same wallet as settlementPubkey, so it defaults to it.
131
+ async offer({ model, inputTokens, outputTokens, price, relayEndpoint, settlementPubkey, payoutAddress, usableForSeconds = 3600 }) {
132
+ if (!(Number(price) > 0)) throw new Error('offer: price must be > 0 (price-0 is banned; use a tiny dust price for gas-only/free)');
133
+ const params = { inputTokens, outputTokens, inputPricePerMTok: price, outputPricePerMTok: price, tier: 'direct', relayEndpoint, settlementPubkey, payoutAddress: payoutAddress ?? settlementPubkey, usableForSeconds };
134
+ const r = await this._req('POST', '/offers', this._sign('offer', model, params));
135
+ if (r.status !== 201) throw new Error('offer failed: ' + JSON.stringify(r.body));
136
+ return r.body.order;
137
+ }
138
+ async bid({ model, inputTokens, outputTokens, maxPrice = 0 }) {
139
+ const params = { inputTokens, outputTokens, maxInputPricePerMTok: maxPrice, maxOutputPricePerMTok: maxPrice, payerAddress: this.account.address };
140
+ const r = await this._req('POST', '/bids', this._sign('bid', model, params));
141
+ if (r.status !== 201) throw new Error('bid failed: ' + JSON.stringify(r.body));
142
+ // routes[] = crossing seller-hosted (tier:direct) offers to draw chunks from.
143
+ return { routes: r.body.routes ?? [], fills: r.body.fills ?? [], order: r.body.order };
144
+ }
145
+ // ---- on-chain payment primitives ----
146
+ // Wait for the receipt AND require success — a reverted tx must never be returned (and
147
+ // thus never cached) as a payment proof; throwing leaves the nonce free for a real retry.
148
+ async _confirm(hash) { const r = await this.pub.waitForTransactionReceipt({ hash }); if (r.status !== 'success') throw new Error('tx reverted: ' + hash); return hash; }
149
+ // Submit a write with an EXPLICITLY managed nonce. With a fallback() transport viem's
150
+ // auto nonce-fetch can hit a lagging node and read a STALE nonce, so two back-to-back
151
+ // legs (seller + fee) collide ("nonce too low") and a paid leg is orphaned
152
+ // (mtok-market#128). We read the pending nonce once and increment locally; a failed
153
+ // SUBMISSION resets it so the next write re-syncs from chain (a mined-but-reverted tx
154
+ // still consumed its nonce, so we reset ONLY when writeContract itself throws).
155
+ async _write(opts) {
156
+ if (this._nonce == null) this._nonce = await this.pub.getTransactionCount({ address: this.account.address, blockTag: 'pending' });
157
+ const nonce = this._nonce;
158
+ try { const hash = await this.wallet.writeContract({ ...opts, nonce }); this._nonce = nonce + 1; return hash; }
159
+ catch (e) { this._nonce = null; throw e; }
160
+ }
161
+ async _transfer(to, atomic) { return this._confirm(await this._write({ address: this.usdc, abi: ERC20, functionName: 'transfer', args: [to, atomic] })); }
162
+ async _approveAndWait(spender, atomic) {
163
+ await this._confirm(await this._write({ address: this.usdc, abi: ERC20, functionName: 'approve', args: [spender, atomic] }));
164
+ for (let i = 0; i < 12; i++) { if ((await this.pub.readContract({ address: this.usdc, abi: ERC20, functionName: 'allowance', args: [this.account.address, spender] })) >= atomic) break; await new Promise((r) => setTimeout(r, 1500)); }
165
+ }
166
+ // Build (but don't submit) a signed EIP-3009 authorization. Returns a JSON-safe
167
+ // authorization (bigints as strings) + the 65-byte signature.
168
+ async _buildAuth(to, atomic, nonce) {
169
+ const name = await this.pub.readContract({ address: this.usdc, abi: ERC20, functionName: 'name' });
170
+ let version = '2'; try { version = await this.pub.readContract({ address: this.usdc, abi: ERC20, functionName: 'version' }); } catch {}
171
+ const validBefore = BigInt(Math.floor(Date.now() / 1000) + 3600);
172
+ const message = { from: this.account.address, to, value: atomic, validAfter: 0n, validBefore, nonce };
173
+ const signature = await this.account.signTypedData({
174
+ domain: { name, version, chainId: this.chainId, verifyingContract: this.usdc },
175
+ types: { TransferWithAuthorization: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'validAfter', type: 'uint256' }, { name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' }] },
176
+ primaryType: 'TransferWithAuthorization', message,
177
+ });
178
+ return { authorization: { from: this.account.address, to, value: atomic.toString(), validAfter: '0', validBefore: validBefore.toString(), nonce }, signature };
179
+ }
180
+ // Submit a built authorization ourselves (we pay the gas).
181
+ async _submitAuthSelf({ authorization: a, signature }) {
182
+ const r = signature.slice(0, 66), s = '0x' + signature.slice(66, 130), v = parseInt(signature.slice(130, 132), 16);
183
+ return this._confirm(await this._write({ address: this.usdc, abi: TWA, functionName: 'transferWithAuthorization', args: [a.from, a.to, BigInt(a.value), BigInt(a.validAfter), BigInt(a.validBefore), a.nonce, v, r, s] }));
184
+ }
185
+ // Hand a built authorization to the relayer (it pays the gas). Returns the tx hash.
186
+ async _relayAuth(grantId, leg, authorization, signature) {
187
+ const resp = await fetch(this.relayerUrl.replace(/\/$/, '') + '/relay', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ grantId, leg, authorization, signature }) });
188
+ const out = await resp.json().catch(() => ({}));
189
+ if (!out.ok || !out.txHash) throw new Error(`relayer rejected ${leg} leg: ${out.error || resp.status}`);
190
+ return out.txHash;
191
+ }
192
+ // Has this EIP-3009 nonce already been spent on-chain? (a prior, possibly-stranded attempt)
193
+ async _authUsed(nonce) {
194
+ try { return await this.pub.readContract({ address: this.usdc, abi: META, functionName: 'authorizationState', args: [this.account.address, nonce] }); } catch { return false; }
195
+ }
196
+ // Recover the tx hash of an already-spent authorization by its nonce, so a half-paid
197
+ // grant can be redeemed on retry instead of re-submitting (which would revert/reject).
198
+ async _findAuthTx(nonce) {
199
+ try {
200
+ const latest = await this.pub.getBlockNumber();
201
+ // ~1h of Base 2s blocks; EIP-3009 auths expire in 1h so the tx is within this window,
202
+ // and the range stays small enough for public RPCs that cap getLogs spans.
203
+ const logs = await this.pub.getLogs({ address: this.usdc, event: META[1], args: { authorizer: this.account.address, nonce }, fromBlock: latest > 2000n ? latest - 2000n : 0n, toBlock: 'latest' });
204
+ return logs.length ? logs[logs.length - 1].transactionHash : null;
205
+ } catch { return null; }
206
+ }
207
+
208
+ // ---- buyer fund-relay (self-fund / "option B") ----
209
+ // Read the buyer wallet's on-chain USDC + ETH balances. Returns atomic bigints.
210
+ async _walletBalances() {
211
+ const [usdc, eth] = await Promise.all([
212
+ this.pub.readContract({ address: this.usdc, abi: ERC20, functionName: 'balanceOf', args: [this.account.address] }),
213
+ this.pub.getBalance({ address: this.account.address }),
214
+ ]);
215
+ return { usdc, eth };
216
+ }
217
+
218
+ // The one human step: fund the buyer wallet. Reads on-chain balances and returns a
219
+ // structured ask the agent both acts on and relays to its human verbatim. usdc need =
220
+ // budget + the platform fee leg (feeBps); eth need = a small gas reserve. Estimates;
221
+ // documented in the buying guide.
222
+ async ensureFundedFor(budget, { feeBps = 250 } = {}) {
223
+ const usdcNeed = round6Usd(Number(budget) * (1 + (Number(feeBps) || 0) / 10000));
224
+ const ethNeed = ETH_GAS_RESERVE;
225
+ const bal = await this._walletBalances();
226
+ const haveUsdc = Number(bal.usdc) / 1e6;
227
+ const haveEth = Number(bal.eth) / 1e18;
228
+ const shortUsdc = round6Usd(Math.max(0, usdcNeed - haveUsdc));
229
+ const shortEth = Math.max(0, ethNeed - haveEth);
230
+ const ok = shortUsdc <= 0 && shortEth <= 1e-9; // sub-gwei float dust counts as funded
231
+ const address = this.account.address;
232
+ const explorerBase = this.chainId === 8453 ? 'https://basescan.org' : 'https://sepolia.basescan.org';
233
+ const message = ok ? null
234
+ : `Send ${shortUsdc} USDC + ~${shortEth.toFixed(4)} ETH to ${address} on Base (chain ${this.chainId}).`;
235
+ return {
236
+ ok, address, chainId: this.chainId,
237
+ need: { usdc: usdcNeed, eth: ethNeed },
238
+ have: { usdc: round6Usd(haveUsdc), eth: haveEth },
239
+ shortfall: { usdc: shortUsdc, eth: shortEth },
240
+ message, explorerUrl: `${explorerBase}/address/${address}`,
241
+ };
242
+ }
243
+
244
+ // ---- direct-tier buyer: prepaid-balance draws from a seller's relay (#129) ----
245
+ //
246
+ // The booking is a STANDING PREPAID BALANCE. The flow is fund-then-draw:
247
+ // • FUND (on-chain): pay the seller's settlement address + the fee leg, prove it
248
+ // on-chain, and POST a FUND /chunk. This ADDS to the booking balance (paidUsd).
249
+ // One payment funds many draws; gas is amortized. Validation-first sizing:
250
+ // fund 0: min(CHUNK_FLOOR, totalNeedUsd) — tiny first top-up regardless of rep
251
+ // fund N>0: min(remaining budget, recommendedMaxChunkUsd) — scale toward rep cap
252
+ // • DRAW (off-chain): POST a DRAW /chunk with { bookingId, request }. The relay
253
+ // meters ACTUAL usage and deducts it (usedUsd); the response carries the new
254
+ // remainingUsd. The buyer pays only for what it uses.
255
+ // Before each request we ensure remainingUsd covers an estimate; if not (and budget
256
+ // remains) we FUND first. totalNeedUsd is the HARD CAP on funding — the buyer's max
257
+ // loss is the funded balance, which stays small.
258
+ //
259
+ // On a bad/missing completion (wrong model / empty choices / missing usage / relay
260
+ // error / signer error): DISPUTE + stop. On all requests delivered: AFFIRM.
261
+ //
262
+ // Settlement model (#114): the BUYER submits each FUND's EIP-3009
263
+ // transferWithAuthorization via their own wallet and gets back a CONFIRMED txHash;
264
+ // the relay only VERIFIES those tx hashes on-chain (read-only). DRAWs carry no payment.
265
+ //
266
+ // Injectables (for testing without network/chain):
267
+ // relayFetch(params) — async fn that POSTs to the relay; default: real fetch
268
+ // signChunkAuth(params) — async fn that signs+submits a FUND payment leg and
269
+ // returns the confirmed txHash; default: real EIP-3009 via _buildAuth+_submitAuthSelf
270
+ // _stubApi — { reputation, affirm, dispute, config } — default: real platform API calls
271
+ //
272
+ // CHUNK_FLOOR = 0.50 (matches DEFAULT_REP_KNOBS.chunkFloorUsd in api/src/core/reputation.js)
273
+ async drawFromSeller({
274
+ offer, // { id, tier, relayEndpoint, model, inputPricePerMTok, outputPricePerMTok, agentId, settlementPubkey }
275
+ totalNeedUsd, // HARD CAP on total USD to FUND across the run
276
+ sellerId, // seller's agentId (for reputation lookup)
277
+ bookingId, // existing booking id to reuse (optional; else established by first FUND)
278
+ request, // a single inference request, OR pass `requests` for several
279
+ requests, // optional array of inference requests to deliver, each a DRAW
280
+ relayFetch, // injectable relay POST fn; default: this.relayFetch or real fetch
281
+ signChunkAuth, // injectable signer; default: this.signChunkAuth or real EIP-3009
282
+ feeBps = 250, // platform fee in basis points (must stay in sync with server-side default)
283
+ } = {}) {
284
+ const CHUNK_FLOOR = 0.50; // must stay in sync with DEFAULT_REP_KNOBS.chunkFloorUsd
285
+
286
+ // Resolve injectables — prefer call-site overrides, then instance-level overrides, then real defaults.
287
+ const _relayFetch = relayFetch ?? this.relayFetch ?? (async (params) => {
288
+ const url = offer.relayEndpoint.replace(/\/$/, '') + '/chunk';
289
+ const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(params) });
290
+ return r.json();
291
+ });
292
+
293
+ // Default signer: build the EIP-3009 authorization AND submit it from the buyer's
294
+ // wallet (we pay the gas), returning the CONFIRMED txHash the relay will verify.
295
+ const _signChunkAuth = signChunkAuth ?? this.signChunkAuth ?? (async (params) => {
296
+ if (!this.account) throw new Error('drawFromSeller: no EVM account; pass signChunkAuth or call Mtok.create()');
297
+ const built = await this._buildAuth(params.to, usdToAtomic(params.amountUsd), params.nonce);
298
+ return this._submitAuthSelf(built);
299
+ });
300
+
301
+ const _api = this._stubApi ?? {
302
+ reputation: async (sid) => {
303
+ const r = await this._req('GET', `/agents/${encodeURIComponent(sid)}/reputation`);
304
+ if (r.status !== 200) throw new Error('reputation lookup failed: ' + JSON.stringify(r.body));
305
+ return r.body.reputation ?? r.body;
306
+ },
307
+ affirm: async (id) => {
308
+ const r = await this._req('POST', `/bookings/${encodeURIComponent(id)}/affirm`, {});
309
+ if (r.status !== 200 && r.status !== 204) throw new Error('affirm failed: ' + JSON.stringify(r.body));
310
+ },
311
+ dispute: async (id) => {
312
+ const r = await this._req('POST', `/bookings/${encodeURIComponent(id)}/dispute`, {});
313
+ if (r.status !== 200 && r.status !== 204) throw new Error('dispute failed: ' + JSON.stringify(r.body));
314
+ },
315
+ config: async () => {
316
+ const r = await this._req('GET', '/config', null, false);
317
+ if (r.status !== 200) throw new Error('config fetch failed: ' + JSON.stringify(r.body));
318
+ return r.body;
319
+ },
320
+ };
321
+
322
+ // The requests to deliver (each a DRAW). `requests` wins; else a single `request`;
323
+ // else a single null request (lets a caller fund+probe without a payload).
324
+ const reqList = Array.isArray(requests) && requests.length ? requests : [request ?? null];
325
+
326
+ // 1. Fetch seller reputation and platform config (once, before the loop).
327
+ const rep = await _api.reputation(sellerId);
328
+ const recommendedMaxChunkUsd = rep.recommendedMaxChunkUsd ?? CHUNK_FLOOR;
329
+ const config = await _api.config();
330
+ // #64: the platform fee address is a fixed per-chain constant. PIN it and refuse a
331
+ // /api/config that disagrees, so a tampered or MITM'd config cannot redirect the
332
+ // buyer's 2.5% fee leg to an attacker. (The platform also verifies the fee leg
333
+ // server-side against its own address, so a mismatch would fail the trade anyway;
334
+ // this stops the buyer paying the wrong address in the first place.) Unknown chains
335
+ // have no pin and fall back to the fetched value.
336
+ const pinnedFee = PINNED_FEE_ADDRESSES[this.chainId];
337
+ if (pinnedFee && String(config.feeAddress || '').toLowerCase() !== pinnedFee.toLowerCase()) {
338
+ throw new Error(`fee_address_mismatch: /api/config returned ${config.feeAddress} for chain ${this.chainId} but the pinned platform fee address is ${pinnedFee}. Refusing to pay a possibly-tampered fee address.`);
339
+ }
340
+
341
+ // Estimate a draw's cost (USD) so we know when to top up. Use the offer's output
342
+ // price against the request's max_tokens (a generous upper bound; actual metered
343
+ // usage is what gets deducted). Falls back to the floor when no hint is available.
344
+ const outPrice = Number(offer.outputPricePerMTok) || 0;
345
+ const estimateCost = (r) => {
346
+ const maxOut = Number(r?.max_tokens) || 0;
347
+ if (outPrice > 0 && maxOut > 0) return (maxOut / 1e6) * outPrice;
348
+ return CHUNK_FLOOR; // unknown size → assume a floor-sized draw
349
+ };
350
+
351
+ let remainingBudget = totalNeedUsd; // funding budget left (hard cap)
352
+ let remainingUsd = 0; // current booking balance (off-chain)
353
+ let fundN = 0; // FUND chunk counter (sizing + idempotency n)
354
+ let drawN = 0; // DRAW counter (idempotency n)
355
+ let fundedUsd = 0; // total on-chain funded (chunk USD)
356
+ let drawnUsd = 0; // total metered usage actually drawn
357
+ const chunks = [];
358
+ const outputParts = [];
359
+ let activeBookingId = bookingId ?? null;
360
+
361
+ // FUND one on-chain top-up: sign both legs, POST a FUND /chunk, update balance.
362
+ // Returns { ok, error? }. On a signer/relay/report failure, ok=false (caller disputes).
363
+ const doFund = async (targetUsd) => {
364
+ const chunkUsd = fundN === 0
365
+ ? Math.min(CHUNK_FLOOR, remainingBudget)
366
+ : Math.min(remainingBudget, Math.max(targetUsd, recommendedMaxChunkUsd));
367
+ if (!(chunkUsd > 0.0001)) return { ok: false, error: new Error('funding budget exhausted; cannot top up') };
368
+
369
+ let sellerTxHash, feeTxHash;
370
+ try {
371
+ sellerTxHash = await Promise.resolve(_signChunkAuth({
372
+ leg: 'seller', to: offer.settlementPubkey, amountUsd: chunkUsd,
373
+ nonce: '0x' + crypto.randomBytes(32).toString('hex'), offerId: offer.id, n: fundN,
374
+ }));
375
+ feeTxHash = await Promise.resolve(_signChunkAuth({
376
+ leg: 'fee', to: config.feeAddress, amountUsd: chunkUsd * (config.feeBps ?? feeBps) / 10000,
377
+ nonce: '0x' + crypto.randomBytes(32).toString('hex'), offerId: offer.id, n: fundN,
378
+ }));
379
+ } catch (e) {
380
+ return { ok: false, error: e };
381
+ }
382
+
383
+ let booking;
384
+ try {
385
+ booking = await _relayFetch({
386
+ bookingId: activeBookingId, n: fundN, sellerTxHash, feeTxHash,
387
+ priceUsd: chunkUsd, model: offer.model, buyerId: this.agentId,
388
+ });
389
+ } catch (e) {
390
+ return { ok: false, error: e };
391
+ }
392
+ if (!booking || booking.error || booking.remainingUsd == null) {
393
+ return { ok: false, error: new Error('FUND failed: ' + JSON.stringify(booking ?? null)) };
394
+ }
395
+
396
+ activeBookingId = activeBookingId ?? booking._bookingId ?? null;
397
+ remainingUsd = Number(booking.remainingUsd) || 0;
398
+ fundedUsd += chunkUsd;
399
+ remainingBudget -= chunkUsd;
400
+ chunks.push({ kind: 'fund', n: fundN, usd: chunkUsd, remainingUsd, sellerTxHash, feeTxHash });
401
+ fundN++;
402
+ return { ok: true };
403
+ };
404
+
405
+ // 2. Deliver each request, funding first whenever the balance is short.
406
+ for (const reqItem of reqList) {
407
+ const est = estimateCost(reqItem);
408
+
409
+ // Ensure the balance can fund this draw; top up if short and budget remains.
410
+ while (remainingUsd < est && remainingBudget > 0.0001) {
411
+ const f = await doFund(est);
412
+ if (!f.ok) {
413
+ if (activeBookingId) await _api.dispute(activeBookingId).catch(() => {});
414
+ return { output: outputParts.join(''), chunks, drawnUsd, fundedUsd, disputed: true, affirmed: false };
415
+ }
416
+ }
417
+
418
+ // If we still can't fund a draw (budget cap hit), stop cleanly: affirm what we got.
419
+ if (remainingUsd <= 1e-6) break;
420
+
421
+ // DRAW: no payment, just the booking + the request. The relay meters actual
422
+ // usage and returns the post-draw remainingUsd.
423
+ let completion, drawError;
424
+ try {
425
+ completion = await _relayFetch({ bookingId: activeBookingId, n: drawN, model: offer.model, buyerId: this.agentId, request: reqItem });
426
+ } catch (e) {
427
+ drawError = e;
428
+ }
429
+
430
+ const isGood = !drawError
431
+ && completion
432
+ && Array.isArray(completion.choices)
433
+ && completion.choices.length > 0
434
+ && completion.choices[0]?.message?.content != null
435
+ && completion.model === offer.model
436
+ && completion.usage != null;
437
+
438
+ const usedThisDraw = isGood ? round6Usd(meterUsd(completion.usage, offer)) : 0;
439
+ chunks.push({ kind: 'draw', n: drawN, completion: isGood ? completion : null, usedUsd: usedThisDraw, remainingUsd: completion?.remainingUsd ?? remainingUsd, error: drawError?.message ?? completion?.error ?? null });
440
+
441
+ if (!isGood) {
442
+ // Bad draw — dispute and stop. Max loss = the funded balance (kept small).
443
+ if (activeBookingId) await _api.dispute(activeBookingId).catch(() => {});
444
+ return { output: outputParts.join(''), chunks, drawnUsd, fundedUsd, disputed: true, affirmed: false };
445
+ }
446
+
447
+ activeBookingId = activeBookingId ?? completion._bookingId ?? null;
448
+ if (completion.remainingUsd != null) remainingUsd = Number(completion.remainingUsd) || 0;
449
+ else remainingUsd = Math.max(0, remainingUsd - usedThisDraw);
450
+ drawnUsd += usedThisDraw;
451
+ outputParts.push(completion.choices[0].message.content ?? '');
452
+ drawN++;
453
+ }
454
+
455
+ // 3. All requests delivered — affirm and return the summary.
456
+ if (activeBookingId) await _api.affirm(activeBookingId).catch(() => {});
457
+ return { output: outputParts.join(''), chunks, drawnUsd, fundedUsd, disputed: false, affirmed: true };
458
+ }
459
+
460
+ // Send structured feedback (write-only telemetry; NEVER affects your reputation).
461
+ // payload: { phase, ok, code?, expected?, note?, role?, ref?, sdk? }
462
+ async feedback(payload) {
463
+ const r = await this._req('POST', '/feedback', payload ?? {});
464
+ return r.body;
465
+ }
466
+
467
+ // One-call buyer convenience over the explicit steps. Discovers via the book (a free
468
+ // read), gates on funding, then tries crossing tier:direct offers cheapest-first,
469
+ // drawing against each via drawFromSeller (which funds the floor first, tops up toward
470
+ // the seller's recommendedMaxChunkUsd within budget, meters usage, and affirms/disputes).
471
+ // Returns a STATUS OBJECT (never throws on an expected outcome) so an agent can branch
472
+ // without try/catch: { status: 'ok' | 'funding_required' | 'no_offers' | 'all_disputed' }.
473
+ // buy({ model, budget, prompt | messages | requests, maxPrice?, sellerId? })
474
+ // budget is the hard funding cap (== drawFromSeller totalNeedUsd) and your max loss.
475
+ async buy({ model, budget, prompt, messages, requests, maxPrice = 0, sellerId } = {}) {
476
+ if (!model || !(Number(budget) > 0)) throw new Error('buy: model and a positive budget are required');
477
+ const reqList = Array.isArray(requests) && requests.length
478
+ ? requests
479
+ : [{ model, messages: messages ?? [{ role: 'user', content: String(prompt ?? '') }], max_tokens: 256 }];
480
+
481
+ // 1. Fund gate — surface the human ask if short, do nothing on-chain.
482
+ const funding = await this.ensureFundedFor(budget);
483
+ if (!funding.ok) return { status: 'funding_required', funding };
484
+
485
+ // 2. Discover via the book (free read). Filter to open direct offers within maxPrice.
486
+ const book = await this._req('GET', `/book?model=${encodeURIComponent(model)}`);
487
+ let offers = (book.body?.offers || []).filter((o) =>
488
+ o.tier === 'direct' && o.status === 'open'
489
+ && (!sellerId || o.agentId === sellerId)
490
+ && (!(maxPrice > 0) || (Number(o.outputPricePerMTok) <= maxPrice && Number(o.inputPricePerMTok) <= maxPrice)));
491
+ offers.sort((a, b) => (Number(a.outputPricePerMTok) || 0) - (Number(b.outputPricePerMTok) || 0));
492
+ if (!offers.length) return { status: 'no_offers', model, maxPrice };
493
+
494
+ // 3. Try cheapest-first; a bad/disputed draw moves to the next route.
495
+ const tried = [];
496
+ for (const o of offers) {
497
+ const offer = { id: o.id, tier: 'direct', relayEndpoint: o.relayEndpoint, model, inputPricePerMTok: o.inputPricePerMTok, outputPricePerMTok: o.outputPricePerMTok, settlementPubkey: o.settlementPubkey, agentId: o.agentId };
498
+ const res = await this.drawFromSeller({ offer, totalNeedUsd: budget, sellerId: o.agentId, requests: reqList });
499
+ const draws = (res.chunks || []).filter((c) => c.kind === 'draw' && c.completion);
500
+ if (res.affirmed && !res.disputed && draws.length) {
501
+ const last = draws[draws.length - 1];
502
+ const funds = (res.chunks || []).filter((c) => c.kind === 'fund');
503
+ return {
504
+ status: 'ok', sellerId: o.agentId, offerId: o.id,
505
+ completions: draws.map((c) => c.completion),
506
+ fundedUsd: res.fundedUsd, spentUsd: res.drawnUsd, remainingUsd: last.remainingUsd,
507
+ txHashes: funds.flatMap((f) => [f.sellerTxHash, f.feeTxHash].filter(Boolean)),
508
+ };
509
+ }
510
+ tried.push({ sellerId: o.agentId, offerId: o.id, disputed: !!res.disputed });
511
+ }
512
+ return { status: 'all_disputed', model, tried };
513
+ }
514
+ }
515
+
516
+ // Metered USD of an OpenAI-shaped usage block at the offer's per-MTok prices.
517
+ function meterUsd(usage, offer) {
518
+ const inTok = Number(usage?.prompt_tokens ?? usage?.input_tokens ?? 0) || 0;
519
+ const outTok = Number(usage?.completion_tokens ?? usage?.output_tokens ?? 0) || 0;
520
+ return (inTok * (Number(offer.inputPricePerMTok) || 0) + outTok * (Number(offer.outputPricePerMTok) || 0)) / 1e6;
521
+ }
522
+ const round6Usd = (n) => Math.round(n * 1e6) / 1e6;
523
+
524
+ // Lowercase alias so the served sdk.mjs is importable as `mtok` (matching client.mjs's
525
+ // `mtok` export and the house style), e.g. `const { mtok } = await import('.../sdk.mjs')`.
526
+ // Both `Mtok` and `mtok` name the same class.
527
+ export { Mtok as mtok };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "mtok-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Reference client for the mtok.market non-custodial token spot market — manages agent identity + wallet and pays on-chain however the market asks.",
5
+ "type": "module",
6
+ "main": "mtok.mjs",
7
+ "exports": {
8
+ ".": "./mtok.mjs"
9
+ },
10
+ "files": ["mtok.mjs", "README.md"],
11
+ "homepage": "https://mtok.market",
12
+ "keywords": ["mtok", "ai", "inference", "tokens", "marketplace", "agents", "base", "usdc", "x402", "non-custodial"],
13
+ "publishConfig": { "access": "public" },
14
+ "scripts": {
15
+ "example": "node example.mjs",
16
+ "build:sdk": "node ../scripts/build-sdk-bundle.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "dependencies": {
22
+ "viem": "^2.53.1"
23
+ },
24
+ "license": "MIT",
25
+ "devDependencies": {
26
+ "esbuild": "^0.28.1"
27
+ },
28
+ "allowScripts": {
29
+ "esbuild@0.28.1": true
30
+ }
31
+ }