diff --git a/src/app.ts b/src/app.ts index 0d1591e162..4ede7aa2ab 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,6 +22,7 @@ import { jupiterRoutes } from './connectors/jupiter/jupiter.routes'; import { meteoraRoutes } from './connectors/meteora/meteora.routes'; import { okxRoutes } from './connectors/okx/okx.routes'; import { orcaRoutes } from './connectors/orca/orca.routes'; +import { oreConnectorRoutes } from './connectors/ore/ore.routes'; import { pancakeswapRoutes } from './connectors/pancakeswap/pancakeswap.routes'; import { pancakeswapSolRoutes } from './connectors/pancakeswap-sol/pancakeswap-sol.routes'; import { raydiumRoutes } from './connectors/raydium/raydium.routes'; @@ -122,6 +123,10 @@ const swaggerOptions = { name: '/connector/pancakeswap', description: 'PancakeSwap EVM connector endpoints', }, + { + name: '/connector/ore', + description: 'ORE mining game connector endpoints (experimental)', + }, { name: '/connector/dflow', description: 'DFlow connector endpoints', @@ -351,6 +356,9 @@ const configureGatewayServer = () => { // PancakeSwap Solana routes app.register(pancakeswapSolRoutes, { prefix: '/connectors/pancakeswap-sol' }); + + // ORE mining game routes (experimental) + app.register(oreConnectorRoutes.ore, { prefix: '/connectors/ore/ore' }); }; // Register routes on main server diff --git a/src/connectors/ore/ORE_INTEGRATION_GUIDE.md b/src/connectors/ore/ORE_INTEGRATION_GUIDE.md new file mode 100644 index 0000000000..23dfd66d33 --- /dev/null +++ b/src/connectors/ore/ORE_INTEGRATION_GUIDE.md @@ -0,0 +1,242 @@ +# ORE Program Integration Guide + +Reference documentation for the Hummingbot Gateway connector for the ORE mining game on Solana. + +Instruction and account layouts below were taken from the ORE `api` crate +(regolith-labs/ore: `api/src/instruction.rs`, `api/src/sdk.rs`, `api/src/state/*.rs`) and +**verified against live mainnet transactions** of the deployed program. This connector covers +the **mining** game only. Staking lives in a separate program (regolith-labs/ore-stake) and is +out of scope here. + +## Program Overview + +ORE v3 is a proof-of-work style mining game where participants deploy SOL to a 5x5 grid (25 +squares). Each round, a winning square is determined by on-chain entropy; participants who +deployed to that square split the prize pool and earn ORE tokens. + +**Key URLs:** +- App: https://ore.supply/ +- Repository: https://github.com/regolith-labs/ore +- Rust API crate: `api/` (consts, instruction, sdk, state) + +## Program IDs & Constants + +``` +ORE Program: oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv +ORE Token Mint: oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp (11 decimals) +Entropy Program: 3jSkUuYBoJzQPMEzTvkDFXCZUBksPamrVhrnHR9igu2X +Entropy Var: BWCaDY96Xe4WkFq1M7UiCCRcChsJ3p51L5KrGzhxgm2E (entropy var_pda(board, 0)) +``` + +The singleton PDAs resolve to fixed addresses (from `api/src/consts.rs`): + +``` +Board: BrcSxdp1nXFzou1YyDnQJcPNBNHgoypZmTsyKBSLLXzi (["board"]) +Config: 9c9X7aDRAF41faiDs94ELjT19UrGnn72wBW9hPsS4Awy (["config"]) +Treasury: 45db2FSR4mcXdSVVZbKbwojU6uYDpMyhpEi7cC8nHaWG (["treasury"]) +``` + +## Framework Notes + +ORE v3 is built with **Steel** (https://github.com/regolith-labs/steel), NOT Anchor: + +1. **Instruction discriminators** are single `u8` values (not 8-byte Anchor discriminators). +2. **Account discriminators** are 8 bytes with a simple numeric pattern, e.g. `[105, 0, 0, 0, 0, 0, 0, 0]`. +3. Account structs are `#[repr(C)]` Pod types — parse them by fixed byte offsets. + +### Instruction Discriminators (`OreInstruction`) + +``` +automate = 0 reset = 9 setAdmin = 15 +checkpoint = 2 buyback = 13 newVar = 19 +claimSol = 3 wrap = 14 bury = 24 +claimOre = 4 liq = 25 +close = 5 +deploy = 6 +log = 8 +``` + +> Note: staking instructions (deposit/withdraw/claimYield) do **not** exist in this program. +> Earlier drafts of this connector assumed discriminators 10–12 for staking; those are wrong. + +### Account Discriminators (first 8 bytes, `OreAccount`) + +``` +Automation = [100, ...] Treasury = [104, ...] +Config = [101, ...] Board = [105, ...] +Miner = [103, ...] Round = [109, ...] +``` + +## PDA Seeds + +| Account | Seeds | Notes | +|---------|-------|-------| +| Automation | `["automation", authority]` | Per-user automation config | +| Board | `["board"]` | Singleton, tracks current round | +| Config | `["config"]` | Singleton, program settings | +| Miner | `["miner", authority]` | Per-user mining state | +| Round | `["round", round_id (u64 LE)]` | Per-round state | +| Treasury | `["treasury"]` | Singleton, token vault | + +## Core Mining Flow + +### 1. Deploy SOL to Squares — `deploy` (disc 6) + +**Args:** `amount: u64` (lamports), `squares: u32` (bitmask over squares 0–24). Data = `[6][amount u64 LE][squares u32 LE]` (13 bytes). + +**Accounts (12):** +``` + 0 signer [signer, writable] + 1 authority [writable] # usually == signer + 2 automation [writable] # PDA ["automation", authority] + 3 board [writable] # PDA ["board"] + 4 config [writable] # PDA ["config"] + 5 miner [writable] # PDA ["miner", authority] + 6 round [writable] # PDA ["round", board.round_id] + 7 treasury [writable] # PDA ["treasury"] + 8 systemProgram [] + 9 oreProgram [] +10 entropyVar [writable] # BWCaDY96Xe4WkFq1M7UiCCRcChsJ3p51L5KrGzhxgm2E +11 entropyProgram [] # 3jSkUuYBoJzQPMEzTvkDFXCZUBksPamrVhrnHR9igu2X +``` + +**Square bitmask examples:** square 0 → `1`; center square (index 12) → `4096`; all 25 → `33554431`. + +### 2. Settle Rewards After Round Ends — `checkpoint` (disc 2) + +**Args:** none. Data = `[2]`. + +**Accounts (8):** +``` +0 signer [signer, writable] +1 authority [writable] # == signer +2 automation [writable] # PDA ["automation", authority] +3 board [writable] # PDA ["board"] +4 miner [writable] # PDA ["miner", authority] +5 round [writable] # PDA ["round", completed_round_id] +6 treasury [writable] # PDA ["treasury"] +7 systemProgram [] +``` + +### 3. Claim SOL Rewards — `claimSol` (disc 3) + +**Args:** none. Data = `[3]`. + +**Accounts (5):** +``` +0 signer [signer, writable] +1 board [writable] # PDA ["board"] +2 miner [writable] # PDA ["miner", signer] +3 systemProgram [] +4 oreProgram [] +``` + +Withdraws `miner.rewards_sol` to the signer. + +### 4. Claim ORE Rewards — `claimOre` (disc 4) + +**Args:** `bps: u64` — portion to claim in basis points (10000 = 100%, clamped on-chain). Data = `[4][bps u64 LE]` (9 bytes). + +**Accounts (11):** +``` + 0 signer [signer, writable] + 1 board [writable] # PDA ["board"] + 2 miner [writable] # PDA ["miner", signer] + 3 mint [writable] # oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp + 4 recipient [writable] # signer's ORE ATA + 5 treasury [writable] # PDA ["treasury"] + 6 treasuryTokens [writable] # treasury's ORE ATA + 7 systemProgram [] + 8 tokenProgram [] # TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA + 9 associatedTokenProgram [] # ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL +10 oreProgram [] +``` + +Withdraws the requested portion of the miner's ORE rewards to the recipient ATA. + +## Key Account Structures + +All fields are little-endian; layouts follow the `#[repr(C)]` Rust structs exactly. `Numeric` +is a 16-byte fixed-point value. Offsets below are relative to the account data start (the 8-byte +discriminator precedes the struct). + +### Board (singleton) — 40 bytes total +``` +@8 round_id: u64 +@16 start_slot: u64 +@24 end_slot: u64 +@32 production_cost_ema: u64 +``` + +### Miner (per user) — 752 bytes total +``` +@8 authority: Pubkey +@40 auto_return: u64 +@48 checkpoint_id: u64 +@56 checkpoint_fee: u64 +@64 deployed: [u64; 25] # SOL deployed per square this round +@264 mass: [u64; 25] # time-weighted SOL per square +@464 cumulative: [u64; 25] # cumulative mass per square before this miner +@664 round_id: u64 # last active round +@672 rewards_factor: Numeric # 16 bytes +@688 rewards_sol: u64 # claimable SOL +@696 refined_ore: u64 +@704 rewards_ore: u64 # claimable ORE +@712 last_claim_ore_at: i64 +@720 last_claim_sol_at: i64 +@728 lifetime_rewards_ore: u64 +@736 lifetime_deployed: u64 +@744 lifetime_rewards_sol: u64 +``` + +### Round (per round) — 952 bytes total +``` +@8 id: u64 +@16 deployed: [u64; 25] # total SOL per square +@216 mass: [u64; 25] +@416 count: [u64; 25] # unique miners per square +@616 slot_hash: [u8; 32] # entropy (zero until finalized) +@648 expires_at: u64 +@656 motherlode: u64 +@664 rent_payer: Pubkey +@696 rewards: [u64; 25] # ORE reward per square +@896 total_vaulted: u64 +@904 total_winnings: u64 +@912 total_miners: u64 +@920 top_miner: Pubkey # winner, SPLIT_ADDRESS if split, system if none +``` + +`total_deployed` and `top_miner_reward` are not stored; compute them as `sum(deployed)` and +`sum(rewards)` respectively. + +### Treasury (singleton) — 48 bytes total +``` +@8 motherlode: u64 +@16 miner_rewards_factor: Numeric # 16 bytes +@32 total_refined: u64 +@40 total_unclaimed: u64 +``` + +## Winning Square (RNG) + +For a finalized round, XOR the four 8-byte little-endian chunks of `slot_hash`, then take +`rng % 25` for the 0-indexed winning square (the connector reports it 1-indexed). + +## Typical Integration Workflow + +``` +1. Fetch Board account → current round_id, end_slot +2. Fetch Round account → prize pool, per-square deployments, time remaining +3. deploy SOL to chosen squares +4. Wait for the round to end (board.end_slot passes) +5. checkpoint to settle rewards for that round +6. claimSol and/or claimOre to withdraw +``` + +## Serialization Notes + +1. All integers are little-endian. +2. PublicKeys are 32 bytes. +3. Instruction data: `[discriminator (1 byte)] [args...]`. +4. Account data: `[discriminator (8 bytes)] [fields...]`. +5. Arrays like `[u64; 25]` are 25 consecutive u64 LE values (200 bytes). diff --git a/src/connectors/ore/ore-routes/accountInfo.ts b/src/connectors/ore/ore-routes/accountInfo.ts new file mode 100644 index 0000000000..c83cc3a9a2 --- /dev/null +++ b/src/connectors/ore/ore-routes/accountInfo.ts @@ -0,0 +1,51 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { Ore } from '../ore'; +import { + OreAccountInfoRequest, + OreAccountInfoRequestType, + OreAccountInfoResponse, + OreAccountInfoResponseType, +} from '../schemas'; + +export const accountInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: OreAccountInfoRequestType; + Reply: OreAccountInfoResponseType; + }>( + '/account-info', + { + schema: { + description: 'Get ORE miner account information for a wallet', + tags: ['/connector/ore'], + querystring: OreAccountInfoRequest, + response: { + 200: OreAccountInfoResponse, + }, + }, + }, + async (request) => { + try { + const network = request.query.network || 'mainnet-beta'; + const { walletAddress, roundId } = request.query; + + if (!walletAddress) { + throw httpErrors.badRequest('walletAddress is required'); + } + + const ore = await Ore.getInstance(network); + return await ore.getAccountInfo(walletAddress, roundId); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + throw httpErrors.internalServerError('Internal server error'); + } + }, + ); +}; + +export default accountInfoRoute; diff --git a/src/connectors/ore/ore-routes/boardInfo.ts b/src/connectors/ore/ore-routes/boardInfo.ts new file mode 100644 index 0000000000..288853fd4f --- /dev/null +++ b/src/connectors/ore/ore-routes/boardInfo.ts @@ -0,0 +1,46 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { Ore } from '../ore'; +import { + OreBoardInfoRequest, + OreBoardInfoRequestType, + OreBoardInfoResponse, + OreBoardInfoResponseType, +} from '../schemas'; + +export const boardInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: OreBoardInfoRequestType; + Reply: OreBoardInfoResponseType; + }>( + '/board-info', + { + schema: { + description: 'Get ORE board and current round information', + tags: ['/connector/ore'], + querystring: OreBoardInfoRequest, + response: { + 200: OreBoardInfoResponse, + }, + }, + }, + async (request) => { + try { + const network = request.query.network || 'mainnet-beta'; + const { roundId } = request.query; + const ore = await Ore.getInstance(network); + return await ore.getBoardInfo(roundId); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + throw httpErrors.internalServerError('Internal server error'); + } + }, + ); +}; + +export default boardInfoRoute; diff --git a/src/connectors/ore/ore-routes/checkpoint.ts b/src/connectors/ore/ore-routes/checkpoint.ts new file mode 100644 index 0000000000..74ad57b5dd --- /dev/null +++ b/src/connectors/ore/ore-routes/checkpoint.ts @@ -0,0 +1,183 @@ +import { PublicKey, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { Ore } from '../ore'; +import { createCheckpointInstruction } from '../ore.instructions'; +import { + OreCheckpointRequest, + OreCheckpointRequestType, + OreCheckpointResponse, + OreCheckpointResponseType, +} from '../schemas'; + +const ORE_DECIMALS = 11; + +export async function checkpoint( + network: string, + walletAddress: string, + roundIdStr?: string, +): Promise { + // Validate wallet address + try { + new PublicKey(walletAddress); + } catch { + throw httpErrors.badRequest(`Invalid wallet address: ${walletAddress}`); + } + + const ore = await Ore.getInstance(network); + const { wallet, isHardwareWallet } = await ore.prepareWallet(walletAddress); + + // Determine round ID to checkpoint + let roundId: bigint; + if (roundIdStr) { + roundId = BigInt(roundIdStr); + } else { + // Default to the miner's last round + const miner = await ore.getMinerAccount(walletAddress); + if (!miner) { + throw httpErrors.notFound(`Miner account not found for wallet: ${walletAddress}`); + } + roundId = miner.roundId; + } + + // Verify miner has participated in this round + const miner = await ore.getMinerAccount(walletAddress); + if (!miner) { + throw httpErrors.notFound(`Miner account not found for wallet: ${walletAddress}`); + } + + // Check if already checkpointed (miner.roundId has moved past the requested round) + if (miner.roundId > roundId) { + throw httpErrors.badRequest( + `Round ${roundId} has already been checkpointed. Miner is now on round ${miner.roundId}.`, + ); + } + + // Verify miner actually participated in the specified round + if (miner.roundId !== roundId) { + throw httpErrors.badRequest( + `Miner last participated in round ${miner.roundId}, not round ${roundId}. ` + + `Checkpoint is only needed for rounds you participated in.`, + ); + } + + // Get round info to determine winning square and calculate results + const round = await ore.getRoundAccount(roundId); + + // Calculate winning square from slotHash + const isFinalized = !round.slotHash.every((b) => b === 0); + if (!isFinalized) { + throw httpErrors.badRequest(`Round ${roundId} is not yet finalized. Wait for the round to complete.`); + } + + const view = new DataView(round.slotHash.buffer, round.slotHash.byteOffset, 32); + const r1 = view.getBigUint64(0, true); + const r2 = view.getBigUint64(8, true); + const r3 = view.getBigUint64(16, true); + const r4 = view.getBigUint64(24, true); + const rng = r1 ^ r2 ^ r3 ^ r4; + const winningSquareIndex = Number(rng % 25n); // 0-indexed internally + const winningSquare = winningSquareIndex + 1; // 1-indexed for API response + + // Get miner's deployed squares and total + const deployedSquares: number[] = []; + let totalDeployedLamports = 0n; + let deployedToWinningSquare = false; + + for (let i = 0; i < 25; i++) { + if (miner.deployed[i] > 0n) { + deployedSquares.push(i + 1); // 1-indexed for API response + totalDeployedLamports += miner.deployed[i]; + if (i === winningSquareIndex) { + deployedToWinningSquare = true; + } + } + } + + // Capture rewards before checkpoint + const rewardsSolBefore = miner.rewardsSol; + const rewardsOreBefore = miner.rewardsOre; + + // Create checkpoint instruction + const signerPubkey = isHardwareWallet ? (wallet as PublicKey) : (wallet as any).publicKey; + const checkpointIx = createCheckpointInstruction(signerPubkey, roundId); + + // Build transaction + const solana = ore.solana; + const { blockhash } = await solana.connection.getLatestBlockhash('confirmed'); + + const messageV0 = new TransactionMessage({ + payerKey: signerPubkey, + recentBlockhash: blockhash, + instructions: [checkpointIx], + }).compileToV0Message(); + + const transaction = new VersionedTransaction(messageV0); + + logger.info(`Creating checkpoint for round ${roundId}`); + + // Sign and send + const signature = await ore.signAndSendTransaction(transaction, walletAddress, isHardwareWallet); + + // Get updated miner account to see new rewards + const minerAfter = await ore.getMinerAccount(walletAddress); + const rewardsSolAfter = minerAfter ? minerAfter.rewardsSol : rewardsSolBefore; + const rewardsOreAfter = minerAfter ? minerAfter.rewardsOre : rewardsOreBefore; + + // Calculate winnings from this checkpoint + const wonSolLamports = rewardsSolAfter - rewardsSolBefore; + const wonOreRaw = rewardsOreAfter - rewardsOreBefore; + + return { + signature, + roundId: Number(roundId), + winningSquare, + deployedSquares, + deployedSol: Number(totalDeployedLamports) / 1_000_000_000, + won: deployedToWinningSquare, + wonSol: Number(wonSolLamports) / 1_000_000_000, + wonOre: Number(wonOreRaw) / 10 ** ORE_DECIMALS, + }; +} + +export const checkpointRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: OreCheckpointRequestType; + Reply: OreCheckpointResponseType; + }>( + '/check-round', + { + schema: { + description: 'Settle miner rewards for a completed round and return results', + tags: ['/connector/ore'], + body: OreCheckpointRequest, + response: { + 200: OreCheckpointResponse, + }, + }, + }, + async (request) => { + try { + const network = request.body.network || 'mainnet-beta'; + const walletAddress = request.body.walletAddress; + const { roundId } = request.body; + + if (!walletAddress) { + throw httpErrors.badRequest('walletAddress is required'); + } + + return await checkpoint(network, walletAddress, roundId); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + throw httpErrors.internalServerError('Internal server error'); + } + }, + ); +}; + +export default checkpointRoute; diff --git a/src/connectors/ore/ore-routes/claimOre.ts b/src/connectors/ore/ore-routes/claimOre.ts new file mode 100644 index 0000000000..3393bb3980 --- /dev/null +++ b/src/connectors/ore/ore-routes/claimOre.ts @@ -0,0 +1,102 @@ +import { PublicKey, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { Ore } from '../ore'; +import { createClaimOreInstruction } from '../ore.instructions'; +import { + OreClaimOreRequest, + OreClaimOreRequestType, + OreTransactionResponse, + OreTransactionResponseType, +} from '../schemas'; + +export async function claimOre(network: string, walletAddress: string): Promise { + // Validate wallet address + try { + new PublicKey(walletAddress); + } catch { + throw httpErrors.badRequest(`Invalid wallet address: ${walletAddress}`); + } + + const ore = await Ore.getInstance(network); + const { wallet, isHardwareWallet } = await ore.prepareWallet(walletAddress); + + // Verify miner account exists + const miner = await ore.getMinerAccount(walletAddress); + if (!miner) { + throw httpErrors.notFound(`Miner account not found for wallet: ${walletAddress}`); + } + + // Check if there are ORE rewards to claim + if (miner.rewardsOre <= 0n) { + throw httpErrors.badRequest('No ORE rewards available to claim'); + } + + // Create claim ORE instruction (claim 100% = 10000 bps). + const signerPubkey = isHardwareWallet ? (wallet as PublicKey) : (wallet as any).publicKey; + const claimOreIx = createClaimOreInstruction(signerPubkey, 10000n); + + // Build transaction + const solana = ore.solana; + const { blockhash } = await solana.connection.getLatestBlockhash('confirmed'); + + const messageV0 = new TransactionMessage({ + payerKey: signerPubkey, + recentBlockhash: blockhash, + instructions: [claimOreIx], + }).compileToV0Message(); + + const transaction = new VersionedTransaction(messageV0); + + const rewardsOre = miner.rewardsOre; + logger.info(`Claiming ${rewardsOre} ORE token rewards`); + + // Sign and send + const signature = await ore.signAndSendTransaction(transaction, walletAddress, isHardwareWallet); + + return { + signature, + message: `Claimed ${rewardsOre} ORE token rewards`, + }; +} + +export const claimOreRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: OreClaimOreRequestType; + Reply: OreTransactionResponseType; + }>( + '/claim-ore', + { + schema: { + description: 'Claim ORE token rewards from mining', + tags: ['/connector/ore'], + body: OreClaimOreRequest, + response: { + 200: OreTransactionResponse, + }, + }, + }, + async (request) => { + try { + const network = request.body.network || 'mainnet-beta'; + const walletAddress = request.body.walletAddress; + + if (!walletAddress) { + throw httpErrors.badRequest('walletAddress is required'); + } + + return await claimOre(network, walletAddress); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + throw httpErrors.internalServerError('Internal server error'); + } + }, + ); +}; + +export default claimOreRoute; diff --git a/src/connectors/ore/ore-routes/claimSol.ts b/src/connectors/ore/ore-routes/claimSol.ts new file mode 100644 index 0000000000..5e07ec36f2 --- /dev/null +++ b/src/connectors/ore/ore-routes/claimSol.ts @@ -0,0 +1,102 @@ +import { PublicKey, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { Ore } from '../ore'; +import { createClaimSolInstruction } from '../ore.instructions'; +import { + OreClaimSolRequest, + OreClaimSolRequestType, + OreTransactionResponse, + OreTransactionResponseType, +} from '../schemas'; + +export async function claimSol(network: string, walletAddress: string): Promise { + // Validate wallet address + try { + new PublicKey(walletAddress); + } catch { + throw httpErrors.badRequest(`Invalid wallet address: ${walletAddress}`); + } + + const ore = await Ore.getInstance(network); + const { wallet, isHardwareWallet } = await ore.prepareWallet(walletAddress); + + // Verify miner account exists + const miner = await ore.getMinerAccount(walletAddress); + if (!miner) { + throw httpErrors.notFound(`Miner account not found for wallet: ${walletAddress}`); + } + + // Check if there are SOL rewards to claim + if (miner.rewardsSol <= 0n) { + throw httpErrors.badRequest('No SOL rewards available to claim'); + } + + // Create claim SOL instruction + const signerPubkey = isHardwareWallet ? (wallet as PublicKey) : (wallet as any).publicKey; + const claimSolIx = createClaimSolInstruction(signerPubkey); + + // Build transaction + const solana = ore.solana; + const { blockhash } = await solana.connection.getLatestBlockhash('confirmed'); + + const messageV0 = new TransactionMessage({ + payerKey: signerPubkey, + recentBlockhash: blockhash, + instructions: [claimSolIx], + }).compileToV0Message(); + + const transaction = new VersionedTransaction(messageV0); + + const rewardsLamports = miner.rewardsSol; + logger.info(`Claiming ${rewardsLamports} lamports in SOL rewards`); + + // Sign and send + const signature = await ore.signAndSendTransaction(transaction, walletAddress, isHardwareWallet); + + return { + signature, + message: `Claimed ${rewardsLamports} lamports in SOL rewards`, + }; +} + +export const claimSolRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: OreClaimSolRequestType; + Reply: OreTransactionResponseType; + }>( + '/claim-sol', + { + schema: { + description: 'Claim SOL rewards from mining', + tags: ['/connector/ore'], + body: OreClaimSolRequest, + response: { + 200: OreTransactionResponse, + }, + }, + }, + async (request) => { + try { + const network = request.body.network || 'mainnet-beta'; + const walletAddress = request.body.walletAddress; + + if (!walletAddress) { + throw httpErrors.badRequest('walletAddress is required'); + } + + return await claimSol(network, walletAddress); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + throw httpErrors.internalServerError('Internal server error'); + } + }, + ); +}; + +export default claimSolRoute; diff --git a/src/connectors/ore/ore-routes/deploy.ts b/src/connectors/ore/ore-routes/deploy.ts new file mode 100644 index 0000000000..eeaf3a0f49 --- /dev/null +++ b/src/connectors/ore/ore-routes/deploy.ts @@ -0,0 +1,131 @@ +import { PublicKey, TransactionMessage, VersionedTransaction } from '@solana/web3.js'; +import { FastifyPluginAsync } from 'fastify'; + +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { Ore } from '../ore'; +import { createDeployInstruction } from '../ore.instructions'; +import { squaresToBitmask } from '../ore.parser'; +import { OreDeployRequest, OreDeployRequestType, OreTransactionResponse, OreTransactionResponseType } from '../schemas'; + +import { checkpoint } from './checkpoint'; + +const LAMPORTS_PER_SOL = 1_000_000_000; + +export async function deploy( + network: string, + walletAddress: string, + amountSol: number, + squares: number[], +): Promise { + // Validate wallet address + try { + new PublicKey(walletAddress); + } catch { + throw httpErrors.badRequest(`Invalid wallet address: ${walletAddress}`); + } + + // Validate amount + if (amountSol <= 0) { + throw httpErrors.badRequest('Amount must be greater than 0'); + } + + // Validate square indices (1-25) and convert to 0-indexed + const squaresInternal: number[] = []; + for (const sq of squares) { + if (sq < 1 || sq > 25) { + throw httpErrors.badRequest(`Invalid square index: ${sq}. Must be 1-25`); + } + squaresInternal.push(sq - 1); // Convert to 0-indexed for internal use + } + const squaresBitmask = squaresToBitmask(squaresInternal); + + if (squaresBitmask === 0) { + throw httpErrors.badRequest('At least one square must be selected'); + } + + const ore = await Ore.getInstance(network); + const { wallet, isHardwareWallet } = await ore.prepareWallet(walletAddress); + + // Get current board to determine round ID + const board = await ore.getBoardAccount(); + const currentRoundId = board.roundId; + + // Check if the miner has an UNSETTLED past round that must be checkpointed first. + // A round is settled once checkpointId catches up to roundId; a stale settled roundId + // (checkpointId == roundId) needs nothing even if it is far behind the current round. + const miner = await ore.getMinerAccount(walletAddress); + if (miner && miner.checkpointId < miner.roundId && miner.roundId < currentRoundId) { + logger.info(`Miner has an unsettled round ${miner.roundId}, running checkpoint first...`); + await checkpoint(network, walletAddress, miner.roundId.toString()); + } + + // Convert SOL to lamports + const amountLamports = BigInt(Math.floor(amountSol * LAMPORTS_PER_SOL)); + + // Create deploy instruction + const signerPubkey = isHardwareWallet ? (wallet as PublicKey) : (wallet as any).publicKey; + const deployIx = createDeployInstruction(signerPubkey, amountLamports, squaresBitmask, currentRoundId); + + // Build transaction + const solana = ore.solana; + const { blockhash } = await solana.connection.getLatestBlockhash('confirmed'); + + const messageV0 = new TransactionMessage({ + payerKey: signerPubkey, + recentBlockhash: blockhash, + instructions: [deployIx], + }).compileToV0Message(); + + const transaction = new VersionedTransaction(messageV0); + + logger.info(`Deploying ${amountSol} SOL to squares (bitmask: ${squaresBitmask}) for round ${currentRoundId}`); + + // Sign and send + const signature = await ore.signAndSendTransaction(transaction, walletAddress, isHardwareWallet); + + return { + signature, + message: `Deployed ${amountSol} SOL to ${squares.length} square(s)`, + }; +} + +export const deployRoute: FastifyPluginAsync = async (fastify) => { + fastify.post<{ + Body: OreDeployRequestType; + Reply: OreTransactionResponseType; + }>( + '/deploy', + { + schema: { + description: 'Deploy SOL to squares in the current ORE round', + tags: ['/connector/ore'], + body: OreDeployRequest, + response: { + 200: OreTransactionResponse, + }, + }, + }, + async (request) => { + try { + const network = request.body.network || 'mainnet-beta'; + const walletAddress = request.body.walletAddress; + const { amount, squares } = request.body; + + if (!walletAddress) { + throw httpErrors.badRequest('walletAddress is required'); + } + + return await deploy(network, walletAddress, amount, squares); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + throw httpErrors.internalServerError('Internal server error'); + } + }, + ); +}; + +export default deployRoute; diff --git a/src/connectors/ore/ore-routes/index.ts b/src/connectors/ore/ore-routes/index.ts new file mode 100644 index 0000000000..394311ac15 --- /dev/null +++ b/src/connectors/ore/ore-routes/index.ts @@ -0,0 +1,24 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { accountInfoRoute } from './accountInfo'; +import { boardInfoRoute } from './boardInfo'; +import { checkpointRoute } from './checkpoint'; +import { claimOreRoute } from './claimOre'; +import { claimSolRoute } from './claimSol'; +import { deployRoute } from './deploy'; +import { systemInfoRoute } from './systemInfo'; + +export const oreRoutes: FastifyPluginAsync = async (fastify) => { + // GET routes - Info endpoints + await fastify.register(accountInfoRoute); + await fastify.register(boardInfoRoute); + await fastify.register(systemInfoRoute); + + // POST routes - Mining operations + await fastify.register(deployRoute); + await fastify.register(checkpointRoute); + await fastify.register(claimSolRoute); + await fastify.register(claimOreRoute); +}; + +export default oreRoutes; diff --git a/src/connectors/ore/ore-routes/systemInfo.ts b/src/connectors/ore/ore-routes/systemInfo.ts new file mode 100644 index 0000000000..e856938020 --- /dev/null +++ b/src/connectors/ore/ore-routes/systemInfo.ts @@ -0,0 +1,45 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { httpErrors } from '../../../services/error-handler'; +import { logger } from '../../../services/logger'; +import { Ore } from '../ore'; +import { + OreSystemInfoRequest, + OreSystemInfoRequestType, + OreSystemInfoResponse, + OreSystemInfoResponseType, +} from '../schemas'; + +export const systemInfoRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: OreSystemInfoRequestType; + Reply: OreSystemInfoResponseType; + }>( + '/system-info', + { + schema: { + description: 'Get ORE system information (treasury and config)', + tags: ['/connector/ore'], + querystring: OreSystemInfoRequest, + response: { + 200: OreSystemInfoResponse, + }, + }, + }, + async (request) => { + try { + const network = request.query.network || 'mainnet-beta'; + const ore = await Ore.getInstance(network); + return await ore.getSystemInfo(); + } catch (e) { + logger.error(e); + if (e.statusCode) { + throw e; + } + throw httpErrors.internalServerError('Internal server error'); + } + }, + ); +}; + +export default systemInfoRoute; diff --git a/src/connectors/ore/ore.config.ts b/src/connectors/ore/ore.config.ts new file mode 100644 index 0000000000..55e429eb04 --- /dev/null +++ b/src/connectors/ore/ore.config.ts @@ -0,0 +1,108 @@ +import { PublicKey } from '@solana/web3.js'; + +import { AvailableNetworks } from '../../services/base'; + +export namespace OreConfig { + // Program IDs + export const ORE_PROGRAM_ID = new PublicKey('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv'); + export const ORE_TOKEN_MINT = new PublicKey('oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp'); + export const ENTROPY_PROGRAM_ID = new PublicKey('3jSkUuYBoJzQPMEzTvkDFXCZUBksPamrVhrnHR9igu2X'); + + // The entropy `var` account for the board, i.e. entropy_api::state::var_pda(board, 0). + // This is a fixed protocol constant (see ORE `api/src/consts.rs` VAR_ADDRESS) and is the + // account the on-chain `deploy` instruction requires. Verified against live mainnet txs. + export const ENTROPY_VAR_ADDRESS = new PublicKey('BWCaDY96Xe4WkFq1M7UiCCRcChsJ3p51L5KrGzhxgm2E'); + + // Token program IDs + export const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + export const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); + export const SYSTEM_PROGRAM_ID = new PublicKey('11111111111111111111111111111111'); + + // Instruction discriminators (single u8 values for Steel framework). + // Source of truth: ORE `api/src/instruction.rs` (OreInstruction enum). Staking + // instructions are NOT here — staking is a separate program (regolith-labs/ore-stake). + export const DISCRIMINATORS = { + automate: 0, + checkpoint: 2, + claimSol: 3, + claimOre: 4, + close: 5, + deploy: 6, + log: 8, + reset: 9, + buyback: 13, + wrap: 14, + setAdmin: 15, + newVar: 19, + bury: 24, + liq: 25, + } as const; + + // Account discriminators (first 8 bytes). Source: ORE `api/src/state/mod.rs` (OreAccount enum). + export const ACCOUNT_DISCRIMINATORS = { + Automation: [100, 0, 0, 0, 0, 0, 0, 0], + Config: [101, 0, 0, 0, 0, 0, 0, 0], + Miner: [103, 0, 0, 0, 0, 0, 0, 0], + Treasury: [104, 0, 0, 0, 0, 0, 0, 0], + Board: [105, 0, 0, 0, 0, 0, 0, 0], + Round: [109, 0, 0, 0, 0, 0, 0, 0], + } as const; + + // PDA seeds + export const PDA_SEEDS = { + automation: 'automation', + board: 'board', + config: 'config', + miner: 'miner', + round: 'round', + treasury: 'treasury', + } as const; + + // Supported networks (ORE v3 is only on mainnet-beta) + export const chain = 'solana'; + export const networks = ['mainnet-beta'] as const; + export type Network = (typeof networks)[number]; + + // Trading types (ore is a new trading type for mining game) + export const tradingTypes = ['ore'] as const; + + export interface RootConfig { + availableNetworks: Array; + } + + export const config: RootConfig = { + availableNetworks: [ + { + chain, + networks: [...networks], + }, + ], + }; + + // Helper to derive PDAs + export function getBoardPDA(): [PublicKey, number] { + return PublicKey.findProgramAddressSync([Buffer.from(PDA_SEEDS.board)], ORE_PROGRAM_ID); + } + + export function getConfigPDA(): [PublicKey, number] { + return PublicKey.findProgramAddressSync([Buffer.from(PDA_SEEDS.config)], ORE_PROGRAM_ID); + } + + export function getTreasuryPDA(): [PublicKey, number] { + return PublicKey.findProgramAddressSync([Buffer.from(PDA_SEEDS.treasury)], ORE_PROGRAM_ID); + } + + export function getMinerPDA(authority: PublicKey): [PublicKey, number] { + return PublicKey.findProgramAddressSync([Buffer.from(PDA_SEEDS.miner), authority.toBuffer()], ORE_PROGRAM_ID); + } + + export function getAutomationPDA(authority: PublicKey): [PublicKey, number] { + return PublicKey.findProgramAddressSync([Buffer.from(PDA_SEEDS.automation), authority.toBuffer()], ORE_PROGRAM_ID); + } + + export function getRoundPDA(roundId: bigint): [PublicKey, number] { + const roundIdBuffer = Buffer.alloc(8); + roundIdBuffer.writeBigUInt64LE(roundId); + return PublicKey.findProgramAddressSync([Buffer.from(PDA_SEEDS.round), roundIdBuffer], ORE_PROGRAM_ID); + } +} diff --git a/src/connectors/ore/ore.instructions.ts b/src/connectors/ore/ore.instructions.ts new file mode 100644 index 0000000000..186c86b792 --- /dev/null +++ b/src/connectors/ore/ore.instructions.ts @@ -0,0 +1,193 @@ +import { getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token'; +import { PublicKey, TransactionInstruction, SystemProgram } from '@solana/web3.js'; + +import { OreConfig } from './ore.config'; + +/** + * Instruction builders for the ORE program. + * ORE uses the Steel framework with single-byte discriminators (NOT Anchor 8-byte discriminators). + * Instruction data format: [discriminator (1 byte)] [args...] with all integers little-endian. + * + * Account layouts and discriminators are taken from the ORE `api` crate + * (regolith-labs/ore: `api/src/sdk.rs`, `api/src/instruction.rs`) and were verified against + * live mainnet transactions of the deployed program (oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv). + */ + +// ============================================================================ +// Instruction Data Builders +// ============================================================================ + +/** + * Build deploy instruction data. + * Args: amount (u64), squares bitmask (u32) → Deploy { amount: [u8;8], squares: [u8;4] } + */ +function buildDeployData(amountLamports: bigint, squaresBitmask: number): Buffer { + const buffer = Buffer.alloc(1 + 8 + 4); + buffer.writeUInt8(OreConfig.DISCRIMINATORS.deploy, 0); + buffer.writeBigUInt64LE(amountLamports, 1); + buffer.writeUInt32LE(squaresBitmask, 9); + return buffer; +} + +/** Build checkpoint instruction data. Args: none. */ +function buildCheckpointData(): Buffer { + const buffer = Buffer.alloc(1); + buffer.writeUInt8(OreConfig.DISCRIMINATORS.checkpoint, 0); + return buffer; +} + +/** Build claimSol instruction data. Args: none. */ +function buildClaimSolData(): Buffer { + const buffer = Buffer.alloc(1); + buffer.writeUInt8(OreConfig.DISCRIMINATORS.claimSol, 0); + return buffer; +} + +/** + * Build claimOre instruction data. + * Args: bps (u64) → ClaimORE { bps: [u8;8] }. bps is the portion to claim in basis points + * (10000 = 100%), clamped on-chain to <= 10000. + */ +function buildClaimOreData(bps: bigint): Buffer { + const buffer = Buffer.alloc(1 + 8); + buffer.writeUInt8(OreConfig.DISCRIMINATORS.claimOre, 0); + buffer.writeBigUInt64LE(bps, 1); + return buffer; +} + +// ============================================================================ +// Instruction Builders +// ============================================================================ + +/** + * Create deploy instruction. + * Deploys SOL to selected squares for the current round. + * Account layout (12): ORE `sdk::deploy`. + */ +export function createDeployInstruction( + signer: PublicKey, + amountLamports: bigint, + squaresBitmask: number, + currentRoundId: bigint, +): TransactionInstruction { + // For a user-signed deploy the signer is also the miner authority. + const authority = signer; + const [automation] = OreConfig.getAutomationPDA(authority); + const [board] = OreConfig.getBoardPDA(); + const [config] = OreConfig.getConfigPDA(); + const [miner] = OreConfig.getMinerPDA(authority); + const [round] = OreConfig.getRoundPDA(currentRoundId); + const [treasury] = OreConfig.getTreasuryPDA(); + + const keys = [ + { pubkey: signer, isSigner: true, isWritable: true }, + { pubkey: authority, isSigner: false, isWritable: true }, + { pubkey: automation, isSigner: false, isWritable: true }, + { pubkey: board, isSigner: false, isWritable: true }, + { pubkey: config, isSigner: false, isWritable: true }, + { pubkey: miner, isSigner: false, isWritable: true }, + { pubkey: round, isSigner: false, isWritable: true }, + { pubkey: treasury, isSigner: false, isWritable: true }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: OreConfig.ORE_PROGRAM_ID, isSigner: false, isWritable: false }, + // Entropy accounts. + { pubkey: OreConfig.ENTROPY_VAR_ADDRESS, isSigner: false, isWritable: true }, + { pubkey: OreConfig.ENTROPY_PROGRAM_ID, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: OreConfig.ORE_PROGRAM_ID, + data: buildDeployData(amountLamports, squaresBitmask), + }); +} + +/** + * Create checkpoint instruction. + * Settles miner rewards for a completed round. + * Account layout (8): ORE `sdk::checkpoint`. + */ +export function createCheckpointInstruction(signer: PublicKey, completedRoundId: bigint): TransactionInstruction { + const authority = signer; + const [automation] = OreConfig.getAutomationPDA(authority); + const [board] = OreConfig.getBoardPDA(); + const [miner] = OreConfig.getMinerPDA(authority); + const [round] = OreConfig.getRoundPDA(completedRoundId); + const [treasury] = OreConfig.getTreasuryPDA(); + + const keys = [ + { pubkey: signer, isSigner: true, isWritable: true }, + { pubkey: authority, isSigner: false, isWritable: true }, + { pubkey: automation, isSigner: false, isWritable: true }, + { pubkey: board, isSigner: false, isWritable: true }, + { pubkey: miner, isSigner: false, isWritable: true }, + { pubkey: round, isSigner: false, isWritable: true }, + { pubkey: treasury, isSigner: false, isWritable: true }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: OreConfig.ORE_PROGRAM_ID, + data: buildCheckpointData(), + }); +} + +/** + * Create claimSol instruction. + * Claims SOL rewards from the miner account. + * Account layout (5): ORE `sdk::claim_sol`. + */ +export function createClaimSolInstruction(signer: PublicKey): TransactionInstruction { + const [board] = OreConfig.getBoardPDA(); + const [miner] = OreConfig.getMinerPDA(signer); + + const keys = [ + { pubkey: signer, isSigner: true, isWritable: true }, + { pubkey: board, isSigner: false, isWritable: true }, + { pubkey: miner, isSigner: false, isWritable: true }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: OreConfig.ORE_PROGRAM_ID, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: OreConfig.ORE_PROGRAM_ID, + data: buildClaimSolData(), + }); +} + +/** + * Create claimOre instruction. + * Claims a portion (bps of 10000) of the miner's ORE rewards from the treasury vault. + * Account layout (11): ORE `sdk::claim_ore`. + */ +export function createClaimOreInstruction(signer: PublicKey, bps: bigint): TransactionInstruction { + const [board] = OreConfig.getBoardPDA(); + const [miner] = OreConfig.getMinerPDA(signer); + const [treasury] = OreConfig.getTreasuryPDA(); + + // Treasury's ORE token account (source) and signer's ORE token account (recipient). + const treasuryTokens = getAssociatedTokenAddressSync(OreConfig.ORE_TOKEN_MINT, treasury, true); + const recipient = getAssociatedTokenAddressSync(OreConfig.ORE_TOKEN_MINT, signer); + + const keys = [ + { pubkey: signer, isSigner: true, isWritable: true }, + { pubkey: board, isSigner: false, isWritable: true }, + { pubkey: miner, isSigner: false, isWritable: true }, + { pubkey: OreConfig.ORE_TOKEN_MINT, isSigner: false, isWritable: true }, + { pubkey: recipient, isSigner: false, isWritable: true }, + { pubkey: treasury, isSigner: false, isWritable: true }, + { pubkey: treasuryTokens, isSigner: false, isWritable: true }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: ASSOCIATED_TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: OreConfig.ORE_PROGRAM_ID, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: OreConfig.ORE_PROGRAM_ID, + data: buildClaimOreData(bps), + }); +} diff --git a/src/connectors/ore/ore.parser.ts b/src/connectors/ore/ore.parser.ts new file mode 100644 index 0000000000..148be099d3 --- /dev/null +++ b/src/connectors/ore/ore.parser.ts @@ -0,0 +1,301 @@ +import { PublicKey } from '@solana/web3.js'; + +import { OreConfig } from './ore.config'; + +/** + * Account data structures parsed from on-chain ORE program state. + * + * ORE uses the Steel framework: each account is an 8-byte discriminator ([enumValue, 0, 0, 0, 0, 0, 0, 0]) + * followed by a `#[repr(C)]` Pod struct. All integers are little-endian. + * + * Layouts mirror the ORE `api` crate (regolith-labs/ore: `api/src/state/*.rs`) and were verified + * against on-chain account sizes on mainnet. `Numeric` is a 16-byte fixed-point value. + */ + +// ============================================================================ +// Parsed Account Types +// ============================================================================ + +export interface BoardAccount { + roundId: bigint; + startSlot: bigint; + endSlot: bigint; + productionCostEma: bigint; +} + +export interface MinerAccount { + authority: PublicKey; + autoReturn: bigint; + checkpointId: bigint; + checkpointFee: bigint; + deployed: bigint[]; // 25 u64 values (SOL per square) + mass: bigint[]; // 25 u64 values + cumulative: bigint[]; // 25 u64 values + roundId: bigint; + rewardsFactor: Uint8Array; // 16 bytes (Numeric) + rewardsSol: bigint; + refinedOre: bigint; + rewardsOre: bigint; + lastClaimOreAt: bigint; + lastClaimSolAt: bigint; + lifetimeRewardsOre: bigint; + lifetimeDeployed: bigint; + lifetimeRewardsSol: bigint; +} + +export interface RoundAccount { + id: bigint; + deployed: bigint[]; // 25 u64 values + mass: bigint[]; // 25 u64 values + count: bigint[]; // 25 u64 values (unique miners per square) + slotHash: Uint8Array; // 32 bytes (entropy) + expiresAt: bigint; + motherlode: bigint; + rentPayer: PublicKey; + rewards: bigint[]; // 25 u64 values (ORE reward per square) + totalVaulted: bigint; + totalWinnings: bigint; + totalMiners: bigint; + topMiner: PublicKey; + // Derived (not stored on-chain): + totalDeployed: bigint; // sum(deployed) + topMinerReward: bigint; // sum(rewards) +} + +export interface TreasuryAccount { + motherlode: bigint; + minerRewardsFactor: Uint8Array; // 16 bytes (Numeric) + totalRefined: bigint; + totalUnclaimed: bigint; +} + +// ============================================================================ +// Parsing Helpers +// ============================================================================ + +function readU64LE(data: Buffer, offset: number): bigint { + return data.readBigUInt64LE(offset); +} + +function readI64LE(data: Buffer, offset: number): bigint { + return data.readBigInt64LE(offset); +} + +function readPublicKey(data: Buffer, offset: number): PublicKey { + return new PublicKey(data.subarray(offset, offset + 32)); +} + +function readU64Array(data: Buffer, offset: number, count: number): bigint[] { + const result: bigint[] = []; + for (let i = 0; i < count; i++) { + result.push(readU64LE(data, offset + i * 8)); + } + return result; +} + +function verifyDiscriminator(data: Buffer, expected: readonly number[]): boolean { + for (let i = 0; i < 8; i++) { + if (data[i] !== expected[i]) { + return false; + } + } + return true; +} + +// ============================================================================ +// Account Parsers +// ============================================================================ + +/** + * Parse Board account (ORE `state::Board`). Struct size 32 bytes (+8 disc = 40). + * round_id (u64), start_slot (u64), end_slot (u64), production_cost_ema (u64). + */ +export function parseBoardAccount(data: Buffer): BoardAccount { + if (!verifyDiscriminator(data, OreConfig.ACCOUNT_DISCRIMINATORS.Board)) { + throw new Error('Invalid Board account discriminator'); + } + + return { + roundId: readU64LE(data, 8), + startSlot: readU64LE(data, 16), + endSlot: readU64LE(data, 24), + productionCostEma: readU64LE(data, 32), + }; +} + +/** + * Parse Miner account (ORE `state::Miner`). Struct size 744 bytes (+8 disc = 752). + * Field order matches the Rust struct exactly. + */ +export function parseMinerAccount(data: Buffer): MinerAccount { + if (!verifyDiscriminator(data, OreConfig.ACCOUNT_DISCRIMINATORS.Miner)) { + throw new Error('Invalid Miner account discriminator'); + } + + let offset = 8; + + const authority = readPublicKey(data, offset); + offset += 32; + const autoReturn = readU64LE(data, offset); + offset += 8; + const checkpointId = readU64LE(data, offset); + offset += 8; + const checkpointFee = readU64LE(data, offset); + offset += 8; + const deployed = readU64Array(data, offset, 25); + offset += 200; + const mass = readU64Array(data, offset, 25); + offset += 200; + const cumulative = readU64Array(data, offset, 25); + offset += 200; + const roundId = readU64LE(data, offset); + offset += 8; + const rewardsFactor = new Uint8Array(data.subarray(offset, offset + 16)); + offset += 16; + const rewardsSol = readU64LE(data, offset); + offset += 8; + const refinedOre = readU64LE(data, offset); + offset += 8; + const rewardsOre = readU64LE(data, offset); + offset += 8; + const lastClaimOreAt = readI64LE(data, offset); + offset += 8; + const lastClaimSolAt = readI64LE(data, offset); + offset += 8; + const lifetimeRewardsOre = readU64LE(data, offset); + offset += 8; + const lifetimeDeployed = readU64LE(data, offset); + offset += 8; + const lifetimeRewardsSol = readU64LE(data, offset); + + return { + authority, + autoReturn, + checkpointId, + checkpointFee, + deployed, + mass, + cumulative, + roundId, + rewardsFactor, + rewardsSol, + refinedOre, + rewardsOre, + lastClaimOreAt, + lastClaimSolAt, + lifetimeRewardsOre, + lifetimeDeployed, + lifetimeRewardsSol, + }; +} + +/** + * Parse Round account (ORE `state::Round`). Struct size 944 bytes (+8 disc = 952). + * Field order matches the Rust struct exactly. + */ +export function parseRoundAccount(data: Buffer): RoundAccount { + if (!verifyDiscriminator(data, OreConfig.ACCOUNT_DISCRIMINATORS.Round)) { + throw new Error('Invalid Round account discriminator'); + } + + let offset = 8; + + const id = readU64LE(data, offset); + offset += 8; + const deployed = readU64Array(data, offset, 25); + offset += 200; + const mass = readU64Array(data, offset, 25); + offset += 200; + const count = readU64Array(data, offset, 25); + offset += 200; + const slotHash = new Uint8Array(data.subarray(offset, offset + 32)); + offset += 32; + const expiresAt = readU64LE(data, offset); + offset += 8; + const motherlode = readU64LE(data, offset); + offset += 8; + const rentPayer = readPublicKey(data, offset); + offset += 32; + const rewards = readU64Array(data, offset, 25); + offset += 200; + const totalVaulted = readU64LE(data, offset); + offset += 8; + const totalWinnings = readU64LE(data, offset); + offset += 8; + const totalMiners = readU64LE(data, offset); + offset += 8; + const topMiner = readPublicKey(data, offset); + + const totalDeployed = deployed.reduce((a, b) => a + b, 0n); + const topMinerReward = rewards.reduce((a, b) => a + b, 0n); + + return { + id, + deployed, + mass, + count, + slotHash, + expiresAt, + motherlode, + rentPayer, + rewards, + totalVaulted, + totalWinnings, + totalMiners, + topMiner, + totalDeployed, + topMinerReward, + }; +} + +/** + * Parse Treasury account (ORE `state::Treasury`). Struct size 40 bytes (+8 disc = 48). + * motherlode (u64), miner_rewards_factor (Numeric, 16 bytes), total_refined (u64), total_unclaimed (u64). + */ +export function parseTreasuryAccount(data: Buffer): TreasuryAccount { + if (!verifyDiscriminator(data, OreConfig.ACCOUNT_DISCRIMINATORS.Treasury)) { + throw new Error('Invalid Treasury account discriminator'); + } + + return { + motherlode: readU64LE(data, 8), + minerRewardsFactor: new Uint8Array(data.subarray(16, 32)), + totalRefined: readU64LE(data, 32), + totalUnclaimed: readU64LE(data, 40), + }; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Convert square indices array to bitmask. + * @param squares Array of square indices (0-24) + * @returns Bitmask as a number + */ +export function squaresToBitmask(squares: number[]): number { + let bitmask = 0; + for (const square of squares) { + if (square < 0 || square > 24) { + throw new Error(`Invalid square index: ${square}. Must be 0-24.`); + } + bitmask |= 1 << square; + } + return bitmask; +} + +/** + * Convert bitmask to square indices array. + * @param bitmask Bitmask number + * @returns Array of square indices + */ +export function bitmaskToSquares(bitmask: number): number[] { + const squares: number[] = []; + for (let i = 0; i < 25; i++) { + if (bitmask & (1 << i)) { + squares.push(i); + } + } + return squares; +} diff --git a/src/connectors/ore/ore.routes.ts b/src/connectors/ore/ore.routes.ts new file mode 100644 index 0000000000..c9b26b44f9 --- /dev/null +++ b/src/connectors/ore/ore.routes.ts @@ -0,0 +1,27 @@ +import sensible from '@fastify/sensible'; +import type { FastifyPluginAsync } from 'fastify'; + +// Import routes +import { oreRoutes } from './ore-routes'; + +// ORE mining/staking routes wrapper +const oreRoutesWrapper: FastifyPluginAsync = async (fastify) => { + await fastify.register(sensible); + + await fastify.register(async (instance) => { + instance.addHook('onRoute', (routeOptions) => { + if (routeOptions.schema && routeOptions.schema.tags) { + routeOptions.schema.tags = ['/connector/ore']; + } + }); + + await instance.register(oreRoutes); + }); +}; + +// Export the ORE routes +export const oreConnectorRoutes = { + ore: oreRoutesWrapper, +}; + +export default oreConnectorRoutes; diff --git a/src/connectors/ore/ore.ts b/src/connectors/ore/ore.ts new file mode 100644 index 0000000000..ef00c03f01 --- /dev/null +++ b/src/connectors/ore/ore.ts @@ -0,0 +1,311 @@ +import { Keypair, PublicKey, Transaction, VersionedTransaction } from '@solana/web3.js'; + +import { Solana } from '../../chains/solana/solana'; +import { SolanaLedger } from '../../chains/solana/solana-ledger'; +import { httpErrors } from '../../services/error-handler'; +import { logger } from '../../services/logger'; + +import { OreConfig } from './ore.config'; +import { + parseBoardAccount, + parseMinerAccount, + parseRoundAccount, + parseTreasuryAccount, + BoardAccount, + MinerAccount, + RoundAccount, + TreasuryAccount, +} from './ore.parser'; +import { OreAccountInfoResponseType, OreBoardInfoResponseType, OreSystemInfoResponseType } from './schemas'; + +export class Ore { + private static _instances: { [name: string]: Ore }; + public solana: Solana; + public config: OreConfig.RootConfig; + + private constructor() { + this.config = OreConfig.config; + this.solana = null as any; + } + + /** Gets singleton instance of Ore */ + public static async getInstance(network: string): Promise { + if (!Ore._instances) { + Ore._instances = {}; + } + + if (!Ore._instances[network]) { + const instance = new Ore(); + await instance.init(network); + Ore._instances[network] = instance; + } + + return Ore._instances[network]; + } + + /** Initializes Ore instance */ + private async init(network: string) { + try { + this.solana = await Solana.getInstance(network); + logger.info('ORE connector initialized'); + } catch (error) { + logger.error('ORE connector initialization failed:', error); + throw error; + } + } + + // ============================================================================ + // Account Fetching Methods + // ============================================================================ + + /** Fetch Board account (singleton) */ + async getBoardAccount(): Promise { + const [boardPDA] = OreConfig.getBoardPDA(); + const accountInfo = await this.solana.connection.getAccountInfo(boardPDA, 'confirmed'); + + if (!accountInfo) { + throw httpErrors.notFound('Board account not found'); + } + + return parseBoardAccount(accountInfo.data as Buffer); + } + + /** Fetch Treasury account (singleton) */ + async getTreasuryAccount(): Promise { + const [treasuryPDA] = OreConfig.getTreasuryPDA(); + const accountInfo = await this.solana.connection.getAccountInfo(treasuryPDA, 'confirmed'); + + if (!accountInfo) { + throw httpErrors.notFound('Treasury account not found'); + } + + return parseTreasuryAccount(accountInfo.data as Buffer); + } + + /** Fetch Round account by ID */ + async getRoundAccount(roundId: bigint): Promise { + const [roundPDA] = OreConfig.getRoundPDA(roundId); + const accountInfo = await this.solana.connection.getAccountInfo(roundPDA, 'confirmed'); + + if (!accountInfo) { + throw httpErrors.notFound(`Round account not found for round ${roundId}`); + } + + return parseRoundAccount(accountInfo.data as Buffer); + } + + /** Fetch Miner account for a wallet */ + async getMinerAccount(walletAddress: string): Promise { + let walletPubkey: PublicKey; + try { + walletPubkey = new PublicKey(walletAddress); + } catch { + throw httpErrors.badRequest(`Invalid wallet address: ${walletAddress}`); + } + + const [minerPDA] = OreConfig.getMinerPDA(walletPubkey); + const accountInfo = await this.solana.connection.getAccountInfo(minerPDA, 'confirmed'); + + if (!accountInfo) { + return null; // Miner account doesn't exist yet + } + + return parseMinerAccount(accountInfo.data as Buffer); + } + + // ============================================================================ + // High-Level Info Methods (for routes) + // ============================================================================ + + /** Get board info including round state */ + async getBoardInfo(roundId?: number): Promise { + const board = await this.getBoardAccount(); + const requestedRoundId = roundId !== undefined ? BigInt(roundId) : board.roundId; + const round = await this.getRoundAccount(requestedRoundId); + + const [roundPDA] = OreConfig.getRoundPDA(requestedRoundId); + + // Calculate seconds left in round (only relevant for current round) + const currentSlot = await this.solana.connection.getSlot('confirmed'); + const slotsRemaining = Number(board.endSlot) - currentSlot; + // Solana averages ~400ms per slot + // For historical rounds, secondsLeft will be 0 + const isCurrentRound = requestedRoundId === board.roundId; + const secondsLeft = isCurrentRound ? Math.max(0, Math.floor(slotsRemaining * 0.4)) : 0; + + // Calculate winning square from slotHash + // slotHash is all zeros for current/unfinalized rounds + // RNG: XOR four 8-byte chunks of the 32-byte hash, then mod 25 + const isFinalized = !round.slotHash.every((b) => b === 0); + let winningSquare: number | null = null; + let winningSquareIndex: number | null = null; // 0-indexed for internal use + if (isFinalized) { + const view = new DataView(round.slotHash.buffer, round.slotHash.byteOffset, 32); + const r1 = view.getBigUint64(0, true); + const r2 = view.getBigUint64(8, true); + const r3 = view.getBigUint64(16, true); + const r4 = view.getBigUint64(24, true); + const rng = r1 ^ r2 ^ r3 ^ r4; + winningSquareIndex = Number(rng % 25n); // 0-indexed internally + winningSquare = winningSquareIndex + 1; // 1-indexed for API response + } + + // Build squares dictionary (1-25) with SOL amounts + const squares: Record = {}; + for (let i = 0; i < 25; i++) { + squares[(i + 1).toString()] = { + deployed: Number(round.deployed[i]) / 1_000_000_000, // Convert lamports to SOL + miners: Number(round.count[i]), + }; + } + + // Get winner miners count (miners who deployed to winning square) + const winnerMiners = winningSquareIndex !== null ? Number(round.count[winningSquareIndex]) : 0; + + // Check for ORE winner + // topMiner is system program if no winner, "SpLiT1111..." if split among winners + const SYSTEM_PROGRAM = '11111111111111111111111111111111'; + const SPLIT_ADDRESS_PREFIX = 'SpLiT'; + const topMinerAddress = round.topMiner.toBase58(); + const isNoWinner = topMinerAddress === SYSTEM_PROGRAM; + const isSplit = topMinerAddress.startsWith(SPLIT_ADDRESS_PREFIX); + + const ORE_DECIMALS = 11; + + return { + roundId: Number(requestedRoundId), + roundAddress: roundPDA.toBase58(), + secondsLeft, + winningSquare, + winnerMiners, + oreWinnerSplit: isSplit, + oreWinner: !isNoWinner && !isSplit ? topMinerAddress : null, + oreReward: Number(round.topMinerReward) / 10 ** ORE_DECIMALS, + squares, + totalDeployedSol: Number(round.totalDeployed) / 1_000_000_000, + totalVaultedSol: Number(round.totalVaulted) / 1_000_000_000, + totalWinningsSol: Number(round.totalWinnings) / 1_000_000_000, + motherlodeOre: Number(round.motherlode) / 10 ** ORE_DECIMALS, + totalMiners: Number(round.totalMiners), + expiresAt: Number(round.expiresAt), + }; + } + + /** Get miner account info for a wallet */ + async getAccountInfo(walletAddress: string, roundId?: number): Promise { + const walletPubkey = new PublicKey(walletAddress); + + // Fetch the miner account (may not exist yet) + const miner = await this.getMinerAccount(walletAddress); + + // Get current round from board if no roundId specified + const board = await this.getBoardAccount(); + const currentRoundId = roundId !== undefined ? BigInt(roundId) : board.roundId; + + // Build deployment per square (1-25) + // Only show miner's deployed amounts if they participated in the requested round + const deployedSol: Record = {}; + const minerParticipatedInRound = miner && miner.roundId === currentRoundId; + for (let i = 0; i < 25; i++) { + deployedSol[(i + 1).toString()] = minerParticipatedInRound ? Number(miner.deployed[i]) / 1_000_000_000 : 0; + } + + const [minerPDA] = OreConfig.getMinerPDA(walletPubkey); + + const ORE_DECIMALS = 11; + + return { + // Account address + mineAddress: miner ? minerPDA.toBase58() : null, + // Mine info + lastRound: miner ? Number(miner.roundId) : null, + checkedRound: miner ? Number(miner.checkpointId) : null, + currentRound: { + roundId: Number(currentRoundId), + deployedSol, + }, + rewardsSol: miner ? Number(miner.rewardsSol) / 1_000_000_000 : 0, + rewardsOre: miner ? Number(miner.rewardsOre) / 10 ** ORE_DECIMALS : 0, + lifetimeRewardsSol: miner ? Number(miner.lifetimeRewardsSol) / 1_000_000_000 : 0, + lifetimeRewardsOre: miner ? Number(miner.lifetimeRewardsOre) / 10 ** ORE_DECIMALS : 0, + lifetimeDeployed: miner ? Number(miner.lifetimeDeployed) / 1_000_000_000 : 0, + }; + } + + /** Get system info (treasury + token supply) */ + async getSystemInfo(): Promise { + const treasury = await this.getTreasuryAccount(); + + const [treasuryPDA] = OreConfig.getTreasuryPDA(); + + // Fetch ORE token supply from mint + const tokenSupplyInfo = await this.solana.connection.getTokenSupply(OreConfig.ORE_TOKEN_MINT); + const circulatingSupplyRaw = BigInt(tokenSupplyInfo.value.amount); + + const ORE_DECIMALS = 11; + const MAX_SUPPLY_ORE = 5_000_000; // 5 million ORE max supply + + // Circulating supply from token mint + const circulatingSupplyOre = Number(circulatingSupplyRaw) / 10 ** ORE_DECIMALS; + + // Buried = totalRefined - circulatingSupply (refined but burned) + const totalRefinedOre = Number(treasury.totalRefined) / 10 ** ORE_DECIMALS; + const buriedOre = Math.max(0, totalRefinedOre - circulatingSupplyOre); + + return { + treasuryAddress: treasuryPDA.toBase58(), + maxSupplyOre: MAX_SUPPLY_ORE, + circulatingSupplyOre, + buriedOre, + totalRefinedOre, + totalUnclaimedOre: Number(treasury.totalUnclaimed) / 10 ** ORE_DECIMALS, + motherlodeOre: Number(treasury.motherlode) / 10 ** ORE_DECIMALS, + }; + } + + // ============================================================================ + // Wallet Helpers (for hardware wallet support) + // ============================================================================ + + /** Prepare wallet for transaction signing */ + public async prepareWallet(walletAddress: string): Promise<{ + wallet: Keypair | PublicKey; + isHardwareWallet: boolean; + }> { + const isHardwareWallet = await this.solana.isHardwareWallet(walletAddress); + const wallet = isHardwareWallet ? new PublicKey(walletAddress) : await this.solana.getWallet(walletAddress); + + return { wallet, isHardwareWallet }; + } + + /** Sign and send transaction (with hardware wallet support) */ + public async signAndSendTransaction( + transaction: VersionedTransaction | Transaction, + walletAddress: string, + isHardwareWallet: boolean, + ): Promise { + if (isHardwareWallet) { + logger.info(`Hardware wallet detected for ${walletAddress}. Signing transaction with Ledger.`); + const ledger = new SolanaLedger(); + const signedTx = await ledger.signTransaction(walletAddress, transaction); + const signature = await this.solana.connection.sendRawTransaction(signedTx.serialize()); + await this.solana.connection.confirmTransaction(signature, 'confirmed'); + return signature; + } else { + // Regular wallet signing + const wallet = await this.solana.getWallet(walletAddress); + if (transaction instanceof Transaction) { + transaction.sign(wallet); + const signature = await this.solana.connection.sendRawTransaction(transaction.serialize()); + await this.solana.connection.confirmTransaction(signature, 'confirmed'); + return signature; + } else { + // VersionedTransaction + transaction.sign([wallet]); + const signature = await this.solana.connection.sendRawTransaction(transaction.serialize()); + await this.solana.connection.confirmTransaction(signature, 'confirmed'); + return signature; + } + } + } +} diff --git a/src/connectors/ore/schemas.ts b/src/connectors/ore/schemas.ts new file mode 100644 index 0000000000..f5389f549b --- /dev/null +++ b/src/connectors/ore/schemas.ts @@ -0,0 +1,257 @@ +import { Static, Type } from '@sinclair/typebox'; + +import { getSolanaChainConfig } from '../../chains/solana/solana.config'; + +import { OreConfig } from './ore.config'; + +// Get chain config for defaults +const solanaChainConfig = getSolanaChainConfig(); + +// ============================================================================ +// Response Schemas +// ============================================================================ + +// Square info for each square on the 5x5 board +const SquareInfo = Type.Object({ + deployed: Type.Number({ description: 'SOL deployed to this square' }), + miners: Type.Number({ description: 'Number of miners who deployed to this square' }), +}); + +// Board Info Response (includes current round info) +export const OreBoardInfoResponse = Type.Object({ + roundId: Type.Number({ description: 'Round number' }), + roundAddress: Type.String({ description: 'Round PDA address' }), + secondsLeft: Type.Number({ description: 'Seconds remaining in current round (0 for historical rounds)' }), + winningSquare: Type.Union([Type.Number(), Type.Null()], { + description: 'Winning square (1-25), null if round not finalized', + }), + winnerMiners: Type.Number({ description: 'Number of miners who won (deployed to winning square)' }), + oreWinnerSplit: Type.Boolean({ description: 'True if ORE reward was split among all winners' }), + oreWinner: Type.Union([Type.String(), Type.Null()], { + description: 'ORE winner address (single winner), null if split or no winner', + }), + oreReward: Type.Number({ description: 'ORE reward amount' }), + squares: Type.Record(Type.String(), SquareInfo, { + description: 'Square data indexed 1-25 (5x5 grid)', + }), + totalDeployedSol: Type.Number({ description: 'Total SOL deployed this round' }), + totalVaultedSol: Type.Number({ description: 'Total SOL vaulted this round' }), + totalWinningsSol: Type.Number({ description: 'Total SOL winnings this round' }), + motherlodeOre: Type.Number({ description: 'Prize pool in ORE' }), + totalMiners: Type.Number({ description: 'Total number of unique miners' }), + expiresAt: Type.Number({ description: 'Round expiration timestamp (unix seconds)' }), +}); + +export type OreBoardInfoResponseType = Static; + +// Account Info Response (miner) +export const OreAccountInfoResponse = Type.Object({ + // Account address + mineAddress: Type.Union([Type.String(), Type.Null()], { description: 'Mine PDA address (null if not created)' }), + // Mine info + lastRound: Type.Union([Type.Number(), Type.Null()], { + description: 'Last round the miner deployed to (null if never mined)', + }), + checkedRound: Type.Union([Type.Number(), Type.Null()], { + description: 'Last round the miner checkpointed (null if never checkpointed)', + }), + currentRound: Type.Object({ + roundId: Type.Union([Type.Number(), Type.Null()], { description: 'Current round ID' }), + deployedSol: Type.Record(Type.String(), Type.Number(), { + description: 'SOL deployed per square this round (1-25)', + }), + }), + rewardsSol: Type.Number({ description: 'Claimable SOL rewards' }), + rewardsOre: Type.Number({ description: 'Claimable ORE rewards' }), + lifetimeRewardsSol: Type.Number({ description: 'Lifetime SOL rewards' }), + lifetimeRewardsOre: Type.Number({ description: 'Lifetime ORE rewards' }), + lifetimeDeployed: Type.Number({ description: 'Lifetime SOL deployed' }), +}); + +export type OreAccountInfoResponseType = Static; + +// System Info Response (treasury + config combined) +export const OreSystemInfoResponse = Type.Object({ + treasuryAddress: Type.String({ description: 'Treasury PDA address' }), + maxSupplyOre: Type.Number({ description: 'Maximum ORE supply (5 million)' }), + circulatingSupplyOre: Type.Number({ description: 'Circulating ORE supply (from token mint)' }), + buriedOre: Type.Number({ description: 'Buried (burned) ORE' }), + totalRefinedOre: Type.Number({ description: 'Total refined ORE' }), + totalUnclaimedOre: Type.Number({ description: 'Total unclaimed ORE rewards' }), + motherlodeOre: Type.Number({ description: 'Motherlode prize pool in ORE' }), +}); + +export type OreSystemInfoResponseType = Static; + +// Transaction Response +export const OreTransactionResponse = Type.Object({ + signature: Type.String({ description: 'Transaction signature' }), + message: Type.Optional(Type.String({ description: 'Additional message' })), +}); + +export type OreTransactionResponseType = Static; + +// Checkpoint Response (with details about the round) +export const OreCheckpointResponse = Type.Object({ + signature: Type.String({ description: 'Transaction signature' }), + roundId: Type.Number({ description: 'Round that was checkpointed' }), + winningSquare: Type.Number({ description: 'Winning square (1-25)' }), + deployedSquares: Type.Array(Type.Number(), { description: 'Squares you deployed to (1-25)' }), + deployedSol: Type.Number({ description: 'Total SOL you deployed' }), + won: Type.Boolean({ description: 'Whether you deployed to the winning square' }), + wonSol: Type.Number({ description: 'SOL winnings from this round' }), + wonOre: Type.Number({ description: 'ORE winnings from this round' }), +}); + +export type OreCheckpointResponseType = Static; + +// ============================================================================ +// Request Schemas - GET Routes +// ============================================================================ + +// Board Info Request +export const OreBoardInfoRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...OreConfig.networks], + }), + ), + roundId: Type.Optional( + Type.Number({ + description: 'Optional round ID to fetch historical round info (defaults to current round)', + }), + ), +}); + +export type OreBoardInfoRequestType = Static; + +// Account Info Request +export const OreAccountInfoRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...OreConfig.networks], + }), + ), + walletAddress: Type.String({ + description: 'Wallet address to query account info for', + examples: [solanaChainConfig.defaultWallet], + }), + roundId: Type.Optional( + Type.Number({ + description: 'Optional round ID to fetch historical round info (defaults to current round)', + }), + ), +}); + +export type OreAccountInfoRequestType = Static; + +// System Info Request +export const OreSystemInfoRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...OreConfig.networks], + }), + ), +}); + +export type OreSystemInfoRequestType = Static; + +// ============================================================================ +// Request Schemas - POST Mining Routes +// ============================================================================ + +// Deploy Request +export const OreDeployRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...OreConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address', + default: solanaChainConfig.defaultWallet, + }), + ), + amount: Type.Number({ + description: 'Amount of SOL to deploy (in SOL, not lamports)', + minimum: 0, + examples: [0.1], + }), + squares: Type.Array(Type.Number(), { + description: 'Square(s) to deploy to (1-25). SOL is split evenly across selected squares.', + examples: [[13], [1, 6, 11, 16, 21]], + }), +}); + +export type OreDeployRequestType = Static; + +// Checkpoint Request +export const OreCheckpointRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...OreConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address', + default: solanaChainConfig.defaultWallet, + }), + ), + roundId: Type.Optional( + Type.String({ + description: 'Round ID to checkpoint (defaults to last completed round)', + }), + ), +}); + +export type OreCheckpointRequestType = Static; + +// Claim SOL Request +export const OreClaimSolRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...OreConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address', + default: solanaChainConfig.defaultWallet, + }), + ), +}); + +export type OreClaimSolRequestType = Static; + +// Claim ORE Request +export const OreClaimOreRequest = Type.Object({ + network: Type.Optional( + Type.String({ + description: 'Solana network to use', + default: solanaChainConfig.defaultNetwork, + enum: [...OreConfig.networks], + }), + ), + walletAddress: Type.Optional( + Type.String({ + description: 'Wallet address', + default: solanaChainConfig.defaultWallet, + }), + ), +}); + +export type OreClaimOreRequestType = Static; diff --git a/src/templates/connectors/ore.yml b/src/templates/connectors/ore.yml new file mode 100644 index 0000000000..675618ad8d --- /dev/null +++ b/src/templates/connectors/ore.yml @@ -0,0 +1,12 @@ +# ORE Mining Game Connector Configuration (Experimental) +# This connector provides access to the ORE v3 mining game on Solana + +# Program IDs are hardcoded in the connector +# ORE Program: oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv +# ORE Token Mint: oreoU2P8bN6jkk3jbaiVxYnG1dCXcYxwhwyK9jSybcp + +# Default transaction confirmation commitment level +commitment: confirmed + +# Default priority fee in microlamports (optional, 0 = use network default) +priorityFee: 0 diff --git a/src/templates/namespace/ore-schema.json b/src/templates/namespace/ore-schema.json new file mode 100644 index 0000000000..746fb0a641 --- /dev/null +++ b/src/templates/namespace/ore-schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "commitment": { + "type": "string", + "description": "Default transaction confirmation commitment level", + "enum": ["processed", "confirmed", "finalized"] + }, + "priorityFee": { + "type": "number", + "description": "Default priority fee in microlamports (0 = use network default)" + } + }, + "additionalProperties": false, + "required": ["commitment", "priorityFee"] +} diff --git a/src/templates/root.yml b/src/templates/root.yml index 05c0702ba0..cb18793635 100644 --- a/src/templates/root.yml +++ b/src/templates/root.yml @@ -104,6 +104,10 @@ configurations: configurationPath: connectors/pancakeswap-sol.yml schemaPath: pancakeswap-sol-schema.json + $namespace ore: + configurationPath: connectors/ore.yml + schemaPath: ore-schema.json + $namespace dflow: configurationPath: connectors/dflow.yml schemaPath: dflow-schema.json