import { keccak256, stringToHex, zeroAddress, type Address, type Hex } from 'viem' import * as z from 'zod/mini' import * as Campaigns from './Campaigns.js' import * as Schema from '../Schema.js' const uint = Schema.DecimalString const hash = Schema.Hash const address = Schema.Address.check( z.refine((value) => value !== zeroAddress, { error: 'address must be nonzero' }), ) /** Zod schemas for the isolated reward signer protocol. */ export namespace schema { const Domain = z.strictObject({ chainId: Schema.ChainId, earnShare: address, earnVault: address, factory: address, treasury: Schema.Address, }) const Install = z.strictObject({ boostRewards: z.boolean(), domain: Domain, operation: z.literal('deploy'), targetYield: z.boolean(), treasury: Schema.Address, }) const TargetYield = z.strictObject({ controller: address, domain: Domain, funders: z.array(address).check(z.minLength(1)), maxEarnShareSupply: uint, operation: z.literal('targetYield'), requestedAssets: z.array(uint).check(z.minLength(1)), }) const Publish = z.strictObject({ distributor: address, domain: Domain, expectedRootVersion: uint, operation: z.literal('publish'), statement: z.custom(), }) const Settle = z.strictObject({ assets: uint, distributor: address, domain: Domain, expectedRootVersion: uint, funder: address, minEarnShares: uint, operation: z.literal('settle'), settlementId: hash, statement: z.custom(), }) const Push = z.strictObject({ batches: z.array(z.custom()).check(z.minLength(1), z.maxLength(2)), distributor: address, domain: Domain, operation: z.literal('push'), rootVersion: uint, statementHash: hash, }) /** One typed reward operation accepted by the isolated signer. */ export const Intent = z.discriminatedUnion('operation', [ Install, Publish, Push, Settle, TargetYield, ]) /** One complete typed signing request. */ export const Request = z.strictObject({ attemptId: z.string().check(z.regex(/^rat_[0-9a-z]{24}$/)), id: hash, intent: Intent, }) /** Exact signed transaction returned before broadcast. */ export const Response = z.strictObject({ expiresAt: z.nullable(z.string()), nonce: z.nullable(uint), signedBytes: Schema.Hex, signer: Schema.Address, transactionHash: hash, }) /** Public readiness returned by the signer without exposing secrets. */ export const Health = z.strictObject({ ok: z.literal(true), signer: Schema.Address }) } /** One complete typed signing request. */ export type Request = z.output /** Exact signed transaction returned before broadcast. */ export type Response = z.output /** Reads the isolated signer's readiness and public account. */ export async function health( fetch: typeof globalThis.fetch, ): Promise> { const response = await fetch('https://earn-reward-signer.internal/health') if (!response.ok) throw new Error(`Reward signer health responded ${response.status}.`) return schema.Health.parse(await response.json()) } /** Returns a deterministic id for one typed signer intent. */ export function id(intent: Request['intent']): Hex { return keccak256(stringToHex(canonical(schema.Intent.parse(intent)))) } /** Selects the deployed controller entrypoint for one nonempty funding set. */ export function targetYieldFunction(funderCount: number): 'fund' | 'fundBatchExact' { if (!Number.isInteger(funderCount) || funderCount < 1) throw new Error('target-yield funding requires at least one funder') return funderCount === 1 ? 'fund' : 'fundBatchExact' } /** Requests one typed signature from the isolated signer Worker. */ export async function sign(options: sign.Options): Promise { const request = schema.Request.parse(options.request) if (id(request.intent) !== request.id) throw new Error('reward signer intent id mismatch') const response = await options.fetch('https://earn-reward-signer.internal/v1/sign', { body: JSON.stringify(request), headers: { 'content-type': 'application/json' }, method: 'POST', }) if (!response.ok) { const fallback = `Reward signer responded ${response.status}.` const body = await response.text() let message: unknown try { message = (JSON.parse(body) as { error?: unknown }).error } catch { message = undefined } throw new Error(typeof message === 'string' && message ? message.slice(0, 500) : fallback) } return schema.Response.parse(await response.json()) } export declare namespace sign { /** Typed signer request transport. */ type Options = { /** Isolated signer service fetch. */ fetch: typeof globalThis.fetch /** Complete typed request. */ request: Request } } function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]` if (value && typeof value === 'object') return `{${Object.entries(value) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`) .join(',')}}` return JSON.stringify(value) } /** Address fields required by one signer request. */ export type Domain = { /** Supported Tempo chain. */ chainId: number /** Vault EarnShare. */ earnShare: Address /** Bound EarnVault. */ earnVault: Address /** Reviewed rewards factory. */ factory: Address /** Immutable reward treasury, or zero when no boost distributor exists. */ treasury: Address }