From 1ea14faeeb75b4b11b25d938cc6d8e2ed3106322 Mon Sep 17 00:00:00 2001 From: Smartcoded Date: Fri, 7 Aug 2026 13:25:23 +0000 Subject: [PATCH 1/2] Showcase: correct PipTrade skill terminal claim, document the quote and safety APIs The skill said the /rh terminal runs limit, TWAP and stop orders. The live page says the Pro terminal is being rebuilt, so a buyer agent would have promised a user orders that hit a coming-soon screen. Corrected to market orders today. It also listed POST /api/quote without field names, units, response shape or error codes, and never said how to obtain the GO CAUTION BLOCK verdict it referenced throughout, so neither was callable. Both are now documented from the live service: - a dry quote needs no session, so any agent can price a route - amount is in base units, the most common integration error - full request, response and error taxonomy - GET /api/rh/sellcheck as the public safety gate, and the chain parameter whose absence returns a false CAUTION and a wrong decimals value - scripts/pip-quote.mjs, a runnable read-only end-to-end example Frontmatter moved version under metadata to match the Agent Skills spec. Every documented claim verified against the live service. --- .../skills/pip-trade-agent/SKILL.md | 216 +++++++++++++----- .../pip-trade-agent/scripts/pip-quote.mjs | 97 ++++++++ 2 files changed, 260 insertions(+), 53 deletions(-) create mode 100644 showcase/pip-conversational-trader/skills/pip-trade-agent/scripts/pip-quote.mjs diff --git a/showcase/pip-conversational-trader/skills/pip-trade-agent/SKILL.md b/showcase/pip-conversational-trader/skills/pip-trade-agent/SKILL.md index a463808..e27825d 100644 --- a/showcase/pip-conversational-trader/skills/pip-trade-agent/SKILL.md +++ b/showcase/pip-conversational-trader/skills/pip-trade-agent/SKILL.md @@ -1,95 +1,205 @@ --- name: pip-trade-agent -description: Hand a trade to PipTrade Agent in plain english, read the live quote, respect the token safety gate, and confirm a self-custody settlement that is private by default. Buyer-side, approval-gated, keeps the user in custody. -version: 1.0.0 +description: Quote and execute a token trade through PipTrade Agent in plain english, across every listed chain, with a token safety gate and self-custody signing that is private by default. Use when a buyer agent needs a live cross-chain quote, a honeypot check before trading, or a settlement that does not link the user wallet to the destination. Public read endpoints need no auth; anything that moves value is signed by the user. license: MIT +compatibility: Requires network access to https://piptradedex.xyz. Read endpoints and dry quotes are public. Executing a trade requires the user signed in at /app with their own wallet. +metadata: + version: "1.1.0" + builder: PipTradeDex --- # Pip Trade Agent -Use this skill when a buyer agent wants to execute a token trade for a user through PipTrade Agent instead of building routing, quoting, and token safety from scratch. The skill is buyer-side, approval-gated, and custody-preserving. It never signs for the user and never invents an intent the user did not state. +Hand a trade to PipTrade Agent instead of building routing, quoting, and token safety yourself. Buyer-side, approval-gated, custody-preserving. It never signs for the user and never invents an intent the user did not state. ## When To Use - A user asked, in words, to buy, sell, swap, or send a token. -- The buyer agent wants a live quote with price impact, minimum received, and route before committing. -- The trade should be gated by a token safety read (GO, CAUTION, or BLOCK). +- You want a live quote with amount out, minimum received, and settlement time before committing. +- You want a token safety read (GO, CAUTION, BLOCK) before trading an unknown token. - The user must keep their keys and sign in their own wallet. -- Settlement should be private by default, breaking the on-chain link between the wallet and the destination. +- The settlement should not publicly link the user wallet to the destination. ## When NOT To Use -- Do not use it to sign or move funds without the user in the loop. -- Do not use it to invent a token, a side, or an amount the user did not state. -- Do not use it to send funds to an address the user did not give. -- Do not use it to skip the token safety read on a token about to be traded. -- Do not use it to claim privacy stronger than unlinkability by construction, which is not a zero knowledge proof. +- To sign or move funds without the user in the loop. +- To invent a token, a side, or an amount the user did not state. +- To send funds to an address the user did not give. +- To skip the safety read on a token about to be traded. +- To claim privacy stronger than unlinkability by construction, which is not a zero knowledge proof. -## Inputs +## Quick Reference + +Base URL `https://piptradedex.xyz`. -- One trade intent in plain english, for example buy 10 usd of PIP, or swap 2 usdc on base to sol and send it over. -- The chain context if the user named one, otherwise let Pip infer from the token. -- A per-trade spend cap and an allowlist of tokens the agent may act on. -- The user approval state for anything that signs. +| Endpoint | Method | Auth | Returns | +| --- | --- | --- | --- | +| `/api/tokens` | GET | none | tradeable registry: `assetId`, `symbol`, `blockchain`, `decimals`, `price` | +| `/api/prices` | GET | none | live prices by symbol | +| `/api/markets` | GET | none | market list with 24h change and volume | +| `/api/pip/stats` | GET | none | $PIP token stats | +| `/api/rh/tokens` | GET | none | the listed Robinhood Chain universe | +| `/api/rh/sellcheck` | GET | none | can this token be sold, the honeypot gate | +| `/api/quote` | POST | none for a dry quote | live cross-chain quote | +| `/api/app2/safety` | POST | signed-in user | full GoPlus rating, verdict and checks | +| `/api/holdings`, `/api/wallets` | GET | signed-in user | balances for that wallet | +| `/api/rh/quote`, `/api/rh/swap`, `/api/rh/withdraw` | POST | signed-in user | Robinhood Chain trade and private send | -## Outputs +A GET against a POST-only route returns the landing page HTML with status 200, not a 405. Always POST where the table says POST, and parse the body rather than trusting the status. -- A parsed intent, side, token, and amount, echoed back for confirmation. -- A live quote, price impact, minimum received, route, and estimated time. -- A token safety verdict, GO, CAUTION, or BLOCK. -- A settled transaction reference on success, delivered to the user own wallet. +## Getting A Quote -## Approval Gates +`POST /api/quote` is the unified intent quote. One instruction moves value across every listed chain and token. A cross-chain move such as `swap 2 usdc on base to sol and send it over` is a single intent, not a bridge plus a swap. -The user, or the operator acting for the user, must approve: +**A dry quote needs no session.** Any agent can price a route before involving the user. Only execution requires the user signed in. -1. the parsed intent before any quote is treated as final, -2. any token that returns CAUTION, -3. any trade above the per-trade spend cap, -4. the signature itself, which always happens in the user own wallet. +Request: + +| Field | Required | Notes | +| --- | --- | --- | +| `originAsset` | yes | `assetId` from `/api/tokens` | +| `destinationAsset` | yes | `assetId` from `/api/tokens` | +| `amount` | yes | **base units, not human units** (see below) | +| `recipient` | no | address on the DESTINATION chain. Omitted on a dry quote, a chain-correct placeholder is used | +| `refundTo` | no | address on the ORIGIN chain | +| `private` | no | settlement is already private whenever the private rail is available | +| `dry` | no | defaults `true`. `dry:false` requests a real deposit address and requires a session | + +### 🚨 Amount is in base units + +Multiply by the token `decimals` from `/api/tokens`. This is the single most common integration error, and it fails as an unhelpful `busy` rather than a clear message. + +- 5 USDC on Base, `decimals: 6`, send `"5000000"`. +- 1 ETH, `decimals: 18`, send `"1000000000000000000"`. +- Sending `"5"` for USDC quotes 0.000005 USDC and the route fails. + +### Worked example + +```bash +curl -s -X POST https://piptradedex.xyz/api/quote \ + -H 'content-type: application/json' \ + -d '{ + "originAsset": "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near", + "destinationAsset": "nep141:base.omft.near", + "amount": "5000000" + }' +``` + +Response: + +```json +{ + "ok": true, + "private": true, + "amountInFormatted": "5.0", + "amountOutFormatted": "0.002602097377635916", + "amountInUsd": "4.998260000000", + "amountOutUsd": "4.980986842218", + "minAmountOut": "2576076403859556", + "timeEstimate": 37, + "depositAddress": null, + "depositMemo": null +} +``` + +`minAmountOut` is in base units of the destination token. `timeEstimate` is seconds. `depositAddress` is null on a dry quote and populated only on a real, signed-in execution. + +### Errors + +Every failure returns HTTP 200 with `ok:false` unless noted. Read `error`, never the status alone. + +| `error` | Meaning | What to do | +| --- | --- | --- | +| `missing_fields` (400) | an `originAsset`, `destinationAsset` or `amount` is absent | fix the request | +| `amount_range` | outside the route minimum or maximum | read the `min` object, which carries `amount`, `usd`, `sym` and `chain`, then re-quote | +| `no_liquidity` | no route at that size right now | try a smaller size or a different pair | +| `unsupported` | that pair is not routable | pick another pair from `/api/tokens` | +| `busy` | unclassified router failure, often a wrong `amount` unit | check base units first, then retry | +| `private_activating` | the private rail is warming up | retry shortly | +| `not_logged_in` (401) | the endpoint needs the user session | hand back to the user at `/app` | -If any approval is missing, stop and hand control back to the user. A BLOCK verdict is a hard stop, never trade through it. +## The Safety Gate -## Workflow +The gate has two levels. Use the public one before you quote, and the full one when the user is signed in. -1. Read the user words and map them to a single intent, side, token, amount, and destination if a send. If anything is unclear, ask one short question, never guess. -2. Confirm the token is on the agent allowlist and the amount is within the per-trade cap. -3. Request a quote and a token safety read. Show price impact, minimum received, route, estimated time, and the GO CAUTION BLOCK verdict. +**Public, no auth.** `GET /api/rh/sellcheck?address=<0x...>&chain=base|robinhood` answers the question that matters most on an unknown token: can it be sold again. + +🚨 **Always send `chain`.** It defaults to a Robinhood Chain sell simulation, so a healthy Base token comes back `sellable:null, reason:"unverifiable"` and you would gate a good trade on a false CAUTION. The same response also carries a wrong `decimals` in that state, so take decimals from `/api/tokens`, never from this endpoint. + +```bash +curl -s "https://piptradedex.xyz/api/rh/sellcheck?address=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913&chain=base" +# {"ok":true,"address":"0x833589...","chain":"base","sellable":true,"venue":"routed"} +``` + +| Field | Meaning | +| --- | --- | +| `sellable: true` | a sell simulated successfully, treat as GO on this check alone | +| `sellable: false` | the sell failed, treat as BLOCK, this is the honeypot signal | +| `sellable: null` with `reason` | unverifiable, for example `unverifiable` on a non-token address, treat as CAUTION and get user approval | +| `venue` | where it would route, for example `bonding` for $PIP on the Virtuals curve | + +A malformed address returns `400 {"ok":false,"error":"bad_address"}`. Never map that to BLOCK; it is your bug, not the token's. + +**Full scan, session required.** `POST /api/app2/safety` with `{"chain":"base","address":"0x..."}` returns a GoPlus-backed rating with per-check detail (honeypot, tax, ownership). Returns `401 not_logged_in` without a session and is rate limited to 30 calls a minute. Use `{"rh":true,"sym":"PIP"}` for a Robinhood Chain token and `{"native":true,"sym":"ETH","chain":"base"}` for a native asset, which is always safe by construction. + +Map both into one verdict: BLOCK on `sellable:false` or a failing scan, CAUTION on `null`/unverifiable, GO only when a check actually passed. Absence of evidence is CAUTION, never GO. + +## Executing A Trade + +The agent shapes the intent. The user signs. Nothing here signs on the agent behalf. + +1. Map the user words to one intent: side, token, amount, and destination if a send. If anything is unclear, ask one short question, never guess. +2. Confirm the token is on your allowlist and the amount is within your per-trade cap. +3. Quote it, and read the token safety verdict. 4. On BLOCK, stop. On CAUTION, get explicit user approval. On GO, proceed. -5. Present the full quote to the user and let the user sign in their own wallet. The agent never signs. -6. On success, return the settled transaction reference. Settlement is private by default, so the destination is not directly linked to the source. +5. Present the full quote and let the user sign in their own wallet at `/app`. +6. On success, return the settled transaction reference. + +### Approval gates + +The user, or the operator acting for the user, must approve: -## Interface +1. the parsed intent before any quote is treated as final, +2. any token that returns CAUTION, +3. any trade above the per-trade spend cap, +4. the signature itself, which always happens in the user own wallet. -PipTrade Agent runs at https://piptradedex.xyz/app for chat and https://piptradedex.xyz/rh for the terminal. On the terminal the same intent maps to a market, limit, TWAP, or stop order, run by a keeper, on any listed Robinhood Chain token, with the same safety gate and self-custody signing. $PIP is a Virtuals token traded through the Virtuals bonding curve. +If any approval is missing, stop and hand control back. A BLOCK verdict is a hard stop, never trade through it. -## Endpoints +## Surfaces -Read only, public, callable by any agent today at https://piptradedex.xyz: +- `/app` is the chat surface. Plain english intents, a GO CAUTION BLOCK safety read before any trade, deposit from another chain by intent, price alerts, and a referral link. +- `/rh` is the Robinhood Chain terminal on the same wallet. Market trades on any listed token, with the same safety gate and self-custody signing. Limit, TWAP and stop orders are being rebuilt and are not available today, so do not promise them to a user. -- GET /api/prices, live token prices. -- GET /api/markets, market list with 24h change and volume. -- GET /api/tokens, the tradeable token registry with decimals and logos. -- GET /api/pip/stats, $PIP token stats. +$PIP is a Virtuals token traded through the Virtuals bonding curve. -At /app, intent based, signed in with the user own wallet. The agent shapes the intent, the user signs, and nothing here signs on the agent behalf: +There is no anonymous trade endpoint. Reads and dry quotes are open to any agent; anything that moves value requires the user signed in with their own keys, which is the point. -- POST /api/quote, the unified intent quote and swap. One instruction moves value across every listed chain and token, private by default, so the on-chain link between the wallet and the destination is broken. A cross-chain move like swap 2 usdc on base to sol and send it over is a single intent. -- GET /api/wallets and GET /api/holdings, balances for the signed-in wallet. -- The /app chat drives the rest on the same signed-in wallet, a GO CAUTION BLOCK token safety read before any trade, deposit from another chain by intent, price alerts in plain english, and a referral link. +## Runnable Example -At /rh, the Robinhood Chain terminal on the same wallet: +`scripts/pip-quote.mjs` does the whole read-only flow in one file: resolve two `assetId`s from `/api/tokens`, convert a human amount to base units using the real `decimals`, run the public sell check, then quote. No auth, no signing, nothing moves. -- GET /api/rh/tokens, the listed token universe. -- POST /api/rh/quote and POST /api/rh/swap, a market trade on any listed token with a safety badge. -- Market, limit, TWAP, and stop orders, run by a keeper. -- POST /api/rh/withdraw, a private send. +```bash +node scripts/pip-quote.mjs 5 USDC base ETH base +``` -There is no anonymous trade endpoint. Trading always requires the user signed in with their own keys, which is the point. An agent uses the public reads freely and shapes the signed-in intents through the user at /app. +Read it before writing your own client. It encodes the base-units conversion and the error branching that this API actually requires. ## Safety Invariants - The user keeps their keys. The agent and the service never hold them. - Every trade shows a full quote before it signs. - Every token is safety checked, and a high risk token is gated. -- Privacy is unlinkability by construction, stated plainly, never oversold. +- Privacy is unlinkability by construction, stated plainly, never oversold. It breaks the public link between the source wallet and the destination. It is not a zero knowledge proof. + +## Common Mistakes + +| Mistake | Symptom | Fix | +| --- | --- | --- | +| Human amount instead of base units | `busy` on a valid pair | multiply by `decimals` from `/api/tokens` | +| GET on a POST route | HTML body, status 200 | POST, and parse the body | +| Trusting the HTTP status | a failed quote read as success | branch on `ok` and `error` | +| Hardcoding an `assetId` | breaks when a listing changes | resolve from `/api/tokens` every run | +| Omitting `chain` on the sell check | false CAUTION on a healthy token, wrong `decimals` | always pass `chain`, take decimals from `/api/tokens` | +| Promising limit, TWAP or stop orders | user hits a coming-soon screen | market orders today, the rest are being rebuilt | +| Calling privacy zero knowledge | overclaim a reviewer will catch | say unlinkability by construction | diff --git a/showcase/pip-conversational-trader/skills/pip-trade-agent/scripts/pip-quote.mjs b/showcase/pip-conversational-trader/skills/pip-trade-agent/scripts/pip-quote.mjs new file mode 100644 index 0000000..eb7aba2 --- /dev/null +++ b/showcase/pip-conversational-trader/skills/pip-trade-agent/scripts/pip-quote.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/* + * PipTrade Agent, the whole read-only flow in one file. + * + * node pip-quote.mjs + * node pip-quote.mjs 5 USDC base ETH base + * + * Resolves both assetIds from the live registry, converts a HUMAN amount to base + * units using the real decimals, runs the public sell check, then quotes. + * No auth, no signing, nothing moves. + * + * The base-units conversion is the point. Sending "5" instead of "5000000" for a + * 6dp token quotes 0.000005 and fails as an unhelpful `busy`. + */ +const BASE = process.env.PIP_BASE || 'https://piptradedex.xyz' + +async function api(path, init) { + const r = await fetch(BASE + path, { + ...init, + headers: { accept: 'application/json', ...(init?.body ? { 'content-type': 'application/json' } : {}) }, + }) + const text = await r.text() + let body + try { body = JSON.parse(text) } catch { + // a GET on a POST-only route returns the landing page HTML at 200, not a 405 + throw new Error(`${path} returned non-JSON (status ${r.status}); check the method`) + } + return { status: r.status, body } +} + +/** human amount -> base units, exactly, without floating point drift */ +export function toBaseUnits(human, decimals) { + const s = String(human).trim() + if (!/^\d+(\.\d+)?$/.test(s)) throw new Error(`bad amount: ${human}`) + const [whole, frac = ''] = s.split('.') + if (frac.length > decimals) throw new Error(`${human} has more precision than ${decimals} decimals`) + return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt((frac + '0'.repeat(decimals)).slice(0, decimals) || '0')).toString() +} + +const pick = (tokens, sym, chain) => { + const hit = tokens.find( + (t) => String(t.symbol).toUpperCase() === sym.toUpperCase() && String(t.blockchain).toLowerCase() === chain.toLowerCase(), + ) + if (!hit) throw new Error(`${sym} on ${chain} is not listed. Check GET /api/tokens.`) + return hit +} + +async function main() { + const [amount, fromSym, fromChain, toSym, toChain] = process.argv.slice(2) + if (!amount || !fromSym || !fromChain || !toSym || !toChain) { + console.error('usage: node pip-quote.mjs ') + process.exit(2) + } + + // 1. resolve identity from the registry, never hardcode an assetId + const { body: reg } = await api('/api/tokens') + const from = pick(reg.tokens, fromSym, fromChain) + const to = pick(reg.tokens, toSym, toChain) + const base = toBaseUnits(amount, from.decimals) + console.log(`from ${from.symbol}@${from.blockchain} decimals ${from.decimals}`) + console.log(`to ${to.symbol}@${to.blockchain}`) + console.log(`amount ${amount} -> ${base} base units`) + + // 2. public safety gate. only meaningful for a contract address. + if (/^0x[0-9a-fA-F]{40}$/.test(from.contractAddress || '')) { + // ALWAYS pass chain. Without it the check runs as a Robinhood Chain sell and a + // perfectly good Base token comes back sellable:null/unverifiable (a false CAUTION). + const { body: sc } = await api(`/api/rh/sellcheck?address=${from.contractAddress}&chain=${encodeURIComponent(from.blockchain)}`) + const verdict = sc.sellable === true ? 'GO' : sc.sellable === false ? 'BLOCK' : 'CAUTION' + console.log(`safety ${verdict} sellable=${sc.sellable}${sc.reason ? ` reason=${sc.reason}` : ''}`) + // absence of evidence is CAUTION, never GO + if (verdict === 'BLOCK') { console.error('BLOCK is a hard stop. Not quoting.'); process.exit(1) } + } + + // 3. dry quote. no session needed. + const { body: q } = await api('/api/quote', { + method: 'POST', + body: JSON.stringify({ originAsset: from.assetId, destinationAsset: to.assetId, amount: base }), + }) + if (!q.ok) { + // branch on `ok` and `error`, never the HTTP status + const hint = q.error === 'busy' ? ' (check base units first)' : '' + console.error(`quote failed: ${q.error}${hint}`) + if (q.min) console.error(`minimum: ${q.min.amount} ${q.min.sym} on ${q.min.chain}`) + process.exit(1) + } + console.log(`\nout ${q.amountOutFormatted} ${to.symbol} ($${Number(q.amountOutUsd).toFixed(4)})`) + console.log(`in ${q.amountInFormatted} ${from.symbol} ($${Number(q.amountInUsd).toFixed(4)})`) + console.log(`min ${q.minAmountOut} base units of ${to.symbol}`) + console.log(`eta ${q.timeEstimate}s`) + console.log(`private ${q.private}`) + console.log('\nThis is a quote only. The user signs at ' + BASE + '/app to execute.') +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((e) => { console.error(String(e.message)); process.exit(1) }) +} From cdfe8cc68330cbd67d076ac95bb860b8fd6914db Mon Sep 17 00:00:00 2001 From: Smartcoded Date: Fri, 7 Aug 2026 13:41:00 +0000 Subject: [PATCH 2/2] Showcase: link the example script instead of committing it Per review, showcase packages stay markdown-only. The runnable example now lives on a domain the builder controls and the skill links to it: https://piptradedex.xyz/agent/pip-quote.mjs The parts worth reading are still inline in the skill: the base-units conversion and the error branching. Only the runnable file moved out. --- .../skills/pip-trade-agent/SKILL.md | 7 +- .../pip-trade-agent/scripts/pip-quote.mjs | 97 ------------------- 2 files changed, 4 insertions(+), 100 deletions(-) delete mode 100644 showcase/pip-conversational-trader/skills/pip-trade-agent/scripts/pip-quote.mjs diff --git a/showcase/pip-conversational-trader/skills/pip-trade-agent/SKILL.md b/showcase/pip-conversational-trader/skills/pip-trade-agent/SKILL.md index e27825d..f04ab78 100644 --- a/showcase/pip-conversational-trader/skills/pip-trade-agent/SKILL.md +++ b/showcase/pip-conversational-trader/skills/pip-trade-agent/SKILL.md @@ -177,13 +177,14 @@ There is no anonymous trade endpoint. Reads and dry quotes are open to any agent ## Runnable Example -`scripts/pip-quote.mjs` does the whole read-only flow in one file: resolve two `assetId`s from `/api/tokens`, convert a human amount to base units using the real `decimals`, run the public sell check, then quote. No auth, no signing, nothing moves. +[`pip-quote.mjs`](https://piptradedex.xyz/agent/pip-quote.mjs) does the whole read-only flow in one file: resolve two `assetId`s from `/api/tokens`, convert a human amount to base units using the real `decimals`, run the public sell check, then quote. No auth, no signing, nothing moves. ```bash -node scripts/pip-quote.mjs 5 USDC base ETH base +curl -sO https://piptradedex.xyz/agent/pip-quote.mjs +node pip-quote.mjs 5 USDC base ETH base ``` -Read it before writing your own client. It encodes the base-units conversion and the error branching that this API actually requires. +Read it before writing your own client. It encodes the exact base-units conversion and the error branching this API requires, both of which are easy to get wrong. ## Safety Invariants diff --git a/showcase/pip-conversational-trader/skills/pip-trade-agent/scripts/pip-quote.mjs b/showcase/pip-conversational-trader/skills/pip-trade-agent/scripts/pip-quote.mjs deleted file mode 100644 index eb7aba2..0000000 --- a/showcase/pip-conversational-trader/skills/pip-trade-agent/scripts/pip-quote.mjs +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env node -/* - * PipTrade Agent, the whole read-only flow in one file. - * - * node pip-quote.mjs - * node pip-quote.mjs 5 USDC base ETH base - * - * Resolves both assetIds from the live registry, converts a HUMAN amount to base - * units using the real decimals, runs the public sell check, then quotes. - * No auth, no signing, nothing moves. - * - * The base-units conversion is the point. Sending "5" instead of "5000000" for a - * 6dp token quotes 0.000005 and fails as an unhelpful `busy`. - */ -const BASE = process.env.PIP_BASE || 'https://piptradedex.xyz' - -async function api(path, init) { - const r = await fetch(BASE + path, { - ...init, - headers: { accept: 'application/json', ...(init?.body ? { 'content-type': 'application/json' } : {}) }, - }) - const text = await r.text() - let body - try { body = JSON.parse(text) } catch { - // a GET on a POST-only route returns the landing page HTML at 200, not a 405 - throw new Error(`${path} returned non-JSON (status ${r.status}); check the method`) - } - return { status: r.status, body } -} - -/** human amount -> base units, exactly, without floating point drift */ -export function toBaseUnits(human, decimals) { - const s = String(human).trim() - if (!/^\d+(\.\d+)?$/.test(s)) throw new Error(`bad amount: ${human}`) - const [whole, frac = ''] = s.split('.') - if (frac.length > decimals) throw new Error(`${human} has more precision than ${decimals} decimals`) - return (BigInt(whole) * 10n ** BigInt(decimals) + BigInt((frac + '0'.repeat(decimals)).slice(0, decimals) || '0')).toString() -} - -const pick = (tokens, sym, chain) => { - const hit = tokens.find( - (t) => String(t.symbol).toUpperCase() === sym.toUpperCase() && String(t.blockchain).toLowerCase() === chain.toLowerCase(), - ) - if (!hit) throw new Error(`${sym} on ${chain} is not listed. Check GET /api/tokens.`) - return hit -} - -async function main() { - const [amount, fromSym, fromChain, toSym, toChain] = process.argv.slice(2) - if (!amount || !fromSym || !fromChain || !toSym || !toChain) { - console.error('usage: node pip-quote.mjs ') - process.exit(2) - } - - // 1. resolve identity from the registry, never hardcode an assetId - const { body: reg } = await api('/api/tokens') - const from = pick(reg.tokens, fromSym, fromChain) - const to = pick(reg.tokens, toSym, toChain) - const base = toBaseUnits(amount, from.decimals) - console.log(`from ${from.symbol}@${from.blockchain} decimals ${from.decimals}`) - console.log(`to ${to.symbol}@${to.blockchain}`) - console.log(`amount ${amount} -> ${base} base units`) - - // 2. public safety gate. only meaningful for a contract address. - if (/^0x[0-9a-fA-F]{40}$/.test(from.contractAddress || '')) { - // ALWAYS pass chain. Without it the check runs as a Robinhood Chain sell and a - // perfectly good Base token comes back sellable:null/unverifiable (a false CAUTION). - const { body: sc } = await api(`/api/rh/sellcheck?address=${from.contractAddress}&chain=${encodeURIComponent(from.blockchain)}`) - const verdict = sc.sellable === true ? 'GO' : sc.sellable === false ? 'BLOCK' : 'CAUTION' - console.log(`safety ${verdict} sellable=${sc.sellable}${sc.reason ? ` reason=${sc.reason}` : ''}`) - // absence of evidence is CAUTION, never GO - if (verdict === 'BLOCK') { console.error('BLOCK is a hard stop. Not quoting.'); process.exit(1) } - } - - // 3. dry quote. no session needed. - const { body: q } = await api('/api/quote', { - method: 'POST', - body: JSON.stringify({ originAsset: from.assetId, destinationAsset: to.assetId, amount: base }), - }) - if (!q.ok) { - // branch on `ok` and `error`, never the HTTP status - const hint = q.error === 'busy' ? ' (check base units first)' : '' - console.error(`quote failed: ${q.error}${hint}`) - if (q.min) console.error(`minimum: ${q.min.amount} ${q.min.sym} on ${q.min.chain}`) - process.exit(1) - } - console.log(`\nout ${q.amountOutFormatted} ${to.symbol} ($${Number(q.amountOutUsd).toFixed(4)})`) - console.log(`in ${q.amountInFormatted} ${from.symbol} ($${Number(q.amountInUsd).toFixed(4)})`) - console.log(`min ${q.minAmountOut} base units of ${to.symbol}`) - console.log(`eta ${q.timeEstimate}s`) - console.log(`private ${q.private}`) - console.log('\nThis is a quote only. The user signs at ' + BASE + '/app to execute.') -} - -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((e) => { console.error(String(e.message)); process.exit(1) }) -}