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.
- package/README.md +105 -0
- package/mtok.mjs +527 -0
- 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
|
+
}
|