This commit is contained in:
Felix Faerber committed 2026-05-20 15:08:45 +03:00
commit c54e888458
67 files changed
+5933

No files matched your search

+64
View File
@@ -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.
+238
View File
@@ -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
+47
View File
@@ -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
}
+353
View File
@@ -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`})
}
+89
View File
@@ -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
}
+120
View File
@@ -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()}`
}
+113
View File
@@ -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,
}
}
+4
View File
@@ -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';
+292
View File
@@ -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)
}
+178
View File
@@ -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')
},
})
}
}
+115
View File
@@ -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>
+54
View File
@@ -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)
}
+130
View File
@@ -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
}
+114
View File
@@ -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
}