Skip to content

Commit a255558

Browse files
trilltinoclaude
andcommitted
feat(txodds): Pay-with-Phantom/Solflare button — real Solana Pay from a user wallet
Adds a user-facing Solana Pay path alongside the agent settlement: a human can buy the read with their own wallet. The browser connects Phantom/Solflare, builds a reference-tagged SOL transfer to the seller (the order-bound sha256 reference as a read-only key), signs it, and the proxy submits to devnet + verifies on-chain via validateTransfer. - proxy: /api/pay-intent (recipient + amount + bound reference) and /api/pay-verify (verifyPayment). - web: PayButton (wallet detect → signTransaction → sendRawTransaction(devnet) → verify), Phantom- purple styling; Buffer polyfill in index.html for @solana/web3.js in a no-bundler browser. - This is where Solana Pay is actually used. Docs (WORKSHOP slide 3/5.5, txodds README) updated. Verified: /api/pay-intent returns the bound reference + seller; /api/pay-verify handles bad sigs; esm.sh serves web3.js; button + import served. (Full click-through needs a Devnet-funded wallet.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f1f35a6 commit a255558

6 files changed

Lines changed: 114 additions & 6 deletions

File tree

WORKSHOP.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@
4040
- **In this demo the reference is bound to the data:**
4141
`reference = sha256("txodds:<fixtureId>:<favourite>@<fairOdds>:<nonce>")`.
4242
So the on-chain escrow PDA **provably is the read you bought** — anyone with the preimage can verify it.
43-
- The **Solana Pay** helpers (`generatePaymentUrl` / `verifyPayment` in `runtime/src/solana/pay.ts`)
44-
implement the canonical "reference as a read-only account" pattern and ship in the SDK for the
45-
pay-per-call path; the oracle settles through the **escrow + arbiter** directly.
46-
- Code: `server/proxy.ts → boundReference()` · `agent/escrow.ts → escrowPda()` · `runtime/src/solana/pay.ts`.
43+
- **Solana Pay is wired in two ways:** (1) the agents/escrow settle programmatically; (2) a
44+
**"Pay with Phantom / Solflare" button** lets a *human* buy the read — a real Solana Pay
45+
reference-tagged transfer to the seller, signed by the user's wallet and **verified on-chain**
46+
(`validateTransfer`). Same order-bound reference; submitted to devnet by the proxy.
47+
- Code: `server/proxy.ts → boundReference()` / `/api/pay-intent` / `/api/pay-verify` ·
48+
`runtime/src/solana/pay.ts → verifyPayment` · `web/app.js → PayButton`.
4749

4850
---
4951

@@ -97,7 +99,8 @@
9799

98100
Pick a fixture → verified de-margined board with **fair odds** per outcome → the agent's **read** (LLM,
99101
deterministic fallback) → **auto-settle through the arbiter** (buyer → vault → arbiter releases to the
100-
seller), Explorer links, reference bound to the read.
102+
seller), Explorer links, reference bound to the read. Or click **"Pay with Phantom / Solflare"** to buy
103+
it yourself — a real Solana Pay transfer from your wallet, verified on-chain (needs a Devnet wallet).
101104

102105
---
103106

examples/txodds/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ little devnet SOL. It subscribes that wallet to the free World Cup tier on devne
5656
fixtures with verified live odds**. The browser never sees the token or the key — everything sensitive
5757
stays in the proxy. Without funding/a key, the board shows clearly-labelled sample data.
5858

59+
On the board you can also click **Pay with Phantom / Solflare** to buy the read yourself: a real
60+
**Solana Pay** reference-tagged transfer from your wallet to the seller, verified on-chain by the proxy
61+
(`/api/pay-intent` + `/api/pay-verify`). Needs a Devnet-funded wallet.
62+
5963
## CoralOS round (the multi-agent view)
6064

6165
The web demo above is one agent. For the **multi-agent** version — a buyer agent + a World Cup seller

