|
| 1 | +/** |
| 2 | + * Broker agent — the swarm coordinator. It is BOTH a buyer and a seller: |
| 3 | + * |
| 4 | + * buyer ──"request <service>"──▶ broker |
| 5 | + * broker quotes every seller in SWARM_SELLERS (each in its own thread) |
| 6 | + * broker buys from the cheapest (broker PAYS the seller on-chain) |
| 7 | + * broker resells to the buyer at +MARKUP (buyer PAYS the broker on-chain) |
| 8 | + * |
| 9 | + * Two on-chain settlements per request; the broker keeps the spread. Reuses the kit's |
| 10 | + * seller payment.ts (to charge the buyer) and a buyer-style wallet.ts (to pay sellers). |
| 11 | + * |
| 12 | + * Env: BROKER_KEYPAIR_B58 (pays sellers), SELLER_WALLET (= broker pubkey, receives from buyer), |
| 13 | + * SWARM_SELLERS (csv), MARKUP, BROKER_MAX_SOL, SOLANA_RPC_URL. |
| 14 | + */ |
| 15 | +import { startCoralAgent } from '@pay/agent-runtime' |
| 16 | +import { generatePaymentUrl, verifyPayment } from './payment.js' |
| 17 | +import { payFromUrl, getBrokerPublicKey } from './wallet.js' |
| 18 | + |
| 19 | +const SELLERS = (process.env.SWARM_SELLERS || 'seller-cheap,seller-premium') |
| 20 | + .split(',').map((s) => s.trim()).filter(Boolean) |
| 21 | +const MARKUP = parseFloat(process.env.MARKUP ?? '1.2') |
| 22 | +const MAX_SOL = parseFloat(process.env.BROKER_MAX_SOL ?? '0.01') |
| 23 | + |
| 24 | +interface Quote { seller: string; thread: string; amount: number; reference: string; url: string } |
| 25 | + |
| 26 | +function parsePaymentRequired(text: string): Omit<Quote, 'seller' | 'thread'> | null { |
| 27 | + const amount = parseFloat(text.match(/amount=([\d.]+)/)?.[1] ?? '') |
| 28 | + const reference = text.match(/reference=([1-9A-HJ-NP-Za-km-z]{32,44})/)?.[1] |
| 29 | + const url = text.match(/url=(solana:[^\s"\\]+)/)?.[1] |
| 30 | + if (!amount || !reference || !url) return null |
| 31 | + return { amount, reference, url } |
| 32 | +} |
| 33 | + |
| 34 | +await startCoralAgent({ agentName: 'broker' }, async (ctx) => { |
| 35 | + console.error(`[broker] wallet ${getBrokerPublicKey()} | sellers=${SELLERS.join(',')} | markup=${MARKUP}`) |
| 36 | + |
| 37 | + while (true) { |
| 38 | + try { |
| 39 | + const ask = await ctx.waitForMention(30_000) |
| 40 | + if (!ask || !/^request/i.test(ask.text.trim())) continue |
| 41 | + const buyerThread = ask.threadId |
| 42 | + const buyer = ask.sender ? [ask.sender] : [] |
| 43 | + const service = ask.text.trim().replace(/^request\s*/i, '').trim() || 'jupiter' |
| 44 | + console.error(`[broker] buyer wants "${service}" — shopping ${SELLERS.length} sellers`) |
| 45 | + |
| 46 | + // 1. Quote every seller (each in its own thread). |
| 47 | + const quotes: Quote[] = [] |
| 48 | + for (const seller of SELLERS) { |
| 49 | + try { |
| 50 | + const thread = await ctx.createThread(`broker-${seller}-${Date.now()}`, [seller]) |
| 51 | + await ctx.send(`request ${service}`, thread, [seller]) |
| 52 | + const reply = await ctx.waitForMentionInThread(thread, 45_000) |
| 53 | + const q = reply && parsePaymentRequired(reply.text) |
| 54 | + if (q) { |
| 55 | + quotes.push({ seller, thread, ...q }) |
| 56 | + console.error(`[broker] ${seller}: ${q.amount} SOL`) |
| 57 | + } else { |
| 58 | + console.error(`[broker] ${seller}: no quote`) |
| 59 | + } |
| 60 | + } catch (e) { |
| 61 | + console.error(`[broker] ${seller} error: ${e}`) |
| 62 | + } |
| 63 | + } |
| 64 | + if (quotes.length === 0) { |
| 65 | + if (buyerThread) await ctx.send('NO_SELLERS_AVAILABLE', buyerThread, buyer) |
| 66 | + continue |
| 67 | + } |
| 68 | + |
| 69 | + // 2. Buy from the cheapest seller (broker PAYS on-chain). |
| 70 | + quotes.sort((a, b) => a.amount - b.amount) |
| 71 | + const best = quotes[0] |
| 72 | + console.error(`[broker] cheapest = ${best.seller} @ ${best.amount} SOL — buying`) |
| 73 | + let data: string |
| 74 | + try { |
| 75 | + const sig = await payFromUrl(best.url, MAX_SOL) |
| 76 | + await ctx.send(`paid ${sig} reference=${best.reference}`, best.thread, [best.seller]) |
| 77 | + const delivered = await ctx.waitForMentionInThread(best.thread, 45_000) |
| 78 | + if (!delivered || !/DELIVERED/i.test(delivered.text)) throw new Error('seller did not deliver') |
| 79 | + data = delivered.text.replace(/^[\s\S]*?DELIVERED\s*/i, '').trim() |
| 80 | + } catch (e) { |
| 81 | + console.error(`[broker] upstream buy failed: ${e}`) |
| 82 | + if (buyerThread) await ctx.send('UPSTREAM_FAILED', buyerThread, buyer) |
| 83 | + continue |
| 84 | + } |
| 85 | + |
| 86 | + // 3. Resell to the buyer at a markup (buyer PAYS the broker). |
| 87 | + const price = +(best.amount * MARKUP).toFixed(6) |
| 88 | + const charge = generatePaymentUrl(service, price) |
| 89 | + console.error(`[broker] reselling @ ${price} SOL (cost ${best.amount} from ${best.seller}, margin ${(price - best.amount).toFixed(6)})`) |
| 90 | + await ctx.reply(ask, `PAYMENT_REQUIRED reference=${charge.reference} amount=${charge.amountSol} url=${charge.url} via=${best.seller}`) |
| 91 | + |
| 92 | + if (!buyerThread) continue |
| 93 | + const proof = await ctx.waitForMentionInThread(buyerThread, 120_000) |
| 94 | + const buyerSig = proof?.text.match(/paid\s+(\S+)/)?.[1] |
| 95 | + if (proof && buyerSig && (await verifyPayment(buyerSig, charge.reference, charge.amountSol))) { |
| 96 | + await ctx.reply(proof, `DELIVERED ${data}`) |
| 97 | + console.error('[broker] delivered to buyer ✓') |
| 98 | + } else { |
| 99 | + console.error('[broker] buyer payment not verified') |
| 100 | + await ctx.reply(ask, 'PAYMENT_NOT_VERIFIED') |
| 101 | + } |
| 102 | + } catch (e) { |
| 103 | + console.error(`[broker] cycle error: ${e}`) |
| 104 | + await new Promise((r) => setTimeout(r, 2000)) |
| 105 | + } |
| 106 | + } |
| 107 | +}) |
0 commit comments