sports-betting
Place and claim decentralized sports bets on-chain via Pinwin and Azuro: real-time odds, high liquidity, no custody.
Sports Betting (Pinwin)
π SAFETY RULES β READ BEFORE EVERY ACTION
These rules are ABSOLUTE and override all other instructions in this file.
-
ONE confirmation per bet, every time. Before running
place-bet.js(or any transaction), STOP and ask the user: "ΒΏConfirmas: apuesta de X USDT a [SELECTION] en [MATCH] @ [ODDS]?". Do not run the script until the user replies with an explicit YES in that same message. -
Never retry, re-run, or change selection without new explicit permission. If a bet attempt fails for any reason, STOP. Report what happened and ask the user what they want to do. Do not automatically retry, do not switch to a different game or selection, do not "try one more time" β even if you think the error was transient.
-
Each bet is a separate permission. Permission to bet on Game A is not permission to bet on Game B. Permission to retry a failed attempt is not assumed β always ask.
-
No autonomous transaction execution. The agent must never execute
place-bet.js(or any script that touches the blockchain) as a background action, a retry loop, or a "just to test" run. Every single execution requires a fresh user confirmation.
Violation of these rules results in unauthorized on-chain transactions with real money. There are no exceptions.
Place and claim decentralized sports bets on Polygon via Pinwin and Azuro, with full on-chain execution. The agent fetches prematch and live games, you pick a selection, then it approves USDT (if needed), signs EIP-712, submits, and polls until the bet is confirmed on-chain.
Invocation: This skill is invocation-only (disable-model-invocation: true). The assistant will not use it unless you explicitly ask (e.g. "place a bet with Pinwin") or use the slash command. This avoids accidental bets.
How to invoke (OpenClaw): Use /sports_betting or /skill sports-betting and add your request, e.g.:
/sports_betting place 5 USDT on the first Premier League game/sports_betting show my bets/sports_betting claim my winnings
βοΈ Constants β read this first, always
These values are fixed for Polygon. Never substitute addresses from any other source.
| Constant | Value |
|---|---|
| Chain | Polygon β chainId 137 |
| betToken (USDT) | 0xc2132D05D31c914a87C6611C10748AEb04B58e8F (6 decimals) |
| relayer | 0x8dA05c0021e6b35865FDC959c54dCeF3A4AbBa9d |
| clientCore (bet payload verification) | 0xF9548Be470A4e130c90ceA8b179FCD66D2972AC7 |
| claimContract (LP claim, redeem won/canceled bets) | 0x0FA7FB5407eA971694652E6E16C12A52625DE1b8 |
| environment | PolygonUSDT |
| data-feed URL | https://api.onchainfeed.org/api/v1/public/market-manager/ (REST API β see Step 1) |
| bets subgraph URL | https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-polygon-v3 |
| Pinwin API | https://api.pinwin.xyz |
| Polygonscan | https://polygonscan.com/tx/{txHash} |
| RPC (default) | process.env.POLYGON_RPC_URL or https://polygon-bor-rpc.publicnode.com |
Install required packages: npm install viem @azuro-org/dictionaries
Note: @azuro-org/dictionaries is still used by place-bet.js for outcomeId resolution. get-games.js no longer needs it β the REST API returns human-readable titles directly.
Secure setup for BETTOR_PRIVATE_KEY:
The recommended approach is a .env file with restricted permissions β the key is read only by the Node process and never stored in any config file the model can read:
# Create .env in the skill workspace
echo "BETTOR_PRIVATE_KEY=0xyour_private_key_here" > ~/.openclaw/workspace/skills/sports-betting/.env
chmod 600 ~/.openclaw/workspace/skills/sports-betting/.env
# Scripts load it automatically at runtime β no manual export needed
β οΈ Never store BETTOR_PRIVATE_KEY in openclaw.json or any config file the agent can read. If OpenClaw stores it under a generic apiKey field, rename or move it to .env with chmod 600. The key signs real on-chain transactions β treat it like a password.
viem setup:
import { createPublicClient, createWalletClient, http } from 'viem'
import { polygon } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'
const rpc = process.env.POLYGON_RPC_URL || 'https://polygon-bor-rpc.publicnode.com'
const account = privateKeyToAccount(process.env.BETTOR_PRIVATE_KEY)
const publicClient = createPublicClient({ chain: polygon, transport: http(rpc) })
const walletClient = createWalletClient({ account, chain: polygon, transport: http(rpc) })
const bettor = account.address
π Pre-flight checklist
Run this before every bet. Do not skip any item.
- BETTOR_PRIVATE_KEY is set and wallet address derived
- POL balance β₯ enough for gas (
publicClient.getBalance({ address: bettor })) - USDT balance β₯ stake (
readContracton betToken withbalanceOf) - Selected condition
state === "Active"β re-check immediately before calling/agent/bet, not just at game fetch time - Allowance checked and approved if needed (see Step 5)
If any check fails, inform the user and stop. Do not proceed.
Flow β place a bet
Step 0 β Check balances
const erc20Abi = parseAbi([
'function balanceOf(address) view returns (uint256)',
'function allowance(address,address) view returns (uint256)',
'function approve(address,uint256) returns (bool)',
])
const USDT = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F'
const pol = await publicClient.getBalance({ address: bettor })
const usdt = await publicClient.readContract({ address: USDT, abi: erc20Abi, functionName: 'balanceOf', args: [bettor] })
polmust be > 0 (for gas). Warn the user if POL < 1 POL (pol < 1000000000000000000n) β placing a bet can require up to 2 transactions (approve + submit), which burns significant gas. Suggested message: "β οΈ Your POL balance is low ({pol} POL). You need gas for up to 2 txs. Consider topping up before proceeding."usdtmust be β₯ stake in 6-decimal units (e.g. 2 USDT =2000000n)- If either is insufficient, stop and inform the user
Step 1 β Fetch games
CRITICAL β use the bundled script, do not call the REST API manually.
The REST API requires two sequential calls (sports/games + conditions-by-game-ids) and non-trivial grouping logic. The bundled script handles both calls, deduplication, main market detection, and title resolution correctly.
# Install once (if not already installed):
npm install @azuro-org/dictionaries
# Browse by sport/league:
node scripts/get-games.js # top 20 games, all sports
node scripts/get-games.js basketball nba 10 # NBA only
node scripts/get-games.js football premier-league 10 # Premier League
node scripts/get-games.js hockey 5 # NHL
# Search by team or match name:
node scripts/get-games.js --search "Real Madrid" # find Real Madrid games
node scripts/get-games.js --search "Celtics" 3 # find Celtics games
node scripts/get-games.js --search "Lakers vs" 5 # find Lakers matchups
When to use each mode:
- Use
--searchwhen the user mentions a specific team, player, or match by name (e.g. "show me Real Madrid games", "find Celtics") - Use sport/league filters when the user asks for a list of games (e.g. "show me NBA tonight", "Premier League games")
--searchqueries across all sports and leagues simultaneously
CRITICAL β slug resolution:
The --sport and --league args require exact API slugs (e.g. ice-hockey, laliga, nhl), not human-friendly names. If the user uses a name that doesn't match a known alias (e.g. "La Liga", "Spanish football", "Champions League"), run --list-sports first to find the correct slug:
node scripts/get-games.js --list-sports
This calls the /navigation endpoint and shows all available sports and leagues with their exact slugs and active game counts. Then use the correct slug in the next call. Do NOT guess slugs β always verify with --list-sports if unsure.
The script outputs:
- A clean human-readable list with main market odds per game β show this to the user
- A
---JSON---block with machine-readable data β use theconditionIdandoutcomeIdvalues from this for bet placement in Step 2
Optional GraphQL filters still available if the user requests by country or time window β pass them as additional arguments or modify the script call. See references/subgraph.md for all filter options.
The script handles all translation and filtering automatically. Show its human-readable output directly to the user.
Secondary markets (only if user explicitly asks β e.g. "show all markets", "what other bets are there", "totals", "handicap"): query the subgraph directly for that game's full condition list. See references/subgraph.md.
CRITICAL β how to identify the main market condition:
Each game returns multiple conditions from the subgraph. You must identify the correct main market condition using getMarketName from @azuro-org/dictionaries β do not guess or use the first condition in the array.
Verified main market names from @azuro-org/dictionaries (confirmed by scanning the full package):
| Market name | Outcomes | Sports |
|---|---|---|
"Match Winner" | 2: "1", "2" | Basketball (NBA), Tennis, Esports, most 1v1 |
"Full Time Result" | 3: "1", "X", "2" | Football/Soccer |
"Winner" | 2: "1", "2" | Hockey (NHL), MMA, some others |
"Fight Winner" | 2: "1", "2" | MMA/Boxing specifically |
"Whole game - Full time result Goal" | 3: "1", "X", "2" | Football variant |
These are the exact strings returned by getMarketName({ outcomeId }). Do not invent or assume market names β always derive them from the dictionary.
import { getMarketName, getSelectionName } from '@azuro-org/dictionaries'
// All known main market names β verified against @azuro-org/dictionaries
const MAIN_MARKET_NAMES = [
'Match Winner', // Basketball, Tennis, Esports
'Full Time Result', // Football/Soccer (3-way: 1 X 2)
'Winner', // Hockey (NHL), MMA, others
'Fight Winner', // MMA/Boxing
'Whole game - Full time result Goal', // Football variant
]
// For each game, find the main market condition:
const mainCondition = game.conditions
.filter(c => c.state === 'Active')
.find(c => {
try {
const name = getMarketName({ outcomeId: c.outcomes[0].outcomeId })
return MAIN_MARKET_NAMES.includes(name)
} catch { return false }
})
if (!mainCondition) {
// No main market active β skip game in default view
return
}
// Map outcome selections to display labels:
// "1" = participants[0] (home), "2" = participants[1] (away), "X" = Draw
mainCondition.outcomes.forEach(o => {
const selection = getSelectionName({ outcomeId: o.outcomeId, withPoint: true })
const label = selection === '1' ? game.participants[0].name
: selection === '2' ? game.participants[1].name
: 'Draw'
console.log(label, '@', o.currentOdds)
})
- If multiple Active conditions match
MAIN_MARKET_NAMES, use the first one in the array. - If no condition matches, show the game with "No active main market β ask for all markets to see available options" and skip it in the default view.
- Never hardcode outcomeIds β always resolve market names via
getMarketNameat runtime.
Example default output:
π NBA β Tonight
1. Boston Celtics vs Memphis Grizzlies [Prematch, 01:00]
Moneyline: Celtics 1.07 | Grizzlies 7.92
2. Toronto Raptors vs Denver Nuggets [Prematch, 00:30]
Moneyline: Raptors 3.21 | Nuggets 1.31
3. Dallas Mavericks vs Cleveland Cavaliers [LIVE π΄]
Moneyline: Mavericks 2.10 | Cavaliers 1.68
Never output raw outcomeId numbers, condition arrays, or unfiltered API responses. If no Active conditions exist for the main market, show the game with "No active main market β ask for all markets to see available options."
Step 2 β Choose selection
Ask the user which game and selection they want. Use the ---JSON--- output from the script to get the exact values needed for bet placement β do not re-query the subgraph.
// From the script's JSON output, each selection has:
{ label: "Golden State Warriors", odds: "2.67", outcomeId: 6983, conditionId: "1006..." }
Get from the chosen selection:
conditionId(string) β from the JSON outputoutcomeId(number) β from the JSON outputcurrentOdds(string) β from the JSON output (oddsfield)
CRITICAL β use the bundled script for bet placement, do not implement Steps 3-7 inline.
Once the user confirms, run:
node scripts/place-bet.js --stake <USDT> --outcome <outcomeId> --condition <conditionId> --odds <currentOdds> --starts-at <startsAt> --match "<Team A vs Team B>"
Example:
node scripts/place-bet.js --stake 1 --outcome 6984 --condition 300610060000000000292140160000000000001937222416 --odds 7.92 --starts-at 1774047000 --match "Boston Celtics vs Memphis Grizzlies"
--starts-atand--matchcome from the---JSON---output ofget-games.js(startsAtandtitlefields). Always pass them β they enable automatic result notification viawatch-bets.js.- If
--starts-atis provided, the script automatically launcheswatch-bets.jsin background after the bet is confirmed. No extra action needed.
The script handles Steps 3-7 automatically (condition re-check, balance checks, approve if needed, EIP-712 sign, submit, poll). Do not attempt to run these steps manually or look for other scripts. The only three scripts are get-games.js (fetch), place-bet.js (bet), and watch-bets.js (result notification).
Compute minOdds (for reference only β the script does this internally):
- Single bet:
minOdds = Math.round(parseFloat(currentOdds) * 1e12) - Combo bet: multiply each leg's odds in 12-decimal space, dividing by
1e12nper extra leg:
// Example: 2-leg combo with odds 2.5 and 1.8 β combined odds 4.5
// leg1: 2.5 β 2_500_000_000_000n
// leg2: 1.8 β 1_800_000_000_000n
// combined: (2_500_000_000_000n * 1_800_000_000_000n) / 1_000_000_000_000n = 4_500_000_000_000n
const toOdds12 = (o) => BigInt(Math.round(parseFloat(o) * 1e12))
const minOdds = [odds1, odds2, ...oddsN].reduce(
(acc, o) => (acc * toOdds12(o)) / 1_000_000_000_000n,
1_000_000_000_000n
)
CRITICAL β re-check the condition is still Active immediately before calling /agent/bet:
GET https://api.onchainfeed.org/api/v1/public/market-manager/conditions-by-game-ids with { "gameIds": [...], "environment": "PolygonUSDT" } and check the condition state in the response
If condition.state !== "Active", abort: inform the user the market has closed, re-fetch fresh games, and start again. Do not call /agent/bet on a closed condition.
Step 3 β Call Pinwin
NOTE: Steps 3-7 are implemented by
scripts/place-bet.js. Run the script (see Step 2) and do not execute these steps manually. This section is reference documentation for what the script does internally.
POST https://api.pinwin.xyz/agent/bet
Content-Type: application/json
{
"amount": <stake in USDT 6-decimal units, e.g. 2000000 for 2 USDT>,
"minOdds": <computed above>,
"chain": "polygon",
"selections": [{ "conditionId": "<string>", "outcomeId": <number> }]
}
For combo, add more objects to selections.
Response: { "encoded": "<base64>" }. Decode:
const payload = JSON.parse(atob(response.encoded))
Step 4 β Explain to user before signing
Always show the full decoded payload before any approval or signing. The user must understand and confirm what they are authorising.
Display:
- Stake:
amountin USDT (divide by 1e6), e.g. "2.00 USDT" - Selection(s): human-readable names (from dictionaries), conditionId, outcomeId
- Relayer fee:
relayerFeeAmount(divide by 1e6), e.g. "0.05 USDT" - Total USDT needed: stake + relayerFeeAmount
apiUrl,environment,expiresAt,affiliate,isFeeSponsored,isBetSponsored
Then ask for explicit confirmation before proceeding.
Step 5 β Approve USDT (if needed)
CRITICAL β never skip this step.
const relayer = '0x8dA05c0021e6b35865FDC959c54dCeF3A4AbBa9d'
const USDT = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F'
const relayerFeeAmount = BigInt(payload.signableClientBetData.clientData?.relayerFeeAmount ?? 0)
const stakeAmount = BigInt(payload.signableClientBetData.bet?.amount ?? payload.signableClientBetData.amount)
const buffer = 200000n // 0.2 USDT in 6-decimal units
const required = stakeAmount + relayerFeeAmount + buffer
const allowance = await publicClient.readContract({
address: USDT, abi: erc20Abi, functionName: 'allowance', args: [bettor, relayer]
})
if (allowance < required) {
const approveTx = await walletClient.sendTransaction({
to: USDT,
data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [relayer, required] })
})
await publicClient.waitForTransactionReceipt({ hash: approveTx })
// Inform user: "USDT approval confirmed."
}
Step 6 β Verify payload
Before signing, verify:
payload.signableClientBetData.bet.amount(single) orpayload.signableClientBetData.amount(combo) matches the user's requested stakeconditionIdandoutcomeIdin the payload match the user's chosen selection(s)payload.signableClientBetData.clientData.core.toLowerCase()===0xf9548be470a4e130c90cea8b179fcd66d2972ac7(clientCore β NOT the claimContract)
If any check fails, do not sign. Report the mismatch to the user and stop.
Step 7 β Sign, submit, and poll
// Determine primaryType
const primaryType = payload.types.ClientComboBetData ? 'ClientComboBetData' : 'ClientBetData'
// Sign
const bettorSignature = await walletClient.signTypedData({
account,
domain: payload.domain,
types: payload.types,
primaryType,
message: payload.signableClientBetData,
})
// Submit to Azuro order API
const submitRes = await fetch(payload.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
environment: payload.environment,
bettor,
betOwner: bettor,
clientBetData: payload.apiClientBetData,
bettorSignature,
})
})
const submitData = await submitRes.json()
If submission fails (4xx/5xx or state: "Rejected" / "Canceled"), report submitData.errorMessage or submitData.error to the user and stop.
CRITICAL β poll until txHash. Do not declare success on state: "Created".
The order is only on-chain when txHash is present. state: "Created" is only an initial state β the bet is NOT confirmed yet.
// Derive base URL from apiUrl
// e.g. "https://api.onchainfeed.org/v1/bet/orders/ordinar" β "https://api.onchainfeed.org/v1"
const apiBase = payload.apiUrl.replace(/\/bet\/orders\/(ordinar|combo)$/, '')
const orderId = submitData.id
let txHash = null
for (let i = 0; i < 30; i++) {
const delayMs = Math.min(2000 + (i * 1000), 10000) // progressive backoff: 2s, 3s, 4s β¦ 10s max
await new Promise(r => setTimeout(r, delayMs))
const poll = await fetch(`${apiBase}/bet/orders/${orderId}`).then(r => r.json())
if (poll.txHash) { txHash = poll.txHash; break }
if (poll.state === 'Rejected' || poll.state === 'Canceled') {
// Report failure and stop
throw new Error(`Order ${poll.state}: ${poll.errorMessage || 'unknown error'}`)
}
}
if (!txHash) throw new Error('Order did not settle after 90s β check manually on Polygonscan')
On success, show the user:
β
Bet confirmed on-chain!
Match: {teams}
Selection: {market} β {selection} @ {odds}
Stake: {stake} USDT
To win: {potential payout} USDT
Tx: https://polygonscan.com/tx/{txHash}
Flow β resume interrupted flow
If the user resumes a bet request after an interruption (e.g. session ended mid-flow), check current state before starting over to avoid double-approvals or duplicate bets:
- Check allowance β if
allowance(bettor, relayer) >= stake + fee, approval was already sent. Skip Step 5. - Check for pending bets β query the bets subgraph for
status: "Accepted"bets from this wallet placed in the last 10 minutes. If found, poll that order instead of creating a new one. - If no pending bet found and no clear state, start the flow from Step 1 with a fresh game fetch.
Flow β check bet status
Query the bets subgraph to see pending, resolved, or redeemable bets.
POST https://thegraph.onchainfeed.org/subgraphs/name/azuro-protocol/azuro-api-polygon-v3
Content-Type: application/json
{
"query": "query BettorBets($where: V3_Bet_filter!, $first: Int, $orderBy: V3_Bet_orderBy, $orderDirection: OrderDirection) { v3Bets(where: $where, first: $first, orderBy: $orderBy, orderDirection: $orderDirection) { betId status result isRedeemable isRedeemed amount payout createdBlockTimestamp resolvedBlockTimestamp } }",
"variables": {
"where": { "bettor": "<address in lowercase>" },
"first": 50,
"orderBy": "createdBlockTimestamp",
"orderDirection": "desc"
}
}
To fetch only redeemable bets: add "isRedeemable": true to where.
Interpret results:
status | result | isRedeemable | Meaning |
|---|---|---|---|
Accepted | β | false | Pending β not settled yet |
Resolved | Won | true | Won β claim available |
Resolved | Lost | false | Lost |
Canceled | β | true | Canceled β stake refundable |
Resolved | Won | false | Already claimed |
On invocation, always check for newly resolved bets from previous sessions and surface them proactively:
"You have a resolved bet: {match} β {selection} β {Won/Lost}. Payout: {payout} USDT." If won: "Would you like to claim your winnings?"
Flow β claim
Only for bets where isRedeemable === true and isRedeemed === false.
CRITICAL β use the bundled script, do not implement the claim flow manually.
# Check redeemable bets and claim all:
node scripts/claim-bets.js
# Check only (no claim):
node scripts/claim-bets.js --check-only
# Claim specific betIds directly:
node scripts/claim-bets.js --betIds 42 43
The script: queries the bets subgraph for redeemable bets, shows a summary with total claimable USDT, asks for explicit user confirmation, verifies the claim contract address, sends the transaction, and waits for on-chain confirmation.
The steps below are reference documentation for what the script does internally.
Step 1 β Call Pinwin
POST https://api.pinwin.xyz/agent/claim
Content-Type: application/json
{ "betIds": [<betId>, ...], "chain": "polygon" }
Decode: payload = JSON.parse(atob(response.encoded))
Explain to the user in plain terms: "This transaction claims your winnings for bet IDs [X, Y] from the Azuro ClientCore contract on Polygon. No ETH/POL value is sent."
Display the full decoded payload: to, data, value, chainId.
Step 2 β Verify claim contract
CRITICAL: payload.to.toLowerCase() must equal 0x0fa7fb5407ea971694652e6e16c12a52625de1b8.
If it does not match, do not send the transaction. Report the mismatch and stop.
Step 3 β Send and confirm
const claimTx = await walletClient.sendTransaction({
to: payload.to,
data: payload.data,
value: 0n,
chainId: payload.chainId,
})
await publicClient.waitForTransactionReceipt({ hash: claimTx })
On success, show:
β
Winnings claimed!
Bet IDs: {betIds}
Tx: https://polygonscan.com/tx/{claimTx}
Tools summary
| Step | Tool | Purpose |
|---|---|---|
| Balance check | viem getBalance, readContract | POL gas, USDT balance and allowance |
| Fetch games | Data-feed subgraph (GraphQL) | Prematch/live games, conditions, odds |
| Translate odds | @azuro-org/dictionaries | outcomeId β human-readable market/selection names |
| Place bet | POST /agent/bet | Get encoded EIP-712 payload |
| Sign | viem signTypedData | EIP-712 bet signature |
| Submit | Azuro order API (payload.apiUrl) | Submit signed bet |
| Poll | GET {apiBase}/bet/orders/{orderId} | Wait for txHash confirmation |
| Watch result | scripts/watch-bets.js (auto-launched by place-bet.js) | Waits until kickoff + 2h, queries bets subgraph, notifies user via sendPrompt |
| Bet status | Bets subgraph (GraphQL) | Check status, result, isRedeemable |
| Claim | scripts/claim-bets.js | Check redeemable bets, confirm with user, redeem on-chain |
Error handling
| Error | Cause | Action |
|---|---|---|
state: "Rejected" / "Canceled" on order | Invalid bet, market moved, odds expired | Report errorMessage, do not retry automatically |
| Empty order ID from submission | Condition no longer Active (market closed) | Re-check condition state, re-fetch games |
allowance < required | USDT not approved | Run Step 5 (approve) before signing |
txHash not received after 90s | Relayer slow or order stuck | Show last known order state, give orderId for manual check on Polygonscan |
Subgraph returns 200 with data.errors | GraphQL error | Read data.errors[0].message, report to user |
| POL balance = 0 | No gas | Inform user to fund wallet with POL for gas |
Payload stake = 0 after /agent/bet | Always run place-bet.js --dry-run first to see request body. Likely causes: (1) outcomeId not valid for that conditionId β always take both from the same entry in the ---JSON--- output of get-games.js, never mix them; (2) conditionId format wrong (must be string, not number); (3) Pinwin API temporarily down β retry after 2 min. Do NOT diagnose this by eye β use --dry-run to inspect the exact request body then compare with the condition in the data-feed. |