init
This commit is contained in:
commit
c54e888458
67 files changed
+5933
No files matched your search
@@ -0,0 +1,64 @@
|
||||
# Vendored from t4t-container
|
||||
|
||||
These files are copied verbatim from
|
||||
[`token-for-token/t4t`](https://github.com/token-for-token/t4t) at commit
|
||||
**`f525b8bf93349dcaef2e234a6ad9e49871dcf12d`** (path:
|
||||
`container/src/lib/*.ts`, `container/src/modes/gateway/{models,selector}.ts`).
|
||||
|
||||
The hosted gateway is a hosted multi-tenant frontend that uses the same chain
|
||||
+ Swarm + PSS protocol. We reuse the engine code as a library; rewriting it
|
||||
would invite drift, and a separate npm package is premature.
|
||||
|
||||
## Files vendored
|
||||
|
||||
```
|
||||
src/t4t/lib/
|
||||
├── abi.ts # ProviderRegistry + JobEscrow + ERC20 ABIs
|
||||
├── bzz-price.ts # CoinGecko USD price (cosmetic display only)
|
||||
├── chain.ts # viem clients + read/write wrappers (postJob, claimJob, …)
|
||||
├── crypto.ts # ECIES (payload encryption)
|
||||
├── envelope.ts # PSS envelope sign/verify, topic derivation, dedup
|
||||
├── keys.ts # PSS keypair generation (secp256k1)
|
||||
├── logger.ts # ⚠️ LOCAL EDIT — re-exports project logger (see below)
|
||||
├── stamps.ts # operator-managed postage batch helpers
|
||||
├── swarm.ts # bee-js wrapper (chunks + PssTransport)
|
||||
├── types.ts # shared chain types
|
||||
└── wallet.ts # BIP39 / private-key helpers
|
||||
src/t4t/modes/gateway/
|
||||
├── models.ts # ModelDiscovery (LRU-cached registry scan)
|
||||
└── selector.ts # selectProvider() — stateless, filters by model+price+rep
|
||||
```
|
||||
|
||||
The `modes/gateway/` depth mirrors the upstream `container/src/modes/gateway/`
|
||||
layout so the internal `import {…} from '../../lib/chain'` paths keep working
|
||||
verbatim. **Don't flatten this** — it's the difference between zero edits to
|
||||
vendored files and a maintenance burden on every sync.
|
||||
|
||||
## Files deliberately NOT vendored
|
||||
|
||||
| Source path | Reason |
|
||||
|---|---|
|
||||
| `lib/onboarding.ts`, `modes/gateway/admin.ts`, `modes/gateway/server.ts` | Replaced by Elysia signup + dashboard + routes. |
|
||||
| `lib/jobs-db.ts` | Replaced by Prisma `GatewayJob` + `userId` tenant attribution. |
|
||||
| `lib/job-index.ts` | Replaced by Postgres-backed `src/chain/jobIndex.ts`. |
|
||||
| `lib/config.ts` | Replaced by `src/env.ts` + per-tenant rows. |
|
||||
| `lib/admin-html.ts`, `lib/admin-stamps.ts` | Single-operator admin UI. |
|
||||
| `lib/inference.ts` | Provider-side (not used by gateway). |
|
||||
| `modes/gateway/index.ts` | `handleChat` is refactored into `src/gateway/handleChat.ts`. |
|
||||
|
||||
## Drift policy
|
||||
|
||||
- **No edits** to vendored files. The one explicit exception is `lib/logger.ts`
|
||||
(re-exports the project logger so vendored code shares the same pino
|
||||
instance). Any further required change must be filed upstream first.
|
||||
- Re-sync upstream every two weeks:
|
||||
|
||||
```bash
|
||||
diff -ru ../t4t/container/src/lib/ src/t4t/lib/
|
||||
diff -ru ../t4t/container/src/modes/gateway/{models,selector}.ts \
|
||||
src/t4t/gateway/{models,selector}.ts
|
||||
```
|
||||
|
||||
Bump the SHA at the top of this file when re-syncing.
|
||||
- If upstream renames or restructures these files, mirror the change here in the
|
||||
same commit that updates the SHA.
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Hand-written ABIs for the three T4T contracts so the container builds
|
||||
* without a Forge artifact pipeline. Regenerate from `contracts/out/` if
|
||||
* the Solidity sources drift.
|
||||
*/
|
||||
|
||||
export const providerRegistryAbi = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'register',
|
||||
stateMutability: 'nonpayable',
|
||||
inputs: [
|
||||
{name: 'pssPublicKey', type: 'bytes32'},
|
||||
{name: 'swarmOverlay', type: 'bytes32'},
|
||||
{name: 'metadataURI', type: 'string'},
|
||||
{name: 'initialStake', type: 'uint128'},
|
||||
],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'updateOfferings',
|
||||
stateMutability: 'nonpayable',
|
||||
inputs: [
|
||||
{
|
||||
name: 'offerings',
|
||||
type: 'tuple[]',
|
||||
components: [
|
||||
{name: 'modelId', type: 'string'},
|
||||
{name: 'inputPricePerMillionTokens', type: 'uint128'},
|
||||
{name: 'outputPricePerMillionTokens', type: 'uint128'},
|
||||
{name: 'maxContextTokens', type: 'uint128'},
|
||||
{name: 'maxLatencySeconds', type: 'uint64'},
|
||||
],
|
||||
},
|
||||
],
|
||||
outputs: [],
|
||||
},
|
||||
{type: 'function', name: 'heartbeat', stateMutability: 'nonpayable', inputs: [], outputs: []},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'MIN_STAKE',
|
||||
stateMutability: 'view',
|
||||
inputs: [],
|
||||
outputs: [{type: 'uint128'}],
|
||||
},
|
||||
{type: 'function', name: 'deactivate', stateMutability: 'nonpayable', inputs: [], outputs: []},
|
||||
{type: 'function', name: 'withdrawStake', stateMutability: 'nonpayable', inputs: [], outputs: []},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'isLive',
|
||||
stateMutability: 'view',
|
||||
inputs: [{name: 'owner', type: 'address'}],
|
||||
outputs: [{type: 'bool'}],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'getProvider',
|
||||
stateMutability: 'view',
|
||||
inputs: [{name: 'owner', type: 'address'}],
|
||||
outputs: [
|
||||
{
|
||||
type: 'tuple',
|
||||
components: [
|
||||
{name: 'owner', type: 'address'},
|
||||
{name: 'pssPublicKey', type: 'bytes32'},
|
||||
{name: 'swarmOverlay', type: 'bytes32'},
|
||||
{name: 'metadataURI', type: 'string'},
|
||||
{name: 'stake', type: 'uint128'},
|
||||
{name: 'lastHeartbeat', type: 'uint64'},
|
||||
{name: 'totalJobs', type: 'uint32'},
|
||||
{name: 'successfulJobs', type: 'uint32'},
|
||||
{name: 'active', type: 'bool'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'getOfferings',
|
||||
stateMutability: 'view',
|
||||
inputs: [{name: 'owner', type: 'address'}],
|
||||
outputs: [
|
||||
{
|
||||
type: 'tuple[]',
|
||||
components: [
|
||||
{name: 'modelId', type: 'string'},
|
||||
{name: 'inputPricePerMillionTokens', type: 'uint128'},
|
||||
{name: 'outputPricePerMillionTokens', type: 'uint128'},
|
||||
{name: 'maxContextTokens', type: 'uint128'},
|
||||
{name: 'maxLatencySeconds', type: 'uint64'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'listProviders',
|
||||
stateMutability: 'view',
|
||||
inputs: [
|
||||
{name: 'cursor', type: 'uint256'},
|
||||
{name: 'limit', type: 'uint256'},
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
type: 'tuple[]',
|
||||
components: [
|
||||
{name: 'owner', type: 'address'},
|
||||
{name: 'pssPublicKey', type: 'bytes32'},
|
||||
{name: 'swarmOverlay', type: 'bytes32'},
|
||||
{name: 'metadataURI', type: 'string'},
|
||||
{name: 'stake', type: 'uint128'},
|
||||
{name: 'lastHeartbeat', type: 'uint64'},
|
||||
{name: 'totalJobs', type: 'uint32'},
|
||||
{name: 'successfulJobs', type: 'uint32'},
|
||||
{name: 'active', type: 'bool'},
|
||||
],
|
||||
},
|
||||
{name: 'nextCursor', type: 'uint256'},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export const jobEscrowAbi = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'postJob',
|
||||
stateMutability: 'nonpayable',
|
||||
inputs: [
|
||||
{name: 'provider', type: 'address'},
|
||||
{name: 'requestHash', type: 'bytes32'},
|
||||
{name: 'modelId', type: 'string'},
|
||||
{name: 'maxPayment', type: 'uint128'},
|
||||
{name: 'deliveryDeadline', type: 'uint64'},
|
||||
],
|
||||
outputs: [{name: 'jobId', type: 'bytes32'}],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'ackJob',
|
||||
stateMutability: 'nonpayable',
|
||||
inputs: [{name: 'jobId', type: 'bytes32'}],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'claimJob',
|
||||
stateMutability: 'nonpayable',
|
||||
inputs: [
|
||||
{name: 'jobId', type: 'bytes32'},
|
||||
{name: 'responseHash', type: 'bytes32'},
|
||||
{name: 'actualPayment', type: 'uint128'},
|
||||
{name: 'clientSig', type: 'bytes'},
|
||||
],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'cancelJob',
|
||||
stateMutability: 'nonpayable',
|
||||
inputs: [{name: 'jobId', type: 'bytes32'}],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'timeoutJob',
|
||||
stateMutability: 'nonpayable',
|
||||
inputs: [{name: 'jobId', type: 'bytes32'}],
|
||||
outputs: [],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'jobs',
|
||||
stateMutability: 'view',
|
||||
inputs: [{name: 'jobId', type: 'bytes32'}],
|
||||
outputs: [
|
||||
{name: 'client', type: 'address'},
|
||||
{name: 'provider', type: 'address'},
|
||||
{name: 'requestHash', type: 'bytes32'},
|
||||
{name: 'responseHash', type: 'bytes32'},
|
||||
{name: 'modelId', type: 'string'},
|
||||
{name: 'maxPayment', type: 'uint128'},
|
||||
{name: 'postedAt', type: 'uint64'},
|
||||
{name: 'ackedAt', type: 'uint64'},
|
||||
{name: 'ackDeadline', type: 'uint64'},
|
||||
{name: 'deliveryDeadline', type: 'uint64'},
|
||||
{name: 'status', type: 'uint8'},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'event',
|
||||
name: 'JobPosted',
|
||||
inputs: [
|
||||
{name: 'jobId', type: 'bytes32', indexed: true},
|
||||
{name: 'client', type: 'address', indexed: true},
|
||||
{name: 'provider', type: 'address', indexed: true},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'event',
|
||||
name: 'JobClaimed',
|
||||
inputs: [
|
||||
{name: 'jobId', type: 'bytes32', indexed: true},
|
||||
{name: 'responseHash', type: 'bytes32'},
|
||||
{name: 'paid', type: 'uint128'},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export const erc20Abi = [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'approve',
|
||||
stateMutability: 'nonpayable',
|
||||
inputs: [
|
||||
{name: 'spender', type: 'address'},
|
||||
{name: 'amount', type: 'uint256'},
|
||||
],
|
||||
outputs: [{type: 'bool'}],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'allowance',
|
||||
stateMutability: 'view',
|
||||
inputs: [
|
||||
{name: 'owner', type: 'address'},
|
||||
{name: 'spender', type: 'address'},
|
||||
],
|
||||
outputs: [{type: 'uint256'}],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'balanceOf',
|
||||
stateMutability: 'view',
|
||||
inputs: [{name: 'owner', type: 'address'}],
|
||||
outputs: [{type: 'uint256'}],
|
||||
},
|
||||
] as const
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* BZZ → USD price lookup via CoinGecko's free tier. Cached 5 min so the
|
||||
* admin UI can show a USD-equivalent alongside PLUR/BZZ without hammering
|
||||
* the API. Failures fall back to the last good value (or null if we've
|
||||
* never had one) — the calling page just hides the USD column.
|
||||
*/
|
||||
import type {Logger} from './logger'
|
||||
|
||||
const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price?ids=swarm-bzz&vs_currencies=usd'
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
interface Cached {
|
||||
usd: number
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
let cached: Cached | null = null
|
||||
let inflight: Promise<number | null> | null = null
|
||||
|
||||
export async function getBzzUsd(logger?: Logger): Promise<number | null> {
|
||||
const now = Date.now()
|
||||
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.usd
|
||||
if (inflight) return inflight
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const r = await fetch(COINGECKO_URL, {headers: {accept: 'application/json'}})
|
||||
if (!r.ok) {
|
||||
logger?.warn({status: r.status}, 'CoinGecko BZZ price fetch returned non-2xx')
|
||||
return cached?.usd ?? null
|
||||
}
|
||||
const data = (await r.json()) as Record<string, {usd?: number}>
|
||||
const usd = data['swarm-bzz']?.usd
|
||||
if (typeof usd !== 'number' || !isFinite(usd) || usd <= 0) {
|
||||
logger?.warn({data}, 'CoinGecko BZZ price missing or invalid')
|
||||
return cached?.usd ?? null
|
||||
}
|
||||
cached = {usd, fetchedAt: now}
|
||||
return usd
|
||||
} catch (err) {
|
||||
logger?.warn({err}, 'CoinGecko BZZ price fetch failed')
|
||||
return cached?.usd ?? null
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
return inflight
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
createPublicClient,
|
||||
createWalletClient,
|
||||
http,
|
||||
parseEventLogs,
|
||||
type Address,
|
||||
type Hex,
|
||||
type PublicClient,
|
||||
type WalletClient,
|
||||
} from 'viem'
|
||||
import {privateKeyToAccount} from 'viem/accounts'
|
||||
import {gnosis, gnosisChiado} from 'viem/chains'
|
||||
import {erc20Abi, jobEscrowAbi, providerRegistryAbi} from './abi'
|
||||
import type {ModelOffering, ProviderRow} from './types'
|
||||
|
||||
/** Mirrors `JobEscrow.ACK_WINDOW` (spec §3). Used by the client to schedule
|
||||
* the no-ack cancel deadline locally. Keep in sync with the contract. */
|
||||
export const ACK_WINDOW_SECONDS = 30
|
||||
|
||||
export interface TxEvent {
|
||||
kind: string
|
||||
hash: Hex
|
||||
toAddress: Address
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
export interface ChainClient {
|
||||
pub: PublicClient
|
||||
wallet: WalletClient
|
||||
address: Address
|
||||
registry: Address
|
||||
escrow: Address
|
||||
xbzz: Address
|
||||
/** Optional sink for every write tx after it's been submitted. Wired in by
|
||||
* the provider/client startup to persist to `transactions` in jobs.db. */
|
||||
onTx?: (e: TxEvent) => void
|
||||
}
|
||||
|
||||
export interface ChainOpts {
|
||||
rpcUrl: string
|
||||
privateKey: Hex
|
||||
registry: Address
|
||||
escrow: Address
|
||||
xbzz: Address
|
||||
chainId?: number
|
||||
onTx?: (e: TxEvent) => void
|
||||
}
|
||||
|
||||
export function makeChain(opts: ChainOpts): ChainClient {
|
||||
const account = privateKeyToAccount(opts.privateKey)
|
||||
const chain = opts.chainId === 10200 ? gnosisChiado : gnosis
|
||||
const transport = http(opts.rpcUrl)
|
||||
return {
|
||||
pub: createPublicClient({chain, transport}),
|
||||
wallet: createWalletClient({chain, transport, account}),
|
||||
address: account.address,
|
||||
registry: opts.registry,
|
||||
escrow: opts.escrow,
|
||||
xbzz: opts.xbzz,
|
||||
onTx: opts.onTx,
|
||||
}
|
||||
}
|
||||
|
||||
/** Notify the txLog sink. Swallow any error in the callback so a faulty DB
|
||||
* write never takes down a chain operation. */
|
||||
function emit(c: ChainClient, e: TxEvent): void {
|
||||
try {
|
||||
c.onTx?.(e)
|
||||
} catch {
|
||||
// intentionally ignored
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ProviderRegistry ----------
|
||||
|
||||
export async function registerProvider(
|
||||
c: ChainClient,
|
||||
args: {
|
||||
pssPublicKey: Hex
|
||||
swarmOverlay: Hex
|
||||
metadataURI: string
|
||||
initialStake: bigint
|
||||
},
|
||||
): Promise<Hex> {
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.registry,
|
||||
abi: providerRegistryAbi,
|
||||
functionName: 'register',
|
||||
args: [args.pssPublicKey as `0x${string}`, args.swarmOverlay as `0x${string}`, args.metadataURI, args.initialStake],
|
||||
})
|
||||
emit(c, {kind: 'registerProvider', hash, toAddress: c.registry, note: `stake=${args.initialStake.toString()}`})
|
||||
return hash
|
||||
}
|
||||
|
||||
export async function updateOfferings(c: ChainClient, offerings: ModelOffering[]): Promise<Hex> {
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.registry,
|
||||
abi: providerRegistryAbi,
|
||||
functionName: 'updateOfferings',
|
||||
args: [offerings],
|
||||
})
|
||||
emit(c, {kind: 'updateOfferings', hash, toAddress: c.registry, note: `models=${offerings.length}`})
|
||||
return hash
|
||||
}
|
||||
|
||||
export async function sendHeartbeat(c: ChainClient): Promise<Hex> {
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.registry,
|
||||
abi: providerRegistryAbi,
|
||||
functionName: 'heartbeat',
|
||||
args: [],
|
||||
})
|
||||
emit(c, {kind: 'heartbeat', hash, toAddress: c.registry})
|
||||
return hash
|
||||
}
|
||||
|
||||
export async function deactivateProvider(c: ChainClient): Promise<Hex> {
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.registry,
|
||||
abi: providerRegistryAbi,
|
||||
functionName: 'deactivate',
|
||||
args: [],
|
||||
})
|
||||
emit(c, {kind: 'deactivateProvider', hash, toAddress: c.registry})
|
||||
return hash
|
||||
}
|
||||
|
||||
export async function withdrawStake(c: ChainClient): Promise<Hex> {
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.registry,
|
||||
abi: providerRegistryAbi,
|
||||
functionName: 'withdrawStake',
|
||||
args: [],
|
||||
})
|
||||
emit(c, {kind: 'withdrawStake', hash, toAddress: c.registry})
|
||||
return hash
|
||||
}
|
||||
|
||||
export async function readMinStake(c: ChainClient): Promise<bigint> {
|
||||
return (await c.pub.readContract({
|
||||
address: c.registry,
|
||||
abi: providerRegistryAbi,
|
||||
functionName: 'MIN_STAKE',
|
||||
args: [],
|
||||
})) as bigint
|
||||
}
|
||||
|
||||
export async function readXbzzBalance(c: ChainClient, owner: Address): Promise<bigint> {
|
||||
return (await c.pub.readContract({
|
||||
address: c.xbzz,
|
||||
abi: [
|
||||
{
|
||||
type: 'function',
|
||||
name: 'balanceOf',
|
||||
stateMutability: 'view',
|
||||
inputs: [{name: 'o', type: 'address'}],
|
||||
outputs: [{type: 'uint256'}],
|
||||
},
|
||||
],
|
||||
functionName: 'balanceOf',
|
||||
args: [owner],
|
||||
})) as bigint
|
||||
}
|
||||
|
||||
export async function getProvider(c: ChainClient, owner: Address): Promise<ProviderRow> {
|
||||
const raw = await c.pub.readContract({
|
||||
address: c.registry,
|
||||
abi: providerRegistryAbi,
|
||||
functionName: 'getProvider',
|
||||
args: [owner],
|
||||
})
|
||||
return raw as unknown as ProviderRow
|
||||
}
|
||||
|
||||
export async function getOfferings(c: ChainClient, owner: Address): Promise<ModelOffering[]> {
|
||||
const raw = await c.pub.readContract({
|
||||
address: c.registry,
|
||||
abi: providerRegistryAbi,
|
||||
functionName: 'getOfferings',
|
||||
args: [owner],
|
||||
})
|
||||
return raw as unknown as ModelOffering[]
|
||||
}
|
||||
|
||||
export async function listProviders(
|
||||
c: ChainClient,
|
||||
cursor = 0n,
|
||||
limit = 50n,
|
||||
): Promise<{page: ProviderRow[]; nextCursor: bigint}> {
|
||||
const [page, nextCursor] = (await c.pub.readContract({
|
||||
address: c.registry,
|
||||
abi: providerRegistryAbi,
|
||||
functionName: 'listProviders',
|
||||
args: [cursor, limit],
|
||||
})) as unknown as [ProviderRow[], bigint]
|
||||
return {page, nextCursor}
|
||||
}
|
||||
|
||||
// ---------- JobEscrow ----------
|
||||
|
||||
export async function postJob(
|
||||
c: ChainClient,
|
||||
args: {
|
||||
provider: Address
|
||||
requestHash: Hex
|
||||
modelId: string
|
||||
maxPayment: bigint
|
||||
deliveryDeadline: number
|
||||
},
|
||||
): Promise<{txHash: Hex; jobId: Hex}> {
|
||||
const txHash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.escrow,
|
||||
abi: jobEscrowAbi,
|
||||
functionName: 'postJob',
|
||||
args: [args.provider, args.requestHash, args.modelId, args.maxPayment, BigInt(args.deliveryDeadline)],
|
||||
})
|
||||
const receipt = await c.pub.waitForTransactionReceipt({hash: txHash})
|
||||
const events = parseEventLogs({abi: jobEscrowAbi, eventName: 'JobPosted', logs: receipt.logs})
|
||||
const ev = events[0]
|
||||
if (!ev) throw new Error('postJob: JobPosted event missing from receipt')
|
||||
const jobId = ev.args.jobId as Hex
|
||||
emit(c, {kind: 'postJob', hash: txHash, toAddress: c.escrow, note: `model=${args.modelId} jobId=${jobId}`})
|
||||
return {txHash, jobId}
|
||||
}
|
||||
|
||||
export async function ackJob(c: ChainClient, jobId: Hex): Promise<Hex> {
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.escrow,
|
||||
abi: jobEscrowAbi,
|
||||
functionName: 'ackJob',
|
||||
args: [jobId],
|
||||
})
|
||||
emit(c, {kind: 'ackJob', hash, toAddress: c.escrow, note: `jobId=${jobId}`})
|
||||
return hash
|
||||
}
|
||||
|
||||
export async function readJob(
|
||||
c: ChainClient,
|
||||
jobId: Hex,
|
||||
): Promise<{
|
||||
client: Address
|
||||
provider: Address
|
||||
requestHash: Hex
|
||||
responseHash: Hex
|
||||
modelId: string
|
||||
maxPayment: bigint
|
||||
postedAt: bigint
|
||||
ackedAt: bigint
|
||||
ackDeadline: bigint
|
||||
deliveryDeadline: bigint
|
||||
status: number
|
||||
}> {
|
||||
const raw = (await c.pub.readContract({
|
||||
address: c.escrow,
|
||||
abi: jobEscrowAbi,
|
||||
functionName: 'jobs',
|
||||
args: [jobId],
|
||||
})) as readonly [Address, Address, Hex, Hex, string, bigint, bigint, bigint, bigint, bigint, number]
|
||||
return {
|
||||
client: raw[0],
|
||||
provider: raw[1],
|
||||
requestHash: raw[2],
|
||||
responseHash: raw[3],
|
||||
modelId: raw[4],
|
||||
maxPayment: raw[5],
|
||||
postedAt: raw[6],
|
||||
ackedAt: raw[7],
|
||||
ackDeadline: raw[8],
|
||||
deliveryDeadline: raw[9],
|
||||
status: raw[10],
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelJob(c: ChainClient, jobId: Hex): Promise<Hex> {
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.escrow,
|
||||
abi: jobEscrowAbi,
|
||||
functionName: 'cancelJob',
|
||||
args: [jobId],
|
||||
})
|
||||
emit(c, {kind: 'cancelJob', hash, toAddress: c.escrow, note: `jobId=${jobId}`})
|
||||
return hash
|
||||
}
|
||||
|
||||
export async function timeoutJob(c: ChainClient, jobId: Hex): Promise<Hex> {
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.escrow,
|
||||
abi: jobEscrowAbi,
|
||||
functionName: 'timeoutJob',
|
||||
args: [jobId],
|
||||
})
|
||||
emit(c, {kind: 'timeoutJob', hash, toAddress: c.escrow, note: `jobId=${jobId}`})
|
||||
return hash
|
||||
}
|
||||
|
||||
export async function claimJob(
|
||||
c: ChainClient,
|
||||
args: {jobId: Hex; responseHash: Hex; actualPayment: bigint; clientSig?: Hex},
|
||||
): Promise<Hex> {
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.escrow,
|
||||
abi: jobEscrowAbi,
|
||||
functionName: 'claimJob',
|
||||
args: [args.jobId, args.responseHash, args.actualPayment, args.clientSig ?? '0x'],
|
||||
})
|
||||
emit(c, {kind: 'claimJob', hash, toAddress: c.escrow, note: `jobId=${args.jobId} amount=${args.actualPayment.toString()}`})
|
||||
return hash
|
||||
}
|
||||
|
||||
// ---------- xBZZ ----------
|
||||
|
||||
export async function ensureAllowance(
|
||||
c: ChainClient,
|
||||
spender: Address,
|
||||
needed: bigint,
|
||||
): Promise<void> {
|
||||
const current = (await c.pub.readContract({
|
||||
address: c.xbzz,
|
||||
abi: erc20Abi,
|
||||
functionName: 'allowance',
|
||||
args: [c.address, spender],
|
||||
})) as bigint
|
||||
if (current >= needed) return
|
||||
const hash = await c.wallet.writeContract({
|
||||
chain: c.wallet.chain!,
|
||||
account: c.wallet.account!,
|
||||
address: c.xbzz,
|
||||
abi: erc20Abi,
|
||||
functionName: 'approve',
|
||||
args: [spender, 2n ** 256n - 1n],
|
||||
})
|
||||
emit(c, {kind: 'approve', hash, toAddress: c.xbzz, note: `spender=${spender} max`})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* ECIES payload encryption for Swarm chunks (spec §6).
|
||||
*
|
||||
* Hybrid scheme: secp256k1 ECDH → HKDF-SHA256 → AES-256-GCM.
|
||||
*
|
||||
* ciphertext = ephPubCompressed(33) || iv(12) || aesGcm(plaintext)
|
||||
*
|
||||
* The recipient's on-chain `pssPublicKey` is a 32-byte X coordinate (Y-even
|
||||
* by convention; see lib/keys.ts), so we prepend 0x02 to reconstruct the
|
||||
* compressed point. The ephemeral pub key is emitted compressed (33 bytes)
|
||||
* since both Y parities can occur for ephemerals.
|
||||
*/
|
||||
import {gcm} from '@noble/ciphers/aes'
|
||||
import {secp256k1} from '@noble/curves/secp256k1'
|
||||
import {hkdf} from '@noble/hashes/hkdf'
|
||||
import {sha256} from '@noble/hashes/sha2'
|
||||
import {hexToBytes, randomBytes} from '@noble/hashes/utils'
|
||||
import {pssPubKeyXToCompressed} from './keys'
|
||||
import type {Hex} from './types'
|
||||
|
||||
const EPH_PUB_LEN = 33
|
||||
const IV_LEN = 12
|
||||
const AES_KEY_LEN = 32
|
||||
const HKDF_INFO = new TextEncoder().encode('t4t-ecies-v1')
|
||||
|
||||
export interface PayloadCipher {
|
||||
encrypt(plaintext: Uint8Array, recipientPubKeyX: Hex): Promise<Uint8Array>
|
||||
decrypt(ciphertext: Uint8Array): Promise<Uint8Array>
|
||||
}
|
||||
|
||||
export class EciesCipher implements PayloadCipher {
|
||||
private readonly selfPriv: Uint8Array
|
||||
|
||||
constructor(selfPssPrivateKey: Hex) {
|
||||
const priv = hexToBytes(selfPssPrivateKey.replace(/^0x/, ''))
|
||||
if (priv.length !== 32) throw new Error(`PSS private key must be 32 bytes, got ${priv.length}`)
|
||||
if (!secp256k1.utils.isValidPrivateKey(priv)) throw new Error('invalid secp256k1 private key')
|
||||
this.selfPriv = priv
|
||||
}
|
||||
|
||||
async encrypt(plaintext: Uint8Array, recipientPubKeyX: Hex): Promise<Uint8Array> {
|
||||
const recipientPub = pssPubKeyXToCompressed(recipientPubKeyX)
|
||||
const ephPriv = secp256k1.utils.randomPrivateKey()
|
||||
const ephPub = secp256k1.getPublicKey(ephPriv, true) // 33 bytes
|
||||
const shared = secp256k1.getSharedSecret(ephPriv, recipientPub, true) // [parity || X]
|
||||
const key = hkdf(sha256, shared.subarray(1), undefined, HKDF_INFO, AES_KEY_LEN)
|
||||
const iv = randomBytes(IV_LEN)
|
||||
const ct = gcm(key, iv).encrypt(plaintext)
|
||||
const out = new Uint8Array(EPH_PUB_LEN + IV_LEN + ct.length)
|
||||
out.set(ephPub, 0)
|
||||
out.set(iv, EPH_PUB_LEN)
|
||||
out.set(ct, EPH_PUB_LEN + IV_LEN)
|
||||
return out
|
||||
}
|
||||
|
||||
async decrypt(ciphertext: Uint8Array): Promise<Uint8Array> {
|
||||
if (ciphertext.length < EPH_PUB_LEN + IV_LEN + 16) {
|
||||
throw new Error('ciphertext too short for ECIES envelope')
|
||||
}
|
||||
const ephPub = ciphertext.subarray(0, EPH_PUB_LEN)
|
||||
const iv = ciphertext.subarray(EPH_PUB_LEN, EPH_PUB_LEN + IV_LEN)
|
||||
const ct = ciphertext.subarray(EPH_PUB_LEN + IV_LEN)
|
||||
const shared = secp256k1.getSharedSecret(this.selfPriv, ephPub, true)
|
||||
const key = hkdf(sha256, shared.subarray(1), undefined, HKDF_INFO, AES_KEY_LEN)
|
||||
return gcm(key, iv).decrypt(ct)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op cipher kept around for tests where you don't want to deal with key
|
||||
* material. Production code paths construct EciesCipher.
|
||||
*/
|
||||
export class PassthroughCipher implements PayloadCipher {
|
||||
async encrypt(plaintext: Uint8Array): Promise<Uint8Array> {
|
||||
return plaintext
|
||||
}
|
||||
async decrypt(ciphertext: Uint8Array): Promise<Uint8Array> {
|
||||
return ciphertext
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonEncrypt(cipher: PayloadCipher, recipient: Hex, value: unknown): Promise<Uint8Array> {
|
||||
return cipher.encrypt(new TextEncoder().encode(JSON.stringify(value)), recipient)
|
||||
}
|
||||
|
||||
export async function jsonDecrypt<T>(cipher: PayloadCipher, ct: Uint8Array): Promise<T> {
|
||||
const pt = await cipher.decrypt(ct)
|
||||
return JSON.parse(new TextDecoder().decode(pt)) as T
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {isAddressEqual, recoverMessageAddress, toHex} from 'viem'
|
||||
import {
|
||||
type Envelope,
|
||||
type EnvelopeType,
|
||||
type Hex,
|
||||
PROTOCOL_VERSION,
|
||||
type SignMessage,
|
||||
type UnsignedEnvelope,
|
||||
} from './types'
|
||||
|
||||
/** PSS topics, spec §5.1. */
|
||||
export function providerTopic(wallet: Hex): string {
|
||||
return `t4t:provider:${wallet.toLowerCase()}`
|
||||
}
|
||||
export function clientTopic(wallet: Hex): string {
|
||||
return `t4t:client:${wallet.toLowerCase()}`
|
||||
}
|
||||
|
||||
export function randomNonce(): Hex {
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
return toHex(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic JSON encoding so signatures are stable across implementations.
|
||||
* Sorts keys at every level and emits no whitespace.
|
||||
*/
|
||||
export function canonicalize(value: unknown): string {
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) throw new Error('non-finite number')
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (typeof value === 'bigint') return JSON.stringify(value.toString())
|
||||
if (typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']'
|
||||
if (typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>
|
||||
const keys = Object.keys(obj).sort()
|
||||
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalize(obj[k])).join(',') + '}'
|
||||
}
|
||||
throw new Error(`unserializable: ${typeof value}`)
|
||||
}
|
||||
|
||||
export interface BuildEnvelopeArgs<TBody> {
|
||||
from: Hex
|
||||
to: Hex
|
||||
type: EnvelopeType
|
||||
body: TBody
|
||||
ts?: number
|
||||
nonce?: Hex
|
||||
}
|
||||
|
||||
export async function signEnvelope<TBody>(
|
||||
args: BuildEnvelopeArgs<TBody>,
|
||||
signMessage: SignMessage,
|
||||
): Promise<Envelope<TBody>> {
|
||||
const unsigned: UnsignedEnvelope<TBody> = {
|
||||
v: PROTOCOL_VERSION,
|
||||
type: args.type,
|
||||
from: args.from,
|
||||
to: args.to,
|
||||
ts: args.ts ?? Math.floor(Date.now() / 1000),
|
||||
nonce: args.nonce ?? randomNonce(),
|
||||
body: args.body,
|
||||
}
|
||||
const sig = await signMessage(canonicalize(unsigned))
|
||||
return {...unsigned, sig}
|
||||
}
|
||||
|
||||
/** True iff the signature recovers to `env.from` and the version matches. */
|
||||
export async function verifyEnvelope(env: Envelope): Promise<boolean> {
|
||||
if (env.v !== PROTOCOL_VERSION) return false
|
||||
const {sig, ...unsigned} = env
|
||||
try {
|
||||
const recovered = await recoverMessageAddress({
|
||||
message: canonicalize(unsigned),
|
||||
signature: sig,
|
||||
})
|
||||
return isAddressEqual(recovered, env.from)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeEnvelope(env: Envelope): Uint8Array {
|
||||
return new TextEncoder().encode(JSON.stringify(env))
|
||||
}
|
||||
|
||||
export function decodeEnvelope<TBody = unknown>(bytes: Uint8Array | string): Envelope<TBody> {
|
||||
const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes)
|
||||
return JSON.parse(text) as Envelope<TBody>
|
||||
}
|
||||
|
||||
/**
|
||||
* LRU-ish dedup cache for incoming envelopes. Per spec §9, hold the last 10k
|
||||
* nonces and drop on repeat.
|
||||
*/
|
||||
export class DedupCache {
|
||||
private readonly seen = new Set<string>()
|
||||
constructor(private readonly capacity = 10_000) {}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.seen.has(key)
|
||||
}
|
||||
|
||||
mark(key: string): void {
|
||||
if (this.seen.size >= this.capacity) {
|
||||
const oldest = this.seen.values().next().value
|
||||
if (oldest) this.seen.delete(oldest)
|
||||
}
|
||||
this.seen.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable per-envelope dedup key: `from:nonce`. */
|
||||
export function envelopeKey(env: Pick<Envelope, 'from' | 'nonce'>): string {
|
||||
return `${env.from.toLowerCase()}:${env.nonce.toLowerCase()}`
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {secp256k1} from '@noble/curves/secp256k1'
|
||||
import {hkdf} from '@noble/hashes/hkdf'
|
||||
import {sha256} from '@noble/hashes/sha2'
|
||||
import {bytesToHex, hexToBytes, randomBytes} from '@noble/hashes/utils'
|
||||
import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'
|
||||
import {dirname} from 'node:path'
|
||||
import type {Hex} from './types'
|
||||
|
||||
/**
|
||||
* The on-chain registry stores `pssPublicKey` as `bytes32` (spec §4 / contract).
|
||||
* A real secp256k1 compressed public key is 33 bytes (1-byte Y-parity prefix +
|
||||
* 32-byte X). We resolve the mismatch by only minting keys with even-Y parity
|
||||
* — the X coordinate alone is then enough; senders prepend 0x02 to rebuild
|
||||
* the compressed form. Rejection-sampling reaches even-Y in ~2 tries on
|
||||
* average without leaking entropy.
|
||||
*/
|
||||
const PSS_INFO = new TextEncoder().encode('t4t-pss-v1')
|
||||
|
||||
export interface PssKeypair {
|
||||
/** 32-byte secp256k1 private scalar. */
|
||||
privateKey: Hex
|
||||
/** 32-byte X coordinate of the compressed pub key (Y is even by construction). */
|
||||
publicKeyX: Hex
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh random PSS keypair with even-Y parity. Used at first
|
||||
* startup; the result is persisted via {@link loadOrCreatePssKey}.
|
||||
*/
|
||||
export function generatePssKeypair(): PssKeypair {
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const candidate = randomBytes(32)
|
||||
if (!secp256k1.utils.isValidPrivateKey(candidate)) continue
|
||||
const pub = secp256k1.getPublicKey(candidate, true)
|
||||
if (pub[0] === 0x02) return pubFromPriv(candidate, pub)
|
||||
}
|
||||
throw new Error('failed to generate an even-Y PSS keypair')
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically derive a PSS keypair from the wallet's private key.
|
||||
*
|
||||
* Retained for tests and as a one-shot migration helper for operators who
|
||||
* registered with the wallet-derived key in the previous container version.
|
||||
* **Not used in production startup** — see `loadOrCreatePssKey`.
|
||||
*/
|
||||
export function derivePssKeypair(walletKey: Hex): PssKeypair {
|
||||
const ikm = hexToBytes(walletKey.replace(/^0x/, ''))
|
||||
for (let counter = 0; counter < 256; counter++) {
|
||||
const salt = new Uint8Array([counter])
|
||||
const candidate = hkdf(sha256, ikm, salt, PSS_INFO, 32)
|
||||
if (!secp256k1.utils.isValidPrivateKey(candidate)) continue
|
||||
const pub = secp256k1.getPublicKey(candidate, true)
|
||||
if (pub[0] === 0x02) return pubFromPriv(candidate, pub)
|
||||
}
|
||||
throw new Error('failed to derive an even-Y PSS keypair')
|
||||
}
|
||||
|
||||
/** Public-key-only accessor for code paths that don't need the secret. */
|
||||
export function pssPubKeyFromWallet(walletKey: Hex): Hex {
|
||||
return derivePssKeypair(walletKey).publicKeyX
|
||||
}
|
||||
|
||||
/** Reconstruct the 33-byte compressed pubkey from the on-chain 32-byte X. */
|
||||
export function pssPubKeyXToCompressed(pubKeyX: Hex): Uint8Array {
|
||||
const x = hexToBytes(pubKeyX.replace(/^0x/, ''))
|
||||
if (x.length !== 32) throw new Error(`expected 32-byte X coord, got ${x.length}`)
|
||||
const out = new Uint8Array(33)
|
||||
out[0] = 0x02 // even-Y by convention
|
||||
out.set(x, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the PSS keypair from `path`, or generate + persist one if absent.
|
||||
*
|
||||
* The on-disk format is a single line of 64 hex chars (the 32-byte private
|
||||
* scalar). The file is created mode 0600 and its parent directory is created
|
||||
* recursively if missing. The PSS pubkey is derived on load and validated to
|
||||
* have even-Y parity — files written by older versions that picked a key
|
||||
* with odd Y will be rejected so the operator can re-issue rather than
|
||||
* silently producing keys incompatible with the bytes32 registry slot.
|
||||
*/
|
||||
export function loadOrCreatePssKey(path: string): PssKeypair {
|
||||
if (existsSync(path)) return parsePssKeyFile(readFileSync(path, 'utf8'))
|
||||
const fresh = generatePssKeypair()
|
||||
mkdirSync(dirname(path), {recursive: true})
|
||||
const hex = fresh.privateKey.replace(/^0x/, '')
|
||||
writeFileSync(path, hex + '\n', {mode: 0o600})
|
||||
try { chmodSync(path, 0o600) } catch { /* best-effort on platforms without chmod */ }
|
||||
return fresh
|
||||
}
|
||||
|
||||
function parsePssKeyFile(contents: string): PssKeypair {
|
||||
const hex = contents.trim().replace(/^0x/, '')
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
|
||||
throw new Error('PSS key file must contain a 64-hex-char (32-byte) private scalar')
|
||||
}
|
||||
const priv = hexToBytes(hex)
|
||||
if (!secp256k1.utils.isValidPrivateKey(priv)) throw new Error('invalid secp256k1 private key')
|
||||
const pub = secp256k1.getPublicKey(priv, true)
|
||||
if (pub[0] !== 0x02) {
|
||||
throw new Error('PSS key has odd-Y public point; registry expects even-Y. Regenerate the key.')
|
||||
}
|
||||
return pubFromPriv(priv, pub)
|
||||
}
|
||||
|
||||
function pubFromPriv(priv: Uint8Array, pub: Uint8Array): PssKeypair {
|
||||
return {
|
||||
privateKey: ('0x' + bytesToHex(priv)) as Hex,
|
||||
publicKeyX: ('0x' + bytesToHex(pub.subarray(1))) as Hex,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// VENDORED — local edit (allowed by drift policy): re-export the project logger
|
||||
// so vendored t4t code shares the same pino instance as the rest of the service.
|
||||
export { logger } from '../../logger';
|
||||
export type { Logger } from '../../logger';
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Container-managed Swarm postage stamps.
|
||||
*
|
||||
* Pure, mode-agnostic helpers wrapping the Bee `/stamps` and `/chainstate`
|
||||
* endpoints. The provider and gateway modes both call `ensureManagedStamp()`
|
||||
* on boot and `topUpIfBelow()` on a background tick.
|
||||
*
|
||||
* Operator intent (depth + TTL) is configured via the `T4T_STAMP_*` env vars
|
||||
* in `config.ts`; the raw `amount` (per-chunk wei budget) is derived from
|
||||
* `getChainState().currentPrice × blocksPerDay × ttlDays`.
|
||||
*
|
||||
* If `POSTAGE_BATCH_ID` is set, that escape hatch wins and this module is
|
||||
* never consulted — the operator owns the batch lifecycle.
|
||||
*/
|
||||
|
||||
import type {Bee} from '@ethersphere/bee-js'
|
||||
import type {Logger} from './logger'
|
||||
|
||||
// Gnosis Chain block time is ~5s; 86400/5 = 17280.
|
||||
export const BLOCKS_PER_DAY_GNOSIS = 17280n
|
||||
|
||||
export interface StampManageOpts {
|
||||
depth: number
|
||||
ttlDays: number
|
||||
minTtlDays: number
|
||||
label: string
|
||||
dryRun: boolean
|
||||
}
|
||||
|
||||
export interface ManagedStamp {
|
||||
batchID: string
|
||||
/** Source of the resolved id, for log/UI display. */
|
||||
source: 'env' | 'reused' | 'bought' | 'discovered'
|
||||
depth: number
|
||||
remainingDays: number
|
||||
usable: boolean
|
||||
utilization: number
|
||||
usage: number
|
||||
usageText: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** xBZZ cost to buy/top-up a stamp = amount × 2^depth (Bee charges the node
|
||||
* wallet, not the container's wallet). Used for log/UI cost preview only. */
|
||||
export function stampWalletCostWei(amount: bigint, depth: number): bigint {
|
||||
return amount * (1n << BigInt(depth))
|
||||
}
|
||||
|
||||
/** Wei budget per chunk per block, multiplied across the TTL window — the
|
||||
* value Bee expects as `amount` in POST /stamps/{amount}/{depth}. */
|
||||
export async function amountForTtl(bee: Bee, ttlDays: number): Promise<bigint> {
|
||||
const state = await bee.getChainState()
|
||||
const price = BigInt(state.currentPrice)
|
||||
if (price <= 0n) throw new Error(`Bee returned non-positive chainstate.currentPrice (${state.currentPrice})`)
|
||||
return price * BLOCKS_PER_DAY_GNOSIS * BigInt(Math.max(1, Math.trunc(ttlDays)))
|
||||
}
|
||||
|
||||
interface BatchLike {
|
||||
batchID: {toString(): string}
|
||||
label: string
|
||||
usable: boolean
|
||||
depth: number
|
||||
utilization: number
|
||||
usage: number
|
||||
usageText: string
|
||||
duration: {toDays(): number}
|
||||
}
|
||||
|
||||
export function summariseBatch(b: BatchLike, source: ManagedStamp['source']): ManagedStamp {
|
||||
return {
|
||||
batchID: b.batchID.toString(),
|
||||
source,
|
||||
depth: b.depth,
|
||||
remainingDays: b.duration.toDays(),
|
||||
usable: b.usable,
|
||||
utilization: b.utilization,
|
||||
usage: b.usage,
|
||||
usageText: b.usageText,
|
||||
label: b.label,
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick the best `t4t-managed` batch the node already owns: must be usable
|
||||
* and have remaining TTL ≥ minTtlDays. Tiebreak by longest remaining TTL
|
||||
* so we converge on the youngest batch when an old one is about to expire. */
|
||||
export function pickReusable<T extends BatchLike>(
|
||||
all: T[],
|
||||
label: string,
|
||||
minTtlDays: number,
|
||||
): T | null {
|
||||
const candidates = all
|
||||
.filter(b => b.label === label && b.usable && b.duration.toDays() >= minTtlDays)
|
||||
.sort((a, b) => b.duration.toDays() - a.duration.toDays())
|
||||
return candidates[0] ?? null
|
||||
}
|
||||
|
||||
/** Same label but TTL too short — caller can top up rather than buy fresh. */
|
||||
export function pickToppable<T extends BatchLike>(
|
||||
all: T[],
|
||||
label: string,
|
||||
): T | null {
|
||||
const candidates = all
|
||||
.filter(b => b.label === label && b.usable)
|
||||
.sort((a, b) => b.duration.toDays() - a.duration.toDays())
|
||||
return candidates[0] ?? null
|
||||
}
|
||||
|
||||
/** List all batches via Bee — typed loosely so it adapts to bee-js shape
|
||||
* changes (the module only cares about a handful of fields). */
|
||||
async function listAllBatches(bee: Bee): Promise<BatchLike[]> {
|
||||
return (await bee.getAllPostageBatch()) as unknown as BatchLike[]
|
||||
}
|
||||
|
||||
export interface EnsureStampDeps {
|
||||
bee: Bee
|
||||
logger: Logger
|
||||
opts: StampManageOpts
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a managed batch — list, reuse, top-up-then-reuse, or buy.
|
||||
* Idempotent: safe to call from many container instances against the same Bee
|
||||
* node; they converge on the longest-lived `label` batch.
|
||||
*/
|
||||
export async function ensureManagedStamp(deps: EnsureStampDeps): Promise<ManagedStamp> {
|
||||
const {bee, logger, opts} = deps
|
||||
const log = logger.child({stamps: true})
|
||||
|
||||
const all = await listAllBatches(bee)
|
||||
|
||||
const reusable = pickReusable(all, opts.label, opts.minTtlDays)
|
||||
if (reusable) {
|
||||
log.info(
|
||||
{batchID: reusable.batchID.toString(), remainingDays: reusable.duration.toDays(), depth: reusable.depth},
|
||||
'reusing existing t4t-managed batch',
|
||||
)
|
||||
return summariseBatch(reusable, 'reused')
|
||||
}
|
||||
|
||||
const toppable = pickToppable(all, opts.label)
|
||||
if (toppable) {
|
||||
if (opts.dryRun) {
|
||||
log.warn({batchID: toppable.batchID.toString()}, 'dry-run: would top up existing batch instead of buying')
|
||||
return summariseBatch(toppable, 'reused')
|
||||
}
|
||||
const amount = await amountForTtl(bee, opts.ttlDays)
|
||||
log.info(
|
||||
{
|
||||
batchID: toppable.batchID.toString(),
|
||||
amount: amount.toString(),
|
||||
walletCost: stampWalletCostWei(amount, toppable.depth).toString(),
|
||||
},
|
||||
'topping up under-TTL t4t-managed batch instead of buying new',
|
||||
)
|
||||
await bee.topUpBatch(toppable.batchID.toString(), amount)
|
||||
const refreshed = (await listAllBatches(bee)).find(
|
||||
b => b.batchID.toString() === toppable.batchID.toString(),
|
||||
)
|
||||
return summariseBatch(refreshed ?? toppable, 'reused')
|
||||
}
|
||||
|
||||
// No reusable batch — buy fresh.
|
||||
const amount = await amountForTtl(bee, opts.ttlDays)
|
||||
const walletCost = stampWalletCostWei(amount, opts.depth)
|
||||
log.info(
|
||||
{
|
||||
depth: opts.depth,
|
||||
ttlDays: opts.ttlDays,
|
||||
amount: amount.toString(),
|
||||
walletCost: walletCost.toString(),
|
||||
label: opts.label,
|
||||
dryRun: opts.dryRun,
|
||||
},
|
||||
opts.dryRun ? 'dry-run: would buy postage batch' : 'buying new postage batch',
|
||||
)
|
||||
if (opts.dryRun) {
|
||||
throw new Error(
|
||||
'T4T_STAMP_DRY_RUN=true and no reusable batch exists — refusing to buy. ' +
|
||||
'Disable dry-run or pre-purchase a batch labelled "' + opts.label + '".',
|
||||
)
|
||||
}
|
||||
const batchId = await bee.createPostageBatch(amount, opts.depth, {
|
||||
label: opts.label,
|
||||
waitForUsable: true,
|
||||
})
|
||||
const idStr = batchId.toString()
|
||||
log.info({batchID: idStr}, 'postage batch bought and usable')
|
||||
const refreshed = (await listAllBatches(bee)).find(b => b.batchID.toString() === idStr)
|
||||
if (!refreshed) {
|
||||
// Bee returned the id but list missed it — return a minimal stub.
|
||||
return {
|
||||
batchID: idStr,
|
||||
source: 'bought',
|
||||
depth: opts.depth,
|
||||
remainingDays: opts.ttlDays,
|
||||
usable: true,
|
||||
utilization: 0,
|
||||
usage: 0,
|
||||
usageText: '0%',
|
||||
label: opts.label,
|
||||
}
|
||||
}
|
||||
return summariseBatch(refreshed, 'bought')
|
||||
}
|
||||
|
||||
export interface TopUpIfBelowDeps {
|
||||
bee: Bee
|
||||
logger: Logger
|
||||
batchId: string
|
||||
ttlDays: number
|
||||
minTtlDays: number
|
||||
/** Auto-dilute (raise depth by +1) when utilization crosses this fraction.
|
||||
* Doubles bucket capacity but halves remaining TTL, so we top up first when
|
||||
* TTL is also under threshold. Set to 1.0 to disable. */
|
||||
maxUtilization: number
|
||||
/** Hard cap so an unexpected utilization spike can't run depth to infinity. */
|
||||
maxDepth: number
|
||||
dryRun: boolean
|
||||
}
|
||||
|
||||
/** Background tick: top up if remaining TTL falls below `minTtlDays`, and
|
||||
* dilute if utilization crosses `maxUtilization`. Returns the post-tick
|
||||
* remaining-days for the admin UI to surface. */
|
||||
export async function topUpIfBelow(deps: TopUpIfBelowDeps): Promise<{toppedUp: boolean; diluted: boolean; remainingDays: number}> {
|
||||
const all = await listAllBatches(deps.bee)
|
||||
let batch = all.find(b => b.batchID.toString() === deps.batchId)
|
||||
if (!batch) {
|
||||
deps.logger.warn({batchId: deps.batchId}, 'managed batch not found on Bee — skipping top-up tick')
|
||||
return {toppedUp: false, diluted: false, remainingDays: 0}
|
||||
}
|
||||
let toppedUp = false
|
||||
let diluted = false
|
||||
|
||||
// Top-up first — diluting halves remaining TTL, so if we're already short
|
||||
// we'd push the batch below the floor.
|
||||
const remaining = batch.duration.toDays()
|
||||
if (remaining < deps.minTtlDays) {
|
||||
if (deps.dryRun) {
|
||||
deps.logger.warn({batchId: deps.batchId, remaining, threshold: deps.minTtlDays}, 'dry-run: would auto-top-up')
|
||||
} else {
|
||||
const amount = await amountForTtl(deps.bee, deps.ttlDays)
|
||||
deps.logger.info(
|
||||
{
|
||||
batchId: deps.batchId,
|
||||
remaining,
|
||||
threshold: deps.minTtlDays,
|
||||
amount: amount.toString(),
|
||||
walletCost: stampWalletCostWei(amount, batch.depth).toString(),
|
||||
},
|
||||
'auto-topping up managed batch',
|
||||
)
|
||||
await deps.bee.topUpBatch(deps.batchId, amount)
|
||||
toppedUp = true
|
||||
batch = (await listAllBatches(deps.bee)).find(b => b.batchID.toString() === deps.batchId) ?? batch
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-dilute on high utilization. Bee tracks the most-loaded bucket; one
|
||||
// hot bucket can spike utilization long before "total bytes" is anywhere
|
||||
// near full, so this kicks in earlier than the operator expects.
|
||||
if (batch.usage >= deps.maxUtilization && batch.depth < deps.maxDepth) {
|
||||
const newDepth = batch.depth + 1
|
||||
if (deps.dryRun) {
|
||||
deps.logger.warn({batchId: deps.batchId, usage: batch.usage, from: batch.depth, to: newDepth}, 'dry-run: would auto-dilute')
|
||||
} else {
|
||||
deps.logger.info({batchId: deps.batchId, usage: batch.usage, from: batch.depth, to: newDepth}, 'auto-diluting managed batch')
|
||||
await deps.bee.diluteBatch(deps.batchId, newDepth)
|
||||
diluted = true
|
||||
batch = (await listAllBatches(deps.bee)).find(b => b.batchID.toString() === deps.batchId) ?? batch
|
||||
}
|
||||
}
|
||||
|
||||
return {toppedUp, diluted, remainingDays: batch.duration.toDays()}
|
||||
}
|
||||
|
||||
/** Manual top-up triggered from the admin UI. Amount is derived from the
|
||||
* same TTL math so the UI just asks for "days" and the math stays one place. */
|
||||
export async function manualTopUp(
|
||||
bee: Bee,
|
||||
batchId: string,
|
||||
ttlDays: number,
|
||||
): Promise<bigint> {
|
||||
const amount = await amountForTtl(bee, ttlDays)
|
||||
await bee.topUpBatch(batchId, amount)
|
||||
return amount
|
||||
}
|
||||
|
||||
/** Manual dilute (grow capacity) triggered from the admin UI. Halves
|
||||
* remaining TTL per +1 depth — caller should confirm in UI. */
|
||||
export async function manualDilute(bee: Bee, batchId: string, newDepth: number): Promise<void> {
|
||||
await bee.diluteBatch(batchId, newDepth)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {Bee, Topic, type PssSubscription} from '@ethersphere/bee-js'
|
||||
import type {Envelope, Hex} from './types'
|
||||
import {decodeEnvelope, encodeEnvelope, envelopeKey, DedupCache, verifyEnvelope} from './envelope'
|
||||
import type {Logger} from './logger'
|
||||
|
||||
export interface SwarmClientOpts {
|
||||
bee: Bee
|
||||
postageBatchId: string
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
/** Returns true if the Bee error means "the postage batch ran out of bucket
|
||||
* capacity" (HTTP 402 with `code:402, message:"batch is overissued"`). */
|
||||
function isBatchOverissued(err: unknown): boolean {
|
||||
const e = err as {status?: number; responseBody?: {message?: string; code?: number}} | null
|
||||
if (!e || e.status !== 402) return false
|
||||
const msg = e.responseBody?.message ?? ''
|
||||
return /overissued|insufficient/i.test(msg) || e.responseBody?.code === 402
|
||||
}
|
||||
|
||||
/** Emergency self-heal: when a Bee call fails because the batch is full,
|
||||
* dilute it by +1 depth (doubles bucket capacity, halves remaining TTL) and
|
||||
* retry the call exactly once. Logged so the operator sees the recovery.
|
||||
* Bee handles the dilute tx from its own wallet — we don't pay xBZZ here. */
|
||||
const EMERGENCY_DILUTE_MAX_DEPTH = 28
|
||||
async function withBatchRecovery<T>(opts: SwarmClientOpts, fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
if (!isBatchOverissued(err)) throw err
|
||||
const batches = await opts.bee.getAllPostageBatch().catch(() => [])
|
||||
const batch = batches.find(b => b.batchID.toString() === opts.postageBatchId)
|
||||
if (!batch) throw err
|
||||
if (batch.depth >= EMERGENCY_DILUTE_MAX_DEPTH) {
|
||||
opts.logger.error({batchId: opts.postageBatchId, depth: batch.depth}, 'batch overissued and at depth cap — cannot self-heal')
|
||||
throw err
|
||||
}
|
||||
const newDepth = batch.depth + 1
|
||||
opts.logger.warn(
|
||||
{batchId: opts.postageBatchId, from: batch.depth, to: newDepth},
|
||||
'batch overissued — emergency dilute then retry',
|
||||
)
|
||||
await opts.bee.diluteBatch(opts.postageBatchId, newDepth)
|
||||
// Bee's diluteBatch returns when the tx is SUBMITTED, not mined. The local
|
||||
// node won't accept writes at the new depth until the tx confirms on
|
||||
// Gnosis (~5-15s). Poll until the batch's depth actually reflects the
|
||||
// dilute, with a 30s ceiling. Without this, the retry races the chain and
|
||||
// hits the same 402.
|
||||
const deadline = Date.now() + 30_000
|
||||
while (Date.now() < deadline) {
|
||||
const refreshed = await opts.bee.getAllPostageBatch().catch(() => [])
|
||||
const b = refreshed.find(x => x.batchID.toString() === opts.postageBatchId)
|
||||
if (b && b.depth >= newDepth) {
|
||||
opts.logger.info({batchId: opts.postageBatchId, depth: b.depth}, 'dilute confirmed')
|
||||
break
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
}
|
||||
return await fn()
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload a single chunk and return its Swarm reference (hex). */
|
||||
export async function uploadChunk(
|
||||
opts: SwarmClientOpts,
|
||||
bytes: Uint8Array,
|
||||
): Promise<string> {
|
||||
return withBatchRecovery(opts, async () => {
|
||||
const {reference} = await opts.bee.uploadData(opts.postageBatchId, bytes)
|
||||
return reference.toString()
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadChunk(opts: SwarmClientOpts, reference: string): Promise<Uint8Array> {
|
||||
const data = await opts.bee.downloadData(reference)
|
||||
return data.toUint8Array()
|
||||
}
|
||||
|
||||
/** Pick the most usable postage batch the Bee node already owns. We prefer
|
||||
* batches that Bee marks as `usable: true` (i.e. enough block confirmations
|
||||
* and not expired), with the largest remaining capacity as a tiebreaker.
|
||||
* Returns null when the node has no usable batch — the caller should surface
|
||||
* this so the operator can buy or top-up one. */
|
||||
export async function discoverUsableBatchId(bee: Bee): Promise<string | null> {
|
||||
const all = await bee.getAllPostageBatch().catch(() => [])
|
||||
const usable = all.filter(b => b.usable)
|
||||
if (usable.length === 0) return null
|
||||
// Pick the longest-lived as a proxy for "still valid for the next while".
|
||||
usable.sort((a, b) => b.duration.toSeconds() - a.duration.toSeconds())
|
||||
return usable[0]!.batchID.toString()
|
||||
}
|
||||
|
||||
export interface PssTransportOpts extends SwarmClientOpts {
|
||||
selfAddress: Hex
|
||||
dedupCapacity?: number
|
||||
}
|
||||
|
||||
export interface PssSendArgs {
|
||||
topic: string
|
||||
/** Target's Swarm overlay address (hex, 0x-prefixed). */
|
||||
recipientOverlay: Hex
|
||||
/** Recipient's PSS public key (hex, 0x-prefixed compressed pubkey). */
|
||||
recipientPssKey: Hex
|
||||
envelope: Envelope
|
||||
}
|
||||
|
||||
export interface PssSubscribeArgs {
|
||||
topic: string
|
||||
onEnvelope: (env: Envelope) => void | Promise<void>
|
||||
onError?: (err: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper over bee-js PSS that signs nothing and verifies everything.
|
||||
* Envelope signing happens upstream in `envelope.ts`; this layer only routes.
|
||||
*/
|
||||
export class PssTransport {
|
||||
private readonly dedup: DedupCache
|
||||
|
||||
constructor(private readonly opts: PssTransportOpts) {
|
||||
this.dedup = new DedupCache(opts.dedupCapacity ?? 10_000)
|
||||
}
|
||||
|
||||
async send(args: PssSendArgs): Promise<void> {
|
||||
const topic = Topic.fromString(args.topic)
|
||||
// First 2 bytes of the overlay narrow PSS forwarding without doxxing
|
||||
// the full target address (Bee convention).
|
||||
const target = args.recipientOverlay.slice(2, 6)
|
||||
// The on-chain registry stores `pssPublicKey` as bytes32 (X coord only)
|
||||
// and our keygen guarantees even-Y parity (see lib/keys.ts), so prepend
|
||||
// 0x02 to rebuild the 33-byte compressed pubkey form bee-js' PublicKey
|
||||
// class accepts. Passing bare 32 bytes throws
|
||||
// "Bytes#checkByteLength: bytes length is 32 but expected 64".
|
||||
const x = args.recipientPssKey.replace(/^0x/, '')
|
||||
if (x.length !== 64) {
|
||||
throw new Error(`recipient PSS pubkey must be 32-byte X coord (got ${x.length / 2} bytes)`)
|
||||
}
|
||||
const pubKey = '02' + x
|
||||
await withBatchRecovery(this.opts, () =>
|
||||
this.opts.bee.pssSend(
|
||||
this.opts.postageBatchId,
|
||||
topic,
|
||||
target,
|
||||
encodeEnvelope(args.envelope),
|
||||
pubKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
subscribe(args: PssSubscribeArgs): PssSubscription {
|
||||
const topic = Topic.fromString(args.topic)
|
||||
return this.opts.bee.pssSubscribe(topic, {
|
||||
onMessage: async msg => {
|
||||
try {
|
||||
const env = decodeEnvelope(msg.toUtf8())
|
||||
const key = envelopeKey(env)
|
||||
if (this.dedup.has(key)) return
|
||||
if (!(await verifyEnvelope(env))) {
|
||||
this.opts.logger.warn({from: env.from}, 'envelope signature verification failed')
|
||||
return
|
||||
}
|
||||
this.dedup.mark(key)
|
||||
await args.onEnvelope(env)
|
||||
} catch (err) {
|
||||
args.onError?.(err)
|
||||
this.opts.logger.error({err}, 'pss decode failure')
|
||||
}
|
||||
},
|
||||
onError: err => {
|
||||
args.onError?.(err)
|
||||
this.opts.logger.error({err}, 'pss subscription error')
|
||||
},
|
||||
onClose: () => {
|
||||
this.opts.logger.warn({topic: args.topic}, 'pss subscription closed')
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {Hex} from 'viem'
|
||||
|
||||
export type {Hex}
|
||||
|
||||
export const PROTOCOL_VERSION = 1 as const
|
||||
|
||||
export type EnvelopeType = 'job_notify' | 'job_ack' | 'job_deliver'
|
||||
|
||||
export interface UnsignedEnvelope<TBody = unknown> {
|
||||
v: typeof PROTOCOL_VERSION
|
||||
type: EnvelopeType
|
||||
from: Hex
|
||||
to: Hex
|
||||
ts: number
|
||||
nonce: Hex
|
||||
body: TBody
|
||||
}
|
||||
|
||||
export interface Envelope<TBody = unknown> extends UnsignedEnvelope<TBody> {
|
||||
sig: Hex
|
||||
}
|
||||
|
||||
// ---------- Body payloads (spec §5.3) ----------
|
||||
|
||||
export interface JobNotifyBody {
|
||||
jobId: Hex
|
||||
requestHash: string
|
||||
modelId: string
|
||||
/** xBZZ wei as a base-10 string to survive JSON. */
|
||||
maxPayment: string
|
||||
deliveryDeadline: number
|
||||
/** Gateway's PSS pubkey (32-byte X coord, even-Y by convention).
|
||||
* Required so the provider can encrypt the ACK/deliver back without
|
||||
* looking up the gateway's address in the on-chain registry (gateways
|
||||
* aren't registered there). */
|
||||
clientPssPubKey: Hex
|
||||
/** Gateway's Bee overlay for PSS routing. */
|
||||
clientSwarmOverlay: Hex
|
||||
}
|
||||
|
||||
export interface JobAckBody {
|
||||
jobId: Hex
|
||||
estimatedCompletion: number
|
||||
}
|
||||
|
||||
export interface JobDeliverBody {
|
||||
jobId: Hex
|
||||
responseHash: string
|
||||
}
|
||||
|
||||
// ---------- On-chain mirror types ----------
|
||||
|
||||
export interface ProviderRow {
|
||||
owner: Hex
|
||||
pssPublicKey: Hex
|
||||
swarmOverlay: Hex
|
||||
metadataURI: string
|
||||
stake: bigint
|
||||
lastHeartbeat: number
|
||||
totalJobs: number
|
||||
successfulJobs: number
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface ModelOffering {
|
||||
modelId: string
|
||||
inputPricePerMillionTokens: bigint
|
||||
outputPricePerMillionTokens: bigint
|
||||
maxContextTokens: bigint
|
||||
maxLatencySeconds: bigint
|
||||
}
|
||||
|
||||
// ---------- Swarm payload (spec §6) ----------
|
||||
|
||||
export interface OpenAIChatRequest {
|
||||
model: string
|
||||
messages: Array<{role: 'system' | 'user' | 'assistant' | 'tool'; content: string}>
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
stream?: boolean
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface OpenAIChatResponse {
|
||||
id: string
|
||||
object: 'chat.completion'
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
message: {role: 'assistant'; content: string}
|
||||
finish_reason: string
|
||||
}>
|
||||
usage?: {prompt_tokens: number; completion_tokens: number; total_tokens: number}
|
||||
}
|
||||
|
||||
export interface RequestPayload {
|
||||
v: typeof PROTOCOL_VERSION
|
||||
jobId: Hex
|
||||
client: Hex
|
||||
modelId: string
|
||||
openaiRequest: OpenAIChatRequest
|
||||
clientPssPubKey: Hex
|
||||
ts: number
|
||||
}
|
||||
|
||||
export interface ResponsePayload {
|
||||
v: typeof PROTOCOL_VERSION
|
||||
jobId: Hex
|
||||
provider: Hex
|
||||
openaiResponse: OpenAIChatResponse
|
||||
ts: number
|
||||
}
|
||||
|
||||
export type SignMessage = (message: string) => Promise<Hex>
|
||||
@@ -0,0 +1,54 @@
|
||||
import {existsSync, readFileSync, writeFileSync, chmodSync, mkdirSync} from 'node:fs'
|
||||
import {dirname} from 'node:path'
|
||||
import {validateMnemonic, mnemonicToSeedSync} from '@scure/bip39'
|
||||
import {wordlist} from '@scure/bip39/wordlists/english'
|
||||
import {HDKey} from '@scure/bip32'
|
||||
import {generateMnemonic, english} from 'viem/accounts'
|
||||
import {toHex} from 'viem'
|
||||
import type {Hex} from './types'
|
||||
|
||||
/** Standard Ethereum derivation path — first account, first address. */
|
||||
const ETH_PATH = "m/44'/60'/0'/0/0"
|
||||
|
||||
export function newMnemonic(): string {
|
||||
return generateMnemonic(english)
|
||||
}
|
||||
|
||||
export function isMnemonic(input: string): boolean {
|
||||
const words = input.trim().split(/\s+/)
|
||||
if (![12, 15, 18, 21, 24].includes(words.length)) return false
|
||||
return validateMnemonic(words.join(' '), wordlist)
|
||||
}
|
||||
|
||||
export function mnemonicToPrivateKey(mnemonic: string): Hex {
|
||||
if (!isMnemonic(mnemonic)) throw new Error('invalid BIP39 mnemonic')
|
||||
const seed = mnemonicToSeedSync(mnemonic.trim().replace(/\s+/g, ' '))
|
||||
const derived = HDKey.fromMasterSeed(seed).derive(ETH_PATH)
|
||||
if (!derived.privateKey) throw new Error('mnemonic derived no private key')
|
||||
return toHex(derived.privateKey) as Hex
|
||||
}
|
||||
|
||||
/** Accepts a BIP39 mnemonic OR a 0x-prefixed 32-byte private key. */
|
||||
export function parseWalletInput(input: string): Hex {
|
||||
const trimmed = input.trim()
|
||||
if (/^0x[a-fA-F0-9]{64}$/.test(trimmed)) return trimmed as Hex
|
||||
return mnemonicToPrivateKey(trimmed)
|
||||
}
|
||||
|
||||
export function walletFileExists(path: string): boolean {
|
||||
return existsSync(path)
|
||||
}
|
||||
|
||||
export function loadWalletKey(path: string): Hex {
|
||||
const raw = readFileSync(path, 'utf8').trim()
|
||||
if (!/^0x[a-fA-F0-9]{64}$/.test(raw)) throw new Error(`wallet file at ${path} is not a valid private key`)
|
||||
return raw as Hex
|
||||
}
|
||||
|
||||
export function saveWalletKey(path: string, key: Hex): void {
|
||||
if (!/^0x[a-fA-F0-9]{64}$/.test(key)) throw new Error('refusing to save: not a 0x-prefixed 32-byte private key')
|
||||
mkdirSync(dirname(path), {recursive: true})
|
||||
writeFileSync(path, key + '\n', {mode: 0o600})
|
||||
// writeFileSync's mode is only respected on file creation; tighten existing files too.
|
||||
chmodSync(path, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type {ChainClient} from '../../lib/chain'
|
||||
import {getOfferings, listProviders} from '../../lib/chain'
|
||||
import type {ModelOffering, ProviderRow} from '../../lib/types'
|
||||
import {isHeartbeatFresh} from './selector'
|
||||
|
||||
export interface ModelSummary {
|
||||
/** OpenAI-style model id (e.g. "llama3:8b-instruct"). */
|
||||
id: string
|
||||
/** Active, heartbeat-fresh providers advertising this model. */
|
||||
providerCount: number
|
||||
/** Min input price (xBZZ wei per 1M tokens) across providers. */
|
||||
minInputPrice: bigint
|
||||
/** Median input price. */
|
||||
medianInputPrice: bigint
|
||||
/** Min output price (xBZZ wei per 1M tokens) across providers. */
|
||||
minOutputPrice: bigint
|
||||
/** Median output price. */
|
||||
medianOutputPrice: bigint
|
||||
/** Worst declared SLA across the offering set, in seconds. */
|
||||
slowestSlaSeconds: number
|
||||
}
|
||||
|
||||
export interface DiscoverDeps {
|
||||
chain: ChainClient
|
||||
allowedModels?: string[]
|
||||
minProvidersPerModel: number
|
||||
cacheTtlSeconds: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/** A provider plus the model offerings it currently advertises. */
|
||||
export interface ProviderListing {
|
||||
provider: ProviderRow
|
||||
offerings: ModelOffering[]
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
expiresAt: number
|
||||
data: T
|
||||
}
|
||||
|
||||
export class ModelDiscovery {
|
||||
private modelsCache: CacheEntry<ModelSummary[]> | null = null
|
||||
private providersCache: CacheEntry<ProviderListing[]> | null = null
|
||||
|
||||
constructor(private readonly deps: DiscoverDeps) {}
|
||||
|
||||
async list(): Promise<ModelSummary[]> {
|
||||
const now = (this.deps.now ?? Date.now)()
|
||||
if (this.modelsCache && this.modelsCache.expiresAt > now) return this.modelsCache.data
|
||||
const {summaries} = await this.scan()
|
||||
this.modelsCache = {data: summaries, expiresAt: now + this.deps.cacheTtlSeconds * 1000}
|
||||
return summaries
|
||||
}
|
||||
|
||||
async listProviders(): Promise<ProviderListing[]> {
|
||||
const now = (this.deps.now ?? Date.now)()
|
||||
if (this.providersCache && this.providersCache.expiresAt > now) return this.providersCache.data
|
||||
const {providers} = await this.scan()
|
||||
this.providersCache = {data: providers, expiresAt: now + this.deps.cacheTtlSeconds * 1000}
|
||||
return providers
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.modelsCache = null
|
||||
this.providersCache = null
|
||||
}
|
||||
|
||||
private async scan(): Promise<{summaries: ModelSummary[]; providers: ProviderListing[]}> {
|
||||
const allow = this.deps.allowedModels
|
||||
const nowSec = Math.floor(((this.deps.now ?? Date.now)()) / 1000)
|
||||
const byModel = new Map<string, {inputPrices: bigint[]; outputPrices: bigint[]; slowest: number; count: number}>()
|
||||
const providers: ProviderListing[] = []
|
||||
|
||||
let cursor = 0n
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const {page, nextCursor} = await listProviders(this.deps.chain, cursor, 50n)
|
||||
for (const p of page) {
|
||||
if (!isUsable(p, nowSec)) continue
|
||||
const offerings = await getOfferings(this.deps.chain, p.owner)
|
||||
const visible = allow ? offerings.filter(o => allow.includes(o.modelId)) : offerings
|
||||
if (visible.length > 0) providers.push({provider: p, offerings: visible})
|
||||
for (const o of visible) mergeOffering(byModel, o)
|
||||
}
|
||||
if (nextCursor === cursor || page.length === 0) break
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
const summaries: ModelSummary[] = []
|
||||
for (const [id, agg] of byModel) {
|
||||
if (agg.count < this.deps.minProvidersPerModel) continue
|
||||
summaries.push({
|
||||
id,
|
||||
providerCount: agg.count,
|
||||
minInputPrice: agg.inputPrices.reduce((a, b) => (a < b ? a : b)),
|
||||
medianInputPrice: median(agg.inputPrices),
|
||||
minOutputPrice: agg.outputPrices.reduce((a, b) => (a < b ? a : b)),
|
||||
medianOutputPrice: median(agg.outputPrices),
|
||||
slowestSlaSeconds: agg.slowest,
|
||||
})
|
||||
}
|
||||
summaries.sort((a, b) => a.id.localeCompare(b.id))
|
||||
providers.sort((a, b) => a.provider.owner.localeCompare(b.provider.owner))
|
||||
return {summaries, providers}
|
||||
}
|
||||
}
|
||||
|
||||
function isUsable(p: ProviderRow, nowSec: number): boolean {
|
||||
if (!p.active) return false
|
||||
return isHeartbeatFresh(p.lastHeartbeat, nowSec)
|
||||
}
|
||||
|
||||
function mergeOffering(
|
||||
byModel: Map<string, {inputPrices: bigint[]; outputPrices: bigint[]; slowest: number; count: number}>,
|
||||
o: ModelOffering,
|
||||
): void {
|
||||
const slot = byModel.get(o.modelId) ?? {inputPrices: [], outputPrices: [], slowest: 0, count: 0}
|
||||
slot.inputPrices.push(o.inputPricePerMillionTokens)
|
||||
slot.outputPrices.push(o.outputPricePerMillionTokens)
|
||||
slot.slowest = Math.max(slot.slowest, Number(o.maxLatencySeconds))
|
||||
slot.count += 1
|
||||
byModel.set(o.modelId, slot)
|
||||
}
|
||||
|
||||
function median(values: bigint[]): bigint {
|
||||
const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
if (sorted.length % 2 === 1) return sorted[mid]!
|
||||
return (sorted[mid - 1]! + sorted[mid]!) / 2n
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {Address} from 'viem'
|
||||
import type {ChainClient} from '../../lib/chain'
|
||||
import {getOfferings, listProviders} from '../../lib/chain'
|
||||
import type {ModelOffering, ProviderRow} from '../../lib/types'
|
||||
|
||||
export type SelectionStrategy = 'cheapest' | 'top_rep_cheapest' | 'manual'
|
||||
|
||||
export interface SelectionContext {
|
||||
modelId: string
|
||||
/** Optional cap on (input + output) xBZZ wei per 1M tokens combined. */
|
||||
maxPrice?: bigint
|
||||
/** Required when strategy = 'manual'. */
|
||||
manualProvider?: Address
|
||||
}
|
||||
|
||||
function combinedPrice(o: ModelOffering): bigint {
|
||||
return o.inputPricePerMillionTokens + o.outputPricePerMillionTokens
|
||||
}
|
||||
|
||||
export interface CandidateProvider {
|
||||
provider: ProviderRow
|
||||
offering: ModelOffering
|
||||
}
|
||||
|
||||
const MIN_TOTAL_JOBS = 20
|
||||
const MIN_SUCCESS_RATE = 0.95
|
||||
/** Share of routes given to new providers so they can build reputation. */
|
||||
const EXPLORATION_SHARE = 0.05
|
||||
/** Matches `ProviderRegistry.HEARTBEAT_TTL`. Providers stale past this drop
|
||||
* from the selectable set, even if `active` is still true on-chain. */
|
||||
const HEARTBEAT_TTL_SECONDS = 600
|
||||
|
||||
/**
|
||||
* Pull the active provider set, filter to those that offer the requested model
|
||||
* within budget, then rank per the selected strategy. See spec §8.
|
||||
*/
|
||||
export async function selectProvider(
|
||||
chain: ChainClient,
|
||||
strategy: SelectionStrategy,
|
||||
ctx: SelectionContext,
|
||||
): Promise<CandidateProvider | null> {
|
||||
if (strategy === 'manual') {
|
||||
if (!ctx.manualProvider) throw new Error('manual strategy requires T4T_MANUAL_PROVIDER')
|
||||
return matchOffering(chain, ctx.manualProvider, ctx)
|
||||
}
|
||||
|
||||
const candidates: CandidateProvider[] = []
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
let cursor = 0n
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const {page, nextCursor} = await listProviders(chain, cursor, 50n)
|
||||
for (const provider of page) {
|
||||
if (!provider.active) continue
|
||||
if (!isHeartbeatFresh(provider.lastHeartbeat, now)) continue
|
||||
const offerings = await getOfferings(chain, provider.owner)
|
||||
const offering = offerings.find(
|
||||
o =>
|
||||
o.modelId === ctx.modelId &&
|
||||
(ctx.maxPrice === undefined || combinedPrice(o) <= ctx.maxPrice),
|
||||
)
|
||||
if (offering) candidates.push({provider, offering})
|
||||
}
|
||||
if (nextCursor === cursor || page.length === 0) break
|
||||
cursor = nextCursor
|
||||
}
|
||||
return rank(candidates, strategy)
|
||||
}
|
||||
|
||||
export function isHeartbeatFresh(lastHeartbeat: number | bigint, nowSeconds: number): boolean {
|
||||
const last = typeof lastHeartbeat === 'bigint' ? Number(lastHeartbeat) : lastHeartbeat
|
||||
if (last === 0) return false
|
||||
return last + HEARTBEAT_TTL_SECONDS >= nowSeconds
|
||||
}
|
||||
|
||||
async function matchOffering(
|
||||
chain: ChainClient,
|
||||
owner: Address,
|
||||
ctx: SelectionContext,
|
||||
): Promise<CandidateProvider | null> {
|
||||
const offerings = await getOfferings(chain, owner)
|
||||
const offering = offerings.find(
|
||||
o => o.modelId === ctx.modelId && (ctx.maxPrice === undefined || combinedPrice(o) <= ctx.maxPrice),
|
||||
)
|
||||
if (!offering) return null
|
||||
const {page} = await listProviders(chain, 0n, 1n)
|
||||
// We don't strictly need the row for manual mode, so synthesize the parts
|
||||
// the caller cares about from the offerings call.
|
||||
const provider = (page.find(p => p.owner === owner) ?? {owner}) as ProviderRow
|
||||
return {provider, offering}
|
||||
}
|
||||
|
||||
function rank(candidates: CandidateProvider[], strategy: SelectionStrategy): CandidateProvider | null {
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
if (strategy === 'cheapest') {
|
||||
return [...candidates].sort((a, b) => Number(combinedPrice(a.offering) - combinedPrice(b.offering)))[0]!
|
||||
}
|
||||
|
||||
// top_rep_cheapest
|
||||
const newcomers = candidates.filter(c => c.provider.totalJobs < MIN_TOTAL_JOBS)
|
||||
const seasoned = candidates
|
||||
.filter(c => c.provider.totalJobs >= MIN_TOTAL_JOBS)
|
||||
.filter(c => successRate(c.provider) >= MIN_SUCCESS_RATE)
|
||||
|
||||
if (seasoned.length > 0 && newcomers.length > 0 && Math.random() < EXPLORATION_SHARE) {
|
||||
return newcomers[Math.floor(Math.random() * newcomers.length)]!
|
||||
}
|
||||
const pool = seasoned.length > 0 ? seasoned : candidates
|
||||
return [...pool].sort((a, b) => Number(combinedPrice(a.offering) - combinedPrice(b.offering)))[0]!
|
||||
}
|
||||
|
||||
function successRate(p: ProviderRow): number {
|
||||
return p.totalJobs === 0 ? 0 : p.successfulJobs / p.totalJobs
|
||||
}
|
||||
Reference in new issue
Block a user