From 72c717e18d07e9ba32ff0f893948ed024ea9f537 Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Fri, 19 Jun 2026 01:38:42 +0200 Subject: [PATCH 01/10] + --- examples/market-maker-bot/.env.example | 22 + examples/market-maker-bot/.gitignore | 1 + examples/market-maker-bot/ARCHITECTURE.md | 47 ++ examples/market-maker-bot/PLAN.md | 38 ++ examples/market-maker-bot/PROGRESS.md | 40 ++ examples/market-maker-bot/Readme.md | 210 ++++++++ examples/market-maker-bot/index.html | 12 + examples/market-maker-bot/package.json | 24 + examples/market-maker-bot/src/App.tsx | 125 +++++ .../src/components/Panels.tsx | 460 ++++++++++++++++++ .../src/hooks/useMarketMakerBot.ts | 346 +++++++++++++ examples/market-maker-bot/src/lib/client.ts | 159 ++++++ examples/market-maker-bot/src/lib/config.ts | 119 +++++ .../market-maker-bot/src/lib/execution.ts | 183 +++++++ .../market-maker-bot/src/lib/orderBook.ts | 89 ++++ examples/market-maker-bot/src/lib/orders.ts | 51 ++ examples/market-maker-bot/src/lib/strategy.ts | 216 ++++++++ examples/market-maker-bot/src/lib/tokens.ts | 119 +++++ examples/market-maker-bot/src/lib/trades.ts | 33 ++ examples/market-maker-bot/src/lib/types.ts | 217 +++++++++ .../market-maker-bot/src/lib/utxoBranches.ts | 109 +++++ .../market-maker-bot/src/lib/walletStore.ts | 167 +++++++ examples/market-maker-bot/src/main.tsx | 10 + examples/market-maker-bot/src/styles.css | 372 ++++++++++++++ examples/market-maker-bot/tsconfig.json | 22 + examples/market-maker-bot/tsconfig.node.json | 11 + examples/market-maker-bot/vite.config.ts | 12 + package.json | 1 + 28 files changed, 3215 insertions(+) create mode 100644 examples/market-maker-bot/.env.example create mode 100644 examples/market-maker-bot/.gitignore create mode 100644 examples/market-maker-bot/ARCHITECTURE.md create mode 100644 examples/market-maker-bot/PLAN.md create mode 100644 examples/market-maker-bot/PROGRESS.md create mode 100644 examples/market-maker-bot/Readme.md create mode 100644 examples/market-maker-bot/index.html create mode 100644 examples/market-maker-bot/package.json create mode 100644 examples/market-maker-bot/src/App.tsx create mode 100644 examples/market-maker-bot/src/components/Panels.tsx create mode 100644 examples/market-maker-bot/src/hooks/useMarketMakerBot.ts create mode 100644 examples/market-maker-bot/src/lib/client.ts create mode 100644 examples/market-maker-bot/src/lib/config.ts create mode 100644 examples/market-maker-bot/src/lib/execution.ts create mode 100644 examples/market-maker-bot/src/lib/orderBook.ts create mode 100644 examples/market-maker-bot/src/lib/orders.ts create mode 100644 examples/market-maker-bot/src/lib/strategy.ts create mode 100644 examples/market-maker-bot/src/lib/tokens.ts create mode 100644 examples/market-maker-bot/src/lib/trades.ts create mode 100644 examples/market-maker-bot/src/lib/types.ts create mode 100644 examples/market-maker-bot/src/lib/utxoBranches.ts create mode 100644 examples/market-maker-bot/src/lib/walletStore.ts create mode 100644 examples/market-maker-bot/src/main.tsx create mode 100644 examples/market-maker-bot/src/styles.css create mode 100644 examples/market-maker-bot/tsconfig.json create mode 100644 examples/market-maker-bot/tsconfig.node.json create mode 100644 examples/market-maker-bot/vite.config.ts diff --git a/examples/market-maker-bot/.env.example b/examples/market-maker-bot/.env.example new file mode 100644 index 0000000..92346bb --- /dev/null +++ b/examples/market-maker-bot/.env.example @@ -0,0 +1,22 @@ +VITE_NETWORK=testnet +VITE_API_URL= +VITE_API_KEY= + +# Testnet/demo only. Values in VITE_* env vars are exposed to browser code. +VITE_WALLET_SEED= + +VITE_PAIR=HUG/ML +# Use the Mintlayer token id for tokens. Use Coin only for ML. +VITE_BASE_TOKEN=token_id_for_HUG +VITE_QUOTE_TOKEN=Coin +VITE_ORDER_SIZE=0.01 +VITE_SPREAD_BPS=20 +VITE_INVENTORY_TARGET=0.5 +VITE_REBALANCE_THRESHOLD=0.1 +VITE_MAX_POSITION=1.0 +VITE_MAX_ORDERS=10 +VITE_POLL_INTERVAL_MS=15000 +VITE_MAX_UNCONFIRMED_BRANCH_DEPTH=24 +VITE_ALLOW_MAINNET_BROADCAST=false +VITE_ENABLE_FILL_TRADING=true +VITE_ALLOW_SELF_FILLS=false diff --git a/examples/market-maker-bot/.gitignore b/examples/market-maker-bot/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/examples/market-maker-bot/.gitignore @@ -0,0 +1 @@ +.env diff --git a/examples/market-maker-bot/ARCHITECTURE.md b/examples/market-maker-bot/ARCHITECTURE.md new file mode 100644 index 0000000..3197f95 --- /dev/null +++ b/examples/market-maker-bot/ARCHITECTURE.md @@ -0,0 +1,47 @@ +# Market Maker SPA Architecture + +## Overview + +The app is a browser-run market-maker control panel and execution surface. It uses React for visualization and control, while the SDK performs all wallet, transaction, signing, broadcast, and chain/order queries. + +```mermaid +flowchart LR + user["User"] --> ui["React SPA"] + ui --> hook["useMarketMakerBot"] + hook --> sdkClient["SDK Client"] + hook --> strategy["Strategy Engine"] + hook --> execution["Execution Queue"] + execution --> walletState["WalletState"] + execution --> branches["UTXO Branch Manager"] + sdkClient --> api["Mintlayer API"] + walletState --> graph["Chain Visualizer"] + branches --> graph +``` + +## State Model + +- SDK state: client instance, network, addresses, balances, available orders, and account orders. +- Strategy state: configured pair, spread, order size, max orders, inventory target, max position, and circuit breaker status. +- Execution state: draft, signed, broadcasted, mempool, confirmed, rejected, and rebuild-required transaction records. +- Wallet state: local transaction log persisted in the browser and interpreted by SDK `WalletState`. +- Branch state: branch labels over wallet UTXOs, unconfirmed depth, remaining depth budget, and reservation status. + +## Transaction Lifecycle + +1. A strategy or branch action creates an execution request. +2. The execution queue signs/builds the transaction through the SDK. +3. The transaction is inserted into `WalletState` as local so its inputs are reserved. +4. If broadcasting is enabled, `broadcastTx()` submits it and the local record moves to mempool. +5. If broadcast fails, the transaction is marked rejected and dependent local transactions require rebuild. +6. Chain polling reconciles visible orders, balances, and transaction state where the SDK exposes enough information. + +## UTXO Branch Policy + +- The app warns before any branch approaches the network mempool chain limit of 30. +- The default branch spend policy allows only wallet-created unconfirmed outputs and caps depth below 30. +- Branch preparation creates multiple self-transfer outputs from a selected funding asset and address. +- Strategy execution prefers the branch with the most remaining depth budget and no local reservation. + +## Browser Storage + +The first implementation uses a localStorage-backed `WalletTxStore` because it is dependency-free and easy to inspect. The storage adapter is isolated so it can be replaced with IndexedDB without changing wallet logic. diff --git a/examples/market-maker-bot/PLAN.md b/examples/market-maker-bot/PLAN.md new file mode 100644 index 0000000..5495d2c --- /dev/null +++ b/examples/market-maker-bot/PLAN.md @@ -0,0 +1,38 @@ +# Market Maker SPA Implementation Plan + +This file is the workspace handoff copy of the approved plan. Keep it current when the implementation changes so another agent can continue without reading the chat. + +## Scope + +- Build `examples/market-maker-bot` as a Vite React TypeScript SPA. +- Use `@mintlayer/sdk` from the pnpm workspace. +- Run the first bot implementation in the browser with `MnemonicAccountProvider`. +- Treat browser mnemonics as testnet/demo-only and keep mainnet broadcasting disabled by default. +- Visualize wallet state, orders, proposed strategy actions, pending transactions, broadcasted transactions, rejected transactions, and UTXO transaction chains. +- Include a guarded special-purpose UTXO preparation workflow so order transactions can be spread across branches instead of extending one mempool chain toward the 30-transaction limit. + +## SDK Constraints + +- Use existing order methods: `getAvailableOrders()`, `getAccountOrders()`, `createOrder()`, `fillOrder()`, `concludeOrder()`, and `broadcastTx()`. +- The SDK exposes a flat order list, so the app builds a synthetic book for the configured pair. +- Use exported wallet-state primitives from `@mintlayer/sdk` for local transaction lifecycle, balance derivation, and unconfirmed chain depth. +- The browser app can only recover state from local transaction persistence plus chain polling available through the SDK. + +## Milestones + +1. Scaffold the Vite React app and workspace package. +2. Implement SDK mnemonic initialization and testnet guardrails. +3. Add browser transaction persistence and wallet-state derived views. +4. Implement pair order polling and synthetic book visualization. +5. Implement dry-run strategy proposals. +6. Add guarded transaction execution and broadcast tracking. +7. Add UTXO branch preparation and branch-aware transaction selection. +8. Run builds/tests and update `PROGRESS.md`. + +## Safety Defaults + +- `VITE_NETWORK=testnet`. +- Broadcasts are disabled until explicitly enabled in the UI. +- Mainnet auto-broadcast requires an explicit environment override. +- Max unconfirmed branch depth defaults below 30 to preserve recovery room. +- Mnemonics are never logged or persisted by the app. diff --git a/examples/market-maker-bot/PROGRESS.md b/examples/market-maker-bot/PROGRESS.md new file mode 100644 index 0000000..545847b --- /dev/null +++ b/examples/market-maker-bot/PROGRESS.md @@ -0,0 +1,40 @@ +# Market Maker SPA Progress + +## Current Status + +- The Vite React TypeScript SPA implementation is complete for the approved first version. +- The app builds successfully with `pnpm --filter market-maker-bot build`. +- The repository now pins `pnpm@10.15.0` for Node 20 compatibility. +- Token balances are shown by SDK `token_id`; configured token values must use token ids rather than tickers. + +## Completed + +- Created persistent planning memory in `PLAN.md`. +- Created this progress log. +- Added `ARCHITECTURE.md` with the app state model and UTXO branch policy. +- Updated `Readme.md` with installation, env vars, usage, structure, and safety notes. +- Scaffolded a workspace Vite React app with `@mintlayer/sdk`. +- Implemented browser mnemonic SDK initialization with default testnet guardrails. +- Implemented localStorage-backed `WalletState` persistence and wallet snapshots. +- Implemented synthetic orderbook construction from SDK order lists. +- Implemented inventory-aware dry-run strategy proposals. +- Implemented a transaction execution queue for build, sign, local reserve, broadcast, and rejection tracking. +- Implemented UTXO branch visualization and guarded branch preparation requests. + +## In Progress + +- No implementation task is currently in progress. + +## Next + +- Manual test with a funded testnet mnemonic and broadcast disabled first. +- Fix the existing SDK TypeScript error in `packages/sdk/src/mintlayer-connect-sdk.ts` before relying on `pnpm build:sdk` as a green verification step. + +## Verification Log + +- `pnpm --filter market-maker-bot build` passed. +- `pnpm install` passed and repaired workspace dependency links. +- `pnpm build:sdk` failed in existing SDK code at `packages/sdk/src/mintlayer-connect-sdk.ts` where `params.token_id` is `string | undefined` but the HTLC build params require `string`. +- User-reported pnpm 11 failure on Node 20 is an environment/toolchain mismatch; pnpm 11 requires Node 22.13+ and imports `node:sqlite`. +- Token balance display was clarified: the SDK returns `sdkBalances.token` as `Record`, so `VITE_BASE_TOKEN=HUG` will display zero unless `HUG` is the actual token id. +- Order book fetching now uses `/order/pair/{tokenId}_TML` for token/ML pairs and ignores zero-balance orders when building bids/asks. diff --git a/examples/market-maker-bot/Readme.md b/examples/market-maker-bot/Readme.md new file mode 100644 index 0000000..3365d0c --- /dev/null +++ b/examples/market-maker-bot/Readme.md @@ -0,0 +1,210 @@ +# Market Maker Bot (UTXO Network) + +## Overview + +This project is an automated market-making bot designed for a UTXO-based blockchain network. +Unlike account-based systems (e.g. Ethereum), this bot operates on a UTXO model, meaning liquidity management, order tracking, and state reconstruction are derived from unspent transaction outputs. + +The bot relies on an existing blockchain SDK to interact with the network, construct transactions, and query on-chain state. + +Its primary goal is to provide continuous liquidity on selected trading pairs by maintaining bid/ask orders and dynamically rebalancing inventory. + +--- + +## Key Features + +- Automated market making on UTXO-based DEX / orderbook +- Inventory-aware quoting (risk-adjusted spread) +- Real-time order book tracking via SDK +- UTXO-aware balance management (no account abstraction) +- Auto rebalancing between assets in a pair +- Configurable spread, depth, and order size +- Fail-safe order cancellation / replacement logic +- Stateless recovery from blockchain data (rebuild from UTXO set) + +--- + +## Architecture + +### 1. Chain Layer (SDK) +Responsible for all blockchain interactions: +- Fetching UTXOs +- Broadcasting transactions +- Querying order book / DEX state +- Address management + +> All interactions go strictly through the provided SDK. + +### 2. Strategy Layer +Implements market-making logic: +- Spread calculation +- Mid-price estimation +- Inventory exposure control +- Order placement / cancellation decisions + +### 3. Execution Layer +Handles: +- Transaction construction via SDK +- Signing & broadcasting +- Retry logic +- Conflict resolution (UTXO double-spend handling) + +--- + +## Requirements + +- Node.js >= 20 +- pnpm 10.x when using Node 20. pnpm 11 requires Node 22.13+ because it uses `node:sqlite`. +- Access to the UTXO network SDK +- Running API server +- Valid wallet keys for signing transactions + +--- + +## Installation + +From the repository root: + +```bash +corepack prepare pnpm@10.15.0 --activate +pnpm install +pnpm build:sdk +pnpm --filter market-maker-bot dev +``` + +The app is a Vite React SPA and runs entirely in the browser. It consumes `@mintlayer/sdk` from the current pnpm workspace. + +If your shell still resolves to pnpm 11 on Node 20, run the command through the pinned version: + +```bash +npx pnpm@10.15.0 --filter market-maker-bot build +``` + +--- + +## Configuration + +Create a `.env` file: + +```env +VITE_NETWORK=testnet + +VITE_API_URL= +VITE_API_KEY= + +VITE_WALLET_SEED=your-testnet-mnemonic + +VITE_PAIR=HUG/ML +VITE_BASE_TOKEN=token_id_for_HUG +VITE_QUOTE_TOKEN=Coin +VITE_ORDER_SIZE=0.01 +VITE_SPREAD_BPS=20 +VITE_INVENTORY_TARGET=0.5 +VITE_REBALANCE_THRESHOLD=0.1 + +VITE_MAX_POSITION=1.0 +VITE_MAX_ORDERS=10 +VITE_MAX_UNCONFIRMED_BRANCH_DEPTH=24 +VITE_ALLOW_MAINNET_BROADCAST=false +``` + +Important: a `VITE_WALLET_SEED` value is bundled into browser code. Use this only for testnet/demo wallets. Production unattended bots should keep signing keys outside the browser. + +Token configuration uses SDK currency ids. Set `VITE_BASE_TOKEN` / `VITE_QUOTE_TOKEN` to `Coin` for ML or to the actual Mintlayer token id for tokens. Tickers such as `HUG` are display labels only and will not match balances returned by `client.getBalances()`. + +--- + +## Usage + +### Start bot + +```bash +pnpm --filter market-maker-bot start +``` + +### Development mode + +```bash +pnpm --filter market-maker-bot dev +``` + +--- + +## Market Making Logic + +The bot continuously: + +1. Fetches latest order book via SDK +2. Computes mid price +3. Calculates bid/ask spread +4. Evaluates current inventory (UTXO-based balances) +5. Places or updates orders accordingly +6. Cancels stale or unfilled orders +7. Rebalances assets if exposure exceeds threshold + +### UTXO-specific behavior + +- Balance is derived from UTXO aggregation, not account balance +- Orders consume UTXOs when executed +- Partial fills may result in fragmented UTXO sets +- The bot maintains a local UTXO index +- State reconciliation happens on every cycle + +--- + +## Safety Mechanisms + +- Duplicate UTXO detection before transaction broadcast +- Idempotent order placement +- Automatic recovery after crash +- Circuit breaker for abnormal spreads +- Max exposure limits per asset +- Browser transaction state is persisted locally and interpreted through SDK `WalletState` +- UTXO branch depth is visualized and capped below the 30-transaction mempool chain limit +- Mainnet auto-broadcast is disabled unless explicitly overridden + +--- + +## Current Implementation Status + +- React SPA scaffold, SDK initialization, and testnet mnemonic mode are implemented. +- Wallet, balance, local UTXO, orderbook, own order, strategy proposal, transaction queue, and UTXO branch panels are implemented. +- Strategy proposals run as dry-run by default. Disabling dry-run allows signing and optional broadcasting through the SDK. +- UTXO branch preparation is guarded and warns when multiple branch preparation transactions should be prepared one at a time. +- Production unattended signing should move out of the browser before mainnet use. + +--- + +## Project Structure + +```text +examples/market-maker-bot/ +├── PLAN.md +├── PROGRESS.md +├── ARCHITECTURE.md +├── package.json +├── vite.config.ts +├── index.html +├── src/ +│ ├── App.tsx +│ ├── main.tsx +│ ├── components/ +│ ├── hooks/ +│ └── lib/ +``` + +The bot logic is split into SDK client setup, strategy calculation, execution queue, wallet transaction state, and UTXO branch management so it can later be moved to a headless runner if needed. + +Pair orders are fetched from `/order/pair/{tokenId}_TML` for token/ML markets instead of filtering the global `/order` list client-side. + +--- + +## Disclaimer + +Use at your own risk. Test on testnet first. + +--- + +## License + +MIT diff --git a/examples/market-maker-bot/index.html b/examples/market-maker-bot/index.html new file mode 100644 index 0000000..e554aae --- /dev/null +++ b/examples/market-maker-bot/index.html @@ -0,0 +1,12 @@ + + + + + + Mintlayer Market Maker Bot + + +
+ + + diff --git a/examples/market-maker-bot/package.json b/examples/market-maker-bot/package.json new file mode 100644 index 0000000..2e993ac --- /dev/null +++ b/examples/market-maker-bot/package.json @@ -0,0 +1,24 @@ +{ + "name": "market-maker-bot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json && vite build", + "preview": "vite preview", + "start": "vite --host 0.0.0.0" + }, + "dependencies": { + "@mintlayer/sdk": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^6.0.2", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } +} diff --git a/examples/market-maker-bot/src/App.tsx b/examples/market-maker-bot/src/App.tsx new file mode 100644 index 0000000..61e6948 --- /dev/null +++ b/examples/market-maker-bot/src/App.tsx @@ -0,0 +1,125 @@ +import { + BranchPanel, + ConfigPanel, + OrderBookPanel, + StrategyPanel, + TradesPanel, + TransactionPanel, + WalletPanel, +} from './components/Panels'; +import { useMarketMakerBot } from './hooks/useMarketMakerBot'; +import './styles.css'; + +function formatTime(value: number | null): string { + return value ? new Date(value).toLocaleTimeString() : 'never'; +} + +export default function App() { + const { + state, + setConfig, + setBroadcastEnabled, + setDryRun, + initialize, + runCycle, + startLoop, + stopLoop, + resetLocalState, + executeStrategyAction, + executePreparationAction, + } = useMarketMakerBot(); + + const initialized = Boolean(state.runtime.client); + const running = state.runtime.mode === 'running'; + + return ( +
+
+
+

Mintlayer SDK Example

+

Market Maker Bot

+

+ Browser-based testnet SPA for synthetic orderbook tracking, inventory-aware quoting, + transaction lifecycle visualization, and UTXO branch preparation. +

+
+
+ Status + {state.runtime.mode} + Last cycle: {formatTime(state.lastCycleAt)} +
+
+ +
+ + + + + +
+ + {state.runtime.error &&
{state.runtime.error}
} + +
+ + + + void executeStrategyAction(action)} + /> + + + void executePreparationAction(request)} + /> +
+
+ ); +} diff --git a/examples/market-maker-bot/src/components/Panels.tsx b/examples/market-maker-bot/src/components/Panels.tsx new file mode 100644 index 0000000..58f37f1 --- /dev/null +++ b/examples/market-maker-bot/src/components/Panels.tsx @@ -0,0 +1,460 @@ +import type { Dispatch, SetStateAction } from 'react'; + +import { isActiveOrder } from '../lib/orderBook'; +import { getPairOrdersPath } from '../lib/orders'; +import { actionToText, calculateInventory, getTokenBalance } from '../lib/strategy'; +import { formatPairLabel, formatTokenLabel, shortenTokenId, type TokenLabelMap } from '../lib/tokens'; +import type { + BranchInfo, + BranchPreparationPlan, + ExecutionRecord, + ExecutionRequest, + MarketMakerConfig, + MarketOrder, + StrategyAction, + SyntheticBook, + TradeRecord, + WalletSnapshot, +} from '../lib/types'; + +function actionKindLabel(action: StrategyAction): string { + if (action.kind === 'fill-order') { + return action.isOwnOrder ? 'FILL SELF' : 'FILL'; + } + + if (action.kind === 'conclude-order') { + return 'CONCLUDE'; + } + + return 'QUOTE'; +} + +type ConfigPanelProps = { + config: MarketMakerConfig; + tokenLabels: TokenLabelMap; + warnings: string[]; + broadcastEnabled: boolean; + dryRun: boolean; + setConfig: Dispatch>; + setBroadcastEnabled: (enabled: boolean) => void; + setDryRun: (enabled: boolean) => void; +}; + +function setNumber(config: MarketMakerConfig, key: keyof MarketMakerConfig, value: string): MarketMakerConfig { + const parsed = Number(value); + return Number.isFinite(parsed) ? { ...config, [key]: parsed } : config; +} + +export function ConfigPanel(props: ConfigPanelProps) { + const { config, tokenLabels, warnings, broadcastEnabled, dryRun, setConfig, setBroadcastEnabled, setDryRun } = props; + + return ( +
+
+

Strategy Config

+ {formatPairLabel(config, tokenLabels)} +
+
+ + + + + + + + +
+
+ + + + +
+ {warnings.length > 0 && ( +
    + {warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ )} +
+ ); +} + +export function WalletPanel(props: { + wallet: WalletSnapshot | null; + config: MarketMakerConfig; + tokenLabels: TokenLabelMap; + midPrice: number | null; +}) { + const { wallet, config, tokenLabels, midPrice } = props; + const inventory = calculateInventory(wallet, config, midPrice); + const tokenBalances = Object.entries(wallet?.sdkBalances?.token ?? {}); + const configuredTokenBalance = getTokenBalance(wallet, config.baseToken); + const configuredTokenMissing = + wallet && config.baseToken !== 'Coin' && configuredTokenBalance === 0 && tokenBalances.length > 0; + + return ( +
+
+

Wallet State

+ {wallet ? 'loaded' : 'not loaded'} +
+
+
+ ML Balance + {wallet?.sdkBalances?.coin.toFixed(8) ?? '0.00000000'} +
+
+ {formatTokenLabel(config.baseToken, tokenLabels)} Balance + {inventory.baseBalance.toFixed(8)} +
+
+ Base Share + {(inventory.baseShare * 100).toFixed(2)}% +
+
+ Local UTXOs + {wallet?.utxos.length ?? 0} +
+
+
+ Receiving + {wallet?.addresses.receiving.join(', ') || 'Initialize wallet'} +
+
+ Token Balances + {tokenBalances.length > 0 ? ( +
+ {tokenBalances.map(([tokenId, balance]) => ( +
+ {formatTokenLabel(tokenId, tokenLabels)} + {balance.toFixed(8)} + {shortenTokenId(tokenId)} +
+ ))} +
+ ) : ( +

No token balances returned by the SDK for the connected addresses.

+ )} + {configuredTokenMissing && ( +

+ Configured base token `{formatTokenLabel(config.baseToken, tokenLabels)}` did not match any SDK balance. Check the token id value. +

+ )} +
+
+ ); +} + +export function OrderBookPanel(props: { + book: SyntheticBook; + orders: MarketOrder[]; + ownOrders: MarketOrder[]; + config: MarketMakerConfig; + tokenLabels: TokenLabelMap; +}) { + const { book, orders, ownOrders, config, tokenLabels } = props; + const activeOrders = orders.filter(isActiveOrder); + const pairPath = getPairOrdersPath(config); + const baseLabel = formatTokenLabel(config.baseToken, tokenLabels); + const quoteLabel = formatTokenLabel(config.quoteToken, tokenLabels); + + return ( +
+
+

Order Book

+ {activeOrders.length} active +
+
+ Pair + {formatPairLabel(config, tokenLabels)} + {pairPath ?? 'Only token/Coin pairs are supported right now'} +
+
+
+ Best Bid + {book.bestBid?.toFixed(8) ?? '-'} +
+
+ Best Ask + {book.bestAsk?.toFixed(8) ?? '-'} +
+
+ Mid + {book.midPrice?.toFixed(8) ?? '-'} +
+
+ Own Orders + {ownOrders.length} +
+
+
+ + +
+ {activeOrders.length === 0 && ( +

+ No active orders returned for this pair. Filled or zero-balance orders are ignored. +

+ )} + {book.midPrice === null && activeOrders.length > 0 && ( +

+ Orders were fetched, but none matched the configured base/quote token ids for book construction. +

+ )} +
+ ); +} + +function OrderSide(props: { title: string; rows: SyntheticBook['bids']; baseLabel: string; quoteLabel: string }) { + return ( +
+

{props.title}

+ + + + + + + + + + {props.rows.map((row) => ( + + + + + + ))} + +
Price ({props.quoteLabel}/{props.baseLabel}){props.baseLabel}{props.quoteLabel}
{row.price.toFixed(8)}{row.baseAmount.toFixed(8)}{row.quoteAmount.toFixed(8)}
+
+ ); +} + +export function StrategyPanel(props: { + actions: StrategyAction[]; + tokenLabels: TokenLabelMap; + onExecute: (action: StrategyAction) => void; + dryRun: boolean; +}) { + return ( +
+
+

Strategy Proposals

+ {props.actions.length} actions +
+
+ {props.actions.map((action) => ( +
+
+ + {actionKindLabel(action)} {actionToText(action, props.tokenLabels)} + +

{action.reason}

+
+ +
+ ))} + {props.actions.length === 0 &&

No strategy actions. Initialize and refresh market data.

} +
+
+ ); +} + +export function TransactionPanel(props: { records: ExecutionRecord[] }) { + return ( +
+
+

Transactions

+ {props.records.length} tracked +
+
+ {props.records.map((record) => ( +
+
+ {record.kind} - {record.status} +

{record.description}

+ {record.txId && {record.txId}} + {record.error &&

{record.error}

} +
+
+ ))} + {props.records.length === 0 &&

No local, pending, or broadcasted transactions yet.

} +
+
+ ); +} + +export function TradesPanel(props: { trades: TradeRecord[]; tokenLabels: TokenLabelMap; baseToken: string }) { + const baseLabel = formatTokenLabel(props.baseToken, props.tokenLabels); + + return ( +
+
+

Trades (Fills)

+ {props.trades.length} recorded +
+

+ Only fill-order transactions create on-chain trade volume and candles. Quote creation alone does not. +

+
+ {props.trades.map((trade) => ( +
+
+ + {trade.side.toUpperCase()} {trade.expectedBaseAmount.toFixed(8)} {baseLabel} @ {trade.price.toFixed(8)} + +

+ {trade.description} {trade.isOwnOrder ? '(self-fill)' : '(external)'} +

+

+ Paid/received leg: {trade.fillAmount.toFixed(8)} | status: {trade.status} +

+ {trade.txId && {trade.txId}} +
+
+ ))} + {props.trades.length === 0 && ( +

No fills yet. Enable fill trading and execute a FILL proposal with broadcast enabled.

+ )} +
+
+ ); +} + +export function BranchPanel(props: { + branches: BranchInfo[]; + plan: BranchPreparationPlan; + tokenLabels: TokenLabelMap; + onExecute: (request: ExecutionRequest) => void; +}) { + return ( +
+
+

UTXO Branches

+ {props.branches.length} branches +
+
+ {props.branches.map((branch) => ( +
+ {branch.id} + {formatTokenLabel(branch.asset, props.tokenLabels)} + {branch.outpoint} +
+ +
+

Depth {branch.depth}, remaining {branch.remainingDepth}

+ {branch.warning &&

{branch.warning}

} +
+ ))} +
+
+

Preparation Plan

+

+ Source asset: {formatTokenLabel(props.plan.sourceAsset, props.tokenLabels)} ({props.plan.perBranchAmount} per branch) +

+ {props.plan.warnings.map((warning) => ( +

{warning}

+ ))} +
+ {props.plan.actions.map((request) => ( +
+
+ + Prepare branch with {props.plan.perBranchAmount}{' '} + {formatTokenLabel(props.plan.sourceAsset, props.tokenLabels)} + +

Destination: {props.plan.destination || 'wallet not initialized'}

+
+ +
+ ))} + {props.plan.actions.length === 0 &&

No branch preparation needed from the current local state.

} +
+
+
+ ); +} diff --git a/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts new file mode 100644 index 0000000..4ce2c7d --- /dev/null +++ b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts @@ -0,0 +1,346 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { WalletState } from '@mintlayer/sdk'; + +import { createBotClient } from '../lib/client'; +import { loadConfigFromEnv, validateConfig } from '../lib/config'; +import { executeRequest, mergeRecords, strategyActionToRequest } from '../lib/execution'; +import { buildSyntheticBook, isOwnOrder } from '../lib/orderBook'; +import { fetchPairOrders, getPairOrdersPath } from '../lib/orders'; +import { planAllStrategyActions } from '../lib/strategy'; +import { listTrades } from '../lib/trades'; +import { analyzeBranches, createBranchPreparationPlan } from '../lib/utxoBranches'; +import { collectTokenIds, loadTokenLabels, mergeTokenLabels, type TokenLabelMap } from '../lib/tokens'; +import { + clearAllLocalWalletState, + createWalletStateForAddresses, + loadWalletSnapshot, +} from '../lib/walletStore'; +import type { + BotRuntime, + BranchPreparationPlan, + ExecutionRecord, + ExecutionRequest, + MarketMakerConfig, + MarketOrder, + StrategyAction, + SyntheticBook, + TradeRecord, + WalletSnapshot, +} from '../lib/types'; + +type BotState = { + config: MarketMakerConfig; + configWarnings: string[]; + runtime: BotRuntime; + wallet: WalletSnapshot | null; + orders: MarketOrder[]; + ownOrders: MarketOrder[]; + book: SyntheticBook; + actions: StrategyAction[]; + records: ExecutionRecord[]; + broadcastEnabled: boolean; + dryRun: boolean; + preparationPlan: BranchPreparationPlan; + lastCycleAt: number | null; + trades: TradeRecord[]; + tokenLabels: TokenLabelMap; +}; + +const initialConfig = loadConfigFromEnv(); + +const EMPTY_BOOK: SyntheticBook = { + bids: [], + asks: [], + bestBid: null, + bestAsk: null, + midPrice: null, +}; + +function emptyPreparationPlan(config: MarketMakerConfig): BranchPreparationPlan { + return { + sourceAsset: config.baseToken, + targetBranchCount: 4, + perBranchAmount: config.orderSize, + destination: '', + actions: [], + warnings: [], + }; +} + +export function useMarketMakerBot() { + const [config, setConfig] = useState(initialConfig); + const [runtime, setRuntime] = useState({ + mode: 'idle', + client: null, + initializedAt: null, + error: null, + }); + const [walletState, setWalletState] = useState(null); + const [wallet, setWallet] = useState(null); + const [orders, setOrders] = useState([]); + const [ownOrders, setOwnOrders] = useState([]); + const [records, setRecords] = useState([]); + const [broadcastEnabled, setBroadcastEnabled] = useState(false); + const [dryRun, setDryRun] = useState(true); + const [lastCycleAt, setLastCycleAt] = useState(null); + const [tokenLabels, setTokenLabels] = useState({ + Coin: { tokenId: 'Coin', ticker: 'ML', decimals: 11 }, + }); + const loopRef = useRef(null); + + const configWarnings = useMemo(() => validateConfig(config), [config]); + const book = useMemo(() => buildSyntheticBook(orders, config.baseToken, config.quoteToken), [orders, config]); + const actions = useMemo( + () => planAllStrategyActions({ config, book, ownOrders, wallet }), + [book, config, ownOrders, wallet], + ); + const trades = useMemo(() => listTrades(records), [records]); + const branches = useMemo( + () => analyzeBranches(wallet, config.maxUnconfirmedBranchDepth), + [wallet, config.maxUnconfirmedBranchDepth], + ); + const preparationPlan = useMemo( + () => + createBranchPreparationPlan({ + snapshot: wallet, + sourceAsset: config.baseToken, + targetBranchCount: 4, + perBranchAmount: config.orderSize, + }), + [config.baseToken, config.orderSize, wallet], + ); + + const refresh = useCallback(async () => { + if (!runtime.client || !walletState) { + return; + } + + const [pairOrders, accountOrders, walletSnapshot] = await Promise.all([ + fetchPairOrders(config), + runtime.client.getAccountOrders(), + loadWalletSnapshot(runtime.client, walletState, config.maxUnconfirmedBranchDepth), + ]); + + const typedOrders = pairOrders as MarketOrder[]; + const typedOwnOrders = + accountOrders.length > 0 + ? (accountOrders as MarketOrder[]).filter((order) => + typedOrders.some((pairOrder) => pairOrder.order_id === order.order_id), + ) + : typedOrders.filter((order) => isOwnOrder(order, walletSnapshot.addresses)); + + setOrders(typedOrders); + setOwnOrders(typedOwnOrders); + setWallet(walletSnapshot); + + const branchSnapshot = analyzeBranches(walletSnapshot, config.maxUnconfirmedBranchDepth); + const labels = await loadTokenLabels( + config, + collectTokenIds({ config, wallet: walletSnapshot, branches: branchSnapshot }), + ); + setTokenLabels((current) => mergeTokenLabels(current, labels)); + }, [config, config.maxUnconfirmedBranchDepth, runtime.client, walletState]); + + const initialize = useCallback(async () => { + setRuntime((current) => ({ ...current, mode: 'initializing', error: null })); + + try { + const client = await createBotClient(config); + const addresses = client.getAddresses(); + const nextWalletState = await createWalletStateForAddresses(addresses); + const walletSnapshot = await loadWalletSnapshot(client, nextWalletState, config.maxUnconfirmedBranchDepth); + + setWalletState(nextWalletState); + setWallet(walletSnapshot); + + const branchSnapshot = analyzeBranches(walletSnapshot, config.maxUnconfirmedBranchDepth); + const labels = await loadTokenLabels( + config, + collectTokenIds({ config, wallet: walletSnapshot, branches: branchSnapshot }), + ); + setTokenLabels((current) => mergeTokenLabels(current, labels)); + + setRuntime({ + mode: 'ready', + client, + initializedAt: Date.now(), + error: null, + }); + } catch (error) { + setRuntime({ + mode: 'error', + client: null, + initializedAt: null, + error: error instanceof Error ? error.message : String(error), + }); + } + }, [config]); + + const execute = useCallback( + async (request: ExecutionRequest, forceBroadcast = broadcastEnabled) => { + if (!runtime.client || !walletState) { + return; + } + + const duplicate = records.some((record) => record.idempotencyKey === request.idempotencyKey); + if (duplicate) { + return; + } + + const record = await executeRequest({ + client: runtime.client, + walletState, + request, + config, + broadcast: forceBroadcast && !dryRun, + tradeMeta: request.kind === 'fill-order' ? request.tradeMeta : undefined, + }); + + setRecords((current) => mergeRecords(current, record)); + await refresh(); + }, + [broadcastEnabled, config, dryRun, records, refresh, runtime.client, walletState], + ); + + const executeStrategyAction = useCallback( + async (action: StrategyAction) => { + const destination = wallet?.addresses.receiving[0]; + if (!destination) { + return; + } + + await execute(strategyActionToRequest(action, destination)); + }, + [execute, wallet?.addresses.receiving], + ); + + const executePreparationAction = useCallback( + async (request: ExecutionRequest) => { + await execute(request); + }, + [execute], + ); + + const runCycle = useCallback(async () => { + await refresh(); + setLastCycleAt(Date.now()); + }, [refresh]); + + const startLoop = useCallback(() => { + if (loopRef.current !== null) { + return; + } + + setRuntime((current) => ({ ...current, mode: 'running' })); + loopRef.current = window.setInterval(() => { + void runCycle(); + }, config.pollIntervalMs); + void runCycle(); + }, [config.pollIntervalMs, runCycle]); + + const stopLoop = useCallback(() => { + if (loopRef.current !== null) { + window.clearInterval(loopRef.current); + loopRef.current = null; + } + + setRuntime((current) => ({ + ...current, + mode: current.client ? 'ready' : 'idle', + })); + }, []); + + const resetLocalState = useCallback(async () => { + stopLoop(); + clearAllLocalWalletState(); + + setRecords([]); + setOrders([]); + setOwnOrders([]); + setLastCycleAt(null); + + if (!runtime.client) { + setWalletState(null); + setWallet(null); + setRuntime({ + mode: 'idle', + client: null, + initializedAt: null, + error: null, + }); + return; + } + + try { + const addresses = runtime.client.getAddresses(); + const nextWalletState = await createWalletStateForAddresses(addresses); + const walletSnapshot = await loadWalletSnapshot( + runtime.client, + nextWalletState, + config.maxUnconfirmedBranchDepth, + ); + + setWalletState(nextWalletState); + setWallet(walletSnapshot); + setRuntime((current) => ({ + ...current, + mode: 'ready', + error: null, + })); + } catch (error) { + setRuntime((current) => ({ + ...current, + mode: 'error', + error: error instanceof Error ? error.message : String(error), + })); + } + }, [config.maxUnconfirmedBranchDepth, runtime.client, stopLoop]); + + useEffect(() => { + void loadTokenLabels(config, [config.baseToken, config.quoteToken]).then((labels) => { + setTokenLabels((current) => mergeTokenLabels(current, labels)); + }); + }, [config.baseToken, config.quoteToken, config.network, config.apiUrl, config.apiKey]); + + useEffect(() => { + return () => { + if (loopRef.current !== null) { + window.clearInterval(loopRef.current); + } + }; + }, []); + + const state: BotState & { branches: ReturnType } = { + config, + configWarnings, + runtime, + wallet, + orders, + ownOrders, + book, + actions, + records, + broadcastEnabled, + dryRun, + preparationPlan, + lastCycleAt, + branches, + trades, + tokenLabels, + }; + + return { + state, + setConfig, + setBroadcastEnabled, + setDryRun, + initialize, + refresh, + runCycle, + startLoop, + stopLoop, + resetLocalState, + executeStrategyAction, + executePreparationAction, + }; +} diff --git a/examples/market-maker-bot/src/lib/client.ts b/examples/market-maker-bot/src/lib/client.ts new file mode 100644 index 0000000..7dd5f3c --- /dev/null +++ b/examples/market-maker-bot/src/lib/client.ts @@ -0,0 +1,159 @@ +import { Client, MintlayerApiProvider, MnemonicAccountProvider, type ApiProvider } from '@mintlayer/sdk'; + +import type { MarketMakerConfig } from './types'; + +const DEFAULT_API_URLS = { + testnet: 'https://api-server-lovelace.mintlayer.org/api/v2', + mainnet: 'https://api-server.mintlayer.org/api/v2', +}; + +const DEFAULT_BATCH_URLS = { + testnet: 'https://mojito-api.mintlayer.org/mintlayer/testnet/batch', + mainnet: 'https://mojito-api.mintlayer.org/mintlayer/mainnet/batch', +}; + +class HeaderApiProvider implements ApiProvider { + private readonly baseUrl: string; + private readonly batchUrl: string; + private readonly headers: HeadersInit; + + constructor(baseUrl: string, batchUrl: string, apiKey?: string) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.batchUrl = batchUrl.replace(/\/$/, ''); + this.headers = apiKey ? { Authorization: `Bearer ${apiKey}`, 'X-API-Key': apiKey } : {}; + } + + private async get(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { headers: this.headers }); + if (!response.ok) { + throw new Error(`API error ${response.status}: ${path}`); + } + return response.json(); + } + + getChainTip(): Promise { + return this.get('/chain/tip'); + } + + getAddress(addr: string): Promise { + return this.get(`/address/${addr}`); + } + + getAddressDelegations(addr: string): Promise { + return this.get(`/address/${addr}/delegations`); + } + + getAddressTokenAuthority(addr: string): Promise { + return this.get(`/address/${addr}/token-authority`); + } + + getToken(token_id: string): Promise { + return this.get(`/token/${token_id}`); + } + + getNft(token_id: string): Promise { + return this.get(`/nft/${token_id}`); + } + + getOrder(order_id: string): Promise { + return this.get(`/order/${order_id}`); + } + + getOrders(): Promise { + return this.get('/order'); + } + + getPoolDelegations(pool_id: string): Promise { + return this.get(`/pool/${pool_id}/delegations`); + } + + getDelegation(delegation_id: string): Promise { + return this.get(`/delegation/${delegation_id}`); + } + + getTransaction(transaction_id: string): Promise { + return this.get(`/transaction/${transaction_id}`); + } + + async broadcastTransaction(tx: string | { hex: string; json: unknown }): Promise { + const response = await fetch(`${this.baseUrl}/transaction`, { + method: 'POST', + headers: + typeof tx === 'string' + ? { ...this.headers, 'Content-Type': 'text/plain' } + : { ...this.headers, 'Content-Type': 'application/json' }, + body: typeof tx === 'string' ? tx : JSON.stringify({ transaction: tx.hex, json: tx.json }), + }); + + if (!response.ok) { + throw new Error(`Broadcast error ${response.status}`); + } + + return response.json(); + } + + async getAccountUtxos(addresses: string[], network: number): Promise { + const response = await fetch(this.batchUrl, { + method: 'POST', + headers: { ...this.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ids: addresses, + type: '/address/:address/spendable-utxos', + network, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch utxos: ${response.status}`); + } + + const data = await response.json(); + return (data.results ?? []).flat(); + } +} + +function createApiProvider(config: MarketMakerConfig): ApiProvider | undefined { + if (!config.apiUrl && !config.apiKey) { + return undefined; + } + + const baseUrl = config.apiUrl ?? DEFAULT_API_URLS[config.network]; + return new HeaderApiProvider(baseUrl, DEFAULT_BATCH_URLS[config.network], config.apiKey); +} + +export async function createBotClient(config: MarketMakerConfig): Promise { + if (!config.walletSeed) { + throw new Error('VITE_WALLET_SEED is required for browser mnemonic mode.'); + } + + console.log('sss'); + + const accountProvider = new MnemonicAccountProvider(config.walletSeed, config.network, { + receivingAddressCount: 8, + changeAddressCount: 8, + }); + + console.log('accountProvider', accountProvider); + + const apiProvider = createApiProvider(config); + + + console.log('apiProvider', apiProvider); + const client = await Client.create({ + network: config.network, + autoRestore: false, + accountProvider, + ...(apiProvider ? { apiProvider } : {}), + }); + + await client.connect(); + return client; +} + +export function createDefaultApiProvider(config: MarketMakerConfig): MintlayerApiProvider { + return new MintlayerApiProvider(DEFAULT_API_URLS[config.network], DEFAULT_BATCH_URLS[config.network]); +} + +export function resolveApiBaseUrl(config: MarketMakerConfig): string { + return (config.apiUrl ?? DEFAULT_API_URLS[config.network]).replace(/\/$/, ''); +} diff --git a/examples/market-maker-bot/src/lib/config.ts b/examples/market-maker-bot/src/lib/config.ts new file mode 100644 index 0000000..3b07181 --- /dev/null +++ b/examples/market-maker-bot/src/lib/config.ts @@ -0,0 +1,119 @@ +import type { MarketMakerConfig, NetworkName } from './types'; + +const DEFAULT_CONFIG: MarketMakerConfig = { + network: 'testnet', + pair: 'HUG/ML', + baseToken: 'HUG', + quoteToken: 'Coin', + orderSize: 0.01, + spreadBps: 20, + inventoryTarget: 0.5, + rebalanceThreshold: 0.1, + maxPosition: 1, + maxOrders: 10, + pollIntervalMs: 15_000, + maxUnconfirmedBranchDepth: 24, + allowMainnetBroadcast: false, + enableFillTrading: true, + allowSelfFills: false, +}; + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function numberFromEnv(value: unknown, fallback: number): number { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function booleanFromEnv(value: unknown, fallback: boolean): boolean { + if (typeof value !== 'string') { + return fallback; + } + + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); +} + +function networkFromEnv(value: unknown): NetworkName { + return value === 'mainnet' ? 'mainnet' : 'testnet'; +} + +export function loadConfigFromEnv(env: ImportMetaEnv = import.meta.env): MarketMakerConfig { + return { + network: networkFromEnv(env.VITE_NETWORK), + apiUrl: optionalString(env.VITE_API_URL), + apiKey: optionalString(env.VITE_API_KEY), + walletSeed: optionalString(env.VITE_WALLET_SEED), + pair: optionalString(env.VITE_PAIR) ?? DEFAULT_CONFIG.pair, + baseToken: optionalString(env.VITE_BASE_TOKEN) ?? DEFAULT_CONFIG.baseToken, + quoteToken: optionalString(env.VITE_QUOTE_TOKEN) ?? DEFAULT_CONFIG.quoteToken, + orderSize: numberFromEnv(env.VITE_ORDER_SIZE, DEFAULT_CONFIG.orderSize), + spreadBps: numberFromEnv(env.VITE_SPREAD_BPS, DEFAULT_CONFIG.spreadBps), + inventoryTarget: numberFromEnv(env.VITE_INVENTORY_TARGET, DEFAULT_CONFIG.inventoryTarget), + rebalanceThreshold: numberFromEnv(env.VITE_REBALANCE_THRESHOLD, DEFAULT_CONFIG.rebalanceThreshold), + maxPosition: numberFromEnv(env.VITE_MAX_POSITION, DEFAULT_CONFIG.maxPosition), + maxOrders: Math.max(1, Math.floor(numberFromEnv(env.VITE_MAX_ORDERS, DEFAULT_CONFIG.maxOrders))), + pollIntervalMs: Math.max(5_000, Math.floor(numberFromEnv(env.VITE_POLL_INTERVAL_MS, DEFAULT_CONFIG.pollIntervalMs))), + maxUnconfirmedBranchDepth: Math.min( + 29, + Math.max(1, Math.floor(numberFromEnv(env.VITE_MAX_UNCONFIRMED_BRANCH_DEPTH, DEFAULT_CONFIG.maxUnconfirmedBranchDepth))), + ), + allowMainnetBroadcast: booleanFromEnv(env.VITE_ALLOW_MAINNET_BROADCAST, DEFAULT_CONFIG.allowMainnetBroadcast), + enableFillTrading: booleanFromEnv(env.VITE_ENABLE_FILL_TRADING, DEFAULT_CONFIG.enableFillTrading), + allowSelfFills: booleanFromEnv(env.VITE_ALLOW_SELF_FILLS, DEFAULT_CONFIG.allowSelfFills), + }; +} + +export function validateConfig(config: MarketMakerConfig): string[] { + const warnings: string[] = []; + + if (!config.walletSeed) { + warnings.push('VITE_WALLET_SEED is missing. The app can render, but SDK initialization will fail.'); + } + + if (config.network === 'mainnet' && !config.allowMainnetBroadcast) { + warnings.push('Mainnet broadcasting is blocked by default. Set VITE_ALLOW_MAINNET_BROADCAST=true only after review.'); + } + + if (config.spreadBps <= 0) { + warnings.push('Spread must be positive.'); + } + + if (config.orderSize <= 0) { + warnings.push('Order size must be positive.'); + } + + if (config.inventoryTarget < 0 || config.inventoryTarget > 1) { + warnings.push('Inventory target should be between 0 and 1.'); + } + + return warnings; +} + +export function updateConfigNumber( + config: MarketMakerConfig, + key: keyof Pick< + MarketMakerConfig, + | 'orderSize' + | 'spreadBps' + | 'inventoryTarget' + | 'rebalanceThreshold' + | 'maxPosition' + | 'maxOrders' + | 'pollIntervalMs' + | 'maxUnconfirmedBranchDepth' + >, + value: string, +): MarketMakerConfig { + const next = Number(value); + if (!Number.isFinite(next)) { + return config; + } + + return { ...config, [key]: next }; +} diff --git a/examples/market-maker-bot/src/lib/execution.ts b/examples/market-maker-bot/src/lib/execution.ts new file mode 100644 index 0000000..5dd486f --- /dev/null +++ b/examples/market-maker-bot/src/lib/execution.ts @@ -0,0 +1,183 @@ +import type { Client, WalletState } from '@mintlayer/sdk'; + +import type { ExecutionRecord, ExecutionRequest, MarketMakerConfig, StrategyAction } from './types'; + +function now(): number { + return Date.now(); +} + +function createRecord(request: ExecutionRequest): ExecutionRecord { + return { + id: request.id, + kind: request.kind, + description: request.description, + idempotencyKey: request.idempotencyKey, + status: 'draft', + createdAt: now(), + updatedAt: now(), + }; +} + +type BuiltTransaction = { + JSONRepresentation: { + id: string; + inputs: unknown[]; + outputs: unknown[]; + fee?: unknown; + }; +}; + +function getTxJson(tx: BuiltTransaction) { + return tx.JSONRepresentation; +} + +export function strategyActionToRequest(action: StrategyAction, destination: string): ExecutionRequest { + if (action.kind === 'conclude-order') { + return { + id: `exec:${action.id}`, + kind: 'conclude-order', + orderId: action.orderId, + description: action.reason, + idempotencyKey: action.id, + }; + } + + if (action.kind === 'fill-order') { + return { + id: `exec:${action.id}`, + kind: 'fill-order', + orderId: action.orderId, + amount: action.amount, + destination, + description: action.reason, + idempotencyKey: action.id, + tradeMeta: { + side: action.side, + orderId: action.orderId, + fillAmount: action.amount, + price: action.price, + expectedBaseAmount: action.expectedBaseAmount, + expectedQuoteAmount: action.expectedQuoteAmount, + isOwnOrder: action.isOwnOrder, + }, + }; + } + + return { + id: `exec:${action.id}`, + kind: 'create-order', + args: action.args, + description: action.reason, + idempotencyKey: action.id, + }; +} + +export function mergeRecords(records: ExecutionRecord[], next: ExecutionRecord): ExecutionRecord[] { + const existing = records.find((record) => record.idempotencyKey === next.idempotencyKey); + if (existing) { + return records; + } + + return [next, ...records]; +} + +async function buildTransaction(client: Client, request: ExecutionRequest): Promise { + const sdkClient = client as unknown as { + buildCreateOrder: Client['buildCreateOrder']; + buildConcludeOrder: Client['buildConcludeOrder']; + buildFillOrder: Client['buildFillOrder']; + buildTransfer: Client['buildTransfer']; + }; + + if (request.kind === 'create-order') { + return (await sdkClient.buildCreateOrder(request.args)) as unknown as BuiltTransaction; + } + + if (request.kind === 'conclude-order') { + return (await sdkClient.buildConcludeOrder({ order_id: request.orderId })) as unknown as BuiltTransaction; + } + + if (request.kind === 'fill-order') { + return (await sdkClient.buildFillOrder({ + order_id: request.orderId, + amount: request.amount, + destination: request.destination, + })) as unknown as BuiltTransaction; + } + + if (request.tokenId) { + return (await sdkClient.buildTransfer({ to: request.to, amount: request.amount, token_id: request.tokenId })) as unknown as BuiltTransaction; + } + + return (await sdkClient.buildTransfer({ to: request.to, amount: request.amount })) as unknown as BuiltTransaction; +} + +export async function executeRequest(args: { + client: Client; + walletState: WalletState; + request: ExecutionRequest; + config: MarketMakerConfig; + broadcast: boolean; + tradeMeta?: ExecutionRecord['tradeMeta']; +}): Promise { + const { client, walletState, request, config, broadcast, tradeMeta } = args; + const record = createRecord(request); + + if (config.network === 'mainnet' && !config.allowMainnetBroadcast && broadcast) { + return { + ...record, + status: 'rejected', + updatedAt: now(), + error: 'Mainnet broadcasting is blocked by VITE_ALLOW_MAINNET_BROADCAST=false.', + }; + } + + try { + const tx = await buildTransaction(client, request); + const signedHex = await (client as unknown as { signTransaction(tx: BuiltTransaction): Promise }).signTransaction(tx); + const txJson = getTxJson(tx); + await walletState.applyLocalTx(tx); + + const signedRecord: ExecutionRecord = { + ...record, + status: broadcast ? 'local' : 'signed', + updatedAt: now(), + txId: txJson.id, + signedHex, + tradeMeta, + }; + + if (!broadcast) { + return signedRecord; + } + + try { + const broadcastResponse = await client.broadcastTx(signedHex); + await walletState.applyMempoolTx(tx); + return { + ...signedRecord, + status: 'broadcasted', + updatedAt: now(), + broadcastResponse, + }; + } catch (error) { + await walletState.markBroadcastRejected(txJson.id, { + reason: error instanceof Error ? error.message : String(error), + rebuildRequired: true, + }); + return { + ...signedRecord, + status: 'rejected', + updatedAt: now(), + error: error instanceof Error ? error.message : String(error), + }; + } + } catch (error) { + return { + ...record, + status: 'rejected', + updatedAt: now(), + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/examples/market-maker-bot/src/lib/orderBook.ts b/examples/market-maker-bot/src/lib/orderBook.ts new file mode 100644 index 0000000..4fe311c --- /dev/null +++ b/examples/market-maker-bot/src/lib/orderBook.ts @@ -0,0 +1,89 @@ +import type { BookLevel, MarketOrder, SyntheticBook, TokenRef } from './types'; + +function decimalAmount(amount: { decimal?: string; atoms?: string | number } | undefined): number { + const value = amount?.decimal ?? amount?.atoms; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function isActiveOrder(order: MarketOrder): boolean { + return decimalAmount(order.ask_balance) > 0 && decimalAmount(order.give_balance) > 0; +} + +function currencyKey(currency: MarketOrder['ask_currency']): TokenRef { + if (currency.type === 'Coin') { + return 'Coin'; + } + + return currency.token_id ?? 'unknown'; +} + +function sameToken(currency: MarketOrder['ask_currency'], token: TokenRef): boolean { + return currencyKey(currency) === token; +} + +function toBookLevel(order: MarketOrder, baseToken: TokenRef, quoteToken: TokenRef): BookLevel | null { + const askMatchesQuote = sameToken(order.ask_currency, quoteToken); + const askMatchesBase = sameToken(order.ask_currency, baseToken); + const giveMatchesBase = sameToken(order.give_currency, baseToken); + const giveMatchesQuote = sameToken(order.give_currency, quoteToken); + + const askAmount = decimalAmount(order.ask_balance); + const giveAmount = decimalAmount(order.give_balance); + + if (askAmount <= 0 || giveAmount <= 0) { + return null; + } + + if (giveMatchesBase && askMatchesQuote) { + return { + side: 'ask', + orderId: order.order_id, + price: askAmount / giveAmount, + baseAmount: giveAmount, + quoteAmount: askAmount, + ownerAddress: order.conclude_destination, + }; + } + + if (giveMatchesQuote && askMatchesBase) { + return { + side: 'bid', + orderId: order.order_id, + price: giveAmount / askAmount, + baseAmount: askAmount, + quoteAmount: giveAmount, + ownerAddress: order.conclude_destination, + }; + } + + return null; +} + +export function buildSyntheticBook(orders: MarketOrder[], baseToken: TokenRef, quoteToken: TokenRef): SyntheticBook { + const levels = orders + .filter(isActiveOrder) + .map((order) => toBookLevel(order, baseToken, quoteToken)) + .filter((level): level is BookLevel => level !== null); + + const bids = levels.filter((level) => level.side === 'bid').sort((a, b) => b.price - a.price); + const asks = levels.filter((level) => level.side === 'ask').sort((a, b) => a.price - b.price); + const bestBid = bids[0]?.price ?? null; + const bestAsk = asks[0]?.price ?? null; + + return { + bids, + asks, + bestBid, + bestAsk, + midPrice: bestBid !== null && bestAsk !== null ? (bestBid + bestAsk) / 2 : bestBid ?? bestAsk, + }; +} + +export function isOwnOrder(order: MarketOrder, addresses: { receiving: string[]; change: string[] }): boolean { + return [...addresses.receiving, ...addresses.change].includes(order.conclude_destination); +} + +export function formatToken(token: TokenRef): string { + return token === 'Coin' ? 'ML' : token; +} diff --git a/examples/market-maker-bot/src/lib/orders.ts b/examples/market-maker-bot/src/lib/orders.ts new file mode 100644 index 0000000..df58ff3 --- /dev/null +++ b/examples/market-maker-bot/src/lib/orders.ts @@ -0,0 +1,51 @@ +import { resolveApiBaseUrl } from './client'; +import type { MarketMakerConfig, MarketOrder, TokenRef } from './types'; + +function requestHeaders(config: MarketMakerConfig): HeadersInit { + if (!config.apiKey) { + return {}; + } + + return { + Authorization: `Bearer ${config.apiKey}`, + 'X-API-Key': config.apiKey, + }; +} + +/** + * Mintlayer pair slug for token/ML markets, e.g. + * `/order/pair/tmltk1..._TML` + */ +export function buildPairSlug(baseToken: TokenRef, quoteToken: TokenRef): string | null { + if (baseToken !== 'Coin' && quoteToken === 'Coin') { + return `${baseToken}_TML`; + } + + if (baseToken === 'Coin' && quoteToken !== 'Coin') { + return `${quoteToken}_TML`; + } + + return null; +} + +export function getPairOrdersPath(config: MarketMakerConfig): string | null { + const slug = buildPairSlug(config.baseToken, config.quoteToken); + return slug ? `/order/pair/${slug}` : null; +} + +export async function fetchPairOrders(config: MarketMakerConfig): Promise { + const pairPath = getPairOrdersPath(config); + if (!pairPath) { + return []; + } + + const response = await fetch(`${resolveApiBaseUrl(config)}${pairPath}`, { + headers: requestHeaders(config), + }); + + if (!response.ok) { + throw new Error(`Pair order fetch failed (${response.status}): ${pairPath}`); + } + + return (await response.json()) as MarketOrder[]; +} diff --git a/examples/market-maker-bot/src/lib/strategy.ts b/examples/market-maker-bot/src/lib/strategy.ts new file mode 100644 index 0000000..3855260 --- /dev/null +++ b/examples/market-maker-bot/src/lib/strategy.ts @@ -0,0 +1,216 @@ +import type { + BookLevel, + MarketMakerConfig, + MarketOrder, + StrategyAction, + SyntheticBook, + TokenRef, + WalletSnapshot, +} from './types'; +import type { TokenLabelMap } from './tokens'; +import { formatTokenLabel } from './tokens'; + +export function getTokenBalance(snapshot: WalletSnapshot | null, token: string): number { + if (!snapshot?.sdkBalances) { + return 0; + } + + if (token === 'Coin') { + return snapshot.sdkBalances.coin; + } + + return snapshot.sdkBalances.token[token] ?? 0; +} + +function stableId(parts: Array): string { + return parts.map((part) => String(part ?? 'none')).join(':'); +} + +export function calculateInventory(snapshot: WalletSnapshot | null, config: MarketMakerConfig, midPrice: number | null) { + const baseBalance = getTokenBalance(snapshot, config.baseToken); + const quoteBalance = getTokenBalance(snapshot, config.quoteToken); + const quoteValue = quoteBalance; + const baseValue = midPrice ? baseBalance * midPrice : baseBalance; + const totalValue = quoteValue + baseValue; + const baseShare = totalValue > 0 ? baseValue / totalValue : 0; + + return { + baseBalance, + quoteBalance, + baseValue, + quoteValue, + totalValue, + baseShare, + drift: baseShare - config.inventoryTarget, + }; +} + +export function planStrategyActions(args: { + config: MarketMakerConfig; + book: SyntheticBook; + ownOrders: MarketOrder[]; + wallet: WalletSnapshot | null; +}): StrategyAction[] { + const { config, book, ownOrders, wallet } = args; + const actions: StrategyAction[] = []; + const addresses = wallet?.addresses; + const concludeDestination = addresses?.receiving[0]; + + if (!concludeDestination || !book.midPrice) { + return actions; + } + + const inventory = calculateInventory(wallet, config, book.midPrice); + const halfSpread = config.spreadBps / 20_000; + const inventorySkew = Math.max(-0.5, Math.min(0.5, inventory.drift)); + const bidPrice = book.midPrice * (1 - halfSpread - inventorySkew * config.rebalanceThreshold); + const askPrice = book.midPrice * (1 + halfSpread - inventorySkew * config.rebalanceThreshold); + const activeBudget = Math.max(0, config.maxOrders - ownOrders.length); + + if (ownOrders.length > config.maxOrders) { + for (const order of ownOrders.slice(config.maxOrders)) { + actions.push({ + id: stableId(['conclude', order.order_id]), + kind: 'conclude-order', + orderId: order.order_id, + reason: `Own order count exceeds MAX_ORDERS=${config.maxOrders}.`, + }); + } + } + + if (activeBudget <= 0) { + return actions; + } + + if (inventory.baseBalance < config.maxPosition && inventory.quoteBalance > config.orderSize * bidPrice) { + actions.push({ + id: stableId(['bid', config.pair, bidPrice.toFixed(8), config.orderSize]), + kind: 'create-order', + side: 'bid', + price: bidPrice, + reason: 'Quote below mid price to acquire base asset while respecting inventory limits.', + args: { + conclude_destination: concludeDestination, + ask_token: config.baseToken, + ask_amount: config.orderSize, + give_token: config.quoteToken, + give_amount: config.orderSize * bidPrice, + }, + }); + } + + if (inventory.baseBalance >= config.orderSize) { + actions.push({ + id: stableId(['ask', config.pair, askPrice.toFixed(8), config.orderSize]), + kind: 'create-order', + side: 'ask', + price: askPrice, + reason: 'Quote above mid price to sell base asset while keeping exposure bounded.', + args: { + conclude_destination: concludeDestination, + ask_token: config.quoteToken, + ask_amount: config.orderSize * askPrice, + give_token: config.baseToken, + give_amount: config.orderSize, + }, + }); + } + + return actions.slice(0, activeBudget); +} + +function canFillLevel(level: BookLevel, isOwn: boolean, allowSelfFills: boolean): boolean { + return allowSelfFills || !isOwn; +} + +export function planFillActions(args: { + config: MarketMakerConfig; + book: SyntheticBook; + wallet: WalletSnapshot | null; +}): StrategyAction[] { + const { config, book, wallet } = args; + const actions: StrategyAction[] = []; + + if (!config.enableFillTrading || !book.midPrice || !wallet?.addresses.receiving[0]) { + return actions; + } + + const destination = wallet.addresses.receiving[0]; + const inventory = calculateInventory(wallet, config, book.midPrice); + const ownedAddresses = new Set([...wallet.addresses.receiving, ...wallet.addresses.change]); + + // Need more base: take liquidity from the best ask (buy base with quote). + if (inventory.drift < -config.rebalanceThreshold && book.asks.length > 0) { + const level = book.asks[0]; + const isOwn = ownedAddresses.has(level.ownerAddress); + if (canFillLevel(level, isOwn, config.allowSelfFills)) { + const fillAmount = Math.min(config.orderSize * level.price, level.quoteAmount); + if (fillAmount > 0 && inventory.quoteBalance > fillAmount) { + actions.push({ + id: stableId(['fill-buy', level.orderId, fillAmount.toFixed(8)]), + kind: 'fill-order', + side: 'buy', + reason: isOwn + ? 'Inventory below target: fill own ask to rebalance (self-fill enabled).' + : 'Inventory below target: fill external ask to buy base and generate trade volume.', + orderId: level.orderId, + amount: fillAmount, + price: level.price, + expectedBaseAmount: Math.min(config.orderSize, level.baseAmount), + expectedQuoteAmount: fillAmount, + isOwnOrder: isOwn, + }); + } + } + } + + // Need less base: take liquidity from the best bid (sell base for quote). + if (inventory.drift > config.rebalanceThreshold && book.bids.length > 0) { + const level = book.bids[0]; + const isOwn = ownedAddresses.has(level.ownerAddress); + if (canFillLevel(level, isOwn, config.allowSelfFills)) { + const fillAmount = Math.min(config.orderSize, level.baseAmount); + if (fillAmount > 0 && inventory.baseBalance >= fillAmount) { + actions.push({ + id: stableId(['fill-sell', level.orderId, fillAmount.toFixed(8)]), + kind: 'fill-order', + side: 'sell', + reason: isOwn + ? 'Inventory above target: fill own bid to rebalance (self-fill enabled).' + : 'Inventory above target: fill external bid to sell base and generate trade volume.', + orderId: level.orderId, + amount: fillAmount, + price: level.price, + expectedBaseAmount: fillAmount, + expectedQuoteAmount: fillAmount * level.price, + isOwnOrder: isOwn, + }); + } + } + } + + return actions; +} + +export function planAllStrategyActions(args: { + config: MarketMakerConfig; + book: SyntheticBook; + ownOrders: MarketOrder[]; + wallet: WalletSnapshot | null; +}): StrategyAction[] { + return [...planFillActions(args), ...planStrategyActions(args)]; +} + +export function actionToText(action: StrategyAction, labels: TokenLabelMap): string { + const fmt = (token: TokenRef) => formatTokenLabel(token, labels); + + if (action.kind === 'conclude-order') { + return `Conclude stale order ${action.orderId}`; + } + + if (action.kind === 'fill-order') { + return `${action.side.toUpperCase()} fill ${action.amount.toFixed(8)} on ${action.orderId.slice(0, 12)}... @ ${action.price.toFixed(8)}`; + } + + return `${action.side.toUpperCase()} ${action.args.give_amount.toFixed(8)} ${fmt(action.args.give_token)} for ${action.args.ask_amount.toFixed(8)} ${fmt(action.args.ask_token)}`; +} diff --git a/examples/market-maker-bot/src/lib/tokens.ts b/examples/market-maker-bot/src/lib/tokens.ts new file mode 100644 index 0000000..5d997d9 --- /dev/null +++ b/examples/market-maker-bot/src/lib/tokens.ts @@ -0,0 +1,119 @@ +import { resolveApiBaseUrl } from './client'; +import type { BranchInfo, MarketMakerConfig, TokenRef, WalletSnapshot } from './types'; + +export type TokenMetadata = { + tokenId: string; + ticker: string; + decimals: number; +}; + +export type TokenLabelMap = Record; + +const COIN_METADATA: TokenMetadata = { + tokenId: 'Coin', + ticker: 'ML', + decimals: 11, +}; + +function requestHeaders(config: MarketMakerConfig): HeadersInit { + if (!config.apiKey) { + return {}; + } + + return { + Authorization: `Bearer ${config.apiKey}`, + 'X-API-Key': config.apiKey, + }; +} + +export async function fetchTokenMetadata( + config: MarketMakerConfig, + tokenId: string, +): Promise { + if (tokenId === 'Coin') { + return COIN_METADATA; + } + + try { + const response = await fetch(`${resolveApiBaseUrl(config)}/token/${tokenId}`, { + headers: requestHeaders(config), + }); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { + token_ticker?: { string?: string }; + number_of_decimals?: number; + }; + + return { + tokenId, + ticker: data.token_ticker?.string?.trim() || shortenTokenId(tokenId), + decimals: data.number_of_decimals ?? 0, + }; + } catch { + return null; + } +} + +export async function loadTokenLabels( + config: MarketMakerConfig, + tokenIds: Iterable, +): Promise { + const labels: TokenLabelMap = { Coin: COIN_METADATA }; + const uniqueIds = [...new Set([...tokenIds].filter((tokenId) => tokenId && tokenId !== 'Coin'))]; + + await Promise.all( + uniqueIds.map(async (tokenId) => { + const metadata = await fetchTokenMetadata(config, tokenId); + if (metadata) { + labels[tokenId] = metadata; + } + }), + ); + + return labels; +} + +export function mergeTokenLabels(current: TokenLabelMap, next: TokenLabelMap): TokenLabelMap { + return { ...current, ...next }; +} + +export function shortenTokenId(tokenId: string): string { + if (tokenId.length <= 16) { + return tokenId; + } + + return `${tokenId.slice(0, 6)}...${tokenId.slice(-4)}`; +} + +export function formatTokenLabel(token: TokenRef, labels: TokenLabelMap): string { + if (token === 'Coin') { + return labels.Coin?.ticker ?? 'ML'; + } + + return labels[token]?.ticker ?? shortenTokenId(token); +} + +export function formatPairLabel(config: MarketMakerConfig, labels: TokenLabelMap): string { + if (config.pair && !config.pair.includes('tmltk')) { + return config.pair; + } + + return `${formatTokenLabel(config.baseToken, labels)}/${formatTokenLabel(config.quoteToken, labels)}`; +} + +export function collectTokenIds(args: { + config: MarketMakerConfig; + wallet: WalletSnapshot | null; + branches: BranchInfo[]; +}): string[] { + const ids = new Set([args.config.baseToken, args.config.quoteToken]); + + Object.keys(args.wallet?.sdkBalances?.token ?? {}).forEach((tokenId) => ids.add(tokenId)); + args.branches.forEach((branch) => ids.add(branch.asset)); + + return [...ids]; +} diff --git a/examples/market-maker-bot/src/lib/trades.ts b/examples/market-maker-bot/src/lib/trades.ts new file mode 100644 index 0000000..f00fc5f --- /dev/null +++ b/examples/market-maker-bot/src/lib/trades.ts @@ -0,0 +1,33 @@ +import type { ExecutionRecord, TradeRecord } from './types'; + +export function executionRecordToTrade(record: ExecutionRecord): TradeRecord | null { + if (record.kind !== 'fill-order' || !record.tradeMeta) { + return null; + } + + return { + id: record.id, + orderId: record.tradeMeta.orderId, + side: record.tradeMeta.side, + fillAmount: record.tradeMeta.fillAmount, + expectedBaseAmount: record.tradeMeta.expectedBaseAmount, + expectedQuoteAmount: record.tradeMeta.expectedQuoteAmount, + price: record.tradeMeta.price, + status: record.status, + isOwnOrder: record.tradeMeta.isOwnOrder, + txId: record.txId, + timestamp: record.updatedAt, + description: record.description, + }; +} + +export function listTrades(records: ExecutionRecord[]): TradeRecord[] { + return records + .map(executionRecordToTrade) + .filter((trade): trade is TradeRecord => trade !== null) + .sort((left, right) => right.timestamp - left.timestamp); +} + +export function countBroadcastedTrades(records: ExecutionRecord[]): number { + return listTrades(records).filter((trade) => trade.status === 'broadcasted' || trade.status === 'confirmed').length; +} diff --git a/examples/market-maker-bot/src/lib/types.ts b/examples/market-maker-bot/src/lib/types.ts new file mode 100644 index 0000000..1ab446c --- /dev/null +++ b/examples/market-maker-bot/src/lib/types.ts @@ -0,0 +1,217 @@ +import type { Client, CreateOrderArgs, WalletBalance, WalletTx, WalletUtxo } from '@mintlayer/sdk'; + +export type NetworkName = 'testnet' | 'mainnet'; + +export type TokenRef = 'Coin' | string; + +export type BotMode = 'idle' | 'initializing' | 'ready' | 'running' | 'error'; + +export type ExecutionStatus = 'draft' | 'signed' | 'local' | 'broadcasted' | 'confirmed' | 'rejected'; + +export type ExecutionKind = 'create-order' | 'fill-order' | 'conclude-order' | 'prepare-utxo'; + +export type MarketMakerConfig = { + network: NetworkName; + apiUrl?: string; + apiKey?: string; + walletSeed?: string; + pair: string; + baseToken: TokenRef; + quoteToken: TokenRef; + orderSize: number; + spreadBps: number; + inventoryTarget: number; + rebalanceThreshold: number; + maxPosition: number; + maxOrders: number; + pollIntervalMs: number; + maxUnconfirmedBranchDepth: number; + allowMainnetBroadcast: boolean; + enableFillTrading: boolean; + allowSelfFills: boolean; +}; + +export type WalletSnapshot = { + addresses: { + receiving: string[]; + change: string[]; + }; + sdkBalances: { + coin: number; + token: Record; + } | null; + localBalance: WalletBalance | null; + utxos: WalletUtxo[]; + spendableUtxos: WalletUtxo[]; + transactions: WalletTx[]; +}; + +export type OrderCurrency = { + type: 'Coin' | 'Token' | 'TokenV1'; + token_id?: string; +}; + +export type AmountLike = { + atoms?: string | number; + decimal?: string; +}; + +export type MarketOrder = { + order_id: string; + ask_balance: AmountLike; + initially_asked: AmountLike; + ask_currency: OrderCurrency; + give_balance: AmountLike; + initially_given: AmountLike; + give_currency: OrderCurrency; + conclude_destination: string; + nonce?: number; +}; + +export type BookSide = 'bid' | 'ask'; + +export type BookLevel = { + side: BookSide; + orderId: string; + price: number; + baseAmount: number; + quoteAmount: number; + ownerAddress: string; +}; + +export type SyntheticBook = { + bids: BookLevel[]; + asks: BookLevel[]; + midPrice: number | null; + bestBid: number | null; + bestAsk: number | null; +}; + +export type StrategyAction = + | { + id: string; + kind: 'create-order'; + side: BookSide; + reason: string; + price: number; + args: CreateOrderArgs; + } + | { + id: string; + kind: 'conclude-order'; + reason: string; + orderId: string; + } + | { + id: string; + kind: 'fill-order'; + side: 'buy' | 'sell'; + reason: string; + orderId: string; + amount: number; + price: number; + expectedBaseAmount: number; + expectedQuoteAmount: number; + isOwnOrder: boolean; + }; + +export type ExecutionRequest = + | { + id: string; + kind: 'create-order'; + args: CreateOrderArgs; + description: string; + idempotencyKey: string; + } + | { + id: string; + kind: 'conclude-order'; + orderId: string; + description: string; + idempotencyKey: string; + } + | { + id: string; + kind: 'fill-order'; + orderId: string; + amount: number; + destination: string; + description: string; + idempotencyKey: string; + tradeMeta: NonNullable; + } + | { + id: string; + kind: 'prepare-utxo'; + to: string; + amount: number; + tokenId?: string; + description: string; + idempotencyKey: string; + }; + +export type ExecutionRecord = { + id: string; + kind: ExecutionKind; + description: string; + idempotencyKey: string; + status: ExecutionStatus; + createdAt: number; + updatedAt: number; + txId?: string; + signedHex?: string; + error?: string; + broadcastResponse?: unknown; + tradeMeta?: { + side: 'buy' | 'sell'; + orderId: string; + fillAmount: number; + price: number; + expectedBaseAmount: number; + expectedQuoteAmount: number; + isOwnOrder: boolean; + }; +}; + +export type TradeRecord = { + id: string; + orderId: string; + side: 'buy' | 'sell'; + fillAmount: number; + expectedBaseAmount: number; + expectedQuoteAmount: number; + price: number; + status: ExecutionStatus; + isOwnOrder: boolean; + txId?: string; + timestamp: number; + description: string; +}; + +export type BranchInfo = { + id: string; + asset: TokenRef; + outpoint: string; + status: WalletUtxo['status']; + amountAtoms: string; + depth: number; + remainingDepth: number; + reserved: boolean; + warning: string | null; +}; + +export type BranchPreparationPlan = { + sourceAsset: TokenRef; + targetBranchCount: number; + perBranchAmount: number; + destination: string; + actions: ExecutionRequest[]; + warnings: string[]; +}; + +export type BotRuntime = { + mode: BotMode; + client: Client | null; + initializedAt: number | null; + error: string | null; +}; diff --git a/examples/market-maker-bot/src/lib/utxoBranches.ts b/examples/market-maker-bot/src/lib/utxoBranches.ts new file mode 100644 index 0000000..84b04d7 --- /dev/null +++ b/examples/market-maker-bot/src/lib/utxoBranches.ts @@ -0,0 +1,109 @@ +import type { BranchInfo, BranchPreparationPlan, ExecutionRequest, TokenRef, WalletSnapshot } from './types'; + +function utxoAsset(utxo: WalletSnapshot['utxos'][number]): TokenRef { + const value = (utxo.utxo as { value?: { type?: string; token_id?: string } }).value; + if (value?.type === 'TokenV1') { + return value.token_id ?? 'unknown'; + } + + return 'Coin'; +} + +function amountAtoms(utxo: WalletSnapshot['utxos'][number]): string { + const value = (utxo.utxo as { value?: { amount?: { atoms?: string | number } } }).value; + return String(value?.amount?.atoms ?? '0'); +} + +function outpointKey(utxo: WalletSnapshot['utxos'][number]): string { + return `${utxo.txId}:${utxo.outputIndex}`; +} + +export function analyzeBranches(snapshot: WalletSnapshot | null, maxDepth: number): BranchInfo[] { + if (!snapshot) { + return []; + } + + return snapshot.utxos + .filter((utxo) => utxo.status === 'confirmed' || utxo.status === 'unconfirmed') + .map((utxo, index) => { + const depth = utxo.unconfirmedChainDepth; + const remainingDepth = Math.max(0, maxDepth - depth); + const warning = + remainingDepth <= 0 + ? 'Depth budget exhausted' + : remainingDepth <= 3 + ? 'Depth budget is low' + : null; + + return { + id: `branch-${index + 1}`, + asset: utxoAsset(utxo), + outpoint: outpointKey(utxo), + status: utxo.status, + amountAtoms: amountAtoms(utxo), + depth, + remainingDepth, + reserved: utxo.status === 'spent_pending', + warning, + }; + }) + .sort((a, b) => b.remainingDepth - a.remainingDepth); +} + +export function chooseBranch(branches: BranchInfo[], asset: TokenRef): BranchInfo | null { + return branches.find((branch) => branch.asset === asset && branch.remainingDepth > 0 && !branch.reserved) ?? null; +} + +export function createBranchPreparationPlan(args: { + snapshot: WalletSnapshot | null; + sourceAsset: TokenRef; + targetBranchCount: number; + perBranchAmount: number; +}): BranchPreparationPlan { + const { snapshot, sourceAsset, targetBranchCount, perBranchAmount } = args; + const destination = snapshot?.addresses.receiving[0] ?? ''; + const warnings: string[] = []; + const actions: ExecutionRequest[] = []; + + if (!snapshot || !destination) { + warnings.push('Initialize the wallet before preparing branches.'); + return { sourceAsset, targetBranchCount, perBranchAmount, destination, actions, warnings }; + } + + const existingBranches = analyzeBranches(snapshot, Number.MAX_SAFE_INTEGER).filter((branch) => branch.asset === sourceAsset); + const missingBranches = Math.max(0, targetBranchCount - existingBranches.length); + + if (missingBranches === 0) { + warnings.push('Requested branch count is already available in local wallet state.'); + } + + if (perBranchAmount <= 0) { + warnings.push('Per-branch amount must be positive.'); + } + + if (missingBranches > 1) { + warnings.push('Prepare branches one at a time unless the API confirms each prior transaction in mempool to avoid accidental double-spends.'); + } + + for (let index = 0; index < missingBranches; index += 1) { + const id = `prepare:${sourceAsset}:${destination}:${perBranchAmount}:${index}`; + actions.push({ + id, + kind: 'prepare-utxo', + to: destination, + amount: perBranchAmount, + tokenId: sourceAsset === 'Coin' ? undefined : sourceAsset, + description: `Prepare branch ${existingBranches.length + index + 1} with ${perBranchAmount} ${sourceAsset}.`, + idempotencyKey: id, + }); + } + + return { + sourceAsset, + targetBranchCount, + perBranchAmount, + destination, + actions, + warnings, + }; +} diff --git a/examples/market-maker-bot/src/lib/walletStore.ts b/examples/market-maker-bot/src/lib/walletStore.ts new file mode 100644 index 0000000..511279f --- /dev/null +++ b/examples/market-maker-bot/src/lib/walletStore.ts @@ -0,0 +1,167 @@ +import { + WalletState, + type IsMineOutput, + type SyncCursor, + type WalletTx, + type WalletTxStore, +} from '@mintlayer/sdk'; + +import type { Client } from '@mintlayer/sdk'; +import type { WalletSnapshot } from './types'; + +const STORAGE_PREFIX = 'market-maker-bot:wallet-state'; + +type StoredWalletState = { + transactions: WalletTx[]; + cursor: SyncCursor | null; +}; + +function storageKey(accountId: string): string { + return `${STORAGE_PREFIX}:${accountId}`; +} + +function readStoredState(accountId: string): StoredWalletState { + if (typeof window === 'undefined') { + return { transactions: [], cursor: null }; + } + + const raw = window.localStorage.getItem(storageKey(accountId)); + if (!raw) { + return { transactions: [], cursor: null }; + } + + try { + const parsed = JSON.parse(raw) as Partial; + return { + transactions: Array.isArray(parsed.transactions) ? parsed.transactions : [], + cursor: parsed.cursor ?? null, + }; + } catch { + return { transactions: [], cursor: null }; + } +} + +function writeStoredState(accountId: string, state: StoredWalletState): void { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(storageKey(accountId), JSON.stringify(state)); +} + +export function createLocalStorageWalletTxStore(accountId: string): WalletTxStore { + return { + async getTransactions(): Promise { + return readStoredState(accountId).transactions; + }, + + async putTransaction(_accountId: string, tx: WalletTx): Promise { + const state = readStoredState(accountId); + const nextTransactions = new Map(state.transactions.map((item) => [item.txId, item])); + nextTransactions.set(tx.txId, tx); + writeStoredState(accountId, { ...state, transactions: Array.from(nextTransactions.values()) }); + }, + + async removeTransaction(_accountId: string, txId: string): Promise { + const state = readStoredState(accountId); + writeStoredState(accountId, { + ...state, + transactions: state.transactions.filter((item) => item.txId !== txId), + }); + }, + + async getCursor(): Promise { + return readStoredState(accountId).cursor; + }, + + async setCursor(_accountId: string, cursor: SyncCursor): Promise { + const state = readStoredState(accountId); + writeStoredState(accountId, { ...state, cursor }); + }, + }; +} + +export function createAddressOwnership(addresses: { receiving: string[]; change: string[] }): IsMineOutput { + const owned = new Set([...addresses.receiving, ...addresses.change]); + + return (output: unknown): boolean => { + const destination = (output as { destination?: unknown }).destination; + return typeof destination === 'string' && owned.has(destination); + }; +} + +export function getAccountId(addresses: { receiving: string[]; change: string[] }): string { + return [...addresses.receiving, ...addresses.change].join('|') || 'disconnected'; +} + +export function clearLocalWalletState(accountId: string): void { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.removeItem(storageKey(accountId)); +} + +export function clearAllLocalWalletState(): void { + if (typeof window === 'undefined') { + return; + } + + const keysToRemove: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(STORAGE_PREFIX)) { + keysToRemove.push(key); + } + } + + keysToRemove.forEach((key) => window.localStorage.removeItem(key)); +} + +export async function createWalletStateForAddresses(addresses: { + receiving: string[]; + change: string[]; +}): Promise { + const accountId = getAccountId(addresses); + return WalletState.create({ + accountId, + store: createLocalStorageWalletTxStore(accountId), + isMineOutput: createAddressOwnership(addresses), + }); +} + +export async function loadWalletSnapshot( + client: Client, + walletState: WalletState, + maxUnconfirmedBranchDepth: number, +): Promise { + await walletState.ensureFresh(); + + const addresses = client.getAddresses(); + let sdkBalances: WalletSnapshot['sdkBalances'] = null; + + try { + sdkBalances = await client.getBalances(); + } catch { + sdkBalances = null; + } + + return { + addresses, + sdkBalances, + localBalance: walletState.getBalance({ includeUnconfirmed: true }), + utxos: walletState.getUtxos({ + includeSpent: true, + includeRejected: true, + includeConflicted: true, + includeOrphaned: true, + includeUnconfirmed: true, + }), + spendableUtxos: walletState.getSpendableUtxos({ + allowUnconfirmed: true, + allowOwnChangeOnly: true, + maxUnconfirmedChainDepth: maxUnconfirmedBranchDepth, + }), + transactions: await createLocalStorageWalletTxStore(getAccountId(addresses)).getTransactions(getAccountId(addresses)), + }; +} diff --git a/examples/market-maker-bot/src/main.tsx b/examples/market-maker-bot/src/main.tsx new file mode 100644 index 0000000..fa74c07 --- /dev/null +++ b/examples/market-maker-bot/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + , +); diff --git a/examples/market-maker-bot/src/styles.css b/examples/market-maker-bot/src/styles.css new file mode 100644 index 0000000..53875ba --- /dev/null +++ b/examples/market-maker-bot/src/styles.css @@ -0,0 +1,372 @@ +:root { + color: #172033; + background: #f4f7fb; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input { + font: inherit; +} + +button { + border: 0; + border-radius: 10px; + background: #1f5eff; + color: white; + cursor: pointer; + font-weight: 700; + padding: 0.7rem 1rem; +} + +button:disabled { + background: #9aa8c0; + cursor: not-allowed; +} + +button.danger { + background: #b42318; +} + +button.danger:hover:not(:disabled) { + background: #912018; +} + +input { + border: 1px solid #d9e1ee; + border-radius: 10px; + color: #172033; + padding: 0.65rem 0.75rem; + width: 100%; +} + +main { + margin: 0 auto; + max-width: 1440px; + padding: 2rem; +} + +.hero { + align-items: stretch; + display: grid; + gap: 1rem; + grid-template-columns: 1fr 260px; + margin-bottom: 1rem; +} + +.hero h1 { + font-size: clamp(2rem, 4vw, 4rem); + line-height: 1; + margin: 0 0 1rem; +} + +.hero p { + color: #526074; + max-width: 820px; +} + +.eyebrow { + color: #1f5eff; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.heroCard, +.panel, +.toolbar, +.banner { + background: white; + border: 1px solid #dfe7f3; + border-radius: 22px; + box-shadow: 0 18px 50px rgb(31 94 255 / 8%); +} + +.heroCard { + display: flex; + flex-direction: column; + gap: 0.4rem; + justify-content: center; + padding: 1.5rem; +} + +.heroCard span, +.heroCard small, +.muted, +.stats span, +.addressBlock span { + color: #68768d; +} + +.heroCard strong { + font-size: 2rem; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 1rem; +} + +.layout { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.panel { + padding: 1.25rem; +} + +.wide { + grid-column: 1 / -1; +} + +.panelHeader { + align-items: center; + display: flex; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.panelHeader h2, +.panel h3 { + margin: 0; +} + +.badge { + background: #eef4ff; + border-radius: 999px; + color: #1f5eff; + font-size: 0.8rem; + font-weight: 800; + padding: 0.35rem 0.65rem; +} + +.grid { + display: grid; + gap: 0.85rem; +} + +.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +label { + color: #46546b; + display: grid; + font-size: 0.88rem; + font-weight: 700; + gap: 0.35rem; +} + +.switchRow { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-top: 1rem; +} + +.switchLabel { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.switchLabel input { + width: auto; +} + +.warningList, +.warningText { + color: #9a5b00; +} + +.warningList { + margin-bottom: 0; +} + +.errorText { + color: #b42318; +} + +.banner { + margin-bottom: 1rem; + padding: 1rem; +} + +.stats { + display: grid; + gap: 0.85rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.stats div, +.branchCard, +.listItem, +.prepBox, +.addressBlock { + background: #f7f9fd; + border: 1px solid #e2e9f4; + border-radius: 16px; + padding: 1rem; +} + +.stats div { + display: grid; + gap: 0.25rem; +} + +.stats strong { + font-size: 1.2rem; +} + +.addressBlock { + display: grid; + gap: 0.45rem; + margin-top: 1rem; +} + +.tokenList { + display: grid; + gap: 0.5rem; +} + +.tokenList div { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: space-between; +} + +.fieldHint, +.tokenIdHint { + color: #5c667a; + display: block; + font-size: 0.82rem; + margin-top: 0.25rem; +} + +.tokenIdHint { + font-size: 0.75rem; + opacity: 0.85; +} + +code { + color: #1b3f8b; + overflow-wrap: anywhere; +} + +.bookGrid { + display: grid; + gap: 1rem; + grid-template-columns: 1fr 1fr; +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, +td { + border-bottom: 1px solid #e4ebf5; + font-size: 0.88rem; + padding: 0.55rem 0.25rem; + text-align: right; +} + +th:first-child, +td:first-child { + text-align: left; +} + +.list { + display: grid; + gap: 0.75rem; +} + +.listItem { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.listItem p { + color: #526074; + margin: 0.25rem 0 0; +} + +.actionKind { + background: #eef4ff; + border-radius: 999px; + color: #1f5eff; + display: inline-block; + font-size: 0.72rem; + margin-right: 0.35rem; + padding: 0.15rem 0.45rem; + vertical-align: middle; +} + +.branchGrid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.branchCard { + display: grid; + gap: 0.45rem; +} + +.branchCard p { + margin: 0; +} + +.depthBar { + background: #dbe5f4; + border-radius: 999px; + height: 10px; + overflow: hidden; +} + +.depthBar span { + background: #1f5eff; + display: block; + height: 100%; +} + +.prepBox { + margin-top: 1rem; +} + +@media (max-width: 960px) { + main { + padding: 1rem; + } + + .hero, + .layout, + .two, + .bookGrid, + .stats { + grid-template-columns: 1fr; + } +} diff --git a/examples/market-maker-bot/tsconfig.json b/examples/market-maker-bot/tsconfig.json new file mode 100644 index 0000000..c24d82d --- /dev/null +++ b/examples/market-maker-bot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/examples/market-maker-bot/tsconfig.node.json b/examples/market-maker-bot/tsconfig.node.json new file mode 100644 index 0000000..fc35f45 --- /dev/null +++ b/examples/market-maker-bot/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/market-maker-bot/vite.config.ts b/examples/market-maker-bot/vite.config.ts new file mode 100644 index 0000000..2270330 --- /dev/null +++ b/examples/market-maker-bot/vite.config.ts @@ -0,0 +1,12 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + optimizeDeps: { + exclude: ['@mintlayer/wasm-lib'], + }, + server: { + port: 5173, + }, +}); diff --git a/package.json b/package.json index 8ce966c..b1896d0 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "mintlayer-connect-sdk", "version": "0.1.0", "private": true, + "packageManager": "pnpm@10.15.0", "scripts": { "build": "pnpm -r build", "build:sdk": "pnpm --filter @mintlayer/sdk build", From 6e8f9597e7af091e8e1c0fcc25d0e3347d3e87e0 Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Mon, 21 Sep 2026 22:12:51 +0200 Subject: [PATCH 02/10] fix sdk provider and adjust init --- examples/market-maker-bot/src/lib/client.ts | 7 - packages/sdk/src/mintlayer-connect-sdk.ts | 52 ++- pnpm-lock.yaml | 439 +++++++++++++++++++- 3 files changed, 462 insertions(+), 36 deletions(-) diff --git a/examples/market-maker-bot/src/lib/client.ts b/examples/market-maker-bot/src/lib/client.ts index 7dd5f3c..0cea8fc 100644 --- a/examples/market-maker-bot/src/lib/client.ts +++ b/examples/market-maker-bot/src/lib/client.ts @@ -126,19 +126,12 @@ export async function createBotClient(config: MarketMakerConfig): Promise; + private addresses?: Address; + private privateKeys?: Record; private readonly network: Network; + private readonly mnemonic: string; + private readonly receivingAddressCount: number; + private readonly changeAddressCount: number; + private initialization?: Promise; constructor( mnemonic: string, @@ -376,27 +380,46 @@ class MnemonicAccountProvider implements AccountProvider { ) { const { receivingAddressCount = 1, changeAddressCount = 1 } = options; this.network = network === 'mainnet' ? Network.Mainnet : Network.Testnet; + this.mnemonic = mnemonic; + this.receivingAddressCount = receivingAddressCount; + this.changeAddressCount = changeAddressCount; + } + + /** + * Delays key derivation until an async provider operation. This lets callers + * supply the provider to `Client.create()` before the client initializes the + * WASM module. + */ + private async ensureInitialized(): Promise { + if (!this.initialization) { + this.initialization = this.initialize(); + } + await this.initialization; + } - const accountPrivKey = make_default_account_privkey(mnemonic, this.network); + private async initialize(): Promise { + await initWasm(); + + const accountPrivKey = make_default_account_privkey(this.mnemonic, this.network); const receiving: string[] = []; const change: string[] = []; - this.privateKeys = {}; + const privateKeys: Record = {}; - for (let i = 0; i < receivingAddressCount; i++) { + for (let i = 0; i < this.receivingAddressCount; i++) { const privKey = make_receiving_address(accountPrivKey, i); const pubKey = public_key_from_private_key(privKey); const address = pubkey_to_pubkeyhash_address(pubKey, this.network); receiving.push(address); - this.privateKeys[address] = privKey; + privateKeys[address] = privKey; } - for (let i = 0; i < changeAddressCount; i++) { + for (let i = 0; i < this.changeAddressCount; i++) { const privKey = make_change_address(accountPrivKey, i); const pubKey = public_key_from_private_key(privKey); const address = pubkey_to_pubkeyhash_address(pubKey, this.network); change.push(address); - this.privateKeys[address] = privKey; + privateKeys[address] = privKey; } this.addresses = { @@ -404,27 +427,32 @@ class MnemonicAccountProvider implements AccountProvider { mintlayer: { receiving, change }, }, }; + this.privateKeys = privateKeys; } async connect(): Promise
{ - return this.addresses; + await this.ensureInitialized(); + return this.addresses!; } async restore(): Promise
{ - return this.addresses; + await this.ensureInitialized(); + return this.addresses!; } async disconnect(): Promise {} async request(method: string, params: any): Promise { + await this.ensureInitialized(); + if (method === 'signTransaction') { - const signer = new Signer(this.privateKeys, this.network); + const signer = new Signer(this.privateKeys!, this.network); return signer.sign(params.txData); } if (method === 'signChallenge') { const { message, address } = params; - const privateKey = this.privateKeys[address]; + const privateKey = this.privateKeys![address]; if (!privateKey) { throw new Error(`Private key not found for address: ${address}`); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b04fe59..12bef0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,34 @@ importers: specifier: ^4.0.0 version: 4.10.0(webpack@5.109.2) + examples/market-maker-bot: + dependencies: + '@mintlayer/sdk': + specifier: workspace:* + version: link:../../packages/sdk + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.31 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.1.1(vite@8.3.0(@types/node@20.19.43)(jiti@1.21.7)(terser@5.51.0)(yaml@2.9.0)) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.0.16 + version: 8.3.0(@types/node@20.19.43)(jiti@1.21.7)(terser@5.51.0)(yaml@2.9.0) + examples/swap-board-ml-btc: dependencies: '@mintlayer/sdk': @@ -555,28 +583,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@14.0.4': resolution: {integrity: sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@14.0.4': resolution: {integrity: sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@14.0.4': resolution: {integrity: sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@14.0.4': resolution: {integrity: sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==} @@ -612,6 +636,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + '@prisma/client@5.22.0': resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} engines: {node: '>=16.13'} @@ -636,6 +663,99 @@ packages: '@prisma/get-platform@5.22.0': resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -883,61 +1003,51 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -964,6 +1074,22 @@ packages: cpu: [x64] os: [win32] + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1587,6 +1713,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -2679,6 +2809,76 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -3148,6 +3348,10 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3324,6 +3528,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3756,6 +3965,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} @@ -3817,6 +4031,49 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + w3c-xmlserializer@4.0.0: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} @@ -4534,6 +4791,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.150.0': {} + '@prisma/client@5.22.0(prisma@5.22.0)': optionalDependencies: prisma: 5.22.0 @@ -4559,6 +4818,53 @@ snapshots: dependencies: '@prisma/debug': 5.22.0 + '@rolldown/binding-android-arm-eabi@1.2.9': + optional: true + + '@rolldown/binding-android-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-x64@1.2.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.9': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.9': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.16.1': {} @@ -4885,6 +5191,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@20.19.43)(jiti@1.21.7)(terser@5.51.0)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.0(@types/node@20.19.43)(jiti@1.21.7)(terser@5.51.0)(yaml@2.9.0) + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -5562,6 +5873,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@2.1.2: {} + detect-newline@3.1.0: {} detect-node@2.1.0: {} @@ -5791,7 +6104,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -5821,7 +6134,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -5836,7 +6149,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7070,6 +7383,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -7473,6 +7835,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prettier@3.9.6: {} @@ -7658,6 +8026,27 @@ snapshots: dependencies: glob: 7.2.3 + rolldown@1.2.9: + dependencies: + '@oxc-project/types': 0.150.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -8182,6 +8571,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + uc.micro@2.1.0: {} uglify-js@3.19.3: @@ -8258,6 +8649,20 @@ snapshots: vary@1.1.2: {} + vite@8.3.0(@types/node@20.19.43)(jiti@1.21.7)(terser@5.51.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.9 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + jiti: 1.21.7 + terser: 5.51.0 + yaml: 2.9.0 + w3c-xmlserializer@4.0.0: dependencies: xml-name-validator: 4.0.0 From e705ff5f0a6d7e5a2b5e6c49da37ce5255a42d2a Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Mon, 21 Sep 2026 22:23:38 +0200 Subject: [PATCH 03/10] preload orders --- .../market-maker-bot/src/hooks/useMarketMakerBot.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts index 4ce2c7d..10a4ff6 100644 --- a/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts +++ b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts @@ -5,7 +5,7 @@ import { createBotClient } from '../lib/client'; import { loadConfigFromEnv, validateConfig } from '../lib/config'; import { executeRequest, mergeRecords, strategyActionToRequest } from '../lib/execution'; import { buildSyntheticBook, isOwnOrder } from '../lib/orderBook'; -import { fetchPairOrders, getPairOrdersPath } from '../lib/orders'; +import { fetchPairOrders } from '../lib/orders'; import { planAllStrategyActions } from '../lib/strategy'; import { listTrades } from '../lib/trades'; import { analyzeBranches, createBranchPreparationPlan } from '../lib/utxoBranches'; @@ -149,9 +149,20 @@ export function useMarketMakerBot() { const addresses = client.getAddresses(); const nextWalletState = await createWalletStateForAddresses(addresses); const walletSnapshot = await loadWalletSnapshot(client, nextWalletState, config.maxUnconfirmedBranchDepth); + const [pairOrders, accountOrders] = await Promise.all([fetchPairOrders(config), client.getAccountOrders()]); + + const typedOrders = pairOrders as MarketOrder[]; + const typedOwnOrders = + accountOrders.length > 0 + ? (accountOrders as MarketOrder[]).filter((order) => + typedOrders.some((pairOrder) => pairOrder.order_id === order.order_id), + ) + : typedOrders.filter((order) => isOwnOrder(order, walletSnapshot.addresses)); setWalletState(nextWalletState); setWallet(walletSnapshot); + setOrders(typedOrders); + setOwnOrders(typedOwnOrders); const branchSnapshot = analyzeBranches(walletSnapshot, config.maxUnconfirmedBranchDepth); const labels = await loadTokenLabels( From 8389a81cc0367b4b6940185d6a0d51c8f71ebc2f Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Mon, 21 Sep 2026 22:46:12 +0200 Subject: [PATCH 04/10] order book view --- examples/market-maker-bot/src/App.tsx | 66 +++-- .../src/components/Panels.tsx | 115 +++++--- examples/market-maker-bot/src/styles.css | 261 +++++++++++++----- 3 files changed, 301 insertions(+), 141 deletions(-) diff --git a/examples/market-maker-bot/src/App.tsx b/examples/market-maker-bot/src/App.tsx index 61e6948..2597f08 100644 --- a/examples/market-maker-bot/src/App.tsx +++ b/examples/market-maker-bot/src/App.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { BranchPanel, ConfigPanel, @@ -15,6 +17,7 @@ function formatTime(value: number | null): string { } export default function App() { + const [configOpen, setConfigOpen] = useState(false); const { state, setConfig, @@ -33,24 +36,21 @@ export default function App() { const running = state.runtime.mode === 'running'; return ( -
-
-
-

Mintlayer SDK Example

-

Market Maker Bot

-

- Browser-based testnet SPA for synthetic orderbook tracking, inventory-aware quoting, - transaction lifecycle visualization, and UTXO branch preparation. -

-
+
+
- Status + Runtime {state.runtime.mode} Last cycle: {formatTime(state.lastCycleAt)}
-
- -
+ @@ -82,16 +82,6 @@ export default function App() { {state.runtime.error &&
{state.runtime.error}
}
- void executePreparationAction(request)} />
+ + {configOpen && ( +
setConfigOpen(false)}> + +
+ )}
); } diff --git a/examples/market-maker-bot/src/components/Panels.tsx b/examples/market-maker-bot/src/components/Panels.tsx index 58f37f1..bd8557b 100644 --- a/examples/market-maker-bot/src/components/Panels.tsx +++ b/examples/market-maker-bot/src/components/Panels.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { isActiveOrder } from '../lib/orderBook'; @@ -49,7 +50,7 @@ export function ConfigPanel(props: ConfigPanelProps) { const { config, tokenLabels, warnings, broadcastEnabled, dryRun, setConfig, setBroadcastEnabled, setDryRun } = props; return ( -
+

Strategy Config

{formatPairLabel(config, tokenLabels)} @@ -176,7 +177,7 @@ export function WalletPanel(props: { wallet && config.baseToken !== 'Coin' && configuredTokenBalance === 0 && tokenBalances.length > 0; return ( -
+

Wallet State

{wallet ? 'loaded' : 'not loaded'} @@ -235,6 +236,7 @@ export function OrderBookPanel(props: { config: MarketMakerConfig; tokenLabels: TokenLabelMap; }) { + const [detailsOpen, setDetailsOpen] = useState(false); const { book, orders, ownOrders, config, tokenLabels } = props; const activeOrders = orders.filter(isActiveOrder); const pairPath = getPairOrdersPath(config); @@ -242,47 +244,72 @@ export function OrderBookPanel(props: { const quoteLabel = formatTokenLabel(config.quoteToken, tokenLabels); return ( -
-
+
+
-
- Pair - {formatPairLabel(config, tokenLabels)} - {pairPath ?? 'Only token/Coin pairs are supported right now'} + {activeOrders.length} active · details + +
+ +
-
-
- Best Bid - {book.bestBid?.toFixed(8) ?? '-'} -
-
- Best Ask - {book.bestAsk?.toFixed(8) ?? '-'} -
-
- Mid - {book.midPrice?.toFixed(8) ?? '-'} -
-
- Own Orders - {ownOrders.length} + + {detailsOpen && ( +
setDetailsOpen(false)}> +
-
-
- - -
- {activeOrders.length === 0 && ( -

- No active orders returned for this pair. Filled or zero-balance orders are ignored. -

- )} - {book.midPrice === null && activeOrders.length > 0 && ( -

- Orders were fetched, but none matched the configured base/quote token ids for book construction. -

)}
); @@ -321,7 +348,7 @@ export function StrategyPanel(props: { dryRun: boolean; }) { return ( -
+

Strategy Proposals

{props.actions.length} actions @@ -346,7 +373,7 @@ export function StrategyPanel(props: { export function TransactionPanel(props: { records: ExecutionRecord[] }) { return ( -
+

Transactions

{props.records.length} tracked @@ -372,7 +399,7 @@ export function TradesPanel(props: { trades: TradeRecord[]; tokenLabels: TokenLa const baseLabel = formatTokenLabel(props.baseToken, props.tokenLabels); return ( -
+

Trades (Fills)

{props.trades.length} recorded @@ -412,7 +439,7 @@ export function BranchPanel(props: { onExecute: (request: ExecutionRequest) => void; }) { return ( -
+

UTXO Branches

{props.branches.length} branches diff --git a/examples/market-maker-bot/src/styles.css b/examples/market-maker-bot/src/styles.css index 53875ba..07b7dc1 100644 --- a/examples/market-maker-bot/src/styles.css +++ b/examples/market-maker-bot/src/styles.css @@ -13,6 +13,7 @@ body { margin: 0; + overflow: hidden; } button, @@ -22,12 +23,12 @@ input { button { border: 0; - border-radius: 10px; + border-radius: 7px; background: #1f5eff; color: white; cursor: pointer; font-weight: 700; - padding: 0.7rem 1rem; + padding: 0.45rem 0.7rem; } button:disabled { @@ -39,49 +40,39 @@ button.danger { background: #b42318; } +button.secondary { + background: #eef4ff; + color: #1f5eff; +} + +button.secondary:hover:not(:disabled) { + background: #dce9ff; +} + button.danger:hover:not(:disabled) { background: #912018; } input { border: 1px solid #d9e1ee; - border-radius: 10px; + border-radius: 7px; color: #172033; - padding: 0.65rem 0.75rem; + padding: 0.42rem 0.55rem; width: 100%; } main { margin: 0 auto; - max-width: 1440px; - padding: 2rem; -} - -.hero { - align-items: stretch; - display: grid; - gap: 1rem; - grid-template-columns: 1fr 260px; - margin-bottom: 1rem; + max-width: 1800px; + padding: 0.75rem; } -.hero h1 { - font-size: clamp(2rem, 4vw, 4rem); - line-height: 1; - margin: 0 0 1rem; -} - -.hero p { - color: #526074; - max-width: 820px; -} - -.eyebrow { - color: #1f5eff; - font-size: 0.78rem; - font-weight: 800; - letter-spacing: 0.1em; - text-transform: uppercase; +.appShell { + display: flex; + flex-direction: column; + gap: 0.6rem; + height: 100dvh; + overflow: hidden; } .heroCard, @@ -90,16 +81,15 @@ main { .banner { background: white; border: 1px solid #dfe7f3; - border-radius: 22px; - box-shadow: 0 18px 50px rgb(31 94 255 / 8%); + border-radius: 10px; + box-shadow: 0 6px 18px rgb(31 94 255 / 6%); } .heroCard { + align-items: baseline; display: flex; - flex-direction: column; - gap: 0.4rem; - justify-content: center; - padding: 1.5rem; + gap: 0.45rem; + padding: 0.4rem 0.65rem; } .heroCard span, @@ -111,37 +101,120 @@ main { } .heroCard strong { - font-size: 2rem; + font-size: 0.85rem; } .toolbar { + align-items: center; display: flex; flex-wrap: wrap; - gap: 0.75rem; - margin-bottom: 1rem; - padding: 1rem; + gap: 0.4rem; + padding: 0.45rem; +} + +.controlBar .heroCard { + margin-right: 0.1rem; } .layout { display: grid; - gap: 1rem; - grid-template-columns: repeat(2, minmax(0, 1fr)); + flex: 1; + gap: 0.6rem; + grid-template-areas: + "wallet wallet strategy strategy transactions trades" + "book book branches branches branches branches"; + grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + min-height: 0; + overflow: hidden; } .panel { - padding: 1.25rem; + min-height: 0; + overflow: auto; + padding: 0.7rem; } -.wide { - grid-column: 1 / -1; +.walletPanel { + grid-area: wallet; +} + +.orderBookPanel { + grid-area: book; +} + +.strategyPanel { + grid-area: strategy; +} + +.transactionPanel { + grid-area: transactions; +} + +.tradesPanel { + grid-area: trades; +} + +.branchPanel { + grid-area: branches; +} + +.modalBackdrop { + align-items: center; + background: rgb(23 32 51 / 42%); + display: flex; + inset: 0; + justify-content: center; + padding: 1rem; + position: fixed; + z-index: 10; +} + +.configDialog { + background: #f4f7fb; + border: 1px solid #dfe7f3; + border-radius: 12px; + box-shadow: 0 20px 60px rgb(23 32 51 / 25%); + max-height: min(720px, calc(100dvh - 2rem)); + overflow: auto; + padding: 0.7rem; + width: min(760px, 100%); +} + +.orderBookDialog { + background: #f4f7fb; + border: 1px solid #dfe7f3; + border-radius: 12px; + box-shadow: 0 20px 60px rgb(23 32 51 / 25%); + max-height: min(720px, calc(100dvh - 2rem)); + overflow: auto; + padding: 0.7rem; + width: min(980px, 100%); +} + +.dialogHeader { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 0.55rem; +} + +.dialogHeader h2 { + font-size: 1rem; + margin: 0; +} + +.configDialog .configPanel { + grid-area: auto; + overflow: visible; } .panelHeader { align-items: center; display: flex; justify-content: space-between; - gap: 1rem; - margin-bottom: 1rem; + gap: 0.5rem; + margin-bottom: 0.55rem; } .panelHeader h2, @@ -149,18 +222,37 @@ main { margin: 0; } +.panelHeader h2 { + font-size: 0.95rem; +} + +.orderBookHeader { + align-items: center; + background: transparent; + color: #172033; + display: flex; + justify-content: space-between; + padding: 0; + text-align: left; + width: 100%; +} + +.orderBookHeader:hover:not(:disabled) h2 { + color: #1f5eff; +} + .badge { background: #eef4ff; border-radius: 999px; color: #1f5eff; - font-size: 0.8rem; + font-size: 0.7rem; font-weight: 800; - padding: 0.35rem 0.65rem; + padding: 0.2rem 0.45rem; } .grid { display: grid; - gap: 0.85rem; + gap: 0.55rem; } .two { @@ -170,7 +262,7 @@ main { label { color: #46546b; display: grid; - font-size: 0.88rem; + font-size: 0.76rem; font-weight: 700; gap: 0.35rem; } @@ -178,8 +270,8 @@ label { .switchRow { display: flex; flex-wrap: wrap; - gap: 1rem; - margin-top: 1rem; + gap: 0.55rem; + margin-top: 0.6rem; } .switchLabel { @@ -206,13 +298,15 @@ label { } .banner { - margin-bottom: 1rem; - padding: 1rem; + flex: 0 0 auto; + max-height: 4rem; + overflow: auto; + padding: 0.55rem 0.7rem; } .stats { display: grid; - gap: 0.85rem; + gap: 0.45rem; grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -223,8 +317,8 @@ label { .addressBlock { background: #f7f9fd; border: 1px solid #e2e9f4; - border-radius: 16px; - padding: 1rem; + border-radius: 8px; + padding: 0.55rem; } .stats div { @@ -233,13 +327,13 @@ label { } .stats strong { - font-size: 1.2rem; + font-size: 1rem; } .addressBlock { display: grid; - gap: 0.45rem; - margin-top: 1rem; + gap: 0.3rem; + margin-top: 0.55rem; } .tokenList { @@ -275,7 +369,7 @@ code { .bookGrid { display: grid; - gap: 1rem; + gap: 0.6rem; grid-template-columns: 1fr 1fr; } @@ -287,8 +381,8 @@ table { th, td { border-bottom: 1px solid #e4ebf5; - font-size: 0.88rem; - padding: 0.55rem 0.25rem; + font-size: 0.72rem; + padding: 0.35rem 0.2rem; text-align: right; } @@ -299,13 +393,13 @@ td:first-child { .list { display: grid; - gap: 0.75rem; + gap: 0.45rem; } .listItem { align-items: center; display: flex; - gap: 1rem; + gap: 0.55rem; justify-content: space-between; } @@ -327,7 +421,7 @@ td:first-child { .branchGrid { display: grid; - gap: 0.75rem; + gap: 0.45rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } @@ -354,16 +448,37 @@ td:first-child { } .prepBox { - margin-top: 1rem; + margin-top: 0.55rem; +} + +@media (max-width: 1180px) { + .layout { + grid-template-areas: + "wallet strategy transactions" + "book trades branches"; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + } } -@media (max-width: 960px) { +@media (max-width: 720px) { main { - padding: 1rem; + padding: 0.5rem; + } + + .heroCard { + width: 100%; + } + + .layout { + grid-template-areas: + "wallet book" + "strategy transactions" + "trades branches"; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(0, 1fr)); } - .hero, - .layout, .two, .bookGrid, .stats { From 623541714bfeb27b87860674a6e95863978308bf Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Mon, 21 Sep 2026 22:51:11 +0200 Subject: [PATCH 05/10] conclude order ui --- examples/market-maker-bot/src/App.tsx | 3 ++ .../src/components/Panels.tsx | 46 +++++++++++++++++-- .../src/hooks/useMarketMakerBot.ts | 24 ++++++++++ examples/market-maker-bot/src/styles.css | 9 ++++ 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/examples/market-maker-bot/src/App.tsx b/examples/market-maker-bot/src/App.tsx index 2597f08..7cbb8af 100644 --- a/examples/market-maker-bot/src/App.tsx +++ b/examples/market-maker-bot/src/App.tsx @@ -29,6 +29,7 @@ export default function App() { stopLoop, resetLocalState, executeStrategyAction, + concludeOrder, executePreparationAction, } = useMarketMakerBot(); @@ -94,6 +95,8 @@ export default function App() { ownOrders={state.ownOrders} config={state.config} tokenLabels={state.tokenLabels} + canConclude={initialized && !state.dryRun} + onConclude={(orderId) => void concludeOrder(orderId)} /> void; }) { const [detailsOpen, setDetailsOpen] = useState(false); - const { book, orders, ownOrders, config, tokenLabels } = props; + const { book, orders, ownOrders, config, tokenLabels, canConclude, onConclude } = props; const activeOrders = orders.filter(isActiveOrder); const pairPath = getPairOrdersPath(config); const baseLabel = formatTokenLabel(config.baseToken, tokenLabels); @@ -297,9 +299,26 @@ export function OrderBookPanel(props: {
- - + +
+ {!canConclude && ( +

Initialize the SDK and turn off Dry Run in Strategy config to conclude and broadcast an order.

+ )} {activeOrders.length === 0 && (

No active orders returned for this pair. Filled or zero-balance orders are ignored.

)} @@ -315,7 +334,14 @@ export function OrderBookPanel(props: { ); } -function OrderSide(props: { title: string; rows: SyntheticBook['bids']; baseLabel: string; quoteLabel: string }) { +function OrderSide(props: { + title: string; + rows: SyntheticBook['bids']; + baseLabel: string; + quoteLabel: string; + canConclude?: boolean; + onConclude?: (orderId: string) => void; +}) { return (

{props.title}

@@ -325,6 +351,7 @@ function OrderSide(props: { title: string; rows: SyntheticBook['bids']; baseLabe Price ({props.quoteLabel}/{props.baseLabel}) {props.baseLabel} {props.quoteLabel} + {props.onConclude && } @@ -333,6 +360,17 @@ function OrderSide(props: { title: string; rows: SyntheticBook['bids']; baseLabe {row.price.toFixed(8)} {row.baseAmount.toFixed(8)} {row.quoteAmount.toFixed(8)} + {props.onConclude && ( + + + + )} ))} diff --git a/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts index 10a4ff6..b6b6ca7 100644 --- a/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts +++ b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts @@ -225,6 +225,29 @@ export function useMarketMakerBot() { [execute, wallet?.addresses.receiving], ); + const concludeOrder = useCallback( + async (orderId: string) => { + const destination = wallet?.addresses.receiving[0]; + if (!destination) { + return; + } + + await execute( + strategyActionToRequest( + { + id: `manual-conclude:${orderId}`, + kind: 'conclude-order', + orderId, + reason: 'Manually concluded from the order book.', + }, + destination, + ), + true, + ); + }, + [execute, wallet?.addresses.receiving], + ); + const executePreparationAction = useCallback( async (request: ExecutionRequest) => { await execute(request); @@ -352,6 +375,7 @@ export function useMarketMakerBot() { stopLoop, resetLocalState, executeStrategyAction, + concludeOrder, executePreparationAction, }; } diff --git a/examples/market-maker-bot/src/styles.css b/examples/market-maker-bot/src/styles.css index 07b7dc1..e134ee7 100644 --- a/examples/market-maker-bot/src/styles.css +++ b/examples/market-maker-bot/src/styles.css @@ -391,6 +391,15 @@ td:first-child { text-align: left; } +.orderAction { + width: 1%; +} + +.orderAction button { + font-size: 0.7rem; + padding: 0.3rem 0.45rem; +} + .list { display: grid; gap: 0.45rem; From 6e584a0ab8bdf5dd759d92da37e158b85b0368c7 Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Mon, 21 Sep 2026 23:03:51 +0200 Subject: [PATCH 06/10] fix the order conclude input --- packages/sdk/src/mintlayer-connect-sdk.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/mintlayer-connect-sdk.ts b/packages/sdk/src/mintlayer-connect-sdk.ts index 3bc6201..869fa35 100644 --- a/packages/sdk/src/mintlayer-connect-sdk.ts +++ b/packages/sdk/src/mintlayer-connect-sdk.ts @@ -4569,6 +4569,8 @@ class Signer { const optUtxos = new Uint8Array(optUtxosArray); + console.log('tx.JSONRepresentation', tx.JSONRepresentation); + const encodedWitnesses = tx.JSONRepresentation.inputs.map((input: any, index: number) => { let address: string | undefined = undefined; @@ -4582,7 +4584,7 @@ class Signer { address = input.input.authority; } - if (input.input.input_type === 'AccountCommand' && input.input.command === 'FillOrder') { + if (input.input.input_type === 'AccountCommand' && ["ConcludeOrder", "FillOrder"].includes(input.input.command)) { address = input.input.destination; } From ef751d46c6ed6eaa5c0bd4d31bc904d0a8af81b6 Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Tue, 22 Sep 2026 00:01:42 +0200 Subject: [PATCH 07/10] fix order ownership display --- .../src/components/Panels.tsx | 61 ++++++++---- examples/market-maker-bot/src/styles.css | 17 ++++ packages/sdk/src/mintlayer-connect-sdk.ts | 92 +++++++++++++++++-- packages/sdk/src/transaction.ts | 2 +- 4 files changed, 145 insertions(+), 27 deletions(-) diff --git a/examples/market-maker-bot/src/components/Panels.tsx b/examples/market-maker-bot/src/components/Panels.tsx index ccfb948..3eedc1f 100644 --- a/examples/market-maker-bot/src/components/Panels.tsx +++ b/examples/market-maker-bot/src/components/Panels.tsx @@ -239,8 +239,13 @@ export function OrderBookPanel(props: { onConclude: (orderId: string) => void; }) { const [detailsOpen, setDetailsOpen] = useState(false); + const [showForeignOrders, setShowForeignOrders] = useState(true); const { book, orders, ownOrders, config, tokenLabels, canConclude, onConclude } = props; const activeOrders = orders.filter(isActiveOrder); + const ownOrderIds = new Set(ownOrders.map((order) => order.order_id)); + const isOwnOrder = (orderId: string) => ownOrderIds.has(orderId); + const visibleRows = (rows: SyntheticBook['bids']) => + showForeignOrders ? rows : rows.filter((row) => isOwnOrder(row.orderId)); const pairPath = getPairOrdersPath(config); const baseLabel = formatTokenLabel(config.baseToken, tokenLabels); const quoteLabel = formatTokenLabel(config.quoteToken, tokenLabels); @@ -298,22 +303,34 @@ export function OrderBookPanel(props: { {ownOrders.length}
+
{!canConclude && ( @@ -341,6 +358,8 @@ function OrderSide(props: { quoteLabel: string; canConclude?: boolean; onConclude?: (orderId: string) => void; + isOwnOrder?: (orderId: string) => boolean; + highlightOwnOrders?: boolean; }) { return (
@@ -355,24 +374,30 @@ function OrderSide(props: { - {props.rows.map((row) => ( - - {row.price.toFixed(8)} - {row.baseAmount.toFixed(8)} - {row.quoteAmount.toFixed(8)} - {props.onConclude && ( + {props.rows.map((row) => { + const isOwnOrder = props.isOwnOrder?.(row.orderId) ?? false; + + return ( + + {row.price.toFixed(8)} + {row.baseAmount.toFixed(8)} + {row.quoteAmount.toFixed(8)} + {props.onConclude && ( - + {isOwnOrder && ( + + )} - )} - - ))} + )} + + ); + })}
diff --git a/examples/market-maker-bot/src/styles.css b/examples/market-maker-bot/src/styles.css index e134ee7..a9b820a 100644 --- a/examples/market-maker-bot/src/styles.css +++ b/examples/market-maker-bot/src/styles.css @@ -373,6 +373,18 @@ code { grid-template-columns: 1fr 1fr; } +.foreignOrdersToggle { + align-items: center; + display: flex; + font-size: 0.8rem; + gap: 0.4rem; + margin: 0.75rem 0; +} + +.foreignOrdersToggle input { + width: auto; +} + table { border-collapse: collapse; width: 100%; @@ -391,6 +403,11 @@ td:first-child { text-align: left; } +.ownOrder td { + background: #e8f7ee; + border-bottom-color: #b9e2c8; +} + .orderAction { width: 1%; } diff --git a/packages/sdk/src/mintlayer-connect-sdk.ts b/packages/sdk/src/mintlayer-connect-sdk.ts index 869fa35..93e348d 100644 --- a/packages/sdk/src/mintlayer-connect-sdk.ts +++ b/packages/sdk/src/mintlayer-connect-sdk.ts @@ -17,6 +17,7 @@ import initWasm, { data_deposit_fee, encode_signed_transaction, encode_witness, + encode_witness_no_signature, SignatureHashType, extract_htlc_secret, verify_challenge, @@ -27,6 +28,7 @@ import initWasm, { pubkey_to_pubkeyhash_address, sign_challenge, } from '@mintlayer/wasm-lib'; +import type { OrderAdditionalInfo as WasmOrderAdditionalInfo } from '@mintlayer/wasm-lib'; import { Transaction, FEE_BLOCK_HEIGHT } from './transaction'; import { mergeUint8Arrays, @@ -722,6 +724,8 @@ export type { TransactionJSONRepresentation } from './types/transaction'; type AssembledTransaction = AssembledTransactionData & { intent?: string; htlc?: { spend_pubkey: string }; + /** Order state required by wasm-lib when signing FillOrder/ConcludeOrder inputs. */ + orderInfo?: Record; }; /** @@ -952,7 +956,7 @@ type BuildTransactionParams = opts?: TransactionOpts; }; -interface OrderData { +export interface OrderData { order_id: string; ask_balance: AmountFields; nonce: number; @@ -964,6 +968,12 @@ interface OrderData { give_currency: { type: 'Coin' } | { type: 'Token'; token_id: string }; } +/** + * Order state supplied to wasm-lib when signing an order transaction. + * Keys in the surrounding record must be Mintlayer order IDs. + */ +export type OrderAdditionalInfo = WasmOrderAdditionalInfo; + interface ClientOptions { network?: 'testnet' | 'mainnet'; autoRestore?: boolean; @@ -3786,10 +3796,14 @@ class Client { give_token_details = await this.apiProvider.getToken(give_currency.token_id); } - return this.buildTransaction({ + const tx = await this.buildTransaction({ type: 'FillOrder', params: { order_id, amount, destination, order_details, ask_token_details, give_token_details }, }); + tx.orderInfo = { + [order_details.order_id]: Signer.orderAdditionalInfoFromOrder(order_details), + }; + return tx; } /** @@ -3828,7 +3842,11 @@ class Client { this.validateRawId(order_id, 'conclude order', 'order_id'); const order: OrderData = await this.apiProvider.getOrder(order_id); - return this.buildTransaction({ type: 'ConcludeOrder', params: { order } }); + const tx = await this.buildTransaction({ type: 'ConcludeOrder', params: { order } }); + tx.orderInfo = { + [order.order_id]: Signer.orderAdditionalInfoFromOrder(order), + }; + return tx; } /** @@ -4490,10 +4508,52 @@ class Client { class Signer { private keys: Record; private network: Network; + private orderInfo: Record; - constructor(privateKeys: Record, network: Network = Network.Testnet) { + constructor( + privateKeys: Record, + network: Network = Network.Testnet, + orderInfo: Record = {}, + ) { this.keys = privateKeys; this.network = network; + this.orderInfo = { ...orderInfo }; + } + + /** + * Converts an explorer/API order into the shape required by wasm-lib. + */ + static orderAdditionalInfoFromOrder(order: OrderData): OrderAdditionalInfo { + const currencyAmount = ( + currency: OrderData['ask_currency'], + amount: AmountFields, + ): OrderAdditionalInfo['initially_asked'] => + currency.type === 'Coin' + ? { coins: { atoms: String(amount.atoms) } } + : { tokens: { token_id: currency.token_id, amount: { atoms: String(amount.atoms) } } }; + + return { + initially_asked: currencyAmount(order.ask_currency, order.initially_asked), + initially_given: currencyAmount(order.give_currency, order.initially_given), + ask_balance: { atoms: String(order.ask_balance.atoms) }, + give_balance: { atoms: String(order.give_balance.atoms) }, + }; + } + + /** + * Adds or replaces the metadata for an order returned by the API. + * Call this before {@link sign} when signing a manually assembled order transaction. + */ + setOrderInfo(order: OrderData): this { + return this.setOrderAdditionalInfo(order.order_id, Signer.orderAdditionalInfoFromOrder(order)); + } + + /** + * Adds or replaces pre-converted order metadata. The key must equal the order ID. + */ + setOrderAdditionalInfo(orderId: string, info: OrderAdditionalInfo): this { + this.orderInfo[orderId] = info; + return this; } private getPrivateKey(address: string): Uint8Array | undefined { @@ -4502,6 +4562,19 @@ class Signer { private createSignature(tx: AssembledTransaction) { const network = this.network; + // Metadata is transaction-wide: wasm-lib requires every order referenced by + // any input to be present while each individual witness is encoded. + const orderInfo = { ...this.orderInfo, ...tx.orderInfo }; + for (const { input } of tx.JSONRepresentation.inputs as Input[]) { + if ( + input.input_type === 'AccountCommand' && + (input.command === 'FillOrder' || input.command === 'ConcludeOrder') && + !orderInfo[input.order_id] + ) { + throw new Error(`Order metadata not found for order: ${input.order_id}`); + } + } + const optUtxos_ = tx.JSONRepresentation.inputs.map((input: any) => { if (input.input.input_type !== 'UTXO') { return 0; @@ -4569,8 +4642,6 @@ class Signer { const optUtxos = new Uint8Array(optUtxosArray); - console.log('tx.JSONRepresentation', tx.JSONRepresentation); - const encodedWitnesses = tx.JSONRepresentation.inputs.map((input: any, index: number) => { let address: string | undefined = undefined; @@ -4584,7 +4655,12 @@ class Signer { address = input.input.authority; } - if (input.input.input_type === 'AccountCommand' && ["ConcludeOrder", "FillOrder"].includes(input.input.command)) { + if (input.input.input_type === 'AccountCommand' && input.input.command === 'FillOrder') { + // FillOrder has no signature witness in orders V1. + return encode_witness_no_signature(); + } + + if (input.input.input_type === 'AccountCommand' && input.input.command === 'ConcludeOrder') { address = input.input.destination; } @@ -4603,7 +4679,7 @@ class Signer { const block_height = FEE_BLOCK_HEIGHT; const additional_info = { pool_info: {}, - order_info: {}, + order_info: orderInfo, }; const witness = encode_witness( diff --git a/packages/sdk/src/transaction.ts b/packages/sdk/src/transaction.ts index afb859b..0931864 100644 --- a/packages/sdk/src/transaction.ts +++ b/packages/sdk/src/transaction.ts @@ -64,7 +64,7 @@ export const FEE_AMOUNT_PER_KB = BigInt('100000000000'); * raw assembler has always used it, and it is the default for the fluent * builder when no block height was passed to the constructor. */ -export const FEE_BLOCK_HEIGHT = 200000n; +export const FEE_BLOCK_HEIGHT = 800000n; /** * Everything the assembler needs that does not belong to the transaction From 3c322cf689365b6dcff47749b924ed428c2a903c Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Tue, 22 Sep 2026 00:29:09 +0200 Subject: [PATCH 08/10] fix suggestions pool --- .../src/components/Panels.tsx | 12 ++- .../src/hooks/useMarketMakerBot.ts | 28 +++++-- .../market-maker-bot/src/lib/execution.ts | 32 ++++---- examples/market-maker-bot/src/lib/strategy.ts | 80 +++++++++++-------- examples/market-maker-bot/src/lib/types.ts | 4 + .../market-maker-bot/src/lib/walletStore.ts | 55 +++++++++++-- packages/sdk/src/mintlayer-connect-sdk.ts | 33 ++++++-- packages/sdk/src/wallet-state.ts | 6 +- 8 files changed, 178 insertions(+), 72 deletions(-) diff --git a/examples/market-maker-bot/src/components/Panels.tsx b/examples/market-maker-bot/src/components/Panels.tsx index 3eedc1f..9da4f54 100644 --- a/examples/market-maker-bot/src/components/Panels.tsx +++ b/examples/market-maker-bot/src/components/Panels.tsx @@ -196,8 +196,16 @@ export function WalletPanel(props: { {(inventory.baseShare * 100).toFixed(2)}%
- Local UTXOs - {wallet?.utxos.length ?? 0} + Available UTXOs + {wallet?.availableUtxos.length ?? 0} +
+
+ Network UTXOs + {wallet?.networkUtxos.length ?? 0} +
+
+ Internal UTXOs + {wallet?.spendableUtxos.filter((utxo) => utxo.status === 'unconfirmed').length ?? 0}
diff --git a/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts index b6b6ca7..bc89185 100644 --- a/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts +++ b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts @@ -82,6 +82,7 @@ export function useMarketMakerBot() { const [records, setRecords] = useState([]); const [broadcastEnabled, setBroadcastEnabled] = useState(false); const [dryRun, setDryRun] = useState(true); + const [quoteLevelOffset, setQuoteLevelOffset] = useState(0); const [lastCycleAt, setLastCycleAt] = useState(null); const [tokenLabels, setTokenLabels] = useState({ Coin: { tokenId: 'Coin', ticker: 'ML', decimals: 11 }, @@ -90,10 +91,17 @@ export function useMarketMakerBot() { const configWarnings = useMemo(() => validateConfig(config), [config]); const book = useMemo(() => buildSyntheticBook(orders, config.baseToken, config.quoteToken), [orders, config]); - const actions = useMemo( - () => planAllStrategyActions({ config, book, ownOrders, wallet }), - [book, config, ownOrders, wallet], - ); + const actions = useMemo(() => { + const appliedActionIds = new Set( + records + .filter((record) => record.status !== 'rejected') + .map((record) => record.idempotencyKey), + ); + + return planAllStrategyActions({ config, book, ownOrders, wallet, quoteLevelOffset }).filter( + (action) => !appliedActionIds.has(action.id), + ); + }, [book, config, ownOrders, quoteLevelOffset, records, wallet]); const trades = useMemo(() => listTrades(records), [records]); const branches = useMemo( () => analyzeBranches(wallet, config.maxUnconfirmedBranchDepth), @@ -204,13 +212,15 @@ export function useMarketMakerBot() { request, config, broadcast: forceBroadcast && !dryRun, + availableUtxos: wallet?.availableUtxos ?? [], tradeMeta: request.kind === 'fill-order' ? request.tradeMeta : undefined, }); setRecords((current) => mergeRecords(current, record)); await refresh(); + return record; }, - [broadcastEnabled, config, dryRun, records, refresh, runtime.client, walletState], + [broadcastEnabled, config, dryRun, records, refresh, runtime.client, wallet?.availableUtxos, walletState], ); const executeStrategyAction = useCallback( @@ -220,7 +230,13 @@ export function useMarketMakerBot() { return; } - await execute(strategyActionToRequest(action, destination)); + const record = await execute(strategyActionToRequest(action, destination)); + // Advance after both previews and successful broadcasts. The market API + // can take a cycle to reflect a broadcast, so relying only on refreshed + // own orders leaves the proposal box empty in the meantime. + if (record?.status !== 'rejected') { + setQuoteLevelOffset((current) => current + 3); + } }, [execute, wallet?.addresses.receiving], ); diff --git a/examples/market-maker-bot/src/lib/execution.ts b/examples/market-maker-bot/src/lib/execution.ts index 5dd486f..8f98ff4 100644 --- a/examples/market-maker-bot/src/lib/execution.ts +++ b/examples/market-maker-bot/src/lib/execution.ts @@ -1,4 +1,4 @@ -import type { Client, WalletState } from '@mintlayer/sdk'; +import type { Client, TransactionOpts, WalletState, WalletUtxo } from '@mintlayer/sdk'; import type { ExecutionRecord, ExecutionRequest, MarketMakerConfig, StrategyAction } from './types'; @@ -81,35 +81,34 @@ export function mergeRecords(records: ExecutionRecord[], next: ExecutionRecord): return [next, ...records]; } -async function buildTransaction(client: Client, request: ExecutionRequest): Promise { - const sdkClient = client as unknown as { - buildCreateOrder: Client['buildCreateOrder']; - buildConcludeOrder: Client['buildConcludeOrder']; - buildFillOrder: Client['buildFillOrder']; - buildTransfer: Client['buildTransfer']; - }; +async function buildTransaction( + client: Client, + request: ExecutionRequest, + availableUtxos: WalletUtxo[], +): Promise { + const opts: TransactionOpts = { withUTXO: availableUtxos }; if (request.kind === 'create-order') { - return (await sdkClient.buildCreateOrder(request.args)) as unknown as BuiltTransaction; + return (await client.buildCreateOrder(request.args, opts)) as unknown as BuiltTransaction; } if (request.kind === 'conclude-order') { - return (await sdkClient.buildConcludeOrder({ order_id: request.orderId })) as unknown as BuiltTransaction; + return (await client.buildConcludeOrder({ order_id: request.orderId }, opts)) as unknown as BuiltTransaction; } if (request.kind === 'fill-order') { - return (await sdkClient.buildFillOrder({ + return (await client.buildFillOrder({ order_id: request.orderId, amount: request.amount, destination: request.destination, - })) as unknown as BuiltTransaction; + }, opts)) as unknown as BuiltTransaction; } if (request.tokenId) { - return (await sdkClient.buildTransfer({ to: request.to, amount: request.amount, token_id: request.tokenId })) as unknown as BuiltTransaction; + return (await client.buildTransfer({ to: request.to, amount: request.amount, token_id: request.tokenId }, opts)) as unknown as BuiltTransaction; } - return (await sdkClient.buildTransfer({ to: request.to, amount: request.amount })) as unknown as BuiltTransaction; + return (await client.buildTransfer({ to: request.to, amount: request.amount }, opts)) as unknown as BuiltTransaction; } export async function executeRequest(args: { @@ -118,9 +117,10 @@ export async function executeRequest(args: { request: ExecutionRequest; config: MarketMakerConfig; broadcast: boolean; + availableUtxos: WalletUtxo[]; tradeMeta?: ExecutionRecord['tradeMeta']; }): Promise { - const { client, walletState, request, config, broadcast, tradeMeta } = args; + const { client, walletState, request, config, broadcast, tradeMeta, availableUtxos } = args; const record = createRecord(request); if (config.network === 'mainnet' && !config.allowMainnetBroadcast && broadcast) { @@ -133,7 +133,7 @@ export async function executeRequest(args: { } try { - const tx = await buildTransaction(client, request); + const tx = await buildTransaction(client, request, availableUtxos); const signedHex = await (client as unknown as { signTransaction(tx: BuiltTransaction): Promise }).signTransaction(tx); const txJson = getTxJson(tx); await walletState.applyLocalTx(tx); diff --git a/examples/market-maker-bot/src/lib/strategy.ts b/examples/market-maker-bot/src/lib/strategy.ts index 3855260..6b43d32 100644 --- a/examples/market-maker-bot/src/lib/strategy.ts +++ b/examples/market-maker-bot/src/lib/strategy.ts @@ -50,8 +50,9 @@ export function planStrategyActions(args: { book: SyntheticBook; ownOrders: MarketOrder[]; wallet: WalletSnapshot | null; + quoteLevelOffset?: number; }): StrategyAction[] { - const { config, book, ownOrders, wallet } = args; + const { config, book, ownOrders, wallet, quoteLevelOffset = 0 } = args; const actions: StrategyAction[] = []; const addresses = wallet?.addresses; const concludeDestination = addresses?.receiving[0]; @@ -66,6 +67,10 @@ export function planStrategyActions(args: { const bidPrice = book.midPrice * (1 - halfSpread - inventorySkew * config.rebalanceThreshold); const askPrice = book.midPrice * (1 + halfSpread - inventorySkew * config.rebalanceThreshold); const activeBudget = Math.max(0, config.maxOrders - ownOrders.length); + // Keep several price levels queued for each side. Apart from providing a + // more useful market-making ladder, this means applying one proposal still + // leaves other independent proposals ready to review and execute. + const quoteLevels = 3; if (ownOrders.length > config.maxOrders) { for (const order of ownOrders.slice(config.maxOrders)) { @@ -82,38 +87,48 @@ export function planStrategyActions(args: { return actions; } - if (inventory.baseBalance < config.maxPosition && inventory.quoteBalance > config.orderSize * bidPrice) { - actions.push({ - id: stableId(['bid', config.pair, bidPrice.toFixed(8), config.orderSize]), - kind: 'create-order', - side: 'bid', - price: bidPrice, - reason: 'Quote below mid price to acquire base asset while respecting inventory limits.', - args: { - conclude_destination: concludeDestination, - ask_token: config.baseToken, - ask_amount: config.orderSize, - give_token: config.quoteToken, - give_amount: config.orderSize * bidPrice, - }, - }); - } + for (let level = 0; level < quoteLevels; level += 1) { + // Each extra level is one half-spread farther from the top quote. + const quoteLevel = quoteLevelOffset + level; + const levelOffset = quoteLevel * halfSpread; - if (inventory.baseBalance >= config.orderSize) { - actions.push({ - id: stableId(['ask', config.pair, askPrice.toFixed(8), config.orderSize]), - kind: 'create-order', - side: 'ask', - price: askPrice, - reason: 'Quote above mid price to sell base asset while keeping exposure bounded.', - args: { - conclude_destination: concludeDestination, - ask_token: config.quoteToken, - ask_amount: config.orderSize * askPrice, - give_token: config.baseToken, - give_amount: config.orderSize, - }, - }); + if (inventory.baseBalance < config.maxPosition) { + const levelBidPrice = bidPrice * (1 - levelOffset); + if (inventory.quoteBalance > config.orderSize * levelBidPrice) { + actions.push({ + id: stableId(['bid', config.pair, quoteLevel + 1, levelBidPrice.toFixed(8), config.orderSize]), + kind: 'create-order', + side: 'bid', + price: levelBidPrice, + reason: `Bid level ${quoteLevel + 1}: acquire base below mid price while respecting inventory limits.`, + args: { + conclude_destination: concludeDestination, + ask_token: config.baseToken, + ask_amount: config.orderSize, + give_token: config.quoteToken, + give_amount: config.orderSize * levelBidPrice, + }, + }); + } + } + + if (inventory.baseBalance >= config.orderSize) { + const levelAskPrice = askPrice * (1 + levelOffset); + actions.push({ + id: stableId(['ask', config.pair, quoteLevel + 1, levelAskPrice.toFixed(8), config.orderSize]), + kind: 'create-order', + side: 'ask', + price: levelAskPrice, + reason: `Ask level ${quoteLevel + 1}: sell base above mid price while keeping exposure bounded.`, + args: { + conclude_destination: concludeDestination, + ask_token: config.quoteToken, + ask_amount: config.orderSize * levelAskPrice, + give_token: config.baseToken, + give_amount: config.orderSize, + }, + }); + } } return actions.slice(0, activeBudget); @@ -197,6 +212,7 @@ export function planAllStrategyActions(args: { book: SyntheticBook; ownOrders: MarketOrder[]; wallet: WalletSnapshot | null; + quoteLevelOffset?: number; }): StrategyAction[] { return [...planFillActions(args), ...planStrategyActions(args)]; } diff --git a/examples/market-maker-bot/src/lib/types.ts b/examples/market-maker-bot/src/lib/types.ts index 1ab446c..ab9000c 100644 --- a/examples/market-maker-bot/src/lib/types.ts +++ b/examples/market-maker-bot/src/lib/types.ts @@ -41,6 +41,10 @@ export type WalletSnapshot = { token: Record; } | null; localBalance: WalletBalance | null; + /** Confirmed outputs currently reported by the network API. */ + networkUtxos: WalletUtxo[]; + /** Confirmed network outputs plus safe wallet-created unconfirmed outputs. */ + availableUtxos: WalletUtxo[]; utxos: WalletUtxo[]; spendableUtxos: WalletUtxo[]; transactions: WalletTx[]; diff --git a/examples/market-maker-bot/src/lib/walletStore.ts b/examples/market-maker-bot/src/lib/walletStore.ts index 511279f..9a2b45f 100644 --- a/examples/market-maker-bot/src/lib/walletStore.ts +++ b/examples/market-maker-bot/src/lib/walletStore.ts @@ -4,6 +4,7 @@ import { type SyncCursor, type WalletTx, type WalletTxStore, + type WalletUtxo, } from '@mintlayer/sdk'; import type { Client } from '@mintlayer/sdk'; @@ -146,10 +147,56 @@ export async function loadWalletSnapshot( sdkBalances = null; } + let networkUtxos: WalletUtxo[] = []; + try { + const networkEntries = await client.getAccountUtxos(); + networkUtxos = networkEntries.map((entry) => ({ + ...entry, + txId: entry.outpoint.source_id, + outputIndex: entry.outpoint.index, + status: 'confirmed' as const, + txState: 'confirmed' as const, + unconfirmedChainDepth: 0, + })); + } catch { + // Keep locally persisted outputs usable if the network API is temporarily unavailable. + } + + const transactions = await createLocalStorageWalletTxStore(getAccountId(addresses)).getTransactions(getAccountId(addresses)); + const reservedNetworkOutpoints = new Set( + transactions + .filter((tx) => !['rejected', 'conflicted', 'orphaned'].includes(tx.state)) + .flatMap((tx) => { + const txJson = 'JSONRepresentation' in tx.tx ? tx.tx.JSONRepresentation : tx.tx; + return txJson.inputs + .map((input: any) => input?.input) + .filter((input: any) => input?.input_type === 'UTXO') + .map((input: any) => `${input.source_id}:${input.index}`); + }), + ); + const spendableUtxos = walletState.getSpendableUtxos({ + allowUnconfirmed: true, + allowOwnChangeOnly: true, + maxUnconfirmedChainDepth: maxUnconfirmedBranchDepth, + }); + // A transaction may appear in both sources once the API observes it. Prefer + // the network entry then so its confirmed status wins, and never hand the + // assembler the same outpoint twice. + const availableUtxos = Array.from( + new Map( + [ + ...spendableUtxos, + ...networkUtxos.filter((utxo) => !reservedNetworkOutpoints.has(`${utxo.txId}:${utxo.outputIndex}`)), + ].map((utxo) => [`${utxo.txId}:${utxo.outputIndex}`, utxo]), + ).values(), + ); + return { addresses, sdkBalances, localBalance: walletState.getBalance({ includeUnconfirmed: true }), + networkUtxos, + availableUtxos, utxos: walletState.getUtxos({ includeSpent: true, includeRejected: true, @@ -157,11 +204,7 @@ export async function loadWalletSnapshot( includeOrphaned: true, includeUnconfirmed: true, }), - spendableUtxos: walletState.getSpendableUtxos({ - allowUnconfirmed: true, - allowOwnChangeOnly: true, - maxUnconfirmedChainDepth: maxUnconfirmedBranchDepth, - }), - transactions: await createLocalStorageWalletTxStore(getAccountId(addresses)).getTransactions(getAccountId(addresses)), + spendableUtxos, + transactions, }; } diff --git a/packages/sdk/src/mintlayer-connect-sdk.ts b/packages/sdk/src/mintlayer-connect-sdk.ts index 93e348d..ba02b04 100644 --- a/packages/sdk/src/mintlayer-connect-sdk.ts +++ b/packages/sdk/src/mintlayer-connect-sdk.ts @@ -765,7 +765,8 @@ type TransferParams = token_details?: undefined; }; -type TransactionOpts = { +/** Options controlling UTXO selection while assembling a transaction. */ +export type TransactionOpts = { withUTXO?: UtxoEntry[]; forceSpendUtxo?: UtxoEntry[]; }; @@ -1521,6 +1522,17 @@ class Client { } } + /** + * Returns UTXOs currently reported as spendable by the network for all + * connected receiving and change addresses. These are network-confirmed + * inputs; callers may combine them with locally tracked mempool outputs. + */ + async getAccountUtxos(): Promise { + this.ensureInitialized(); + const addresses = [...this.connectedAddresses.receiving, ...this.connectedAddresses.change]; + return this.apiProvider.getAccountUtxos(addresses, this.network === 'mainnet' ? 0 : 1); + } + /** * Returns the delegations for the connected addresses. */ @@ -3368,15 +3380,15 @@ class Client { * @param token_id - Optional token ID (if transferring tokens instead of base coin) * @returns A transaction ready to be signed */ - async buildTransfer({ to, amount, token_id }: TransferArgs): Promise { + async buildTransfer({ to, amount, token_id }: TransferArgs, opts?: TransactionOpts): Promise { this.ensureInitialized(); if (token_id) { this.validateRawId(token_id, 'transfer', 'token_id'); const token = await this.apiProvider.getToken(token_id); const token_details: TokenDetails = token; - return this.buildTransaction({ type: 'Transfer', params: { to, amount, token_id, token_details } }); + return this.buildTransaction({ type: 'Transfer', params: { to, amount, token_id, token_details }, opts }); } else { - return this.buildTransaction({ type: 'Transfer', params: { to, amount } }); + return this.buildTransaction({ type: 'Transfer', params: { to, amount }, opts }); } } @@ -3718,7 +3730,7 @@ class Client { ask_amount, give_token, give_amount, - }: CreateOrderArgs): Promise { + }: CreateOrderArgs, opts?: TransactionOpts): Promise { this.ensureInitialized(); let ask_token_details = null; @@ -3745,6 +3757,7 @@ class Client { ask_token_details, give_token_details, }, + opts, }); } @@ -3777,7 +3790,10 @@ class Client { /** * Builds an order fill transaction without signing it. */ - async buildFillOrder({ order_id, amount, destination }: FillOrderArgs): Promise { + async buildFillOrder( + { order_id, amount, destination }: FillOrderArgs, + opts?: TransactionOpts, + ): Promise { this.ensureInitialized(); this.validateRawId(order_id, 'fill order', 'order_id'); const data = await this.apiProvider.getOrder(order_id); @@ -3799,6 +3815,7 @@ class Client { const tx = await this.buildTransaction({ type: 'FillOrder', params: { order_id, amount, destination, order_details, ask_token_details, give_token_details }, + opts, }); tx.orderInfo = { [order_details.order_id]: Signer.orderAdditionalInfoFromOrder(order_details), @@ -3837,12 +3854,12 @@ class Client { /** * Builds an order conclusion transaction without signing it. */ - async buildConcludeOrder({ order_id }: ConcludeOrderArgs): Promise { + async buildConcludeOrder({ order_id }: ConcludeOrderArgs, opts?: TransactionOpts): Promise { this.ensureInitialized(); this.validateRawId(order_id, 'conclude order', 'order_id'); const order: OrderData = await this.apiProvider.getOrder(order_id); - const tx = await this.buildTransaction({ type: 'ConcludeOrder', params: { order } }); + const tx = await this.buildTransaction({ type: 'ConcludeOrder', params: { order }, opts }); tx.orderInfo = { [order.order_id]: Signer.orderAdditionalInfoFromOrder(order), }; diff --git a/packages/sdk/src/wallet-state.ts b/packages/sdk/src/wallet-state.ts index f002b5c..d132eaa 100644 --- a/packages/sdk/src/wallet-state.ts +++ b/packages/sdk/src/wallet-state.ts @@ -526,14 +526,16 @@ export class WalletState { * unconfirmed wallet-relevant transaction. */ async applyMempoolTx(tx: WalletTxInput | WalletTx): Promise { + const existing = this.transactions.find((item) => item.txId === ('state' in tx ? tx.txId : getTxId(tx))); const walletTx: WalletTx = 'state' in tx - ? { ...tx, state: 'mempool' } + ? { ...existing, ...tx, state: 'mempool' } : { + ...existing, txId: getTxId(tx), tx, state: 'mempool', - timestamp: Date.now(), + timestamp: existing?.timestamp ?? Date.now(), }; await this.store.putTransaction(this.accountId, walletTx); From 7dfd81a2d582ea37f271e324c721860e2560ee93 Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Tue, 22 Sep 2026 01:43:52 +0200 Subject: [PATCH 09/10] fix the sdk order and update bot ui --- examples/market-maker-bot/Readme.md | 9 +- examples/market-maker-bot/src/App.tsx | 13 ++ .../src/components/Panels.tsx | 99 ++++++++- .../src/hooks/useMarketMakerBot.ts | 208 ++++++++++++++++-- examples/market-maker-bot/src/lib/client.ts | 5 +- examples/market-maker-bot/src/lib/config.ts | 26 +++ .../market-maker-bot/src/lib/execution.ts | 19 +- .../market-maker-bot/src/lib/orderBook.ts | 18 ++ examples/market-maker-bot/src/lib/strategy.ts | 93 ++++++++ examples/market-maker-bot/src/lib/types.ts | 17 +- .../market-maker-bot/src/lib/utxoBranches.ts | 24 +- examples/market-maker-bot/src/styles.css | 21 +- packages/sdk/src/mintlayer-connect-sdk.ts | 4 +- packages/sdk/src/transaction.ts | 2 +- packages/sdk/tests/orders.test.ts | 24 +- packages/sdk/tests/transfer.test.ts | 22 +- 16 files changed, 545 insertions(+), 59 deletions(-) diff --git a/examples/market-maker-bot/Readme.md b/examples/market-maker-bot/Readme.md index 3365d0c..66a278f 100644 --- a/examples/market-maker-bot/Readme.md +++ b/examples/market-maker-bot/Readme.md @@ -90,6 +90,7 @@ Create a `.env` file: VITE_NETWORK=testnet VITE_API_URL= +VITE_BATCH_API_URL= VITE_API_KEY= VITE_WALLET_SEED=your-testnet-mnemonic @@ -98,6 +99,7 @@ VITE_PAIR=HUG/ML VITE_BASE_TOKEN=token_id_for_HUG VITE_QUOTE_TOKEN=Coin VITE_ORDER_SIZE=0.01 +VITE_REFERENCE_PRICE=1 VITE_SPREAD_BPS=20 VITE_INVENTORY_TARGET=0.5 VITE_REBALANCE_THRESHOLD=0.1 @@ -106,12 +108,16 @@ VITE_MAX_POSITION=1.0 VITE_MAX_ORDERS=10 VITE_MAX_UNCONFIRMED_BRANCH_DEPTH=24 VITE_ALLOW_MAINNET_BROADCAST=false +VITE_SIMULATE_OWN_FILLS=false +VITE_SIMULATION_TRADE_TIMEOUT_MS=60000 ``` Important: a `VITE_WALLET_SEED` value is bundled into browser code. Use this only for testnet/demo wallets. Production unattended bots should keep signing keys outside the browser. Token configuration uses SDK currency ids. Set `VITE_BASE_TOKEN` / `VITE_QUOTE_TOKEN` to `Coin` for ML or to the actual Mintlayer token id for tokens. Tickers such as `HUG` are display labels only and will not match balances returned by `client.getBalances()`. +`VITE_API_URL` supplies balances and market data. `VITE_BATCH_API_URL` must point to the Mojito-compatible `/batch` service for the *same chain/indexer*, because it supplies the UTXOs used to fund transactions. When using a custom API server, set both; otherwise the bot can display a large balance from one source while assembling transactions from another source's small UTXO set. + --- ## Usage @@ -169,7 +175,8 @@ The bot continuously: - React SPA scaffold, SDK initialization, and testnet mnemonic mode are implemented. - Wallet, balance, local UTXO, orderbook, own order, strategy proposal, transaction queue, and UTXO branch panels are implemented. -- Strategy proposals run as dry-run by default. Disabling dry-run allows signing and optional broadcasting through the SDK. +- Strategy proposals run as dry-run by default. Signing a preview does not reserve UTXOs; only a broadcast attempt reserves its selected inputs while it is pending. +- The Liquidity Simulation panel can drive a testnet lifecycle of quote, self-fill, and conclude/requote. It is disabled by default and runs only while the loop is active, Dry run is off, and broadcasting is enabled. - UTXO branch preparation is guarded and warns when multiple branch preparation transactions should be prepared one at a time. - Production unattended signing should move out of the browser before mainnet use. diff --git a/examples/market-maker-bot/src/App.tsx b/examples/market-maker-bot/src/App.tsx index 7cbb8af..c55bf86 100644 --- a/examples/market-maker-bot/src/App.tsx +++ b/examples/market-maker-bot/src/App.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { BranchPanel, ConfigPanel, + FillSimulationPanel, OrderBookPanel, StrategyPanel, TradesPanel, @@ -29,6 +30,7 @@ export default function App() { stopLoop, resetLocalState, executeStrategyAction, + createManualOrder, concludeOrder, executePreparationAction, } = useMarketMakerBot(); @@ -102,7 +104,18 @@ export default function App() { actions={state.actions} tokenLabels={state.tokenLabels} dryRun={state.dryRun} + manualReferencePrice={state.manualReferencePrice} + canCreateManualOrder={initialized && state.manualReferencePrice > 0 && state.config.orderSize > 0} onExecute={(action) => void executeStrategyAction(action)} + onCreateManualOrder={(side) => void createManualOrder(side)} + /> + diff --git a/examples/market-maker-bot/src/components/Panels.tsx b/examples/market-maker-bot/src/components/Panels.tsx index 9da4f54..4295914 100644 --- a/examples/market-maker-bot/src/components/Panels.tsx +++ b/examples/market-maker-bot/src/components/Panels.tsx @@ -80,6 +80,17 @@ export function ConfigPanel(props: ConfigPanelProps) { onChange={(event) => setConfig(setNumber(config, 'orderSize', event.target.value))} /> +
+
+ Manual quote reference: {props.manualReferencePrice.toFixed(8)} + + +
{props.actions.map((action) => (
@@ -442,6 +465,60 @@ export function StrategyPanel(props: { ); } +export function FillSimulationPanel(props: { + config: MarketMakerConfig; + setConfig: Dispatch>; + running: boolean; + dryRun: boolean; + broadcastEnabled: boolean; + records: ExecutionRecord[]; +}) { + const simulatedRecords = props.records.filter((record) => record.idempotencyKey.startsWith('simulation-')); + const lastRecord = simulatedRecords[0]; + const live = props.running && props.config.simulateOwnFills && !props.dryRun && props.broadcastEnabled; + + return ( +
+
+

Liquidity Simulation

+ {live ? 'live' : 'standby'} +
+

+ On each bot cycle, the simulator places a quote, takes one of this wallet's orders, then concludes the + remaining order before quoting again. These fills are broadcast transactions, not strategy proposals. +

+ + + {!props.running &&

Start Loop to run the lifecycle.

} + {(props.dryRun || !props.broadcastEnabled) && props.config.simulateOwnFills && ( +

Turn off Dry run and enable broadcasting before starting; otherwise no fills are sent.

+ )} + {lastRecord && ( +

Latest simulator action: {lastRecord.kind} · {lastRecord.status}

+ )} +
+ ); +} + export function TransactionPanel(props: { records: ExecutionRecord[] }) { return (
@@ -513,8 +590,12 @@ export function BranchPanel(props: {

UTXO Branches

- {props.branches.length} branches + {props.branches.length} available UTXOs
+

+ These are the UTXOs currently safe for transaction building. The depth limit applies only while an output is + unconfirmed in the mempool; confirmed UTXOs restart at depth 0 and can be used again. +

{props.branches.map((branch) => (
@@ -534,6 +615,22 @@ export function BranchPanel(props: {

Source asset: {formatTokenLabel(props.plan.sourceAsset, props.tokenLabels)} ({props.plan.perBranchAmount} per branch)

+

+ {props.plan.availableBranches.length} of {props.plan.targetBranchCount} requested branches are already available. +

+ {props.plan.availableBranches.length > 0 && ( +
+ {props.plan.availableBranches.map((branch) => ( +
+
+ Use existing UTXO +

{branch.amountAtoms} atoms · {branch.status} · depth {branch.depth}

+ {branch.outpoint} +
+
+ ))} +
+ )} {props.plan.warnings.map((warning) => (

{warning}

))} diff --git a/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts index bc89185..0f5bc5f 100644 --- a/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts +++ b/examples/market-maker-bot/src/hooks/useMarketMakerBot.ts @@ -4,9 +4,9 @@ import type { WalletState } from '@mintlayer/sdk'; import { createBotClient } from '../lib/client'; import { loadConfigFromEnv, validateConfig } from '../lib/config'; import { executeRequest, mergeRecords, strategyActionToRequest } from '../lib/execution'; -import { buildSyntheticBook, isOwnOrder } from '../lib/orderBook'; +import { averageBookPrice, buildSyntheticBook, isOwnOrder } from '../lib/orderBook'; import { fetchPairOrders } from '../lib/orders'; -import { planAllStrategyActions } from '../lib/strategy'; +import { planAllStrategyActions, planSimulatedOwnFill, planStrategyActions, withReferencePrice } from '../lib/strategy'; import { listTrades } from '../lib/trades'; import { analyzeBranches, createBranchPreparationPlan } from '../lib/utxoBranches'; import { collectTokenIds, loadTokenLabels, mergeTokenLabels, type TokenLabelMap } from '../lib/tokens'; @@ -36,6 +36,7 @@ type BotState = { orders: MarketOrder[]; ownOrders: MarketOrder[]; book: SyntheticBook; + manualReferencePrice: number; actions: StrategyAction[]; records: ExecutionRecord[]; broadcastEnabled: boolean; @@ -62,6 +63,7 @@ function emptyPreparationPlan(config: MarketMakerConfig): BranchPreparationPlan targetBranchCount: 4, perBranchAmount: config.orderSize, destination: '', + availableBranches: [], actions: [], warnings: [], }; @@ -88,9 +90,23 @@ export function useMarketMakerBot() { Coin: { tokenId: 'Coin', ticker: 'ML', decimals: 11 }, }); const loopRef = useRef(null); + const cycleInFlightRef = useRef(false); + const simulationTurnRef = useRef(0); + const orphanedPendingOutpointsRef = useRef(new Set()); + const unfillableSimulationOrderIdsRef = useRef(new Set()); const configWarnings = useMemo(() => validateConfig(config), [config]); const book = useMemo(() => buildSyntheticBook(orders, config.baseToken, config.quoteToken), [orders, config]); + // Keep manual proposals consistent with the loop: an empty book still has + // the configured reference price from which to quote. + const strategyBook = useMemo( + () => withReferencePrice(book, config.referencePrice), + [book, config.referencePrice], + ); + const manualReferencePrice = useMemo( + () => averageBookPrice(book) ?? config.referencePrice, + [book, config.referencePrice], + ); const actions = useMemo(() => { const appliedActionIds = new Set( records @@ -98,10 +114,10 @@ export function useMarketMakerBot() { .map((record) => record.idempotencyKey), ); - return planAllStrategyActions({ config, book, ownOrders, wallet, quoteLevelOffset }).filter( + return planAllStrategyActions({ config, book: strategyBook, ownOrders, wallet, quoteLevelOffset }).filter( (action) => !appliedActionIds.has(action.id), ); - }, [book, config, ownOrders, quoteLevelOffset, records, wallet]); + }, [config, ownOrders, quoteLevelOffset, records, strategyBook, wallet]); const trades = useMemo(() => listTrades(records), [records]); const branches = useMemo( () => analyzeBranches(wallet, config.maxUnconfirmedBranchDepth), @@ -114,13 +130,14 @@ export function useMarketMakerBot() { sourceAsset: config.baseToken, targetBranchCount: 4, perBranchAmount: config.orderSize, + maxUnconfirmedBranchDepth: config.maxUnconfirmedBranchDepth, }), - [config.baseToken, config.orderSize, wallet], + [config.baseToken, config.maxUnconfirmedBranchDepth, config.orderSize, wallet], ); const refresh = useCallback(async () => { if (!runtime.client || !walletState) { - return; + return null; } const [pairOrders, accountOrders, walletSnapshot] = await Promise.all([ @@ -129,28 +146,40 @@ export function useMarketMakerBot() { loadWalletSnapshot(runtime.client, walletState, config.maxUnconfirmedBranchDepth), ]); + // An API UTXO response does not guarantee the local broadcast node has + // accepted the parent into its mempool. Keep an orphaned branch excluded + // for this browser session; reinitialize after its parent confirms. + const safeWalletSnapshot = { + ...walletSnapshot, + availableUtxos: walletSnapshot.availableUtxos.filter( + (utxo) => !orphanedPendingOutpointsRef.current.has(`${utxo.txId}:${utxo.outputIndex}`), + ), + }; const typedOrders = pairOrders as MarketOrder[]; const typedOwnOrders = accountOrders.length > 0 ? (accountOrders as MarketOrder[]).filter((order) => - typedOrders.some((pairOrder) => pairOrder.order_id === order.order_id), + typedOrders.some((pairOrder) => pairOrder.order_id === order.order_id), ) - : typedOrders.filter((order) => isOwnOrder(order, walletSnapshot.addresses)); + : typedOrders.filter((order) => isOwnOrder(order, safeWalletSnapshot.addresses)); setOrders(typedOrders); setOwnOrders(typedOwnOrders); - setWallet(walletSnapshot); + setWallet(safeWalletSnapshot); - const branchSnapshot = analyzeBranches(walletSnapshot, config.maxUnconfirmedBranchDepth); + const branchSnapshot = analyzeBranches(safeWalletSnapshot, config.maxUnconfirmedBranchDepth); const labels = await loadTokenLabels( config, - collectTokenIds({ config, wallet: walletSnapshot, branches: branchSnapshot }), + collectTokenIds({ config, wallet: safeWalletSnapshot, branches: branchSnapshot }), ); setTokenLabels((current) => mergeTokenLabels(current, labels)); + return { orders: typedOrders, ownOrders: typedOwnOrders, wallet: safeWalletSnapshot }; }, [config, config.maxUnconfirmedBranchDepth, runtime.client, walletState]); const initialize = useCallback(async () => { setRuntime((current) => ({ ...current, mode: 'initializing', error: null })); + orphanedPendingOutpointsRef.current.clear(); + unfillableSimulationOrderIdsRef.current.clear(); try { const client = await createBotClient(config); @@ -196,7 +225,7 @@ export function useMarketMakerBot() { }, [config]); const execute = useCallback( - async (request: ExecutionRequest, forceBroadcast = broadcastEnabled) => { + async (request: ExecutionRequest, forceBroadcast = broadcastEnabled, executionWallet = wallet) => { if (!runtime.client || !walletState) { return; } @@ -212,15 +241,40 @@ export function useMarketMakerBot() { request, config, broadcast: forceBroadcast && !dryRun, - availableUtxos: wallet?.availableUtxos ?? [], + availableUtxos: executionWallet?.availableUtxos ?? [], tradeMeta: request.kind === 'fill-order' ? request.tradeMeta : undefined, }); + if ( + record.status === 'rejected' && + /orphan transaction/i.test(record.error ?? '') && + executionWallet + ) { + const inputOutpoints = new Set(record.inputOutpoints); + for (const utxo of executionWallet.availableUtxos) { + const outpoint = `${utxo.txId}:${utxo.outputIndex}`; + if (utxo.status === 'unconfirmed' && inputOutpoints.has(outpoint)) { + orphanedPendingOutpointsRef.current.add(outpoint); + } + } + } + if ( + request.kind === 'fill-order' && + request.idempotencyKey.startsWith('simulation-fill:') && + record.status === 'rejected' && + /zero amount|not enough (coin|token) UTXOs/i.test(record.error ?? '') + ) { + // The order book can be one API cycle behind the order fetched by the + // builder. Do not repeatedly attempt an exhausted order, or one whose + // ask currency is not presently available in this wallet's UTXOs. + unfillableSimulationOrderIdsRef.current.add(request.orderId); + } + setRecords((current) => mergeRecords(current, record)); await refresh(); return record; }, - [broadcastEnabled, config, dryRun, records, refresh, runtime.client, wallet?.availableUtxos, walletState], + [broadcastEnabled, config, dryRun, records, refresh, runtime.client, wallet, walletState], ); const executeStrategyAction = useCallback( @@ -241,6 +295,28 @@ export function useMarketMakerBot() { [execute, wallet?.addresses.receiving], ); + const createManualOrder = useCallback( + async (side: 'bid' | 'ask') => { + const destination = wallet?.addresses.receiving[0]; + if (!destination || !Number.isFinite(manualReferencePrice) || manualReferencePrice <= 0 || config.orderSize <= 0) return; + + const price = manualReferencePrice * (side === 'bid' ? 0.95 : 1.05); + const action: StrategyAction = { + // Manual creation is repeatable, unlike the stable automatic proposal IDs. + id: `manual-${side}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, + kind: 'create-order', + side, + price, + reason: `Manual ${side} at ${side === 'bid' ? '-5%' : '+5%'} of the average market price (${manualReferencePrice.toFixed(8)}).`, + args: side === 'bid' + ? { conclude_destination: destination, ask_token: config.baseToken, ask_amount: config.orderSize, give_token: config.quoteToken, give_amount: config.orderSize * price } + : { conclude_destination: destination, ask_token: config.quoteToken, ask_amount: config.orderSize * price, give_token: config.baseToken, give_amount: config.orderSize }, + }; + await execute(strategyActionToRequest(action, destination)); + }, + [config.baseToken, config.orderSize, config.quoteToken, execute, manualReferencePrice, wallet?.addresses.receiving], + ); + const concludeOrder = useCallback( async (orderId: string) => { const destination = wallet?.addresses.receiving[0]; @@ -272,9 +348,103 @@ export function useMarketMakerBot() { ); const runCycle = useCallback(async () => { - await refresh(); - setLastCycleAt(Date.now()); - }, [refresh]); + if (cycleInFlightRef.current) { + return; + } + + cycleInFlightRef.current = true; + try { + const snapshot = await refresh(); + // A simulation creates real candles only when transactions are broadcast. + // Keep dry-run and broadcast-off sessions observational, even while looping. + if (!snapshot || !config.simulateOwnFills || dryRun || !broadcastEnabled) { + return; + } + + const filledOrderIds = new Set( + records + .filter((record) => record.idempotencyKey.startsWith('simulation-fill:') && record.status !== 'rejected') + .map((record) => record.tradeMeta?.orderId) + .filter((orderId): orderId is string => Boolean(orderId)), + ); + const concludedOrderIds = new Set( + records + .filter((record) => record.idempotencyKey.startsWith('simulation-conclude:') && record.status !== 'rejected') + .map((record) => record.idempotencyKey.slice('simulation-conclude:'.length)), + ); + const filledOrderToConclude = snapshot.ownOrders.find( + (order) => filledOrderIds.has(order.order_id) && !concludedOrderIds.has(order.order_id), + ); + + if (filledOrderToConclude) { + const fillRecord = records.find( + (record) => + record.idempotencyKey.startsWith('simulation-fill:') && + record.tradeMeta?.orderId === filledOrderToConclude.order_id && + record.status !== 'rejected', + ); + if (fillRecord && Date.now() - fillRecord.updatedAt < config.simulationTradeTimeoutMs) { + return; + } + await execute( + strategyActionToRequest( + { + id: `simulation-conclude:${filledOrderToConclude.order_id}`, + kind: 'conclude-order', + orderId: filledOrderToConclude.order_id, + reason: 'Liquidity simulation: conclude the partially self-filled order before replacing it.', + }, + snapshot.wallet.addresses.receiving[0], + ), + true, + snapshot.wallet, + ); + return; + } + + const simulatedFill = planSimulatedOwnFill({ + config, + book: withReferencePrice( + buildSyntheticBook(snapshot.orders, config.baseToken, config.quoteToken), + config.referencePrice, + ), + wallet: snapshot.wallet, + alreadyFilledOrderIds: new Set([ + ...filledOrderIds, + ...unfillableSimulationOrderIdsRef.current, + ]), + turn: simulationTurnRef.current++, + }); + if (simulatedFill) { + await execute(strategyActionToRequest(simulatedFill, snapshot.wallet.addresses.receiving[0]), true, snapshot.wallet); + return; + } + + const lifecycleAction = planStrategyActions({ + config, + book: withReferencePrice( + buildSyntheticBook(snapshot.orders, config.baseToken, config.quoteToken), + config.referencePrice, + ), + ownOrders: snapshot.ownOrders, + wallet: snapshot.wallet, + quoteLevelOffset, + })[0]; + if (lifecycleAction) { + const record = await execute( + strategyActionToRequest(lifecycleAction, snapshot.wallet.addresses.receiving[0]), + true, + snapshot.wallet, + ); + if (record?.status !== 'rejected' && lifecycleAction.kind === 'create-order') { + setQuoteLevelOffset((current) => current + 1); + } + } + } finally { + setLastCycleAt(Date.now()); + cycleInFlightRef.current = false; + } + }, [broadcastEnabled, config, dryRun, execute, quoteLevelOffset, records, refresh]); const startLoop = useCallback(() => { if (loopRef.current !== null) { @@ -303,6 +473,8 @@ export function useMarketMakerBot() { const resetLocalState = useCallback(async () => { stopLoop(); clearAllLocalWalletState(); + orphanedPendingOutpointsRef.current.clear(); + unfillableSimulationOrderIdsRef.current.clear(); setRecords([]); setOrders([]); @@ -368,6 +540,7 @@ export function useMarketMakerBot() { orders, ownOrders, book, + manualReferencePrice, actions, records, broadcastEnabled, @@ -391,6 +564,7 @@ export function useMarketMakerBot() { stopLoop, resetLocalState, executeStrategyAction, + createManualOrder, concludeOrder, executePreparationAction, }; diff --git a/examples/market-maker-bot/src/lib/client.ts b/examples/market-maker-bot/src/lib/client.ts index 0cea8fc..a3488a6 100644 --- a/examples/market-maker-bot/src/lib/client.ts +++ b/examples/market-maker-bot/src/lib/client.ts @@ -113,12 +113,13 @@ class HeaderApiProvider implements ApiProvider { } function createApiProvider(config: MarketMakerConfig): ApiProvider | undefined { - if (!config.apiUrl && !config.apiKey) { + if (!config.apiUrl && !config.batchApiUrl && !config.apiKey) { return undefined; } const baseUrl = config.apiUrl ?? DEFAULT_API_URLS[config.network]; - return new HeaderApiProvider(baseUrl, DEFAULT_BATCH_URLS[config.network], config.apiKey); + const batchUrl = config.batchApiUrl ?? DEFAULT_BATCH_URLS[config.network]; + return new HeaderApiProvider(baseUrl, batchUrl, config.apiKey); } export async function createBotClient(config: MarketMakerConfig): Promise { diff --git a/examples/market-maker-bot/src/lib/config.ts b/examples/market-maker-bot/src/lib/config.ts index 3b07181..e9081d0 100644 --- a/examples/market-maker-bot/src/lib/config.ts +++ b/examples/market-maker-bot/src/lib/config.ts @@ -6,6 +6,7 @@ const DEFAULT_CONFIG: MarketMakerConfig = { baseToken: 'HUG', quoteToken: 'Coin', orderSize: 0.01, + referencePrice: 1, spreadBps: 20, inventoryTarget: 0.5, rebalanceThreshold: 0.1, @@ -16,6 +17,8 @@ const DEFAULT_CONFIG: MarketMakerConfig = { allowMainnetBroadcast: false, enableFillTrading: true, allowSelfFills: false, + simulateOwnFills: false, + simulationTradeTimeoutMs: 60_000, }; function optionalString(value: unknown): string | undefined { @@ -47,12 +50,14 @@ export function loadConfigFromEnv(env: ImportMetaEnv = import.meta.env): MarketM return { network: networkFromEnv(env.VITE_NETWORK), apiUrl: optionalString(env.VITE_API_URL), + batchApiUrl: optionalString(env.VITE_BATCH_API_URL), apiKey: optionalString(env.VITE_API_KEY), walletSeed: optionalString(env.VITE_WALLET_SEED), pair: optionalString(env.VITE_PAIR) ?? DEFAULT_CONFIG.pair, baseToken: optionalString(env.VITE_BASE_TOKEN) ?? DEFAULT_CONFIG.baseToken, quoteToken: optionalString(env.VITE_QUOTE_TOKEN) ?? DEFAULT_CONFIG.quoteToken, orderSize: numberFromEnv(env.VITE_ORDER_SIZE, DEFAULT_CONFIG.orderSize), + referencePrice: numberFromEnv(env.VITE_REFERENCE_PRICE, DEFAULT_CONFIG.referencePrice), spreadBps: numberFromEnv(env.VITE_SPREAD_BPS, DEFAULT_CONFIG.spreadBps), inventoryTarget: numberFromEnv(env.VITE_INVENTORY_TARGET, DEFAULT_CONFIG.inventoryTarget), rebalanceThreshold: numberFromEnv(env.VITE_REBALANCE_THRESHOLD, DEFAULT_CONFIG.rebalanceThreshold), @@ -66,6 +71,11 @@ export function loadConfigFromEnv(env: ImportMetaEnv = import.meta.env): MarketM allowMainnetBroadcast: booleanFromEnv(env.VITE_ALLOW_MAINNET_BROADCAST, DEFAULT_CONFIG.allowMainnetBroadcast), enableFillTrading: booleanFromEnv(env.VITE_ENABLE_FILL_TRADING, DEFAULT_CONFIG.enableFillTrading), allowSelfFills: booleanFromEnv(env.VITE_ALLOW_SELF_FILLS, DEFAULT_CONFIG.allowSelfFills), + simulateOwnFills: booleanFromEnv(env.VITE_SIMULATE_OWN_FILLS, DEFAULT_CONFIG.simulateOwnFills), + simulationTradeTimeoutMs: Math.max( + 5_000, + Math.floor(numberFromEnv(env.VITE_SIMULATION_TRADE_TIMEOUT_MS, DEFAULT_CONFIG.simulationTradeTimeoutMs)), + ), }; } @@ -76,6 +86,12 @@ export function validateConfig(config: MarketMakerConfig): string[] { warnings.push('VITE_WALLET_SEED is missing. The app can render, but SDK initialization will fail.'); } + if (config.apiUrl && !config.batchApiUrl) { + warnings.push( + 'VITE_API_URL is custom but VITE_BATCH_API_URL is not set. Balance and spendable-UTXO data can come from different indexers.', + ); + } + if (config.network === 'mainnet' && !config.allowMainnetBroadcast) { warnings.push('Mainnet broadcasting is blocked by default. Set VITE_ALLOW_MAINNET_BROADCAST=true only after review.'); } @@ -88,6 +104,14 @@ export function validateConfig(config: MarketMakerConfig): string[] { warnings.push('Order size must be positive.'); } + if (config.referencePrice <= 0) { + warnings.push('Reference price must be positive.'); + } + + if (config.simulationTradeTimeoutMs < 5_000) { + warnings.push('Liquidity simulation timeout must be at least 5 seconds.'); + } + if (config.inventoryTarget < 0 || config.inventoryTarget > 1) { warnings.push('Inventory target should be between 0 and 1.'); } @@ -100,6 +124,7 @@ export function updateConfigNumber( key: keyof Pick< MarketMakerConfig, | 'orderSize' + | 'referencePrice' | 'spreadBps' | 'inventoryTarget' | 'rebalanceThreshold' @@ -107,6 +132,7 @@ export function updateConfigNumber( | 'maxOrders' | 'pollIntervalMs' | 'maxUnconfirmedBranchDepth' + | 'simulationTradeTimeoutMs' >, value: string, ): MarketMakerConfig { diff --git a/examples/market-maker-bot/src/lib/execution.ts b/examples/market-maker-bot/src/lib/execution.ts index 8f98ff4..179528d 100644 --- a/examples/market-maker-bot/src/lib/execution.ts +++ b/examples/market-maker-bot/src/lib/execution.ts @@ -31,6 +31,15 @@ function getTxJson(tx: BuiltTransaction) { return tx.JSONRepresentation; } +function getUtxoInputOutpoints(tx: BuiltTransaction): string[] { + return tx.JSONRepresentation.inputs.flatMap((entry) => { + const input = (entry as { input?: { input_type?: string; source_id?: string; index?: number } }).input; + return input?.input_type === 'UTXO' && input.source_id !== undefined && input.index !== undefined + ? [`${input.source_id}:${input.index}`] + : []; + }); +} + export function strategyActionToRequest(action: StrategyAction, destination: string): ExecutionRequest { if (action.kind === 'conclude-order') { return { @@ -47,7 +56,7 @@ export function strategyActionToRequest(action: StrategyAction, destination: str id: `exec:${action.id}`, kind: 'fill-order', orderId: action.orderId, - amount: action.amount, + amount: action.exactAmount ?? action.amount, destination, description: action.reason, idempotencyKey: action.id, @@ -136,7 +145,6 @@ export async function executeRequest(args: { const tx = await buildTransaction(client, request, availableUtxos); const signedHex = await (client as unknown as { signTransaction(tx: BuiltTransaction): Promise }).signTransaction(tx); const txJson = getTxJson(tx); - await walletState.applyLocalTx(tx); const signedRecord: ExecutionRecord = { ...record, @@ -144,14 +152,21 @@ export async function executeRequest(args: { updatedAt: now(), txId: txJson.id, signedHex, + inputOutpoints: getUtxoInputOutpoints(tx), tradeMeta, }; if (!broadcast) { + // A signing preview is not a submitted transaction. Recording it in + // WalletState would mark its inputs spent_pending and can strand the + // wallet's large funding UTXO behind a preview. return signedRecord; } try { + // Reserve immediately before submitting so concurrent bot cycles cannot + // reuse an input while the request is in flight. + await walletState.applyLocalTx(tx); const broadcastResponse = await client.broadcastTx(signedHex); await walletState.applyMempoolTx(tx); return { diff --git a/examples/market-maker-bot/src/lib/orderBook.ts b/examples/market-maker-bot/src/lib/orderBook.ts index 4fe311c..bc44126 100644 --- a/examples/market-maker-bot/src/lib/orderBook.ts +++ b/examples/market-maker-bot/src/lib/orderBook.ts @@ -43,6 +43,8 @@ function toBookLevel(order: MarketOrder, baseToken: TokenRef, quoteToken: TokenR baseAmount: giveAmount, quoteAmount: askAmount, ownerAddress: order.conclude_destination, + askToken: quoteToken, + askBalanceAtoms: String(order.ask_balance.atoms ?? '0'), }; } @@ -54,6 +56,8 @@ function toBookLevel(order: MarketOrder, baseToken: TokenRef, quoteToken: TokenR baseAmount: askAmount, quoteAmount: giveAmount, ownerAddress: order.conclude_destination, + askToken: baseToken, + askBalanceAtoms: String(order.ask_balance.atoms ?? '0'), }; } @@ -80,6 +84,20 @@ export function buildSyntheticBook(orders: MarketOrder[], baseToken: TokenRef, q }; } +/** + * The arithmetic mean of all currently visible prices. This is intentionally + * distinct from the best-bid/best-ask mid so manual demo quotes remain useful + * for a one-sided or sparse book. + */ +export function averageBookPrice(book: SyntheticBook): number | null { + const prices = [...book.bids, ...book.asks] + .map((level) => level.price) + .filter((price) => Number.isFinite(price) && price > 0); + + if (prices.length === 0) return null; + return prices.reduce((total, price) => total + price, 0) / prices.length; +} + export function isOwnOrder(order: MarketOrder, addresses: { receiving: string[]; change: string[] }): boolean { return [...addresses.receiving, ...addresses.change].includes(order.conclude_destination); } diff --git a/examples/market-maker-bot/src/lib/strategy.ts b/examples/market-maker-bot/src/lib/strategy.ts index 6b43d32..48d29e3 100644 --- a/examples/market-maker-bot/src/lib/strategy.ts +++ b/examples/market-maker-bot/src/lib/strategy.ts @@ -26,6 +26,13 @@ function stableId(parts: Array): string { return parts.map((part) => String(part ?? 'none')).join(':'); } +function atomsToDecimalString(atoms: bigint, decimals: number): string { + const raw = atoms.toString().padStart(decimals + 1, '0'); + const whole = raw.slice(0, -decimals); + const fraction = raw.slice(-decimals).replace(/0+$/, ''); + return fraction ? `${whole}.${fraction}` : whole; +} + export function calculateInventory(snapshot: WalletSnapshot | null, config: MarketMakerConfig, midPrice: number | null) { const baseBalance = getTokenBalance(snapshot, config.baseToken); const quoteBalance = getTokenBalance(snapshot, config.quoteToken); @@ -45,6 +52,14 @@ export function calculateInventory(snapshot: WalletSnapshot | null, config: Mark }; } +export function withReferencePrice(book: SyntheticBook, referencePrice: number): SyntheticBook { + if (book.midPrice || referencePrice <= 0) { + return book; + } + + return { ...book, midPrice: referencePrice }; +} + export function planStrategyActions(args: { config: MarketMakerConfig; book: SyntheticBook; @@ -207,6 +222,84 @@ export function planFillActions(args: { return actions; } +/** + * Selects one of this wallet's resting orders to take as a synthetic counterparty. + * This deliberately lives outside `planAllStrategyActions`: simulator fills are + * executed by the running bot, never shown as a proposal for manual approval. + */ +export function planSimulatedOwnFill(args: { + config: MarketMakerConfig; + book: SyntheticBook; + wallet: WalletSnapshot | null; + alreadyFilledOrderIds: Set; + turn: number; +}): StrategyAction | null { + const { config, book, wallet, alreadyFilledOrderIds, turn } = args; + if (!wallet?.addresses.receiving[0]) { + return null; + } + + const ownedAddresses = new Set([...wallet.addresses.receiving, ...wallet.addresses.change]); + const inventory = calculateInventory(wallet, config, book.midPrice); + // Leave a remainder so the following cycle can exercise ConcludeOrder too. + const simulatedPortion = 0.5; + const candidates: StrategyAction[] = []; + + for (const level of [...book.asks, ...book.bids]) { + if (!ownedAddresses.has(level.ownerAddress) || alreadyFilledOrderIds.has(level.orderId)) { + continue; + } + + if (level.side === 'ask') { + // Taking an ask pays quote currency and receives base currency. + // Work from the order's remaining atoms. Floating-point half amounts can + // round up by one atom in the SDK and exceed the order balance. + const fillAtoms = BigInt(level.askBalanceAtoms) / 2n; + if (fillAtoms <= 0n) continue; + const exactAmount = atomsToDecimalString(fillAtoms, level.askToken === 'Coin' ? 11 : 11); + const fillAmount = Number(exactAmount); + if (fillAmount > 0 && inventory.quoteBalance >= fillAmount) { + candidates.push({ + id: stableId(['simulation-fill', level.orderId, 'buy', fillAmount.toFixed(8)]), + kind: 'fill-order', + side: 'buy', + reason: 'Liquidity simulation: self-fill own ask to produce an on-chain trade.', + orderId: level.orderId, + amount: fillAmount, + exactAmount, + price: level.price, + expectedBaseAmount: Math.min(config.orderSize * simulatedPortion, level.baseAmount * simulatedPortion), + expectedQuoteAmount: fillAmount, + isOwnOrder: true, + }); + } + } else { + // Taking a bid pays base currency and receives quote currency. + const fillAtoms = BigInt(level.askBalanceAtoms) / 2n; + if (fillAtoms <= 0n) continue; + const exactAmount = atomsToDecimalString(fillAtoms, level.askToken === 'Coin' ? 11 : 11); + const fillAmount = Number(exactAmount); + if (fillAmount > 0 && inventory.baseBalance >= fillAmount) { + candidates.push({ + id: stableId(['simulation-fill', level.orderId, 'sell', fillAmount.toFixed(8)]), + kind: 'fill-order', + side: 'sell', + reason: 'Liquidity simulation: self-fill own bid to produce an on-chain trade.', + orderId: level.orderId, + amount: fillAmount, + exactAmount, + price: level.price, + expectedBaseAmount: fillAmount, + expectedQuoteAmount: fillAmount * level.price, + isOwnOrder: true, + }); + } + } + } + + return candidates.length > 0 ? candidates[turn % candidates.length] : null; +} + export function planAllStrategyActions(args: { config: MarketMakerConfig; book: SyntheticBook; diff --git a/examples/market-maker-bot/src/lib/types.ts b/examples/market-maker-bot/src/lib/types.ts index ab9000c..e01254d 100644 --- a/examples/market-maker-bot/src/lib/types.ts +++ b/examples/market-maker-bot/src/lib/types.ts @@ -13,12 +13,16 @@ export type ExecutionKind = 'create-order' | 'fill-order' | 'conclude-order' | ' export type MarketMakerConfig = { network: NetworkName; apiUrl?: string; + /** Mojito-compatible batch endpoint serving spendable UTXOs for apiUrl's chain. */ + batchApiUrl?: string; apiKey?: string; walletSeed?: string; pair: string; baseToken: TokenRef; quoteToken: TokenRef; orderSize: number; + /** Fallback quote price when the selected pair has no existing book yet. */ + referencePrice: number; spreadBps: number; inventoryTarget: number; rebalanceThreshold: number; @@ -29,6 +33,10 @@ export type MarketMakerConfig = { allowMainnetBroadcast: boolean; enableFillTrading: boolean; allowSelfFills: boolean; + /** Run a live create → self-fill → conclude/requote lifecycle from the bot loop. */ + simulateOwnFills: boolean; + /** Minimum time to leave a simulated fill open before concluding its remainder. */ + simulationTradeTimeoutMs: number; }; export type WalletSnapshot = { @@ -81,6 +89,8 @@ export type BookLevel = { baseAmount: number; quoteAmount: number; ownerAddress: string; + askToken: TokenRef; + askBalanceAtoms: string; }; export type SyntheticBook = { @@ -113,6 +123,7 @@ export type StrategyAction = reason: string; orderId: string; amount: number; + exactAmount?: string; price: number; expectedBaseAmount: number; expectedQuoteAmount: number; @@ -138,7 +149,7 @@ export type ExecutionRequest = id: string; kind: 'fill-order'; orderId: string; - amount: number; + amount: string | number; destination: string; description: string; idempotencyKey: string; @@ -164,6 +175,8 @@ export type ExecutionRecord = { updatedAt: number; txId?: string; signedHex?: string; + /** Wallet UTXO inputs used to assemble this transaction, for retry safety. */ + inputOutpoints?: string[]; error?: string; broadcastResponse?: unknown; tradeMeta?: { @@ -209,6 +222,8 @@ export type BranchPreparationPlan = { targetBranchCount: number; perBranchAmount: number; destination: string; + /** Existing spendable UTXOs that can be used as branches for the source asset. */ + availableBranches: BranchInfo[]; actions: ExecutionRequest[]; warnings: string[]; }; diff --git a/examples/market-maker-bot/src/lib/utxoBranches.ts b/examples/market-maker-bot/src/lib/utxoBranches.ts index 84b04d7..b82fb13 100644 --- a/examples/market-maker-bot/src/lib/utxoBranches.ts +++ b/examples/market-maker-bot/src/lib/utxoBranches.ts @@ -23,10 +23,13 @@ export function analyzeBranches(snapshot: WalletSnapshot | null, maxDepth: numbe return []; } - return snapshot.utxos - .filter((utxo) => utxo.status === 'confirmed' || utxo.status === 'unconfirmed') + // `availableUtxos` is the same de-duplicated, safe-to-spend set supplied to + // the transaction builder. In particular, API UTXOs are confirmed with a + // depth of zero, so a confirmation ends the old mempool chain rather than + // permanently consuming its depth budget. + return snapshot.availableUtxos .map((utxo, index) => { - const depth = utxo.unconfirmedChainDepth; + const depth = utxo.status === 'confirmed' ? 0 : utxo.unconfirmedChainDepth; const remainingDepth = Math.max(0, maxDepth - depth); const warning = remainingDepth <= 0 @@ -43,7 +46,7 @@ export function analyzeBranches(snapshot: WalletSnapshot | null, maxDepth: numbe amountAtoms: amountAtoms(utxo), depth, remainingDepth, - reserved: utxo.status === 'spent_pending', + reserved: false, warning, }; }) @@ -59,22 +62,26 @@ export function createBranchPreparationPlan(args: { sourceAsset: TokenRef; targetBranchCount: number; perBranchAmount: number; + maxUnconfirmedBranchDepth: number; }): BranchPreparationPlan { - const { snapshot, sourceAsset, targetBranchCount, perBranchAmount } = args; + const { snapshot, sourceAsset, targetBranchCount, perBranchAmount, maxUnconfirmedBranchDepth } = args; const destination = snapshot?.addresses.receiving[0] ?? ''; const warnings: string[] = []; const actions: ExecutionRequest[] = []; if (!snapshot || !destination) { warnings.push('Initialize the wallet before preparing branches.'); - return { sourceAsset, targetBranchCount, perBranchAmount, destination, actions, warnings }; + return { sourceAsset, targetBranchCount, perBranchAmount, destination, availableBranches: [], actions, warnings }; } - const existingBranches = analyzeBranches(snapshot, Number.MAX_SAFE_INTEGER).filter((branch) => branch.asset === sourceAsset); + const availableBranches = analyzeBranches(snapshot, maxUnconfirmedBranchDepth).filter( + (branch) => branch.asset === sourceAsset && branch.remainingDepth > 0 && !branch.reserved, + ); + const existingBranches = availableBranches; const missingBranches = Math.max(0, targetBranchCount - existingBranches.length); if (missingBranches === 0) { - warnings.push('Requested branch count is already available in local wallet state.'); + warnings.push('Requested branch count is already available from spendable UTXOs.'); } if (perBranchAmount <= 0) { @@ -103,6 +110,7 @@ export function createBranchPreparationPlan(args: { targetBranchCount, perBranchAmount, destination, + availableBranches, actions, warnings, }; diff --git a/examples/market-maker-bot/src/styles.css b/examples/market-maker-bot/src/styles.css index a9b820a..d9f6418 100644 --- a/examples/market-maker-bot/src/styles.css +++ b/examples/market-maker-bot/src/styles.css @@ -121,8 +121,8 @@ main { flex: 1; gap: 0.6rem; grid-template-areas: - "wallet wallet strategy strategy transactions trades" - "book book branches branches branches branches"; + "wallet wallet strategy strategy simulator trades" + "book book branches branches transactions transactions"; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); min-height: 0; @@ -147,6 +147,23 @@ main { grid-area: strategy; } +.manualQuoteControls { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: -0.1rem 0 0.65rem; +} + +.manualQuoteControls .muted { + flex-basis: 100%; + font-size: 0.78rem; +} + +.fillSimulationPanel { + grid-area: simulator; +} + .transactionPanel { grid-area: transactions; } diff --git a/packages/sdk/src/mintlayer-connect-sdk.ts b/packages/sdk/src/mintlayer-connect-sdk.ts index ba02b04..99e6b19 100644 --- a/packages/sdk/src/mintlayer-connect-sdk.ts +++ b/packages/sdk/src/mintlayer-connect-sdk.ts @@ -933,7 +933,7 @@ type BuildTransactionParams = type: 'FillOrder'; params: { order_id: string; - amount: number; + amount: string | number; destination: string; order_details: OrderData; ask_token_details: TokenDetails; @@ -1083,7 +1083,7 @@ export type CreateOrderArgs = { export type FillOrderArgs = { order_id: string; - amount: number; + amount: string | number; destination: string; }; diff --git a/packages/sdk/src/transaction.ts b/packages/sdk/src/transaction.ts index 0931864..38b803a 100644 --- a/packages/sdk/src/transaction.ts +++ b/packages/sdk/src/transaction.ts @@ -64,7 +64,7 @@ export const FEE_AMOUNT_PER_KB = BigInt('100000000000'); * raw assembler has always used it, and it is the default for the fluent * builder when no block height was passed to the constructor. */ -export const FEE_BLOCK_HEIGHT = 800000n; +export const FEE_BLOCK_HEIGHT = 690000n; /** * Everything the assembler needs that does not belong to the transaction diff --git a/packages/sdk/tests/orders.test.ts b/packages/sdk/tests/orders.test.ts index dc8e62f..3b48ab4 100644 --- a/packages/sdk/tests/orders.test.ts +++ b/packages/sdk/tests/orders.test.ts @@ -163,10 +163,10 @@ test('fill order', async () => { expect(result.JSONRepresentation).toStrictEqual({ "fee": { - "atoms": "40400000000", - "decimal": "0.404", + "atoms": "38200000000", + "decimal": "0.382", }, - "id": "7cf8eb89b786869160e47e27b25cbf577dc2112516cf5b147cd22bd341a18d66", + "id": "74c29ea1eb7fc09ce3d2a7c362c34ac3b9c148394ccdfae5269c6457bdcae278", "inputs": [ { "input": { @@ -217,8 +217,8 @@ test('fill order', async () => { "value": { "type": "Coin", "amount": { - "atoms": "1702165204300000", - "decimal": "17021.652043" + "atoms": "1702167404300000", + "decimal": "17021.674043" } }, "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6" @@ -240,9 +240,7 @@ test('fill order that fail on call', async () => { const result = await spy.mock.results[0]?.value; - console.log(JSON.stringify(result, null, 2)); - - expect(result.JSONRepresentation.fee.decimal).toBe('0.399'); + expect(result.JSONRepresentation.fee.decimal).toBe('0.377'); }); // replay similar tx: https://lovelace.explorer.mintlayer.org/tx/a3a822f5e9099075e07234f435a2cda80cb6e88836331238b882da785973d7ac @@ -303,10 +301,10 @@ test('conclude order - snapshot', async () => { expect(result.JSONRepresentation).toStrictEqual({ "fee": { - "atoms": "40500000000", - "decimal": "0.405", + "atoms": "40400000000", + "decimal": "0.404", }, - "id": "a0e027bed40136528b3c5462b198a7e9781fad6659ab6c0d22b8c2a499148964", + "id": "ab9083c8826d3c9e39605f2f171a09d2bc4ff881f62c18c4dd4bf557409d2861", "inputs": [ { "input": { @@ -367,8 +365,8 @@ test('conclude order - snapshot', async () => { "value": { "type": "Coin", "amount": { - "atoms": "1703165104300000", - "decimal": "17031.651043" + "atoms": "1703165204300000", + "decimal": "17031.652043" } }, "destination": "tmt1qxrwc3gy2lgf4kvqwwfa388vn3cavgrqyyrgswe6" diff --git a/packages/sdk/tests/transfer.test.ts b/packages/sdk/tests/transfer.test.ts index 590860b..1de350a 100644 --- a/packages/sdk/tests/transfer.test.ts +++ b/packages/sdk/tests/transfer.test.ts @@ -71,10 +71,11 @@ test('buildTransaction called with correct params', async () => { }); test('fails transfer if not enough utxo', async () => { - fetchMock.mockIf('https://mojito-api.mintlayer.org/mintlayer/testnet/batch', async () => { - return { - body: JSON.stringify({ results: [[]] }), // no utxos - }; + fetchMock.mockResponse(async req => { + if (req.url.endsWith('/batch')) { + return { body: JSON.stringify({ results: [[]] }) }; // no UTXOs + } + return mocks.defaultRouter(req); }); const client = await Client.create({ network: 'testnet', autoRestore: false }); @@ -87,9 +88,10 @@ test('fails transfer if not enough utxo', async () => { }); test('transfer ignores account entries without utxo', async () => { - fetchMock.mockIf('https://mojito-api.mintlayer.org/mintlayer/testnet/batch', async () => { - return { - body: JSON.stringify({ + fetchMock.mockResponse(async req => { + if (req.url.endsWith('/batch')) { + return { + body: JSON.stringify({ results: [[ { input: { @@ -105,8 +107,10 @@ test('transfer ignores account entries without utxo', async () => { }, ...mocks.utxos, ]], - }), - }; + }), + }; + } + return mocks.defaultRouter(req); }); const client = await Client.create({ network: 'testnet', autoRestore: false }); From af3786be3ccc1315bcbecf72e41287728ffb5d3c Mon Sep 17 00:00:00 2001 From: Sergey Chystiakov Date: Tue, 22 Sep 2026 01:48:14 +0200 Subject: [PATCH 10/10] adjust pnpm in workflow test --- .github/workflows/test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 405344c..47dd24c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,8 +16,6 @@ jobs: - name: Set up pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - with: - version: 8 - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0