From 67022079f42a4f384ad7013cea0f6b50491336f1 Mon Sep 17 00:00:00 2001 From: Ran Hammer Date: Mon, 30 Mar 2026 12:53:45 +0100 Subject: [PATCH] Add spot-advanced-swap-orders skill Gasless, oracle-protected DeFi swap orders (market, limit, TWAP, stop-loss, take-profit, delayed-start) on 8 EVM chains. Non-custodial, audited contracts. Canonical source: https://github.com/orbs-network/spot npm: @orbs-network/spot --- .../spot-advanced-swap-orders/LICENSE.txt | 21 + .../spot-advanced-swap-orders/SKILL.md | 63 + .../agents/openai.yaml | 3 + .../assets/repermit.skeleton.json | 195 +++ .../assets/token-addressbook.md | 97 ++ .../assets/web3-sign-and-submit.example.js | 111 ++ .../spot-advanced-swap-orders/manifest.json | 63 + .../references/01-quickstart.md | 12 + .../references/02-params.md | 39 + .../references/03-sign.md | 6 + .../references/04-patterns.md | 12 + .../scripts/order.js | 1202 +++++++++++++++++ 12 files changed, 1824 insertions(+) create mode 100644 skills/.curated/spot-advanced-swap-orders/LICENSE.txt create mode 100644 skills/.curated/spot-advanced-swap-orders/SKILL.md create mode 100644 skills/.curated/spot-advanced-swap-orders/agents/openai.yaml create mode 100644 skills/.curated/spot-advanced-swap-orders/assets/repermit.skeleton.json create mode 100644 skills/.curated/spot-advanced-swap-orders/assets/token-addressbook.md create mode 100644 skills/.curated/spot-advanced-swap-orders/assets/web3-sign-and-submit.example.js create mode 100644 skills/.curated/spot-advanced-swap-orders/manifest.json create mode 100644 skills/.curated/spot-advanced-swap-orders/references/01-quickstart.md create mode 100644 skills/.curated/spot-advanced-swap-orders/references/02-params.md create mode 100644 skills/.curated/spot-advanced-swap-orders/references/03-sign.md create mode 100644 skills/.curated/spot-advanced-swap-orders/references/04-patterns.md create mode 100644 skills/.curated/spot-advanced-swap-orders/scripts/order.js diff --git a/skills/.curated/spot-advanced-swap-orders/LICENSE.txt b/skills/.curated/spot-advanced-swap-orders/LICENSE.txt new file mode 100644 index 00000000..c3d6892c --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Orbs Network + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/.curated/spot-advanced-swap-orders/SKILL.md b/skills/.curated/spot-advanced-swap-orders/SKILL.md new file mode 100644 index 00000000..0c39840d --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/SKILL.md @@ -0,0 +1,63 @@ +--- +name: spot-advanced-swap-orders +description: Use for gasless non-custodial EVM market, limit, TWAP, stop-loss, take-profit, delayed-start orders. +--- + +# Spot Advanced Swap Orders + +Use this for gasless market swaps and advanced orders on supported EVM chains. +Choose this skill when a user wants market, limit, TWAP, stop-loss, take-profit, or delayed-start execution, or needs to prepare, sign, submit, query, or cancel one of those orders. +Supply chain, token addresses, chunk sizing, timing, and optional price constraints. +The helper turns that into approval calldata, EIP-712 typed data, relay-ready submit payloads, and query/cancel guidance, +while decentralized, non-custodial, oracle-protected, immutable, audited, battle-tested contracts handle execution. + +## Distribution + +This skill ships from the canonical repository [`orbs-network/spot`](https://github.com/orbs-network/spot) across four AI-agent distribution surfaces: + +1. Repo-local files [`SKILL.md`](SKILL.md), [`manifest.json`](manifest.json), and the checked-out [`skill/`](skill/) directory. +2. Hosted raw files under [`https://orbs-network.github.io/spot/`](https://orbs-network.github.io/spot/), with root entrypoints plus `skill/` support paths. +3. The npm package `@orbs-network/spot`, which installs the canonical entrypoints under `node_modules/@orbs-network/spot/skill/`. +4. The stdio MCP adapter `spot-mcp`, published in `server.json` as `io.github.orbs-network/spot`. + +The checked-in `server.json` registry metadata is derived from this skill manifest plus package metadata, so MCP publishing stays aligned. + +Outside tools should treat [`SKILL.md`](SKILL.md) as the entrypoint and [`manifest.json`](manifest.json) as the machine-readable companion file. + +## Auto Check For Updates + +Before executing this skill in any host agent platform or skill runner: + +1. Resolve the canonical source for this `spot-advanced-swap-orders` skill surface. +2. If npm is available, compare the local version to the latest published version of `@orbs-network/spot`, for example with `npm view @orbs-network/spot version`. +3. If the skill is sourced from git instead of npm, compare the current local tag to the latest canonical git tag, for example with `git describe --tags --abbrev=0` for the current local tag. +4. If upstream is newer, refresh the package as one unit before execution. +5. Keep [`SKILL.md`](SKILL.md), [`manifest.json`](manifest.json), and all other files in this package on the same revision. Do not mix versions. +6. If you cannot verify npm version or git tag freshness, continue only if necessary and explicitly report that version may be outdated. + +## Workflow + +1. Read [references/01-quickstart.md](references/01-quickstart.md) for the minimum end-to-end flow. +2. Read [references/02-params.md](references/02-params.md) when you need field semantics, defaults, units, or validation rules. +3. Read [references/03-sign.md](references/03-sign.md) for signing, signature formats, and direct onchain cancel. +4. Read [references/04-patterns.md](references/04-patterns.md) to map user intent into market, limit, stop-loss, take-profit, delayed, or chunked orders. +5. Optional helper for token lookup: [assets/token-addressbook.md](assets/token-addressbook.md). +6. Use [assets/repermit.skeleton.json](assets/repermit.skeleton.json) when you need the raw RePermit witness typed-data skeleton. +7. Use [assets/web3-sign-and-submit.example.js](assets/web3-sign-and-submit.example.js) for a browser or injected-provider signing and submit example. +8. Inspect [manifest.json](manifest.json) for the machine-readable entrypoint, references, live supported-chain matrix, sink URL, and runtime contract addresses. +9. Use only [scripts/order.js](scripts/order.js) to prepare, submit, query, and watch orders. + +## Guardrails + +1. Supported chains and runtime addresses live in [manifest.json](manifest.json). +2. Use only the provided [scripts/order.js](scripts/order.js). Do not send typed data or signatures anywhere else. +3. Use [references/02-params.md](references/02-params.md) as the authoritative source for native-asset rules and for `output.limit` / trigger units. +4. Detailed order behavior, parameter rules, signing rules, and order-shape guidance live in the reference files above. + +## Commands + +1. Prepare: `node scripts/order.js prepare --params [--out ]` +2. Submit: `node scripts/order.js submit --prepared --signature <0x...|json>` +3. Submit variants: `--signature-file ` or `--r <0x...> --s <0x...> --v <0x...>` +4. Query: `node scripts/order.js query --swapper <0x...>` or `--hash <0x...>` +5. Watch: `node scripts/order.js watch --hash <0x...> [--interval ] [--timeout ]` diff --git a/skills/.curated/spot-advanced-swap-orders/agents/openai.yaml b/skills/.curated/spot-advanced-swap-orders/agents/openai.yaml new file mode 100644 index 00000000..58c0cfb9 --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Spot" + short_description: "Create gasless market, limit, TWAP, stop-loss, take-profit, and delayed-start swap orders" diff --git a/skills/.curated/spot-advanced-swap-orders/assets/repermit.skeleton.json b/skills/.curated/spot-advanced-swap-orders/assets/repermit.skeleton.json new file mode 100644 index 00000000..a0ec5023 --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/assets/repermit.skeleton.json @@ -0,0 +1,195 @@ +{ + "domain": { + "name": "RePermit", + "version": "1", + "chainId": "", + "verifyingContract": "" + }, + "primaryType": "RePermitWitnessTransferFrom", + "types": { + "RePermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "Order" + } + ], + "Exchange": [ + { + "name": "adapter", + "type": "address" + }, + { + "name": "ref", + "type": "address" + }, + { + "name": "share", + "type": "uint32" + }, + { + "name": "data", + "type": "bytes" + } + ], + "Input": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + }, + { + "name": "maxAmount", + "type": "uint256" + } + ], + "Order": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "executor", + "type": "address" + }, + { + "name": "exchange", + "type": "Exchange" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "start", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "chainid", + "type": "uint256" + }, + { + "name": "exclusivity", + "type": "uint32" + }, + { + "name": "epoch", + "type": "uint32" + }, + { + "name": "slippage", + "type": "uint32" + }, + { + "name": "freshness", + "type": "uint32" + }, + { + "name": "input", + "type": "Input" + }, + { + "name": "output", + "type": "Output" + } + ], + "Output": [ + { + "name": "token", + "type": "address" + }, + { + "name": "limit", + "type": "uint256" + }, + { + "name": "triggerLower", + "type": "uint256" + }, + { + "name": "triggerUpper", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ] + }, + "message": { + "permitted": { + "token": "", + "amount": "" + }, + "spender": "", + "nonce": "", + "deadline": "", + "witness": { + "reactor": "", + "executor": "", + "exchange": { + "adapter": "", + "ref": "", + "share": "", + "data": "" + }, + "swapper": "", + "nonce": "", + "start": "", + "deadline": "", + "chainid": "", + "exclusivity": "", + "epoch": "", + "slippage": "", + "freshness": "", + "input": { + "token": "", + "amount": "", + "maxAmount": "" + }, + "output": { + "token": "", + "limit": "", + "triggerLower": "", + "triggerUpper": "", + "recipient": "" + } + } + } +} diff --git a/skills/.curated/spot-advanced-swap-orders/assets/token-addressbook.md b/skills/.curated/spot-advanced-swap-orders/assets/token-addressbook.md new file mode 100644 index 00000000..4e227597 --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/assets/token-addressbook.md @@ -0,0 +1,97 @@ +# Common Token Addressbook + +## Ethereum (`1`) + +1. `weth`: `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` +2. `wbtc`: `0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599` +3. `usdc`: `0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48` +4. `usdt`: `0xdAC17F958D2ee523a2206206994597C13D831ec7` +5. `dai`: `0x6B175474E89094C44Da98b954EedeAC495271d0F` +6. `lusd`: `0x5f98805A4E8be255a32880FDeC7F6728C6568bA0` +7. `orbs`: `0xff56Cc6b1E6dEd347aA0B7676C85AB0B3D08B0FA` + +## BNB Chain (`56`) + +1. `wbnb`: `0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c` +2. `btcb`: `0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c` +3. `wbtc`: `0x0555e30da8f98308edb960aa94c0db47230d2b9c` +4. `usdc`: `0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d` +5. `usdt`: `0x55d398326f99059fF775485246999027B3197955` +6. `usd1`: `0x8d0d000ee44948fc98c9b98a4fa4921476f08b0d` +7. `dai`: `0x1AF3F329e8BE154074D8769D1FFa4eE058B1DBc3` +8. `busd`: `0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56` +9. `weth`: `0x2170Ed0880ac9A755fd29B2688956BD959F933F8` +10. `orbs`: `0x43a8cab15D06d3a5fE5854D714C37E7E9246F170` + +## Polygon (`137`) + +1. `wmatic`: `0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270` +2. `wbtc`: `0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6` +3. `usdc`: `0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359` +4. `usdc.e`: `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` +5. `usdt`: `0xc2132D05D31c914a87C6611C10748AEb04B58e8F` +6. `dai`: `0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063` +7. `weth`: `0x7ceb23fd6bc0add59e62ac25578270cff1b9f619` +8. `orbs`: `0x614389EaAE0A6821DC49062D56BDA3d9d45Fa2ff` + +## Arbitrum One (`42161`) + +1. `weth`: `0x82af49447d8a07e3bd95bd0d56f35241523fbab1` +2. `wbtc`: `0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f` +3. `usdc`: `0xaf88d065e77c8cC2239327C5EDb3A432268e5831` +4. `usdt`: `0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9` +5. `dai`: `0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1` +6. `arb`: `0x912CE59144191C1204E64559FE8253a0e49E6548` +7. `orbs`: `0xf3C091ed43de9c270593445163a41A876A0bb3dd` + +## Optimism (`10`) + +1. `weth`: `0x4200000000000000000000000000000000000006` +2. `wbtc`: `0x68f180fcCe6836688e9084f035309E29Bf0A2095` +3. `usdc`: `0x7F5c764cBc14f9669B88837ca1490cCa17c31607` +4. `usdt`: `0x94b008aA00579c1307B0EF2c499aD98a8ce58e58` +5. `dai`: `0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1` +6. `op`: `0x4200000000000000000000000000000000000042` + +## Avalanche (`43114`) + +1. `wavax`: `0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7` +2. `wbtc`: `0x50b7545627a5162F82A992c33b87aDc75187B218` +3. `usdc`: `0xA7D7079b0FEaD91F3e65f86E8915Cb59c1a4C664` +4. `usdt`: `0xc7198437980c041c805A1EDcbA50c1Ce5db95118` +5. `dai`: `0xd586E7F844cEa2F87f50152665BCbc2C279D8d70` +6. `weth`: `0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB` +7. `orbs`: `0x340fE1D898ECCAad394e2ba0fC1F93d27c7b717A` + +## Base (`8453`) + +1. `weth`: `0x4200000000000000000000000000000000000006` +2. `usdc`: `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` +3. `dai`: `0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb` +4. `cbbtc`: `0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf` + +## Mantle (`5000`) + +1. `wmnt`: `0x78c1b0C915c4FAA5FffA6CAbf0219DA63d7f4cb8` +2. `weth`: `0xdeaddeaddeaddeaddeaddeaddeaddeaddead1111` +3. `usdc`: `0x09Bc4E0D864854c6aFB6eB9A9cdF58aC190D0dF9` +4. `usdt`: `0x201eba5cc46D216Ce6DC03F6a759e8E766e956Ae` +5. `usdt0`: `0x779Ded0c9e1022225f8E0630b35a9b54bE713736` + +## Linea (`59144`) + +1. `weth`: `0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f` +2. `wbtc`: `0x3aAB2285ddcDdaD8edf438C1bAB47e1a9D05a9b4` +3. `usdc`: `0x176211869cA2b568f2A7D4EE941E073a821EE1ff` +4. `usdt`: `0xA219439258ca9da29E9Cc4cE5596924745e12B93` +5. `dai`: `0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5` + +## Sonic (`146`) + +1. `ws`: `0x039e2fB66102314Ce7b64Ce5Ce3E5183bc94aD38` +2. `wbtc`: `0x0555E30da8f98308EdB960aa94C0Db47230d2B9c` +3. `weth`: `0x50c42dEAcD8Fc9773493ED674b675bE577f2634b` +4. `usdc`: `0x29219dd400f2Bf60E5a23d13Be72B486D4038894` +5. `usdt`: `0x6047828dc181963ba44974801FF68e538dA5eaF9` +6. `sfc`: `0xFC00FACE00000000000000000000000000000000` + diff --git a/skills/.curated/spot-advanced-swap-orders/assets/web3-sign-and-submit.example.js b/skills/.curated/spot-advanced-swap-orders/assets/web3-sign-and-submit.example.js new file mode 100644 index 00000000..2f79c509 --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/assets/web3-sign-and-submit.example.js @@ -0,0 +1,111 @@ +// Example only. This flow uses eth_signTypedData_v4 via an injected provider, +// but any framework that signs the same prepared.typedData payload is valid. + +import Web3 from "web3"; + +const DEPOSIT_DATA = "0xd0e30db0"; +const ERC20_ABI = [ + { + constant: true, + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + name: "allowance", + outputs: [{ name: "", type: "uint256" }], + type: "function", + }, +]; + +export async function approveSignAndSubmitOrder({ + prepared, + account, + provider = window.ethereum, + wrappedNative, + nativeInputAmount, + sendApproval = true, +}) { + const web3 = new Web3(provider); + const normalizedAccount = account.toLowerCase(); + const expectedSigner = prepared.signing.signer.toLowerCase(); + + // The EIP-712 signer must be the same address that created the order. + if (normalizedAccount !== expectedSigner) { + throw new Error( + `signer mismatch: expected ${prepared.signing.signer}, got ${account}` + ); + } + + // Native input is not supported by the order itself. + // If the input starts as the chain's native asset, wrap it to WNATIVE first. + if ( + nativeInputAmount != null && + nativeInputAmount !== "0" && + nativeInputAmount !== "0x0" + ) { + if (!wrappedNative) { + throw new Error("wrappedNative is required when nativeInputAmount is set"); + } + + await web3.eth.sendTransaction({ + from: account, + to: wrappedNative, + data: DEPOSIT_DATA, + value: nativeInputAmount, + }); + } + + if (prepared.approval && sendApproval) { + // The skill helper is RPC-agnostic, so the caller should use its own + // provider access to avoid resending the same infinite approval. + const token = new web3.eth.Contract(ERC20_ABI, prepared.approval.tx.to); + const allowance = BigInt( + await token.methods + .allowance(account, prepared.approval.spender) + .call() + ); + + if (allowance < BigInt(prepared.approval.amount)) { + await web3.eth.sendTransaction({ + from: account, + to: prepared.approval.tx.to, + data: prepared.approval.tx.data, + value: prepared.approval.tx.value, + }); + } + } + + // This example uses eth_signTypedData_v4, but any equivalent EIP-712 signer works. + const signature = await provider.request({ + method: "eth_signTypedData_v4", + params: [account, JSON.stringify(prepared.typedData)], + }); + + // Submit the order payload plus the returned signature to the relay endpoint. + const response = await fetch(prepared.submit.url, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + ...prepared.submit.body, + signature, + }), + }); + + const text = await response.text(); + let body; + + try { + body = JSON.parse(text); + } catch { + body = text; + } + + return { + ok: response.ok, + status: response.status, + signature, + body, + }; +} diff --git a/skills/.curated/spot-advanced-swap-orders/manifest.json b/skills/.curated/spot-advanced-swap-orders/manifest.json new file mode 100644 index 00000000..6229b925 --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/manifest.json @@ -0,0 +1,63 @@ +{ + "name": "spot-advanced-swap-orders", + "title": "Spot Advanced Swap Orders", + "description": "Create and manage gasless, decentralized, non-custodial, oracle-protected market, limit, TWAP, stop-loss, take-profit, and delayed-start swap orders on supported EVM chains with immutable, audited, battle-tested contracts.", + "entrypoint": "SKILL.md", + "references": [ + "skill/references/01-quickstart.md", + "skill/references/02-params.md", + "skill/references/03-sign.md", + "skill/references/04-patterns.md" + ], + "scripts": [ + "skill/scripts/order.js" + ], + "assets": [ + "skill/assets/token-addressbook.md", + "skill/assets/repermit.skeleton.json", + "skill/assets/web3-sign-and-submit.example.js" + ], + "runtime": { + "url": "https://agents-sink.orbs.network", + "contracts": { + "zero": "0x0000000000000000000000000000000000000000", + "repermit": "0x00002a9C4D9497df5Bd31768eC5d30eEf5405000", + "reactor": "0x000000b33fE4fB9d999Dd684F79b110731c3d000", + "executor": "0x000642A0966d9bd49870D9519f76b5cf823f3000" + }, + "chains": { + "1": { + "name": "Ethereum", + "adapter": "0xC1bB4d5071Fe7109ae2D67AE05826A3fe9116cfc" + }, + "56": { + "name": "BNB Chain", + "adapter": "0x67Feba015c968c76cCB2EEabf197b4578640BE2C" + }, + "137": { + "name": "Polygon", + "adapter": "0x75A3d70Fa6d054d31C896b9Cf8AB06b1c1B829B8" + }, + "146": { + "name": "Sonic", + "adapter": "0x58fD209C81D84739BaD9c72C082350d67E713EEa" + }, + "8453": { + "name": "Base", + "adapter": "0x5906C4dD71D5afFe1a8f0215409E912eB5d593AD" + }, + "42161": { + "name": "Arbitrum One", + "adapter": "0x026B8977319F67078e932a08feAcB59182B5380f" + }, + "43114": { + "name": "Avalanche", + "adapter": "0x4F48041842827823D3750399eCa2832fC2E29201" + }, + "59144": { + "name": "Linea", + "adapter": "0x55E4da2cd634729064bEb294EC682Dc94f5c3f24" + } + } + } +} diff --git a/skills/.curated/spot-advanced-swap-orders/references/01-quickstart.md b/skills/.curated/spot-advanced-swap-orders/references/01-quickstart.md new file mode 100644 index 00000000..823feff8 --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/references/01-quickstart.md @@ -0,0 +1,12 @@ +# Quickstart + +1. Required fields: `chainId`, `swapper`, `input.token`, `input.amount`, `output.token`. +2. Choose the intended order shape before preparing: market, limit, stop-loss, take-profit, delayed-start, or chunked/TWAP. +3. Prepare the order with the helper. +4. If approval is needed, send `prepared.approval.tx` to approve `RePermit`. +5. The skill helper does not read allowance onchain. The calling agent or app should use its own RPC or provider access to compare the current ERC-20 allowance against `prepared.approval.amount`, send that infinite approval only when allowance is lower, and then leave the infinite approval in place with no reset. +6. Sign `prepared.typedData` as the `swapper`. +7. Submit the signed order. +8. Query the order by `swapper` or `hash`. +9. Watch until terminal state. Default polling is every 5 seconds, timeout `0` means no timeout, and transient network errors are retried automatically. +10. When measuring a fill onchain, sum both transfers to the swapper: the main fill and the surplus refund. Measuring only the main fill undercounts actual output by up to the slippage tolerance. diff --git a/skills/.curated/spot-advanced-swap-orders/references/02-params.md b/skills/.curated/spot-advanced-swap-orders/references/02-params.md new file mode 100644 index 00000000..eab9db05 --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/references/02-params.md @@ -0,0 +1,39 @@ +# Params + +Use this file for field semantics, defaults, units, and validation. + +1. Required: `chainId`, `swapper`, `input.token`, `input.amount`, `output.token`. +2. Optional: `input.maxAmount`, `nonce`, `start`, `deadline`, `epoch`, `slippage`, `output.limit`, `output.triggerLower`, `output.triggerUpper`, `output.recipient`. +3. `input.amount` is the fixed per-chunk size. `input.maxAmount` is the total requested size. If omitted, it defaults to `input.amount`. If it is not divisible by `input.amount`, the helper rounds it down to a whole number of chunks. +4. `output.limit`, `output.triggerLower`, and `output.triggerUpper` are output-token amounts per chunk, encoded in the output token's decimals. +5. Future `start` delays the first fill. `epoch` is the delay between chunks, but it is not exact: each chunk can fill anywhere inside its epoch window, only once. Large `epoch` is not a delayed order by itself. +6. Chunked orders should use `epoch > 0`; with `epoch = 0`, only the first chunk can fill. +7. Defaults: + - `input.maxAmount = input.amount` + - `nonce = now` + - `start = now` + - `epoch = 0` for single orders, `60` for chunked orders + - `deadline = start + 300 + chunkCount * epoch` + - `slippage = 500` + - `output.limit = 0` + - `output.recipient = swapper` +8. Higher slippage is still protected by oracle pricing and offchain executors. +9. `output.recipient` is dangerous to change away from `swapper`. +10. Native input is not supported. Wrap to WNATIVE first. Native output, including "back to native" orders, is supported directly with `output.token = 0x0000000000000000000000000000000000000000`. +11. Example: + +```json +{ + "chainId": 42161, + "swapper": "0x1111111111111111111111111111111111111111", + "input": { + "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "amount": "1000000" + }, + "output": { + "token": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", + "limit": "0" + }, + "epoch": 3600 +} +``` diff --git a/skills/.curated/spot-advanced-swap-orders/references/03-sign.md b/skills/.curated/spot-advanced-swap-orders/references/03-sign.md new file mode 100644 index 00000000..d67dbce9 --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/references/03-sign.md @@ -0,0 +1,6 @@ +# Sign And Submit + +1. Sign `prepared.typedData` with any EIP-712-capable wallet or library. The signer must equal `swapper`. `eth_signTypedData_v4` is only one example. +2. Submit with `--prepared ` and exactly one signature mode: `--signature <0x...|json>`, `--signature-file `, or `--r <0x...> --s <0x...> --v <0x...>`. +3. The helper accepts a full 65-byte signature, a JSON string, or JSON/object `r/s/v`. +4. Cancel trustlessly onchain by calling `RePermit.cancel([digest])` as the swapper for the signed RePermit digest. diff --git a/skills/.curated/spot-advanced-swap-orders/references/04-patterns.md b/skills/.curated/spot-advanced-swap-orders/references/04-patterns.md new file mode 100644 index 00000000..6d71d9af --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/references/04-patterns.md @@ -0,0 +1,12 @@ +# Order Patterns + +1. Market swap: `input.amount = input.maxAmount`, `output.limit = 0`. +2. Limit order: `input.amount = input.maxAmount`, `output.limit > 0`. +3. Stop-loss or take-profit: set `output.triggerLower` for stop-loss and/or `output.triggerUpper` for take-profit. +4. Delayed order: set future `start`. +5. Chunked or TWAP-style: set `input.amount < input.maxAmount`. +6. Time-spaced chunked order: set `epoch > 0`. For example, `epoch = 60` means one chunk can fill once anywhere inside each 60-second epoch window. +7. `N chunks`: use one TWAP order instead of manually submitting `N` separate orders. Use `input.maxAmount` as the requested total amount, set `input.amount = floor(total / N)`, and accept any rounded-down remainder as dust. +8. If timing is omitted for a chunked or TWAP order, `epoch` defaults to `60`. Single orders default to `0`. +9. Native output or back to native exposure: set `output.token = 0x0000000000000000000000000000000000000000`. +10. Best execution and oracle protection apply regardless of `output.limit`. diff --git a/skills/.curated/spot-advanced-swap-orders/scripts/order.js b/skills/.curated/spot-advanced-swap-orders/scripts/order.js new file mode 100644 index 00000000..86a68acd --- /dev/null +++ b/skills/.curated/spot-advanced-swap-orders/scripts/order.js @@ -0,0 +1,1202 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const MAX_SLIPPAGE = 5000n; +const DEF_SLIPPAGE = 500n; +const EXCLUSIVITY = 0; +const REF_SHARE = 0; +const FRESHNESS = 30; +const TTL = 300n; +const U32_MAX = 4294967295n; +const MIN_NON_ZERO_EPOCH = 31n; +const MAX_APPROVAL = (1n << 256n) - 1n; +const DEF_WATCH_INTERVAL = 5; +const DEF_WATCH_TIMEOUT = 0; +const TERMINAL_ORDER_STATUSES = new Set(['filled', 'completed', 'cancelled', 'canceled', 'expired', 'failed', 'rejected']); +const NOTE_ORACLE = 'Oracle protection applies to all order types and every chunk.'; +const NOTE_EPOCH = 'epoch is the delay between chunks, but it is not exact: one chunk can fill once anywhere inside each epoch window.'; +const NOTE_SIGN = 'Sign typedData with any EIP-712 flow. eth_signTypedData_v4 is only an example.'; +const WARN_LOW_SLIPPAGE = 'slippage below 5% can reduce fill probability. 5% is the default compromise; higher slippage still uses oracle pricing and offchain executors.'; +const WARN_RECIPIENT = 'recipient differs from swapper and is dangerous to change'; + +const SCRIPT_DIR = __dirname; +const SKILL_DIR = path.resolve(SCRIPT_DIR, '..'); +const REPO_ROOT = path.resolve(SKILL_DIR, '..'); +const SKELETON = path.join(SKILL_DIR, 'assets', 'repermit.skeleton.json'); +const MANIFEST_JSON = path.join(REPO_ROOT, 'manifest.json'); + +let runtimeConfig = null; +let skeletonCache = null; +let ZERO = ''; +let SINK = ''; +let CREATE_URL = ''; +let QUERY_URL = ''; +let REPERMIT = ''; +let REACTOR = ''; +let EXECUTOR = ''; +let SUPPORTED_CHAIN_IDS = ''; +let warnings = []; + +class CliError extends Error {} + +function die(message) { + throw new CliError(message); +} + +function lower(value) { + return String(value).toLowerCase(); +} + +function trim(value) { + return String(value ?? '').trim(); +} + +function warn(message) { + warnings.push(message); + process.stderr.write(`warning: ${message}\n`); +} + +function note(message) { + process.stderr.write(`info: ${message}\n`); +} + +function firstDefined(...values) { + for (const value of values) { + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; +} + +function isPlainObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function objectOrEmpty(value) { + return isPlainObject(value) ? value : {}; +} + +function decimalString(value, name = 'value') { + const text = trim(value); + if (!text) { + die(`${name} is required`); + } + if (/^\d+$/.test(text)) { + return BigInt(text).toString(10); + } + if (/^0[xX][0-9a-fA-F]+$/.test(text)) { + return BigInt(text).toString(10); + } + die(`${name} must be decimal or 0x integer`); +} + +function decimalOnly(value, name = 'value') { + const text = trim(value); + if (!/^\d+$/.test(text)) { + die(`${name} must be decimal`); + } + return BigInt(text); +} + +function compare(a, b) { + const left = decimalOnly(a); + const right = decimalOnly(b); + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +function eq(a, b) { + return compare(a, b) === 0; +} + +function gt(a, b) { + return compare(a, b) === 1; +} + +function add(a, b) { + return (decimalOnly(a) + decimalOnly(b)).toString(10); +} + +function subtract(a, b) { + const left = decimalOnly(a); + const right = decimalOnly(b); + if (left < right) { + die('internal subtraction underflow'); + } + return (left - right).toString(10); +} + +function multiply(a, b) { + return (decimalOnly(a) * decimalOnly(b)).toString(10); +} + +function divideAndRemainder(a, b) { + const left = decimalOnly(a); + const right = decimalOnly(b); + if (right === 0n) { + die('division by zero'); + } + return { + quotient: (left / right).toString(10), + remainder: (left % right).toString(10), + }; +} + +function ensureU32(value, name) { + if (decimalOnly(value, name) > U32_MAX) { + die(`${name} must fit in uint32`); + } +} + +function hexBody(value, name, { allowBare = false } = {}) { + const text = trim(value); + if (/^0x[0-9a-fA-F]*$/.test(text)) { + return text.slice(2); + } + if (allowBare && /^[0-9a-fA-F]+$/.test(text)) { + return text; + } + die(`${name} must be hex`); +} + +function parseAddress(value, name, allowZero = false) { + const text = `0x${hexBody(value, name)}`; + if (text.length !== 42) { + die(`${name} must be a 20-byte 0x address`); + } + if (!allowZero && ZERO && lower(text) === lower(ZERO)) { + die(`${name} cannot be zero`); + } + return text; +} + +function requireHex(value, name) { + const raw = hexBody(value, name); + if (raw.length % 2 !== 0) { + die(`${name} must be hex`); + } + return `0x${raw}`; +} + +function formatError(error) { + if (!error) { + return 'unknown error'; + } + const parts = []; + if (error.message) { + parts.push(error.message); + } + if (error.cause && error.cause.message) { + parts.push(error.cause.message); + } else if (error.cause && error.cause.code) { + parts.push(String(error.cause.code)); + } + return parts.filter(Boolean).join(': ') || 'unknown error'; +} + +function normalizeSizedHex(value, name, size) { + const raw = hexBody(value, name, { allowBare: true }); + if (raw.length !== size) { + die(`${name} must be ${size} hex chars`); + } + return `0x${raw}`; +} + +function padHex64(value, name = 'value') { + const raw = lower(hexBody(value, name, { allowBare: true })); + if (raw.length > 64) { + die(`${name} must fit in uint256`); + } + return `0x${raw.padStart(64, '0')}`; +} + +function parseOptions(args, spec, command) { + const values = Object.fromEntries(Object.values(spec).map((key) => [key, ''])); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const key = spec[arg]; + if (!key) { + die(`unknown ${command} arg: ${arg}`); + } + const value = args[index + 1]; + if (value === undefined) { + die(`${command} arg requires a value: ${arg}`); + } + values[key] = value; + index += 1; + } + return values; +} + +function countPresent(...values) { + return values.reduce((count, value) => count + (value ? 1 : 0), 0); +} + +function approveCalldata(spender, amount) { + const spenderHex = padHex64(parseAddress(spender, 'approve.spender'), 'approve.spender'); + const amountHex = padHex64(decimalOnly(amount, 'approve.amount').toString(16), 'approve.amount'); + return `0x095ea7b3${spenderHex.slice(2)}${amountHex.slice(2)}`; +} + +function normalizeSigV(value, name = 'signature.v') { + const raw = trim(value); + if (!raw) { + die(`${name} is required`); + } + + let decimal; + if (/^0[xX][0-9a-fA-F]+$/.test(raw)) { + decimal = BigInt(raw); + } else if (/^\d+$/.test(raw)) { + decimal = BigInt(raw); + } else if (/^[0-9a-fA-F]+$/.test(raw)) { + decimal = BigInt(`0x${raw}`); + } else { + die(`${name} must be 0, 1, 27, 28, or equivalent hex`); + } + + switch (decimal.toString(10)) { + case '0': + case '27': + return '0x1b'; + case '1': + case '28': + return '0x1c'; + default: + die(`${name} must be 0, 1, 27, 28, or equivalent hex`); + } +} + +function now() { + return Math.floor(Date.now() / 1000).toString(10); +} + +function iso() { + return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +function readSource(src, name) { + const source = trim(src); + if (!source) { + die(`${name} is required`); + } + if (source === '-') { + return fs.readFileSync(0, 'utf8'); + } + try { + return fs.readFileSync(source, 'utf8'); + } catch (error) { + if (error && error.code === 'ENOENT') { + die(`${name} not found: ${source}`); + } + throw error; + } +} + +function parseJson(text, name) { + try { + return JSON.parse(text); + } catch { + die(`${name} must be valid JSON`); + } +} + +function readJsonSource(src, name) { + return parseJson(readSource(src, name), name); +} + +function jsonOrText(text) { + if (text === '') { + return ''; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function writeOutput(value, outFile) { + const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2); + if (outFile) { + fs.writeFileSync(outFile, `${text}\n`); + return; + } + process.stdout.write(`${text}\n`); +} + +function loadRuntimeConfig() { + if (runtimeConfig) { + return runtimeConfig; + } + + let manifest; + try { + manifest = parseJson(fs.readFileSync(MANIFEST_JSON, 'utf8'), 'manifest'); + } catch (error) { + if (error instanceof CliError) { + throw error; + } + if (error && error.code === 'ENOENT') { + die(`skill manifest not found: ${MANIFEST_JSON}`); + } + throw error; + } + + const runtime = manifest && typeof manifest === 'object' ? manifest.runtime : null; + const contracts = runtime && typeof runtime === 'object' ? runtime.contracts : null; + const chains = runtime && typeof runtime === 'object' ? runtime.chains : null; + const url = runtime && typeof runtime.url === 'string' ? runtime.url : ''; + const zero = contracts && typeof contracts.zero === 'string' ? contracts.zero : ''; + const repermit = contracts && typeof contracts.repermit === 'string' ? contracts.repermit : ''; + const reactor = contracts && typeof contracts.reactor === 'string' ? contracts.reactor : ''; + const executor = contracts && typeof contracts.executor === 'string' ? contracts.executor : ''; + + const invalidRuntime = + !url || + !zero || + !repermit || + !reactor || + !executor || + !chains || + typeof chains !== 'object' || + Array.isArray(chains) || + Object.keys(chains).length === 0; + + if (invalidRuntime) { + die(`invalid skill manifest runtime config: ${MANIFEST_JSON}`); + } + + ZERO = parseAddress(zero, 'runtime.contracts.zero', true); + REPERMIT = parseAddress(repermit, 'runtime.contracts.repermit'); + REACTOR = parseAddress(reactor, 'runtime.contracts.reactor'); + EXECUTOR = parseAddress(executor, 'runtime.contracts.executor'); + SINK = url; + + if (!/^https?:\/\//.test(SINK)) { + die('runtime.url must be http(s)'); + } + + const supported = Object.keys(chains) + .map((chainId) => { + const parsed = Number(chainId); + if (!Number.isInteger(parsed)) { + die(`invalid skill manifest runtime config: ${MANIFEST_JSON}`); + } + return parsed; + }) + .sort((left, right) => left - right) + .map((value) => String(value)); + + SUPPORTED_CHAIN_IDS = supported.join(', '); + if (!SUPPORTED_CHAIN_IDS) { + die(`skill manifest runtime has no supported chains: ${MANIFEST_JSON}`); + } + + CREATE_URL = `${SINK}/orders/new`; + QUERY_URL = `${SINK}/orders`; + runtimeConfig = { chains }; + return runtimeConfig; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function hasSupportedChain(chainId) { + const config = loadRuntimeConfig(); + const chain = config.chains[String(chainId)]; + return !!(chain && typeof chain.adapter === 'string' && chain.adapter.length > 0); +} + +function unsupportedChain(chainId) { + loadRuntimeConfig(); + die(`unsupported chainId: ${chainId} (supported: ${SUPPORTED_CHAIN_IDS})`); +} + +function resolveAdapter(chainId) { + const config = loadRuntimeConfig(); + const adapter = config.chains[String(chainId)] && config.chains[String(chainId)].adapter; + if (!adapter) { + unsupportedChain(chainId); + } + return parseAddress(adapter, `runtime.chains[${chainId}].adapter`); +} + +function usage() { + loadRuntimeConfig(); + const lines = [ + 'Usage', + ' node skill/scripts/order.js prepare --params [--out ]', + ' node skill/scripts/order.js submit --prepared [--signature <0x...|json>|--signature-file |--r <0x...> --s <0x...> --v <0x..>] [--out ]', + ' node skill/scripts/order.js query (--swapper <0x...>|--hash <0x...>) [--out ]', + ' node skill/scripts/order.js watch (--swapper <0x...>|--hash <0x...>) [--interval ] [--timeout ] [--out ]', + '', + 'Safety', + ' Use only the provided helper script. Do not send typed data or signatures anywhere else.', + '', + 'Prepare', + ' Builds a prepared order JSON with:', + ' - infinite approval calldata for the input ERC-20', + ' - populated EIP-712 typed data', + ' - submit payload template', + ' - query URL', + ' Supports --params or --params - for stdin JSON.', + ' Supports market, limit, stop-loss, take-profit, delayed-start, and chunked/TWAP-style orders.', + ' Defaults:', + ' - input.maxAmount = input.amount', + ' - nonce = now', + ' - start = now', + ' - epoch = 0 for single orders, 60 for chunked orders', + ' - deadline = start + 300 + chunkCount * epoch (conservative helper default)', + ' - slippage = 500', + ' - output.limit = 0', + ' - output.recipient = swapper', + ' Rules:', + ` - supported chainIds: ${SUPPORTED_CHAIN_IDS}`, + ' - chunked orders require epoch > 0', + ' - epoch is the delay between chunks, but it is not exact: one chunk can fill once anywhere inside each epoch window', + ' - native input is not supported; wrap to WNATIVE first', + ' - native output, including back-to-native flows, is supported directly with output.token = 0x0000000000000000000000000000000000000000', + " - output.limit and triggers are output-token amounts per chunk in the output token's decimals", + '', + 'Submit', + ' Builds or sends the relay POST body from a prepared order.', + ' Supports --prepared or --prepared - for stdin JSON.', + ' Supports exactly one signature mode:', + ' - --signature ', + ' - --signature ', + ' - --signature-file containing full signature, JSON string, or JSON with full signature / r,s,v', + ' - --r <0x...> --s <0x...> --v <0x..>', + " All signature inputs are normalized to the relay's r/s/v object format.", + '', + 'Query', + ' Builds or sends the relay GET request.', + ' Supports only:', + ' - --swapper <0x...>', + ' - --hash <0x...>', + '', + 'Watch', + ' Polls the relay query endpoint until the order reaches a terminal status.', + ' Retries transient network errors automatically.', + ' Defaults:', + ` - interval = ${String(DEF_WATCH_INTERVAL)} seconds`, + ` - timeout = ${String(DEF_WATCH_TIMEOUT)} seconds (0 = no timeout)`, + ' Supports only:', + ' - --swapper <0x...> when exactly one order matches', + ' - --hash <0x...> (recommended)', + ]; + process.stdout.write(`${lines.join('\n')}\n`); +} + +function loadSkeleton() { + if (!skeletonCache) { + skeletonCache = parseJson(fs.readFileSync(SKELETON, 'utf8'), 'typed data skeleton'); + } + return JSON.parse(JSON.stringify(skeletonCache)); +} + +function buildTypedData( + chainId, + swapper, + nonce, + start, + deadline, + epoch, + slippage, + inputToken, + inputAmount, + inputMaxAmount, + outputToken, + outputLimit, + outputTriggerLower, + outputTriggerUpper, + outputRecipient, +) { + const typedData = loadSkeleton(); + typedData.domain.chainId = Number(chainId); + typedData.domain.verifyingContract = REPERMIT; + typedData.message.permitted.token = inputToken; + typedData.message.permitted.amount = inputMaxAmount; + typedData.message.spender = REACTOR; + typedData.message.nonce = nonce; + typedData.message.deadline = deadline; + typedData.message.witness.reactor = REACTOR; + typedData.message.witness.executor = EXECUTOR; + typedData.message.witness.exchange.adapter = resolveAdapter(chainId); + typedData.message.witness.exchange.ref = ZERO; + typedData.message.witness.exchange.share = REF_SHARE; + typedData.message.witness.exchange.data = '0x'; + typedData.message.witness.swapper = swapper; + typedData.message.witness.nonce = nonce; + typedData.message.witness.start = start; + typedData.message.witness.deadline = deadline; + typedData.message.witness.chainid = Number(chainId); + typedData.message.witness.exclusivity = EXCLUSIVITY; + typedData.message.witness.epoch = Number(epoch); + typedData.message.witness.slippage = Number(slippage); + typedData.message.witness.freshness = FRESHNESS; + typedData.message.witness.input.token = inputToken; + typedData.message.witness.input.amount = inputAmount; + typedData.message.witness.input.maxAmount = inputMaxAmount; + typedData.message.witness.output.token = outputToken; + typedData.message.witness.output.limit = outputLimit; + typedData.message.witness.output.triggerLower = outputTriggerLower; + typedData.message.witness.output.triggerUpper = outputTriggerUpper; + typedData.message.witness.output.recipient = outputRecipient; + return typedData; +} + +function signatureFieldsFromObject(parsed) { + if (typeof parsed.signature === 'string') { + return { payload: parsed.signature }; + } + if (typeof parsed.full === 'string') { + return { payload: parsed.full }; + } + const source = isPlainObject(parsed.signature) ? parsed.signature : parsed; + const r = source.r ?? ''; + const s = source.s ?? ''; + const v = source.v ?? ''; + if (!r || !s || !v) { + die('signature JSON must contain a full signature string or r, s, v'); + } + return { r, s, v, kind: 'rsv' }; +} + +function normalizeSignature(payloadInput) { + let payload = trim(payloadInput); + let r = ''; + let s = ''; + let v = ''; + let full = ''; + let kind = ''; + + if (!payload) { + die('signature input is empty'); + } + + try { + const parsed = JSON.parse(payload); + if (typeof parsed === 'string') { + payload = parsed; + } else if (isPlainObject(parsed)) { + ({ payload = payload, r = '', s = '', v = '', kind = '' } = signatureFieldsFromObject(parsed)); + } else { + die('signature JSON must contain a full signature string or r, s, v'); + } + } catch (error) { + if (error instanceof CliError) { + throw error; + } + } + + if (r || s || v) { + r = normalizeSizedHex(r, 'signature.r', 64); + s = normalizeSizedHex(s, 'signature.s', 64); + v = normalizeSigV(v, 'signature.v'); + full = `${r}${s.slice(2)}${v.slice(2)}`; + } else { + let signature = trim(payload); + if (!/^(?:0x)?[0-9a-fA-F]{130}$/.test(signature)) { + die('signature must be full hex, a JSON string, or r/s/v JSON'); + } + if (!signature.startsWith('0x')) { + signature = `0x${signature}`; + } + full = signature; + r = `0x${signature.slice(2, 66)}`; + s = `0x${signature.slice(66, 130)}`; + v = normalizeSigV(`0x${signature.slice(130, 132)}`, 'signature.v'); + if (!kind) { + kind = 'full'; + } + } + + return { + kind, + full, + signature: { r, s, v }, + }; +} + +function prepare(args) { + loadRuntimeConfig(); + + warnings = []; + + const { paramsSource, outFile } = parseOptions( + args, + { '--params': 'paramsSource', '--out': 'outFile' }, + 'prepare', + ); + + const params = readJsonSource(paramsSource, 'params'); + const input = objectOrEmpty(params.input); + const output = objectOrEmpty(params.output); + const nowTs = now(); + const chainId = decimalString(firstDefined(params.chainId, params.chainID), 'chainId'); + if (!hasSupportedChain(chainId)) { + unsupportedChain(chainId); + } + + const swapper = parseAddress(firstDefined(params.swapper, params.account, params.signer), 'swapper'); + const nonce = decimalString(firstDefined(params.nonce, nowTs), 'nonce'); + const start = decimalString(firstDefined(params.start, nowTs), 'start'); + const slippage = decimalString(firstDefined(params.slippage, DEF_SLIPPAGE.toString(10)), 'slippage'); + const inputToken = parseAddress(firstDefined(input.token, params.inputToken), 'input.token'); + const inputAmount = decimalString(firstDefined(input.amount, params.inputAmount), 'input.amount'); + let inputMaxAmount = decimalString( + firstDefined(input.maxAmount, params.inputMaxAmount, inputAmount), + 'input.maxAmount', + ); + const outputToken = parseAddress(firstDefined(output.token, params.outputToken), 'output.token', true); + const outputLimit = decimalString(firstDefined(output.limit, params.outputLimit, '0'), 'output.limit'); + const outputTriggerLower = decimalString( + firstDefined(output.triggerLower, params.outputTriggerLower, '0'), + 'output.triggerLower', + ); + const outputTriggerUpper = decimalString( + firstDefined(output.triggerUpper, params.outputTriggerUpper, '0'), + 'output.triggerUpper', + ); + const recipient = parseAddress(firstDefined(output.recipient, params.recipient, swapper), 'output.recipient'); + + let epoch; + if (params.epoch !== undefined && params.epoch !== null) { + epoch = decimalString(params.epoch, 'epoch'); + } else { + epoch = eq(inputAmount, inputMaxAmount) ? '0' : '60'; + } + + ensureU32(epoch, 'epoch'); + ensureU32(slippage, 'slippage'); + + if (eq(start, '0')) { + die('start must be non-zero'); + } + if (eq(inputAmount, '0')) { + die('input.amount must be non-zero'); + } + if (gt(inputAmount, inputMaxAmount)) { + die('input.amount cannot exceed input.maxAmount'); + } + if (lower(inputToken) === lower(outputToken)) { + die('input.token and output.token must differ'); + } + if (!eq(outputTriggerUpper, '0') && gt(outputTriggerLower, outputTriggerUpper)) { + die('output.triggerLower cannot exceed output.triggerUpper'); + } + if (gt(slippage, MAX_SLIPPAGE.toString(10))) { + die(`slippage cannot exceed ${MAX_SLIPPAGE.toString(10)}`); + } + if (!eq(epoch, '0') && compare(epoch, MIN_NON_ZERO_EPOCH.toString()) === -1) { + die(`non-zero epoch must be >= ${MIN_NON_ZERO_EPOCH.toString()} because helper freshness is ${String(FRESHNESS)}`); + } + if (!eq(epoch, '0') && compare(String(FRESHNESS), epoch) !== -1) { + die('freshness must be < epoch when epoch != 0'); + } + + const requestedInputMaxAmount = inputMaxAmount; + const chunking = divideAndRemainder(inputMaxAmount, inputAmount); + const chunkCount = chunking.quotient; + const remainder = chunking.remainder; + + if (!eq(remainder, '0')) { + inputMaxAmount = subtract(inputMaxAmount, remainder); + warn( + `input.maxAmount is not divisible by input.amount; rounding down from ${requestedInputMaxAmount} to ${inputMaxAmount} to keep fixed chunk sizes`, + ); + } + + if (!eq(inputAmount, inputMaxAmount) && eq(epoch, '0')) { + die(`chunked orders require epoch >= ${MIN_NON_ZERO_EPOCH.toString()}`); + } + + const kind = eq(inputAmount, inputMaxAmount) ? 'single' : 'chunked'; + let deadline; + if (params.deadline !== undefined && params.deadline !== null) { + deadline = decimalString(params.deadline, 'deadline'); + } else { + deadline = add(start, TTL.toString(10)); + if (gt(epoch, '0')) { + deadline = add(deadline, multiply(chunkCount, epoch)); + } + } + + if (gt(start, nowTs)) { + if (!gt(deadline, start)) { + die('deadline must be after start'); + } + } else if (!gt(deadline, nowTs)) { + die('deadline must be after current time'); + } + + if (compare(slippage, DEF_SLIPPAGE.toString(10)) === -1) { + warn(WARN_LOW_SLIPPAGE); + } + if (lower(recipient) !== lower(swapper)) { + warn(WARN_RECIPIENT); + } + + const approvalAmount = MAX_APPROVAL.toString(10); + const approvalData = requireHex(approveCalldata(REPERMIT, approvalAmount), 'approval.tx.data'); + const typedData = buildTypedData( + chainId, + swapper, + nonce, + start, + deadline, + epoch, + slippage, + inputToken, + inputAmount, + inputMaxAmount, + outputToken, + outputLimit, + outputTriggerLower, + outputTriggerUpper, + recipient, + ); + + const prepared = { + meta: { + preparedAt: iso(), + kind, + chunkCount, + chunkInputAmount: inputAmount, + start, + deadline, + epoch, + epochScheduling: NOTE_EPOCH, + limit: outputLimit, + oracleProtection: NOTE_ORACLE, + }, + warnings, + approval: { + token: inputToken, + spender: REPERMIT, + amount: approvalAmount, + tx: { + to: inputToken, + data: approvalData, + value: '0x0', + }, + }, + typedData, + signing: { + signer: swapper, + note: NOTE_SIGN, + }, + submit: { + url: CREATE_URL, + body: { + order: typedData.message, + signature: { + r: null, + s: null, + v: null, + }, + status: 'pending', + }, + }, + query: { + url: QUERY_URL, + }, + }; + + writeOutput(prepared, outFile); + return 0; +} + +function selectOrderPayload(prepared) { + if (prepared && prepared.submit && prepared.submit.body && prepared.submit.body.order) { + return prepared.submit.body.order; + } + if (prepared && prepared.typedData && prepared.typedData.message) { + return prepared.typedData.message; + } + if (prepared && prepared.domain && prepared.types && prepared.message) { + return prepared.message; + } + die('missing order payload'); +} + +async function requestJson(url, options) { + let response; + try { + response = await fetch(url, options); + } catch (error) { + die(`request failed: ${formatError(error)}`); + } + const text = await response.text(); + return { + ok: response.ok, + status: response.status, + response: jsonOrText(text), + }; +} + +function secondsOption(value, name, fallback) { + const selected = trim(value) ? value : fallback; + const parsed = decimalString(selected, name); + ensureU32(parsed, name); + return Number(parsed); +} + +function buildQueryUrl(rawSwapper, rawHash) { + let swapper = trim(rawSwapper); + let hash = trim(rawHash); + + if (!swapper && !hash) { + die('query needs --swapper or --hash'); + } + + let url = QUERY_URL; + if (swapper) { + swapper = parseAddress(swapper, 'swapper'); + url = `${url}?swapper=${encodeURIComponent(swapper)}`; + } + if (hash) { + if (!/^0x[0-9a-fA-F]{64}$/.test(hash)) { + die('hash must be 32-byte 0x hex'); + } + url = url.includes('?') ? `${url}&hash=${encodeURIComponent(hash)}` : `${url}?hash=${encodeURIComponent(hash)}`; + } + + return { swapper, hash, url }; +} + +function responseOrders(response) { + return isPlainObject(response) && Array.isArray(response.orders) ? response.orders : []; +} + +function responseOrderHash(response) { + if (!isPlainObject(response)) { + return ''; + } + + const direct = trim(response.orderHash); + if (/^0x[0-9a-fA-F]{64}$/.test(direct)) { + return direct; + } + + const signedOrder = objectOrEmpty(response.signedOrder); + const nested = trim(signedOrder.hash); + if (/^0x[0-9a-fA-F]{64}$/.test(nested)) { + return nested; + } + + return ''; +} + +function submitOutput(result, request) { + const orderHash = result.ok ? responseOrderHash(result.response) : ''; + const watch = orderHash + ? { + hash: orderHash, + command: `node skill/scripts/order.js watch --hash ${orderHash}`, + url: `${QUERY_URL}?hash=${encodeURIComponent(orderHash)}`, + } + : null; + + return { + ok: result.ok, + status: result.status, + url: request.url, + request, + response: result.response, + orderHash, + watch, + }; +} + +function watchSnapshot(response, { hash }) { + const orders = responseOrders(response); + if (!hash && orders.length > 1) { + die('watch with --swapper requires exactly one matching order; use --hash to disambiguate'); + } + + const order = isPlainObject(orders[0]) ? orders[0] : null; + const metadata = objectOrEmpty(order && order.metadata); + const status = trim(firstDefined(metadata.status, order && order.status)); + const chunkStatuses = Array.isArray(metadata.chunks) + ? metadata.chunks.map((chunk) => trim(chunk && chunk.status)).filter(Boolean) + : []; + + return { + count: orders.length, + status, + chunkStatuses, + }; +} + +function watchOutput(result, url, watchMeta) { + return { + ok: result.ok, + status: result.status, + url, + response: result.response, + watch: watchMeta, + }; +} + +function buildWatchMeta({ + polls, + queryErrors, + intervalSeconds, + timeoutSeconds, + startedAt, + finalStatus, + chunkStatuses, + timedOut, + lastError, +}) { + return { + command: 'watch', + polls, + queryErrors, + intervalSeconds, + timeoutSeconds, + elapsedSeconds: Math.floor((Date.now() - startedAt) / 1000), + finalStatus, + chunkStatuses, + timedOut, + lastError, + }; +} + +async function submit(args) { + loadRuntimeConfig(); + + const { preparedSource, signatureInput, signatureFile, r, s, v, outFile } = parseOptions( + args, + { + '--prepared': 'preparedSource', + '--signature': 'signatureInput', + '--signature-file': 'signatureFile', + '--r': 'r', + '--s': 's', + '--v': 'v', + '--out': 'outFile', + }, + 'submit', + ); + + if (preparedSource === '-' && signatureFile === '-') { + die('submit supports only one stdin source'); + } + + const prepared = readJsonSource(preparedSource, 'prepared'); + if (countPresent(signatureInput, signatureFile, r || s || v) !== 1) { + die('submit needs exactly one of --signature, --signature-file, or --r/--s/--v'); + } + + let normalizedSignature; + if (signatureFile) { + normalizedSignature = normalizeSignature(readSource(signatureFile, 'signature-file')); + } else if (signatureInput) { + normalizedSignature = normalizeSignature(signatureInput); + } else { + if (!r || !s || !v) { + die('--r --s --v must be used together'); + } + normalizedSignature = normalizeSignature(JSON.stringify({ r, s, v })); + } + + const request = { + url: (prepared.submit && prepared.submit.url) || CREATE_URL, + body: { + order: selectOrderPayload(prepared), + signature: normalizedSignature.signature, + status: (prepared.submit && prepared.submit.body && prepared.submit.body.status) || 'pending', + }, + signatureInput: normalizedSignature.kind, + }; + + const result = await requestJson(request.url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(request.body), + }); + + writeOutput(submitOutput(result, request), outFile); + + return result.ok ? 0 : 1; +} + +async function query(args) { + loadRuntimeConfig(); + + const { swapper: rawSwapper, hash: rawHash, outFile } = parseOptions( + args, + { '--swapper': 'swapper', '--hash': 'hash', '--out': 'outFile' }, + 'query', + ); + const { url } = buildQueryUrl(rawSwapper, rawHash); + + const result = await requestJson(url, { method: 'GET' }); + writeOutput( + { + ok: result.ok, + status: result.status, + url, + response: result.response, + }, + outFile, + ); + + return result.ok ? 0 : 1; +} + +async function watchOrder(args) { + loadRuntimeConfig(); + const watchCommand = 'watch'; + + const { swapper: rawSwapper, hash: rawHash, interval, timeout, outFile } = parseOptions( + args, + { + '--swapper': 'swapper', + '--hash': 'hash', + '--interval': 'interval', + '--timeout': 'timeout', + '--out': 'outFile', + }, + watchCommand, + ); + + const intervalSeconds = secondsOption(interval, 'interval', String(DEF_WATCH_INTERVAL)); + const timeoutSeconds = secondsOption(timeout, 'timeout', String(DEF_WATCH_TIMEOUT)); + const { hash, url } = buildQueryUrl(rawSwapper, rawHash); + const startedAt = Date.now(); + let polls = 0; + let queryErrors = 0; + let lastResult = { + ok: false, + status: 0, + response: null, + }; + let lastError = ''; + + while (true) { + polls += 1; + try { + lastResult = await requestJson(url, { method: 'GET' }); + lastError = ''; + } catch (error) { + queryErrors += 1; + lastError = formatError(error); + note(`${watchCommand} retry ${String(queryErrors)} after ${lastError}`); + if (timeoutSeconds !== 0 && Date.now() - startedAt >= timeoutSeconds * 1000) { + const waitMeta = buildWatchMeta({ + polls, + queryErrors, + intervalSeconds, + timeoutSeconds, + startedAt, + finalStatus: '', + chunkStatuses: [], + timedOut: true, + lastError, + }); + writeOutput(watchOutput(lastResult, url, waitMeta), outFile); + return 1; + } + await sleep(intervalSeconds * 1000); + continue; + } + + if (!lastResult.ok) { + queryErrors += 1; + lastError = `query returned HTTP ${String(lastResult.status)}`; + note(`${watchCommand} retry ${String(queryErrors)} after ${lastError}`); + } else { + const snapshot = watchSnapshot(lastResult.response, { hash }); + const finalStatus = snapshot.status || ''; + const chunkStatuses = snapshot.chunkStatuses; + note(`watch status=${finalStatus || 'pending'} chunks=${chunkStatuses.join(',') || '-'}`); + + if (TERMINAL_ORDER_STATUSES.has(lower(finalStatus))) { + const waitMeta = buildWatchMeta({ + polls, + queryErrors, + intervalSeconds, + timeoutSeconds, + startedAt, + finalStatus, + chunkStatuses, + timedOut: false, + lastError, + }); + writeOutput(watchOutput(lastResult, url, waitMeta), outFile); + return 0; + } + } + + if (timeoutSeconds !== 0 && Date.now() - startedAt >= timeoutSeconds * 1000) { + const snapshot = lastResult.ok ? watchSnapshot(lastResult.response, { hash }) : { status: '', chunkStatuses: [] }; + const waitMeta = buildWatchMeta({ + polls, + queryErrors, + intervalSeconds, + timeoutSeconds, + startedAt, + finalStatus: snapshot.status, + chunkStatuses: snapshot.chunkStatuses, + timedOut: true, + lastError, + }); + writeOutput(watchOutput(lastResult, url, waitMeta), outFile); + return 1; + } + + await sleep(intervalSeconds * 1000); + } +} + +async function run() { + const args = process.argv.slice(2); + const command = args[0] || ''; + + if (!command || command === 'help' || command === '--help' || command === '-h') { + usage(); + return command ? 0 : 1; + } + + switch (command) { + case 'prepare': + return prepare(args.slice(1)); + case 'submit': + return submit(args.slice(1)); + case 'query': + return query(args.slice(1)); + case 'watch': + return watchOrder(args.slice(1)); + default: + usage(); + return 1; + } +} + +run() + .then((code) => { + process.exitCode = code; + }) + .catch((error) => { + if (error instanceof CliError) { + process.stderr.write(`error: ${error.message}\n`); + process.exitCode = 1; + return; + } + throw error; + });