11/**
22 * buyer.ts — self-contained LLM buyer for the bare-metal 402 seller (Track 1, Layer B).
33 *
4- * Claude drives the loop: fetch → see 402 → decide to pay → sign transfer → retry. This is the
5- * standalone, runnable version of `LLMBuyerStrategy` (coral-agents/buyer-agent/src/llm_buyer.ts),
6- * inlined here so the example runs without cross-package wiring .
4+ * The shared runtime LLM drives the loop: fetch -> see 402 -> decide to pay -> sign transfer -> retry.
5+ * Set LLM_PROVIDER=venice + VENICE_API_KEY to use Venice/Kimi; OpenAI/Anthropic still work through the
6+ * same `complete()` shim .
77 *
88 * Run: SELLER endpoint must be up (npm run server), then `npm run buyer`.
9- * Env: ANTHROPIC_API_KEY, BUYER_KEYPAIR_B58, SOLANA_RPC_URL, ENDPOINT (default localhost:3001)
9+ * Env: VENICE_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY (+ LLM_PROVIDER), BUYER_KEYPAIR_B58,
10+ * SOLANA_RPC_URL, ENDPOINT (default localhost:3001)
1011 */
11- import Anthropic from '@anthropic-ai/sdk'
1212import {
1313 Connection , Keypair , PublicKey , SystemProgram , Transaction ,
1414 LAMPORTS_PER_SOL , sendAndConfirmTransaction ,
1515} from '@solana/web3.js'
16+ import { complete , parseJsonReply } from '@pay/agent-runtime'
1617
1718const ENDPOINT = process . env . ENDPOINT ?? 'http://localhost:3001/api/data'
1819const BUDGET_LAMPORTS = Number ( process . env . BUYER_MAX_SOL ?? 0.001 ) * LAMPORTS_PER_SOL
@@ -24,6 +25,7 @@ if (process.env.ALLOW_MAINNET !== '1' && /mainnet/i.test(RPC)) {
2425}
2526
2627interface Challenge { recipient : string ; amountSol : number ; reference ?: string }
28+ interface PaymentDecision { pay : boolean ; reason : string }
2729
2830function loadKeypair ( ) : Keypair {
2931 const b58 = process . env . BUYER_KEYPAIR_B58
@@ -41,8 +43,6 @@ function loadKeypair(): Keypair {
4143 return Keypair . fromSecretKey ( bytes )
4244}
4345
44- let lastReference : string | undefined
45-
4646async function payAndRetry ( challenge : Challenge ) : Promise < string > {
4747 if ( challenge . amountSol * LAMPORTS_PER_SOL > BUDGET_LAMPORTS ) {
4848 return `budget exceeded: ${ challenge . amountSol } SOL`
@@ -58,66 +58,80 @@ async function payAndRetry(challenge: Challenge): Promise<string> {
5858 ix . keys . push ( { pubkey : new PublicKey ( challenge . reference ) , isSigner : false , isWritable : false } )
5959 }
6060 const sig = await sendAndConfirmTransaction ( conn , new Transaction ( ) . add ( ix ) , [ keypair ] , { commitment : 'confirmed' } )
61- lastReference = challenge . reference
6261 console . error ( `[buyer] paid ${ challenge . amountSol } SOL sig=${ sig } ` )
6362 const retry = await fetch ( ENDPOINT , {
6463 headers : { 'x-payment-proof' : sig , ...( challenge . reference ? { 'x-payment-reference' : challenge . reference } : { } ) } ,
6564 } )
6665 return ( await retry . text ( ) ) . slice ( 0 , 2000 )
6766}
6867
69- async function main ( ) {
70- const llm = new Anthropic ( )
71- const tools : Anthropic . Tool [ ] = [
72- { name : 'fetch_data' , description : 'Fetch the endpoint; returns data or a 402 challenge.' ,
73- input_schema : { type : 'object' , properties : { } , required : [ ] } } ,
74- { name : 'pay_and_retry' , description : 'Pay a challenge then re-fetch with proof.' ,
75- input_schema : { type : 'object' , properties : {
76- recipient : { type : 'string' } , amountSol : { type : 'number' } , reference : { type : 'string' } ,
77- } , required : [ 'recipient' , 'amountSol' ] } } ,
78- ]
79- const messages : Anthropic . MessageParam [ ] = [ { role : 'user' , content : GOAL } ]
68+ function fallbackDecision ( challenge : Challenge , reason : string ) : PaymentDecision {
69+ return {
70+ pay : challenge . amountSol * LAMPORTS_PER_SOL <= BUDGET_LAMPORTS ,
71+ reason,
72+ }
73+ }
8074
81- for ( let turn = 0 ; turn < 8 ; turn ++ ) {
82- const resp = await llm . messages . create ( {
83- model : process . env . BUYER_MODEL ?? 'claude-haiku-4-5-20251001' ,
84- max_tokens : 1024 ,
85- system : `You are an autonomous data buyer on Solana devnet. fetch_data, and if it returns a
86- 402 challenge, call pay_and_retry with the EXACT recipient/amount/reference from the challenge.
87- Never invent values. When you have the data, summarize it in one sentence and stop.` ,
88- tools, messages,
75+ async function decidePayment ( challenge : Challenge ) : Promise < PaymentDecision > {
76+ try {
77+ const raw = await complete ( {
78+ system :
79+ 'You are an autonomous Solana devnet data buyer. Return JSON {pay:boolean, reason:string}. ' +
80+ 'Pay only when the challenge matches the user goal, is within budget, and includes the exact recipient/amount/reference. ' +
81+ 'Never invent payment values.' ,
82+ user : JSON . stringify ( {
83+ goal : GOAL ,
84+ challenge,
85+ budgetSol : BUDGET_LAMPORTS / LAMPORTS_PER_SOL ,
86+ } ) ,
87+ maxTokens : 160 ,
8988 } )
90- messages . push ( { role : 'assistant' , content : resp . content } )
91-
92- const toolUses = resp . content . filter ( ( c ) : c is Anthropic . ToolUseBlock => c . type === 'tool_use' )
93- if ( toolUses . length === 0 ) {
94- const answer = resp . content . filter ( ( c ) : c is Anthropic . TextBlock => c . type === 'text' ) . map ( c => c . text ) . join ( '\n' )
95- console . error ( `[buyer] DONE: ${ answer } ` )
96- return
97- }
98-
99- const results : Anthropic . ToolResultBlockParam [ ] = [ ]
100- for ( const tu of toolUses ) {
101- if ( tu . name === 'fetch_data' ) {
102- const r = await fetch ( ENDPOINT )
103- if ( r . status === 402 ) {
104- const challenge = JSON . parse ( r . headers . get ( 'x-payment-required' ) ?? ( await r . text ( ) ) ) as Challenge
105- console . error ( `[buyer] 402 challenge: ${ challenge . amountSol } SOL → ${ challenge . recipient } ` )
106- results . push ( { type : 'tool_result' , tool_use_id : tu . id , content : JSON . stringify ( { status : 402 , challenge } ) } )
107- } else {
108- results . push ( { type : 'tool_result' , tool_use_id : tu . id , content : ( await r . text ( ) ) . slice ( 0 , 2000 ) } )
109- }
110- } else if ( tu . name === 'pay_and_retry' ) {
111- const out = await payAndRetry ( tu . input as Challenge )
112- results . push ( { type : 'tool_result' , tool_use_id : tu . id , content : out } )
113- } else {
114- results . push ( { type : 'tool_result' , tool_use_id : tu . id , is_error : true , content : `unknown tool ${ tu . name } ` } )
89+ const parsed = parseJsonReply < { pay ?: unknown ; reason ?: unknown } > ( raw )
90+ if ( typeof parsed ?. pay === 'boolean' ) {
91+ return {
92+ pay : parsed . pay && challenge . amountSol * LAMPORTS_PER_SOL <= BUDGET_LAMPORTS ,
93+ reason : typeof parsed . reason === 'string' ? parsed . reason : 'LLM decision' ,
11594 }
11695 }
117- messages . push ( { role : 'user' , content : results } )
96+ return fallbackDecision ( challenge , 'LLM returned no parseable decision; using budget policy' )
97+ } catch ( e ) {
98+ return fallbackDecision ( challenge , `LLM unavailable; using budget policy (${ ( e as Error ) . message } )` )
11899 }
119- console . error ( '[buyer] loop exhausted without a final answer' )
120- process . exitCode = 1
100+ }
101+
102+ async function summarize ( data : string ) : Promise < string > {
103+ try {
104+ const text = await complete ( {
105+ system : 'Summarize the paid API response in one concise sentence.' ,
106+ user : data . slice ( 0 , 2000 ) ,
107+ maxTokens : 120 ,
108+ } )
109+ return text || data . slice ( 0 , 240 )
110+ } catch {
111+ return data . slice ( 0 , 240 )
112+ }
113+ }
114+
115+ async function readChallenge ( response : Response ) : Promise < Challenge > {
116+ const header = response . headers . get ( 'x-payment-required' )
117+ return JSON . parse ( header ?? ( await response . text ( ) ) ) as Challenge
118+ }
119+
120+ async function main ( ) {
121+ const first = await fetch ( ENDPOINT )
122+ if ( first . status !== 402 ) {
123+ console . error ( `[buyer] DONE: ${ await summarize ( ( await first . text ( ) ) . slice ( 0 , 2000 ) ) } ` )
124+ return
125+ }
126+
127+ const challenge = await readChallenge ( first )
128+ console . error ( `[buyer] 402 challenge: ${ challenge . amountSol } SOL -> ${ challenge . recipient } ` )
129+ const decision = await decidePayment ( challenge )
130+ console . error ( `[buyer] decision: ${ decision . pay ? 'pay' : 'skip' } - ${ decision . reason } ` )
131+ if ( ! decision . pay ) return
132+
133+ const data = await payAndRetry ( challenge )
134+ console . error ( `[buyer] DONE: ${ await summarize ( data ) } ` )
121135}
122136
123137main ( ) . catch ( ( e ) => { console . error ( `[buyer] error: ${ e } ` ) ; process . exitCode = 1 } )
0 commit comments