examples/txodds/server/proxy.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
import nacl from 'tweetnacl'
2727
import bs58 from 'bs58'
2828
import { fileURLToPath } from 'node:url'
29-
import { assertDevnet } from '@pay/agent-runtime'
29+
import { assertDevnet, verifyPayment } from '@pay/agent-runtime'
3030
import { analyzeEdge, fairLine } from '../agent/edge.js'
3131
import {
3232
makeArbiter, initConfig, open as arbiterOpen, arbitrateRelease,
@@ -325,6 +325,27 @@ http
325325
result = await settle(amount, fixtureId)
326326
}
327327
res.end(JSON.stringify(result))
328+
} else if (url.pathname === '/api/pay-intent') {
329+
// Solana Pay intent for a USER wallet (Phantom/Solflare): pay the seller for this read, tagged
330+
// with the order-bound reference. The browser builds + signs the transfer; /api/pay-verify confirms.
331+
const fixtureId = url.searchParams.get('fixtureId') ?? ''
332+
const amountSol = Math.max(0.001, Number(url.searchParams.get('amount') ?? '0.001'))
333+
const recipient = process.env.SELLER_WALLET || process.env.WALLET || buyerKeypair().publicKey.toBase58()
334+
const { reference, order } = await boundReference(fixtureId)
335+
res.end(JSON.stringify({
336+
cluster: 'devnet', recipient, amountSol, reference: reference.toBase58(),
337+
label: 'World Cup Oracle',
338+
message: order.favourite ? `${order.favourite} @ ${order.fairOdds}${order.matchup ? ` (${order.matchup})` : ''}` : 'TxODDS edge',
339+
order,
340+
}))
341+
} else if (url.pathname === '/api/pay-verify') {
342+
// confirm the user's Solana Pay transfer landed on devnet: right recipient + amount + reference.
343+
const sig = url.searchParams.get('sig') ?? ''
344+
const reference = url.searchParams.get('reference') ?? ''
345+
const amountSol = Number(url.searchParams.get('amount') ?? '0.001')
346+
const recipient = url.searchParams.get('recipient') ?? (process.env.SELLER_WALLET || process.env.WALLET || '')
347+
const ok = !!(sig && reference && recipient) && (await verifyPayment(sig, { recipient, amountSol, reference }))
348+
res.end(JSON.stringify({ ok, sig: sig || undefined, explorer: sig ? expl('tx', sig) : undefined }))
328349
} else {
329350
res.statusCode = 404
330351
res.end(JSON.stringify({ error: 'not found' }))

examples/txodds/web/app.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import React, { useState, useEffect } from 'https://esm.sh/react@18.3.1'
77
import { createRoot } from 'https://esm.sh/react-dom@18.3.1/client'
88
import htm from 'https://esm.sh/htm@3.1.1'
9+
import { Connection, PublicKey, Transaction, SystemProgram, LAMPORTS_PER_SOL } from 'https://esm.sh/@solana/web3.js@1.98.4'
910

1011
const html = htm.bind(React.createElement)
1112
const PROXY = window.TXODDS_PROXY ?? 'http://localhost:8801'
@@ -101,6 +102,18 @@ const ESCROW_PROGRAM = 'R5NWNg9eRLWWQU81Xbzz5Du1k7jTDeeT92Ty6qCeXet'
101102
const SETTLE_SOL = 0.001
102103
const shortAddr = (a) => (a ? `${String(a).slice(0, 4)}${String(a).slice(-4)}` : '')
103104
const addrLink = (a) => `https://explorer.solana.com/address/${a}?cluster=devnet`
105+
const txLink = (s) => `https://explorer.solana.com/tx/${s}?cluster=devnet`
106+
const DEVNET_RPC = 'https://api.devnet.solana.com'
107+
108+
// Detect an injected browser wallet (Phantom / Solflare) — no wallet-adapter needed for a no-build app.
109+
function getWallet() {
110+
const w = window
111+
const phantom = w.phantom?.solana ?? (w.solana?.isPhantom ? w.solana : null)
112+
const solflare = w.solflare?.isSolflare ? w.solflare : null
113+
if (phantom) return { name: 'Phantom', provider: phantom }
114+
if (solflare) return { name: 'Solflare', provider: solflare }
115+
return null
116+
}
104117

105118
// ── odds board ──────────────────────────────────────────────────────────────
106119
// LIVE TxODDS markets are messy: Pct values arrive as strings ("41.946"), some priced "NA",
@@ -268,6 +281,60 @@ function SettleResult({ r }) {
268281
<a href=${addrLink(ESCROW_PROGRAM)} target="_blank" rel="noreferrer">escrow program ↗</a></div>`
269282
}
270283

284+
// Pay for the read yourself with Phantom / Solflare — a real Solana Pay reference-tagged transfer to
285+
// the seller, verified on-chain by the proxy. The wallet signs; we submit to devnet so the cluster is
286+
// guaranteed regardless of the wallet's setting. (Needs a Devnet-funded wallet.)
287+
function PayButton({ fixture }) {
288+
const [st, setSt] = useState({ status: 'idle', msg: '' })
289+
const wallet = getWallet()
290+
291+
const pay = async () => {
292+
if (!wallet) { setSt({ status: 'error', msg: 'No Phantom/Solflare detected — install one and switch it to Devnet' }); return }
293+
try {
294+
setSt({ status: 'busy', msg: 'connecting wallet…' })
295+
const { provider } = wallet
296+
const conn = await provider.connect()
297+
const payer = new PublicKey((conn?.publicKey ?? provider.publicKey).toString())
298+
299+
setSt({ status: 'busy', msg: 'building payment…' })
300+
const intent = await (await fetch(`${PROXY}/api/pay-intent?fixtureId=${fixture.FixtureId}&amount=${SETTLE_SOL}`)).json()
301+
const connection = new Connection(DEVNET_RPC, 'confirmed')
302+
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed')
303+
const ix = SystemProgram.transfer({
304+
fromPubkey: payer, toPubkey: new PublicKey(intent.recipient),
305+
lamports: Math.round(intent.amountSol * LAMPORTS_PER_SOL),
306+
})
307+
ix.keys.push({ pubkey: new PublicKey(intent.reference), isSigner: false, isWritable: false }) // Solana Pay reference
308+
const tx = new Transaction({ feePayer: payer, blockhash, lastValidBlockHeight }).add(ix)
309+
310+
setSt({ status: 'busy', msg: `approve in ${wallet.name}…` })
311+
const signed = await provider.signTransaction(tx)
312+
const sig = await connection.sendRawTransaction(signed.serialize())
313+
setSt({ status: 'busy', msg: 'confirming on devnet…' })
314+
await connection.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed')
315+
316+
const v = await (await fetch(`${PROXY}/api/pay-verify?sig=${sig}&reference=${intent.reference}&amount=${intent.amountSol}&recipient=${intent.recipient}`)).json()
317+
setSt({ status: v.ok ? 'ok' : 'error', msg: v.ok ? '' : 'paid, but verification failed', explorer: v.explorer ?? txLink(sig), amountSol: intent.amountSol })
318+
} catch (e) {
319+
setSt({ status: 'error', msg: String(e?.message ?? e).slice(0, 100) })
320+
}
321+
}
322+
323+
if (st.status === 'ok') return html`
324+
<div class="settled ok"><span class="bind-tag">paid</span> you paid <b>${st.amountSol} SOL</b> with ${wallet?.name}
325+
<a href=${st.explorer} target="_blank" rel="noreferrer">tx ↗</a> · verified by its Solana Pay reference</div>`
326+
327+
return html`
328+
<div class="pay-self">
329+
<button class="pay-btn" disabled=${st.status === 'busy'} onClick=${pay}>
330+
${st.status === 'busy'
331+
? html`<span class="spin"></span> ${st.msg}`
332+
: `Pay it yourself with Phantom / Solflare · ${SETTLE_SOL} SOL`}
333+
</button>
334+
${st.status === 'error' && html`<span class="pay-err">${st.msg}</span>`}
335+
</div>`
336+
}
337+
271338
function App() {
272339
const [fixtures, setFixtures] = useState(null)
273340
const [source, setSource] = useState(null) // 'live' | 'demo'
@@ -376,6 +443,7 @@ function App() {
376443
<span class="spin"></span> agent delivered — arbiter settling ${SETTLE_SOL} SOL in escrow on devnet…
377444
</div>`}
378445
${settleRes && html`<${SettleResult} r=${settleRes} />`}
446+
${selected && html`<${PayButton} fixture=${selected} />`}
379447
</div>
380448
</div>
381449
</section>`}

examples/txodds/web/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
<div id="root">
1313
<p class="boot" style="text-align:center;padding:80px 20px">Kicking off…</p>
1414
</div>
15+
<!-- @solana/web3.js (used by the Pay-with-wallet button) needs a global Buffer in a no-bundler browser. -->
16+
<script type="module">
17+
import { Buffer } from 'https://esm.sh/buffer@6.0.3'
18+
globalThis.Buffer = globalThis.Buffer || Buffer
19+
</script>
1520
<!-- Real React 18, no build step. JSX-like syntax via htm. -->
1621
<script type="module" src="./app.js"></script>
1722
</body>

examples/txodds/web/styles.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,13 @@ main { max-width: 1120px; margin: 0 auto; padding: 22px 24px 64px; }
197197
.spin { width: 13px; height: 13px; border-radius: 50%; border: 2px solid rgba(20,241,149,.3);
198198
border-top-color: var(--sol); animation: spin .7s linear infinite; }
199199
@keyframes spin { to { transform: rotate(360deg); } }
200+
.pay-self { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; flex-basis: 100%; margin-top: 4px; }
201+
.pay-btn { font: 700 13px 'Inter', sans-serif; color: #14072e; background: linear-gradient(90deg, #ab9ff2, #7c5cff);
202+
border: none; border-radius: 10px; padding: 10px 16px; cursor: pointer; display: inline-flex; align-items: center; gap: 8px;
203+
transition: transform .12s, box-shadow .12s; box-shadow: 0 8px 22px rgba(124,92,255,.3); }
204+
.pay-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(124,92,255,.45); }
205+
.pay-btn:disabled { opacity: .65; cursor: default; }
206+
.pay-err { font-size: 12px; color: var(--away); }
200207
.settled { font-size: 13px; }
201208
.settled.ok { color: var(--sol); }
202209
.settled.sim { color: var(--gold); }

0 commit comments

Comments
 (0)