diff --git a/.gitignore b/.gitignore index 7a1e3dd23..9d1490101 100644 --- a/.gitignore +++ b/.gitignore @@ -145,5 +145,8 @@ hiero-cli.config.*.local.json #AI agents .claude CLAUDE.md +swap-quote.md +swap-HBAR-token.md + pnpm-lock.yaml \ No newline at end of file diff --git a/my-subgraph/abis/IGreeter.json b/my-subgraph/abis/IGreeter.json new file mode 100644 index 000000000..97b0cb0e8 --- /dev/null +++ b/my-subgraph/abis/IGreeter.json @@ -0,0 +1,29 @@ +[ + { + "inputs": [{"internalType": "string", "name": "_greeting", "type": "string"}], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + {"indexed": false, "internalType": "string", "name": "_greeting", "type": "string"} + ], + "name": "GreetingSet", + "type": "event" + }, + { + "inputs": [], + "name": "greet", + "outputs": [{"internalType": "string", "name": "", "type": "string"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{"internalType": "string", "name": "_greeting", "type": "string"}], + "name": "setGreeting", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/my-subgraph/config/testnet.json b/my-subgraph/config/testnet.json new file mode 100644 index 000000000..098ba4d29 --- /dev/null +++ b/my-subgraph/config/testnet.json @@ -0,0 +1,4 @@ +{ + "startBlock": "1", + "Greeter": "0x0000000000000000000000000000000000000000" +} diff --git a/my-subgraph/graph-node/docker-compose.yaml b/my-subgraph/graph-node/docker-compose.yaml new file mode 100644 index 000000000..737168c1c --- /dev/null +++ b/my-subgraph/graph-node/docker-compose.yaml @@ -0,0 +1,42 @@ +version: '3' +services: + graph-node: + image: graphprotocol/graph-node:v0.27.0 + ports: + - '8000:8000' + - '8001:8001' + - '8020:8020' + - '8030:8030' + - '8040:8040' + depends_on: + - ipfs + - postgres + extra_hosts: + - host.docker.internal:host-gateway + environment: + postgres_host: postgres + postgres_user: 'graph-node' + postgres_pass: 'let-me-in' + postgres_db: 'graph-node' + ipfs: 'ipfs:5001' + ethereum: 'testnet:https://testnet.hashio.io/api' + GRAPH_LOG: info + GRAPH_ETHEREUM_GENESIS_BLOCK_NUMBER: 1 + ipfs: + image: ipfs/go-ipfs:v0.10.0 + ports: + - '5001:5001' + volumes: + - ./data/ipfs:/data/ipfs + postgres: + image: postgres + ports: + - '5432:5432' + command: ['postgres', '-cshared_preload_libraries=pg_stat_statements'] + environment: + POSTGRES_USER: 'graph-node' + POSTGRES_PASSWORD: 'let-me-in' + POSTGRES_DB: 'graph-node' + PGDATA: '/data/postgres' + volumes: + - ./data/postgres:/var/lib/postgresql/data diff --git a/my-subgraph/package.json b/my-subgraph/package.json new file mode 100644 index 000000000..502cf0e03 --- /dev/null +++ b/my-subgraph/package.json @@ -0,0 +1,17 @@ +{ + "name": "hedera-subgraph-greeter", + "version": "1.0.0", + "description": "Hedera subgraph on testnet - scaffolded by hiero-cli", + "scripts": { + "codegen": "graph codegen", + "build": "graph build", + "create-local": "graph create --node http://localhost:8020/ Greeter", + "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 Greeter", + "graph-node": "docker-compose -f ./graph-node/docker-compose.yaml up -d", + "graph-node-down": "docker-compose -f ./graph-node/docker-compose.yaml down" + }, + "dependencies": { + "@graphprotocol/graph-cli": "0.33.0", + "@graphprotocol/graph-ts": "0.27.0" + } +} diff --git a/my-subgraph/schema.graphql b/my-subgraph/schema.graphql new file mode 100644 index 000000000..7c250f6cc --- /dev/null +++ b/my-subgraph/schema.graphql @@ -0,0 +1,4 @@ +type Greeting @entity { + id: ID! + currentGreeting: String! +} diff --git a/my-subgraph/src/mappings.ts b/my-subgraph/src/mappings.ts new file mode 100644 index 000000000..2482467c8 --- /dev/null +++ b/my-subgraph/src/mappings.ts @@ -0,0 +1,18 @@ +/* + * Hedera Subgraph Example - Hedera testnet + * Scaffolded by hiero-cli subgraph plugin. + */ + +import { GreetingSet } from '../generated/Greeter/IGreeter'; +import { Greeting } from '../generated/schema'; + +export function handleGreetingSet(event: GreetingSet): void { + let entity = Greeting.load(event.transaction.hash.toHexString()); + + if (!entity) { + entity = new Greeting(event.transaction.hash.toHex()); + } + + entity.currentGreeting = event.params._greeting; + entity.save(); +} diff --git a/my-subgraph/subgraph.yaml b/my-subgraph/subgraph.yaml new file mode 100644 index 000000000..2d8e31421 --- /dev/null +++ b/my-subgraph/subgraph.yaml @@ -0,0 +1,26 @@ +specVersion: 0.0.4 +description: Graph for Greeter contracts on Hedera testnet +repository: https://github.com/hashgraph/hedera-subgraph-example +schema: + file: ./schema.graphql +dataSources: + - kind: ethereum/contract + name: Greeter + network: testnet + source: + address: "0x0000000000000000000000000000000000000000" + abi: IGreeter + startBlock: 1 + mapping: + kind: ethereum/events + apiVersion: 0.0.6 + language: wasm/assemblyscript + entities: + - Greeting + abis: + - name: IGreeter + file: ./abis/IGreeter.json + eventHandlers: + - event: GreetingSet(string) + handler: handleGreetingSet + file: ./src/mappings.ts diff --git a/payments.csv b/payments.csv new file mode 100644 index 000000000..035fbc6d3 --- /dev/null +++ b/payments.csv @@ -0,0 +1,4 @@ +to,amount +0.0.100,1.5 +0.0.4530,2 +0.0.95215,500t diff --git a/src/core/plugins/plugin-manager.ts b/src/core/plugins/plugin-manager.ts index ed0d8c31c..f38419799 100644 --- a/src/core/plugins/plugin-manager.ts +++ b/src/core/plugins/plugin-manager.ts @@ -72,30 +72,35 @@ export class PluginManager { */ initializePluginState(defaultState: PluginManifest[]): PluginStateEntry[] { const existingEntries = this.pluginManagement.listPlugins(); + const existingNames = new Set(existingEntries.map((e) => e.name)); if (existingEntries.length === 0) { this.logger.info( '[PLUGIN-MANAGEMENT] Initializing default plugin state (first run)...', ); + } - const initialState: PluginStateEntry[] = defaultState.map((manifest) => { - const pluginName = manifest.name; - - return { - name: pluginName, + // Add any default plugins not yet in state (e.g. newly added plugins) + const newEntries: PluginStateEntry[] = []; + for (const manifest of defaultState) { + if (!existingNames.has(manifest.name)) { + const entry: PluginStateEntry = { + name: manifest.name, enabled: true, description: manifest.description, }; - }); - - for (const plugin of initialState) { - this.pluginManagement.savePluginState(plugin); + this.pluginManagement.savePluginState(entry); + newEntries.push(entry); + existingNames.add(manifest.name); } - - return initialState; + } + if (newEntries.length > 0) { + this.logger.info( + `[PLUGIN-MANAGEMENT] Registered ${newEntries.length} new default plugin(s): ${newEntries.map((e) => e.name).join(', ')}`, + ); } - return existingEntries; + return existingEntries.concat(newEntries); } /** diff --git a/src/core/schemas/common-schemas.ts b/src/core/schemas/common-schemas.ts index 1bea038be..b58bb3932 100644 --- a/src/core/schemas/common-schemas.ts +++ b/src/core/schemas/common-schemas.ts @@ -549,12 +549,24 @@ export const TokenTypeSchema = z * - keypair input → { type: 'keypair', accountId: string, privateKey: string } * The keyType must be fetched from mirror node when keypair is provided */ +const KEY_OR_ALIAS_REFINE_MESSAGE = + 'Operator must be an account alias (e.g. my-operator) or accountId:privateKey (e.g. 0.0.123:302e02...). Account ID alone (e.g. 0.0.7982140) is not valid.'; + export const KeyOrAccountAliasSchema = z - .union([AccountIdWithPrivateKeySchema, AccountNameSchema]) - .transform((val) => - typeof val === 'string' - ? { type: 'alias' as const, alias: val } - : { type: 'keypair' as const, ...val }, + .string() + .trim() + .min(1, 'Operator cannot be empty') + .refine((val) => !(/^0\.0\.[1-9]\d*$/.test(val) && !val.includes(':')), { + message: KEY_OR_ALIAS_REFINE_MESSAGE, + }) + .pipe( + z + .union([AccountIdWithPrivateKeySchema, AccountNameSchema]) + .transform((val) => + typeof val === 'string' + ? { type: 'alias' as const, alias: val } + : { type: 'keypair' as const, ...val }, + ), ) .describe( 'Account ID with private key in format {accountId}:{private_key} or account name/alias', diff --git a/src/core/services/contract-query/contract-query-service.ts b/src/core/services/contract-query/contract-query-service.ts index 2082f7853..e2d9e4452 100644 --- a/src/core/services/contract-query/contract-query-service.ts +++ b/src/core/services/contract-query/contract-query-service.ts @@ -35,9 +35,21 @@ export class ContractQueryServiceImpl implements ContractQueryService { this.logger.info( `Calling contract ${params.contractIdOrEvmAddress} "${params.functionName}" function on mirror node`, ); + // Normalize to lowercase hex; some mirror node validators require it + const toHex = ( + contractEvmAddress.startsWith('0x') + ? contractEvmAddress + : `0x${contractEvmAddress}` + ).toLowerCase(); + const dataHex = (data.startsWith('0x') ? data : `0x${data}`).toLowerCase(); + // Mirror node may require "from"; use zero address for read-only queries (same as Hedera SDK behavior when sender is unset) + const fromHex = '0x0000000000000000000000000000000000000000'; const response = await this.mirrorService.postContractCall({ - to: contractEvmAddress, - data: data, + block: 'latest', + from: fromHex, + to: toHex, + data: dataHex, + gas: 2_000_000, }); if (!response || !response.result) { diff --git a/src/core/services/mirrornode/__tests__/unit/hedera-mirrornode-service.test.ts b/src/core/services/mirrornode/__tests__/unit/hedera-mirrornode-service.test.ts index 71eb721ca..6cc15fb45 100644 --- a/src/core/services/mirrornode/__tests__/unit/hedera-mirrornode-service.test.ts +++ b/src/core/services/mirrornode/__tests__/unit/hedera-mirrornode-service.test.ts @@ -38,7 +38,8 @@ const TEST_TOPIC_ID = '0.0.3000'; const TEST_TX_ID = '0.0.1234-1700000000-000000000'; // Network URLs -const TESTNET_URL = 'https://testnet.mirrornode.hedera.com/api/v1'; +const TESTNET_URL = + 'https://testnet.hedera.validationcloud.io/v1/MtxToj_lTWpHFpYDHBw33G3M5Qs4rBJr9nK39V07dbQ/api/v1'; const MAINNET_URL = 'https://mainnet-public.mirrornode.hedera.com/api/v1'; // Timestamps & Values diff --git a/src/core/services/mirrornode/hedera-mirrornode-service.ts b/src/core/services/mirrornode/hedera-mirrornode-service.ts index b9abe60ab..fe0a07447 100644 --- a/src/core/services/mirrornode/hedera-mirrornode-service.ts +++ b/src/core/services/mirrornode/hedera-mirrornode-service.ts @@ -308,8 +308,21 @@ export class HederaMirrornodeServiceDefaultImpl implements HederaMirrornodeServi }); if (!response.ok) { + const body = await response.text(); + let detail = body; + try { + const json = JSON.parse(body) as { + _status?: { messages?: Array<{ message?: string }> }; + }; + const messages = json._status?.messages + ?.map((m) => m.message) + .filter(Boolean); + if (messages?.length) detail = messages.join('; '); + } catch { + // use raw body if not JSON + } throw new Error( - `Failed to call contract via mirror node: ${response.status} ${response.statusText}`, + `Failed to call contract via mirror node: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ''}`, ); } diff --git a/src/core/services/mirrornode/types.ts b/src/core/services/mirrornode/types.ts index f7f3eb398..10425cdab 100644 --- a/src/core/services/mirrornode/types.ts +++ b/src/core/services/mirrornode/types.ts @@ -11,7 +11,10 @@ export const NetworkToBaseUrl = new Map([ SupportedNetwork.MAINNET, 'https://mainnet-public.mirrornode.hedera.com/api/v1', ], - [SupportedNetwork.TESTNET, 'https://testnet.mirrornode.hedera.com/api/v1'], + [ + SupportedNetwork.TESTNET, + 'https://testnet.hedera.validationcloud.io/v1/MtxToj_lTWpHFpYDHBw33G3M5Qs4rBJr9nK39V07dbQ/api/v1', + ], [ SupportedNetwork.PREVIEWNET, 'https://previewnet.mirrornode.hedera.com/api/v1', diff --git a/src/core/services/network/__tests__/unit/network-service.test.ts b/src/core/services/network/__tests__/unit/network-service.test.ts index c8040c1e4..37a0a74e6 100644 --- a/src/core/services/network/__tests__/unit/network-service.test.ts +++ b/src/core/services/network/__tests__/unit/network-service.test.ts @@ -119,7 +119,7 @@ describe('NetworkServiceImpl', () => { expect(config.name).toBe(NETWORK_TESTNET); expect(config.rpcUrl).toBe('https://testnet.hashio.io/api'); expect(config.mirrorNodeUrl).toBe( - 'https://testnet.mirrornode.hedera.com/api/v1', + 'https://testnet.hedera.validationcloud.io/v1/MtxToj_lTWpHFpYDHBw33G3M5Qs4rBJr9nK39V07dbQ/api/v1', ); expect(config.chainId).toBe('0x128'); expect(config.explorerUrl).toBe(`https://hashscan.io/${NETWORK_TESTNET}`); diff --git a/src/core/services/network/network.config.ts b/src/core/services/network/network.config.ts index 6b23b5e7c..6af20207a 100644 --- a/src/core/services/network/network.config.ts +++ b/src/core/services/network/network.config.ts @@ -24,7 +24,8 @@ export const DEFAULT_NETWORKS: Record = { }, testnet: { rpcUrl: 'https://testnet.hashio.io/api', - mirrorNodeUrl: 'https://testnet.mirrornode.hedera.com/api/v1', + mirrorNodeUrl: + 'https://testnet.hedera.validationcloud.io/v1/MtxToj_lTWpHFpYDHBw33G3M5Qs4rBJr9nK39V07dbQ/api/v1', }, previewnet: { rpcUrl: 'https://previewnet.hashio.io/api', diff --git a/src/core/shared/config/cli-options.ts b/src/core/shared/config/cli-options.ts index 37ec87fa3..f8968f541 100644 --- a/src/core/shared/config/cli-options.ts +++ b/src/core/shared/config/cli-options.ts @@ -9,6 +9,9 @@ import credentialsPluginManifest from '@/plugins/credentials/manifest'; import hbarPluginManifest from '@/plugins/hbar/manifest'; import networkPluginManifest from '@/plugins/network/manifest'; import pluginManagementManifest from '@/plugins/plugin-management/manifest'; +import saucerswapPluginManifest from '@/plugins/saucerswap/manifest'; +import splitPaymentsPluginManifest from '@/plugins/split-payments/manifest'; +import subgraphPluginManifest from '@/plugins/subgraph/manifest'; import tokenPluginManifest from '@/plugins/token/manifest'; import topicPluginManifest from '@/plugins/topic/manifest'; @@ -44,6 +47,9 @@ export const DEFAULT_PLUGIN_STATE: PluginManifest[] = [ credentialsPluginManifest, topicPluginManifest, hbarPluginManifest, + splitPaymentsPluginManifest, + subgraphPluginManifest, + saucerswapPluginManifest, contractPluginManifest, configPluginManifest, contractErc20PluginManifest, diff --git a/src/core/utils/register-path-aliases.ts b/src/core/utils/register-path-aliases.ts new file mode 100644 index 000000000..46c4a598c --- /dev/null +++ b/src/core/utils/register-path-aliases.ts @@ -0,0 +1,32 @@ +/** + * Registers @/ path alias at runtime so require('@/core/...') resolves when + * running the compiled CLI (dist/). Needed when path aliases in emitted JS + * were not rewritten by tsc-alias (e.g. partial build or different env). + */ +import * as path from 'path'; + +// When this file lives in dist/core/utils/, dist root is two levels up +const distRoot = path.join(__dirname, '..', '..'); + +const Mod = require('module') as NodeModule & { + _resolveFilename( + request: string, + parent: object, + isMain: boolean, + options?: object, + ): string; +}; +const origResolve = Mod._resolveFilename; + +Mod._resolveFilename = function ( + request: string, + parent: object, + isMain: boolean, + options?: object, +): string { + if (request.startsWith('@/')) { + const resolved = path.join(distRoot, request.slice(2)); + return origResolve.call(this, resolved, parent, isMain, options); + } + return origResolve.apply(this, [request, parent, isMain, options] as never); +}; diff --git a/src/hiero-cli.ts b/src/hiero-cli.ts index 71a6b55b5..bed259287 100755 --- a/src/hiero-cli.ts +++ b/src/hiero-cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import './core/utils/register-path-aliases'; import './core/utils/json-serialize'; import { program } from 'commander'; diff --git a/src/plugins/network/__tests__/unit/list.test.ts b/src/plugins/network/__tests__/unit/list.test.ts index 9bedd125e..9e32fa954 100644 --- a/src/plugins/network/__tests__/unit/list.test.ts +++ b/src/plugins/network/__tests__/unit/list.test.ts @@ -63,7 +63,7 @@ describe('network plugin - list command', () => { expect(result.status).toBe(Status.Success); expect(mockedCheckMirrorNodeHealth).toHaveBeenCalledWith( - 'https://testnet.mirrornode.hedera.com/api/v1', + 'https://testnet.hedera.validationcloud.io/v1/MtxToj_lTWpHFpYDHBw33G3M5Qs4rBJr9nK39V07dbQ/api/v1', ); expect(mockedCheckRpcHealth).toHaveBeenCalledWith( 'https://testnet.hashio.io/api', diff --git a/src/plugins/saucerswap/README.md b/src/plugins/saucerswap/README.md new file mode 100644 index 000000000..e3b2837ac --- /dev/null +++ b/src/plugins/saucerswap/README.md @@ -0,0 +1,131 @@ +# SaucerSwap Plugin + +Get swap quotes and execute real DEX trades on **SaucerSwap V2** (Hedera mainnet and testnet). + +## Running the CLI + +If you're not using a globally installed `hcli`, run commands with: + +```bash +node dist/hiero-cli.js ... +``` + +Examples below use `hcli`; replace with `node dist/hiero-cli.js` if needed. + +## Commands + +### 1. `saucerswap quote` (read-only) + +Get the expected output amount for an exact input swap. No transaction is sent. + +```bash +hcli saucerswap quote --in HBAR --out 0.0.123456 --amount 10 +hcli saucerswap quote -i 0.0.123456 -o HBAR -a 1 +``` + +| Option | Short | Required | Description | +| ---------- | ----- | -------- | ------------------------------------- | +| `--in` | `-i` | Yes | Input: `HBAR` or token ID (0.0.x) | +| `--out` | `-o` | Yes | Output: `HBAR` or token ID (0.0.x) | +| `--amount` | `-a` | Yes | Amount of input (e.g. `10` or `100t`) | + +- **HBAR** in the path uses wrapped HBAR (WHBAR). +- **Amount**: without `t` = display units (e.g. 10 = 10 HBAR); with `t` = smallest unit (e.g. `100t` = 100 tinybar). + +### 2. `saucerswap execute` (on-chain swap) + +Execute a swap: **HBAR → token** or **token → HBAR**. Uses operator as signer and recipient. + +```bash +hcli saucerswap execute --in HBAR --out 0.0.123456 --amount 10 --slippage 0.5 +hcli saucerswap execute -i 0.0.123456 -o HBAR -a 100 -s 1 +``` + +| Option | Short | Required | Description | +| ------------ | ----- | -------- | ---------------------------------- | +| `--in` | `-i` | Yes | Input: `HBAR` or token ID (0.0.x) | +| `--out` | `-o` | Yes | Output: `HBAR` or token ID (0.0.x) | +| `--amount` | `-a` | Yes | Amount of input | +| `--slippage` | `-s` | No | Slippage % (default: 0.5) | + +- **HBAR → token**: Sends HBAR with the call (payable). No approval step. +- **HBAR → WHBAR** (wrap): Uses WhbarHelper `deposit()`; 1:1. **Associate the WHBAR token with your operator account first** (e.g. `hcli token associate …`) or the call will fail with `TOKEN_NOT_ASSOCIATED_TO_ACCOUNT`. +- **Token → HBAR**: Approves the router for the input token, then calls `exactInput`. +- **Minimum output** is set from the quote minus slippage. + +## Network + +**Only mainnet and testnet** are supported (previewnet/localnet are not). The plugin uses the current network or the global `--network` flag. For testnet you must either set the default network or pass `--network testnet` on every run. + +**Option 1 – Set default network to testnet:** + +```bash +hcli network use -g testnet +hcli network set-operator -o +hcli saucerswap quote --in HBAR --out 0.0.123456 --amount 10 +``` + +**Setting the operator:** You cannot pass only an account ID (e.g. `0.0.7982140`). Use either: + +- **Account alias** (if the account was imported): `hcli network set-operator -o my-operator --network testnet` +- **Account ID and private key**: `hcli network set-operator -o 0.0.7982140:302e020100300506032b657004220420... --network testnet` + +If you see _Alias name must contain only letters..._, you passed an account ID only; use the `accountId:privateKey` format or import the account and use its alias. + +**Option 2 – Override for a single run:** + +```bash +hcli saucerswap quote --in HBAR --out 0.0.123456 --amount 10 --network testnet +``` + +If you see _"SaucerSwap is only supported on mainnet and testnet"_, use `--network testnet` or `--network mainnet` explicitly. + +## Requirements + +- Operator set for mainnet (ECDSA account with EVM address for execute). +- For **token → HBAR**: input must be an ERC-20–style contract (token ID resolvable to a contract with `approve`). +- Sufficient balance and gas for execute. + +## Testing on testnet + +1. **Set network and operator:** + + ```bash + hcli --network testnet saucerswap quote --in HBAR --out 0.0.15058 --amount 1 + ``` + + (0.0.15058 is WHBAR on testnet; this quotes HBAR → WHBAR wrap, 1:1.) + +2. **Quote HBAR → token (e.g. WHBAR):** + + ```bash + hcli saucerswap quote --in HBAR --out 0.0.15058 --amount 1 --network testnet + ``` + +3. **Quote token → HBAR (e.g. WHBAR → HBAR):** + + ```bash + hcli saucerswap quote --in 0.0.15058 --out HBAR --amount 1 --network testnet + ``` + +4. **Execute (requires operator with ECDSA and testnet HBAR):** + ```bash + # Use an alias (e.g. after: hcli account import -n my-operator ...) or accountId:privateKey + node dist/hiero-cli.js network set-operator -o my-operator --network testnet + node dist/hiero-cli.js saucerswap execute --in HBAR --out 0.0.15058 --amount 1 --network testnet + ``` + +If quote fails with _"Contract not found"_ or mirror errors, ensure you are using `--network testnet` and that the CLI default network is not previewnet/localnet. + +## How it works + +- **Quote**: Calls SaucerSwap V2 Quoter contract (`quoteExactInput`) via mirror node (read-only). Path is built as `[tokenIn, fee, tokenOut]` with a 0.05% fee tier. +- **Execute**: For **HBAR → WHBAR** (same in/out), calls WhbarHelper `deposit()` with payable HBAR. For other **HBAR → token**, calls Router `exactInput` with payable amount. For **token → HBAR**, approves the router then calls Router `exactInput` with path, recipient, deadline, amount in, and minimum amount out (quote × (1 − slippage)). + +## File layout + +- `manifest.ts` – Plugin and command definitions. +- `constants.ts` – Quoter/router contract IDs, WHBAR, fee tier, ABIs. +- `utils/path-encoding.ts` – Encode swap path (token + fee + token) for V2. +- `commands/quote/` – Input schema, output schema + template, handler (quoter call). +- `commands/execute/` – Input schema, output schema + template, handler (approve + router call or payable router call). diff --git a/src/plugins/saucerswap/commands/execute/handler.ts b/src/plugins/saucerswap/commands/execute/handler.ts new file mode 100644 index 000000000..8ba691e66 --- /dev/null +++ b/src/plugins/saucerswap/commands/execute/handler.ts @@ -0,0 +1,341 @@ +/** + * SaucerSwap swap execute: approve (if token in) + router exactInput. + * Supports HBAR → token and token → HBAR (single hop). + */ +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import type { SwapExecuteOutput } from './output'; + +import { + ContractExecuteTransaction, + ContractFunctionParameters, + ContractId, + Hbar, + Long, +} from '@hashgraph/sdk'; + +import { getBytes, Interface } from 'ethers'; + +import { EntityReferenceType } from '@/core/types/shared.types'; +import { Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; +import { + DEFAULT_POOL_FEE_TIER, + getQuoterId, + getRouterId, + getTokenContractId, + getWhbarHelperId, + getWhbarTokenId, + POOL_FEE_TIER_30_BP, + QUOTER_ABI, + ROUTER_ABI, +} from '@/plugins/saucerswap/constants'; +import { + encodePath, + encodePathHex, +} from '@/plugins/saucerswap/utils/path-encoding'; + +import { SwapExecuteInputSchema } from './input'; + +const ROUTER_GAS = 400_000; + +/** + * Convert bigint to Long for Hedera SDK addUint256. + * Long is a 64-bit integer type re-exported by @hashgraph/sdk and is an + * accepted overload of addUint256. This avoids the silent precision loss + * of Number() for values over 2^53 (~90k HBAR in tinybar units). + * Long handles up to 2^63-1, which is sufficient for all realistic swap amounts. + */ +function toLong(value: bigint): Long { + return Long.fromString(value.toString()); +} + +export async function swapExecuteHandler( + args: CommandHandlerArgs, +): Promise { + const { api } = args; + + const validArgs = SwapExecuteInputSchema.parse(args.args); + const network = api.network.getCurrentNetwork(); + const whbarTokenId = getWhbarTokenId(network); + const quoterId = getQuoterId(network); + const routerId = getRouterId(network); + + const tokenIn = validArgs.in.trim(); + const tokenOut = validArgs.out.trim(); + const inForPath = tokenIn.toUpperCase() === 'HBAR' ? whbarTokenId : tokenIn; + const outForPath = + tokenOut.toUpperCase() === 'HBAR' ? whbarTokenId : tokenOut; + + const amountStr = validArgs.amount.trim(); + const isTinybar = amountStr.endsWith('t'); + const amountInWei = isTinybar + ? BigInt(amountStr.slice(0, -1)) + : BigInt(Math.floor(parseFloat(amountStr) * 1e8)); + + if (amountInWei <= 0n) { + return { + status: Status.Failure, + errorMessage: 'Amount must be positive', + }; + } + + // ── HBAR → WHBAR: wrap via WhbarHelper.deposit() ────────────────────────── + if (inForPath === outForPath && inForPath === whbarTokenId) { + if (tokenIn.toUpperCase() === 'HBAR') { + const operator = api.network.getOperator(network); + if (!operator) { + return { + status: Status.Failure, + errorMessage: 'No operator set. Use hcli network set-operator.', + }; + } + const whbarHelperId = getWhbarHelperId(network); + const payableHbar = Number(amountInWei) / 1e8; + const tx = new ContractExecuteTransaction() + .setContractId(ContractId.fromString(whbarHelperId)) + .setGas(100_000) + .setPayableAmount(new Hbar(payableHbar)) + .setFunction('deposit'); + + const result = await api.txExecution.signAndExecute(tx); + if (!result.success) { + return { + status: Status.Failure, + errorMessage: + result.receipt?.status?.status?.toString() ?? 'Wrap failed', + }; + } + const outputData: SwapExecuteOutput = { + network, + transactionId: result.transactionId ?? '', + tokenIn: validArgs.in, + tokenOut: validArgs.out, + amountIn: validArgs.amount, + amountOut: String(amountInWei), + }; + return { status: Status.Success, outputJson: JSON.stringify(outputData) }; + } + return { + status: Status.Failure, + errorMessage: + 'WHBAR→HBAR (unwrap) is not supported. Use a wallet or SaucerSwap UI.', + }; + } + + if (inForPath === outForPath) { + return { + status: Status.Failure, + errorMessage: `Swap failed: Input and output are the same token (${inForPath}). No pool exists.`, + }; + } + + // ── Quote (try 0.05% then 0.30% fee tier; some pairs only have one) ───────── + const slippagePercent = parseFloat(validArgs.slippage ?? '0.5'); + const slippageMultiplier = 1 - slippagePercent / 100; + + const whbarPathEntityId = + getTokenContractId(network, whbarTokenId) ?? whbarTokenId; + const abiInterface = new Interface(QUOTER_ABI); + const feeTiersToTry = [DEFAULT_POOL_FEE_TIER, POOL_FEE_TIER_30_BP]; + let amountOutWei: bigint = 0n; + let resolvedFeeTier: number | null = null; + + for (const feeTier of feeTiersToTry) { + const pathHex = encodePathHex( + inForPath, + outForPath, + feeTier, + whbarTokenId, + whbarPathEntityId, + ); + const pathBytes = pathHex.startsWith('0x') ? pathHex : `0x${pathHex}`; + try { + const quoteResult = await api.contractQuery.queryContractFunction({ + abiInterface, + contractIdOrEvmAddress: quoterId, + functionName: 'quoteExactInput', + args: [pathBytes, amountInWei], + }); + amountOutWei = BigInt(String(quoteResult.queryResult[0])); + resolvedFeeTier = feeTier; + break; + } catch { + continue; + } + } + + if (resolvedFeeTier == null) { + return { + status: Status.Failure, + errorMessage: formatError( + 'Quote failed (check pair/liquidity or fee tier)', + new Error('CONTRACT_REVERT_EXECUTED'), + ), + }; + } + + const pathBytesUint8 = encodePath( + inForPath, + outForPath, + resolvedFeeTier, + whbarTokenId, + whbarPathEntityId, + ); + const pathHex = encodePathHex( + inForPath, + outForPath, + resolvedFeeTier, + whbarTokenId, + whbarPathEntityId, + ); + const pathBytes = pathHex.startsWith('0x') ? pathHex : `0x${pathHex}`; + + // FIX: use bigint arithmetic throughout — avoid Number() for financial values. + // slippageMultiplier is a float so we scale to basis points (10000) to stay in integers. + const slippageBps = BigInt(Math.round(slippageMultiplier * 10_000)); + const amountOutMinimum = (amountOutWei * slippageBps) / 10_000n; + + // ── Operator / recipient ─────────────────────────────────────────────────── + const operator = api.network.getOperator(network); + if (!operator) { + return { + status: Status.Failure, + errorMessage: 'No operator set. Use hcli network set-operator.', + }; + } + + const accountInfo = await api.mirror.getAccount(operator.accountId); + const recipientEvm = accountInfo.evmAddress; + if (!recipientEvm) { + return { + status: Status.Failure, + errorMessage: + 'Operator account has no EVM address (need ECDSA key for swaps).', + }; + } + + const recipientHex = recipientEvm.startsWith('0x') + ? recipientEvm + : `0x${recipientEvm}`; + const deadline = Math.floor(Date.now() / 1000) + 1200; + + // ── HBAR → token: use multicall(exactInput, refundETH) per SaucerSwap docs ─── + if (tokenIn.toUpperCase() === 'HBAR') { + const payableHbar = Number(amountInWei) / 1e8; + const routerInterface = new Interface(ROUTER_ABI); + const exactInputEncoded = routerInterface.encodeFunctionData('exactInput', [ + { + path: pathBytes, + recipient: recipientHex, + deadline, + amountIn: amountInWei, + amountOutMinimum, + }, + ]); + const refundETHEncoded = routerInterface.encodeFunctionData( + 'refundETH', + [], + ); + const multicallEncoded = routerInterface.encodeFunctionData('multicall', [ + [exactInputEncoded, refundETHEncoded], + ]); + const multicallBytes = getBytes(multicallEncoded); + + const tx = new ContractExecuteTransaction() + .setContractId(ContractId.fromString(routerId)) + .setGas(ROUTER_GAS) + .setPayableAmount(new Hbar(payableHbar)) + .setFunctionParameters(multicallBytes); + + const result = await api.txExecution.signAndExecute(tx); + if (!result.success) { + return { + status: Status.Failure, + errorMessage: + result.receipt?.status?.status?.toString() ?? 'Swap failed', + }; + } + const outputData: SwapExecuteOutput = { + network, + transactionId: result.transactionId ?? '', + tokenIn: validArgs.in, + tokenOut: validArgs.out, + amountIn: validArgs.amount, + // Report the minimum guaranteed output so the user isn't misled + // by the optimistic pre-slippage quote value. + amountOut: String(amountOutMinimum), + }; + return { status: Status.Success, outputJson: JSON.stringify(outputData) }; + } + + // ── token → HBAR: approve then swap (exactInput, no multicall) ─────────────── + const routerInfo = await api.mirror.getContractInfo(routerId); + const routerEvm = routerInfo.evm_address; + if (!routerEvm) { + return { + status: Status.Failure, + errorMessage: 'Router contract has no EVM address', + }; + } + + const contractInfo = await api.identityResolution.resolveContract({ + contractReference: tokenIn, + type: EntityReferenceType.ENTITY_ID, + network, + }); + + const approveParams = new ContractFunctionParameters() + .addAddress(routerEvm.startsWith('0x') ? routerEvm : `0x${routerEvm}`) + .addUint256(toLong(amountInWei)); + + const approveTx = api.contract.contractExecuteTransaction({ + contractId: contractInfo.contractId, + gas: 100_000, + functionName: 'approve', + functionParameters: approveParams, + }); + const approveResult = await api.txExecution.signAndExecute( + approveTx.transaction, + ); + if (!approveResult.success) { + return { + status: Status.Failure, + errorMessage: formatError( + 'Approve failed', + approveResult.receipt?.status, + ), + }; + } + + const exactInputParams = new ContractFunctionParameters() + .addBytes(pathBytesUint8) + .addAddress(recipientHex) + .addUint256(deadline) + .addUint256(toLong(amountInWei)) + .addUint256(toLong(amountOutMinimum)); + + const swapTx = api.contract.contractExecuteTransaction({ + contractId: routerId, + gas: ROUTER_GAS, + functionName: 'exactInput', + functionParameters: exactInputParams, + }); + const swapResult = await api.txExecution.signAndExecute(swapTx.transaction); + if (!swapResult.success) { + return { + status: Status.Failure, + errorMessage: + swapResult.receipt?.status?.status?.toString() ?? 'Swap failed', + }; + } + + const outputData: SwapExecuteOutput = { + network, + transactionId: swapResult.transactionId ?? '', + tokenIn: validArgs.in, + tokenOut: validArgs.out, + amountIn: validArgs.amount, + amountOut: String(amountOutMinimum), + }; + return { status: Status.Success, outputJson: JSON.stringify(outputData) }; +} diff --git a/src/plugins/saucerswap/commands/execute/index.ts b/src/plugins/saucerswap/commands/execute/index.ts new file mode 100644 index 000000000..e61df9c5a --- /dev/null +++ b/src/plugins/saucerswap/commands/execute/index.ts @@ -0,0 +1,3 @@ +export { swapExecuteHandler } from './handler'; +export type { SwapExecuteOutput } from './output'; +export { SwapExecuteOutputSchema, SWAP_EXECUTE_TEMPLATE } from './output'; diff --git a/src/plugins/saucerswap/commands/execute/input.ts b/src/plugins/saucerswap/commands/execute/input.ts new file mode 100644 index 000000000..d423bc64a --- /dev/null +++ b/src/plugins/saucerswap/commands/execute/input.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const SwapExecuteInputSchema = z.object({ + in: z + .string() + .min(1, 'Input token is required (use HBAR or token ID 0.0.x)') + .describe('Input: HBAR or token ID'), + out: z + .string() + .min(1, 'Output token is required') + .describe('Output: HBAR or token ID'), + amount: z + .string() + .min(1, 'Amount is required') + .describe('Amount of input token'), + slippage: z + .string() + .optional() + .default('0.5') + .describe('Slippage tolerance in percent (e.g. 0.5 for 0.5%)'), +}); + +export type SwapExecuteInput = z.infer; diff --git a/src/plugins/saucerswap/commands/execute/output.ts b/src/plugins/saucerswap/commands/execute/output.ts new file mode 100644 index 000000000..01e9501ad --- /dev/null +++ b/src/plugins/saucerswap/commands/execute/output.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { + NetworkSchema, + TransactionIdSchema, +} from '@/core/schemas/common-schemas'; + +export const SwapExecuteOutputSchema = z.object({ + network: NetworkSchema, + transactionId: TransactionIdSchema, + tokenIn: z.string(), + tokenOut: z.string(), + amountIn: z.string(), + amountOut: z.string(), +}); + +export type SwapExecuteOutput = z.infer; + +export const SWAP_EXECUTE_TEMPLATE = ` +✅ Swap executed + +Network: {{network}} +Transaction: {{hashscanLink transactionId "transaction" network}} +In: {{tokenIn}} → {{amountIn}} +Out: {{tokenOut}} → {{amountOut}} +`.trim(); diff --git a/src/plugins/saucerswap/commands/quote/handler.ts b/src/plugins/saucerswap/commands/quote/handler.ts new file mode 100644 index 000000000..e8ccfda3d --- /dev/null +++ b/src/plugins/saucerswap/commands/quote/handler.ts @@ -0,0 +1,226 @@ +/** + * SaucerSwap swap quote: read-only call to quoter contract. + */ +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import type { SwapQuoteOutput } from './output'; + +import { Interface } from 'ethers'; + +import { Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; +import { + DEFAULT_POOL_FEE_TIER, + ensureSaucerSwapNetwork, + getQuoterId, + getTokenContractId, + getWhbarTokenId, + POOL_FEE_TIER_30_BP, + QUOTER_ABI, +} from '@/plugins/saucerswap/constants'; +import { encodePathHex } from '@/plugins/saucerswap/utils/path-encoding'; + +import { SwapQuoteInputSchema } from './input'; + +export async function swapQuoteHandler( + args: CommandHandlerArgs, +): Promise { + const { api } = args; + + const validArgs = SwapQuoteInputSchema.parse(args.args); + const rawNetwork = api.network.getCurrentNetwork(); + ensureSaucerSwapNetwork(rawNetwork); + const whbarTokenId = getWhbarTokenId(rawNetwork); + const quoterId = getQuoterId(rawNetwork); + + const tokenIn = validArgs.in.trim(); + const tokenOut = validArgs.out.trim(); + + const inForPath = tokenIn.toUpperCase() === 'HBAR' ? whbarTokenId : tokenIn; + const outForPath = + tokenOut.toUpperCase() === 'HBAR' ? whbarTokenId : tokenOut; + + // Amount: assume display units for HBAR (8 decimals), for tokens we'd need decimals lookup + const amountStr = validArgs.amount.trim(); + const isTinybar = amountStr.endsWith('t'); + const amountInWei = isTinybar + ? BigInt(amountStr.slice(0, -1)) + : BigInt(Math.floor(parseFloat(amountStr) * 1e8)); // 8 decimals for HBAR/WHBAR + + if (amountInWei <= 0n) { + return { + status: Status.Failure, + errorMessage: 'Amount must be positive', + }; + } + + // HBAR ↔ WHBAR: wrap/unwrap is 1:1 (no DEX pool) + if (inForPath === outForPath) { + if (inForPath === whbarTokenId) { + const outputData: SwapQuoteOutput = { + network: rawNetwork, + tokenIn: validArgs.in, + tokenOut: validArgs.out, + amountIn: validArgs.amount, + amountOut: formatAmountOut(String(amountInWei)), + amountOutRaw: String(amountInWei), + gasEstimate: undefined, + }; + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; + } + return { + status: Status.Failure, + errorMessage: `Quote failed: Input and output are the same token (${inForPath}). No pool exists.`, + }; + } + + // Use WHBAR proxy contract ID in path so quoter finds the pool (token ID ≠ contract ID on Hedera). + const whbarPathEntityId = + getTokenContractId(rawNetwork, whbarTokenId) ?? whbarTokenId; + + const abiInterface = new Interface(QUOTER_ABI); + const feeTiersToTry = [DEFAULT_POOL_FEE_TIER, POOL_FEE_TIER_30_BP]; + let lastError: unknown; + + for (const feeTier of feeTiersToTry) { + const pathHex = encodePathHex( + inForPath, + outForPath, + feeTier, + whbarTokenId, + whbarPathEntityId, + ); + const pathBytes = pathHex.startsWith('0x') ? pathHex : `0x${pathHex}`; + + try { + const result = await api.contractQuery.queryContractFunction({ + abiInterface, + contractIdOrEvmAddress: quoterId, + functionName: 'quoteExactInput', + args: [pathBytes, amountInWei], + }); + + const decoded = result.queryResult; + const amountOut = decoded[0] != null ? String(decoded[0]) : '0'; + const gasEstimate = decoded[3] != null ? String(decoded[3]) : undefined; + + const outputData: SwapQuoteOutput = { + network: rawNetwork, + tokenIn: validArgs.in, + tokenOut: validArgs.out, + amountIn: validArgs.amount, + amountOut: formatAmountOut(amountOut), + amountOutRaw: amountOut, + gasEstimate, + }; + + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; + } catch (error) { + lastError = error; + const msg = error instanceof Error ? error.message : String(error); + if (!msg.includes('CONTRACT_REVERT_EXECUTED')) { + break; + } + + // Fallback: try JSON-RPC eth_call (mirror node contract call can revert for some paths) + try { + const quoteViaRpc = await quoteExactInputViaRpc( + api, + quoterId, + rawNetwork, + abiInterface, + pathBytes, + amountInWei, + ); + if (quoteViaRpc) { + const outputData: SwapQuoteOutput = { + network: rawNetwork, + tokenIn: validArgs.in, + tokenOut: validArgs.out, + amountIn: validArgs.amount, + amountOut: formatAmountOut(quoteViaRpc.amountOut), + amountOutRaw: quoteViaRpc.amountOut, + gasEstimate: quoteViaRpc.gasEstimate, + }; + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; + } + } catch { + // ignore RPC fallback failure, keep lastError from mirror + } + } + } + + return { + status: Status.Failure, + errorMessage: formatError('Quote failed', lastError), + }; +} + +/** + * Call Quoter.quoteExactInput via Hedera JSON-RPC eth_call. + * Used when mirror node /contracts/call returns CONTRACT_REVERT_EXECUTED for the same call. + */ +async function quoteExactInputViaRpc( + api: CommandHandlerArgs['api'], + quoterId: string, + network: string, + abiInterface: Interface, + pathBytes: string, + amountInWei: bigint, +): Promise<{ amountOut: string; gasEstimate?: string } | null> { + const contractInfo = await api.mirror.getContractInfo(quoterId); + const evmAddress = contractInfo.evm_address; + if (!evmAddress) return null; + + const toHex = ( + evmAddress.startsWith('0x') ? evmAddress : `0x${evmAddress}` + ).toLowerCase(); + const data = abiInterface.encodeFunctionData('quoteExactInput', [ + pathBytes, + amountInWei, + ]); + const dataHex = (data.startsWith('0x') ? data : `0x${data}`).toLowerCase(); + + const config = api.network.getNetworkConfig(network); + const rpcUrl = config.rpcUrl; + const body = JSON.stringify({ + jsonrpc: '2.0', + method: 'eth_call', + params: [{ to: toHex, data: dataHex }, 'latest'], + id: 1, + }); + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + if (!response.ok) return null; + + const json = (await response.json()) as { + result?: string; + error?: { code: number; message: string }; + }; + if (json.error || !json.result || json.result === '0x') return null; + + const decoded = abiInterface.decodeFunctionResult( + 'quoteExactInput', + json.result as `0x${string}`, + ); + const amountOut = decoded[0] != null ? String(decoded[0]) : '0'; + const gasEstimate = decoded[3] != null ? String(decoded[3]) : undefined; + return { amountOut, gasEstimate }; +} + +function formatAmountOut(raw: string): string { + const n = BigInt(raw); + if (n >= BigInt(1e8)) return (Number(n) / 1e8).toFixed(4); + return raw; +} diff --git a/src/plugins/saucerswap/commands/quote/index.ts b/src/plugins/saucerswap/commands/quote/index.ts new file mode 100644 index 000000000..19b763e45 --- /dev/null +++ b/src/plugins/saucerswap/commands/quote/index.ts @@ -0,0 +1,3 @@ +export { swapQuoteHandler } from './handler'; +export type { SwapQuoteOutput } from './output'; +export { SwapQuoteOutputSchema, SWAP_QUOTE_TEMPLATE } from './output'; diff --git a/src/plugins/saucerswap/commands/quote/input.ts b/src/plugins/saucerswap/commands/quote/input.ts new file mode 100644 index 000000000..85064ce1d --- /dev/null +++ b/src/plugins/saucerswap/commands/quote/input.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +/** + * --in: HBAR or token ID (0.0.x) or alias + * --out: HBAR or token ID or alias + * --amount: amount of input token (display or with t for smallest unit) + */ +export const SwapQuoteInputSchema = z.object({ + in: z + .string() + .min(1, 'Input token is required (use HBAR or token ID 0.0.x)') + .describe('Input: HBAR or token ID or alias'), + out: z + .string() + .min(1, 'Output token is required') + .describe('Output: HBAR or token ID or alias'), + amount: z + .string() + .min(1, 'Amount is required') + .describe('Amount of input token (e.g. 10 or 100t for smallest unit)'), +}); + +export type SwapQuoteInput = z.infer; diff --git a/src/plugins/saucerswap/commands/quote/output.ts b/src/plugins/saucerswap/commands/quote/output.ts new file mode 100644 index 000000000..a3f56fd90 --- /dev/null +++ b/src/plugins/saucerswap/commands/quote/output.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { NetworkSchema } from '@/core/schemas/common-schemas'; + +export const SwapQuoteOutputSchema = z.object({ + network: NetworkSchema, + tokenIn: z.string(), + tokenOut: z.string(), + amountIn: z.string(), + amountOut: z.string(), + amountOutRaw: z.string().describe('Output amount in token smallest unit'), + gasEstimate: z.string().optional(), +}); + +export type SwapQuoteOutput = z.infer; + +export const SWAP_QUOTE_TEMPLATE = ` +Quote (exact input) + +Network: {{network}} +In: {{tokenIn}} → {{amountIn}} +Out: {{tokenOut}} → {{amountOut}} (raw: {{amountOutRaw}}) +{{#if gasEstimate}} +Gas estimate: {{gasEstimate}} +{{/if}} +`.trim(); diff --git a/src/plugins/saucerswap/constants.ts b/src/plugins/saucerswap/constants.ts new file mode 100644 index 000000000..c5be45351 --- /dev/null +++ b/src/plugins/saucerswap/constants.ts @@ -0,0 +1,92 @@ +/** + * SaucerSwap V2 contract IDs and WHBAR token IDs. + * Mainnet and testnet; plugin uses current network to select. + */ +export const SAUCERSWAP_QUOTER_MAINNET = '0.0.3949424'; +export const SAUCERSWAP_ROUTER_MAINNET = '0.0.3949434'; +export const WHBAR_TOKEN_MAINNET = '0.0.1456986'; + +/** Testnet: from https://docs.saucerswap.finance/developerx/contract-deployments */ +export const SAUCERSWAP_QUOTER_TESTNET = '0.0.1390002'; +export const SAUCERSWAP_ROUTER_TESTNET = '0.0.1414040'; +export const WHBAR_TOKEN_TESTNET = '0.0.15058'; +export const WHBAR_HELPER_MAINNET = '0.0.5808826'; +export const WHBAR_HELPER_TESTNET = '0.0.5286055'; + +export type SupportedSaucerSwapNetwork = 'mainnet' | 'testnet'; + +/** SaucerSwap is only deployed on mainnet and testnet. */ +const SAUCERSWAP_NETWORKS: SupportedSaucerSwapNetwork[] = [ + 'mainnet', + 'testnet', +]; + +export function ensureSaucerSwapNetwork( + network: string, +): SupportedSaucerSwapNetwork { + const normalized = network.toLowerCase(); + if (SAUCERSWAP_NETWORKS.includes(normalized as SupportedSaucerSwapNetwork)) { + return normalized as SupportedSaucerSwapNetwork; + } + throw new Error( + `SaucerSwap is only supported on mainnet and testnet. Current network: ${network}. Use --network testnet or --network mainnet.`, + ); +} + +export function getQuoterId(network: string): string { + return ensureSaucerSwapNetwork(network) === 'testnet' + ? SAUCERSWAP_QUOTER_TESTNET + : SAUCERSWAP_QUOTER_MAINNET; +} +export function getRouterId(network: string): string { + return ensureSaucerSwapNetwork(network) === 'testnet' + ? SAUCERSWAP_ROUTER_TESTNET + : SAUCERSWAP_ROUTER_MAINNET; +} +export function getWhbarTokenId(network: string): string { + return ensureSaucerSwapNetwork(network) === 'testnet' + ? WHBAR_TOKEN_TESTNET + : WHBAR_TOKEN_MAINNET; +} +export function getWhbarHelperId(network: string): string { + return ensureSaucerSwapNetwork(network) === 'testnet' + ? WHBAR_HELPER_TESTNET + : WHBAR_HELPER_MAINNET; +} + +/** + * Known token ID → contract ID for approve() (token→HBAR path). + * On Hedera, token ID and ERC-20 proxy contract ID can differ (e.g. WHBAR). + */ +export const TOKEN_TO_CONTRACT: Record> = { + testnet: { + [WHBAR_TOKEN_TESTNET]: '0.0.15057', // WHBAR token 0.0.15058 → contract 0.0.15057 + }, + mainnet: { + [WHBAR_TOKEN_MAINNET]: '0.0.1456985', // WHBAR token 0.0.1456986 → contract 0.0.1456985 + }, +}; + +export function getTokenContractId( + network: string, + tokenId: string, +): string | undefined { + const net = ensureSaucerSwapNetwork(network) as 'mainnet' | 'testnet'; + return TOKEN_TO_CONTRACT[net]?.[tokenId]; +} + +/** Default pool fee tier: 0.05% = 500 (0x0001F4) */ +export const DEFAULT_POOL_FEE_TIER = 500; + +/** Alternative fee tier: 0.30% = 3000 (0x000BB8); try when 0.05% pool does not exist */ +export const POOL_FEE_TIER_30_BP = 3000; + +export const QUOTER_ABI = [ + 'function quoteExactInput(bytes path, uint256 amountIn) external returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList, uint32[] initializedTicksCrossedList, uint256 gasEstimate)', +]; + +export const ROUTER_ABI = [ + 'function exactInput((bytes path, address recipient, uint256 deadline, uint256 amountIn, uint256 amountOutMinimum)) external payable returns (uint256 amountOut)', + 'function refundETH() external payable', + 'function multicall(bytes[] data) external payable returns (bytes[] results)', +]; diff --git a/src/plugins/saucerswap/index.ts b/src/plugins/saucerswap/index.ts new file mode 100644 index 000000000..1f129f3fe --- /dev/null +++ b/src/plugins/saucerswap/index.ts @@ -0,0 +1,6 @@ +export { + saucerswapPluginManifest, + saucerswapPluginManifest as default, +} from './manifest'; +export { swapQuoteHandler } from './commands/quote'; +export { swapExecuteHandler } from './commands/execute'; diff --git a/src/plugins/saucerswap/manifest.ts b/src/plugins/saucerswap/manifest.ts new file mode 100644 index 000000000..22df49fd4 --- /dev/null +++ b/src/plugins/saucerswap/manifest.ts @@ -0,0 +1,105 @@ +/** + * SaucerSwap plugin: quote and execute swaps on SaucerSwap V2 (Hedera mainnet). + */ +import type { PluginManifest } from '@/core'; +import { OptionType } from '@/core/types/shared.types'; + +import { + swapQuoteHandler, + SwapQuoteOutputSchema, + SWAP_QUOTE_TEMPLATE, +} from './commands/quote'; +import { + swapExecuteHandler, + SwapExecuteOutputSchema, + SWAP_EXECUTE_TEMPLATE, +} from './commands/execute'; + +export const saucerswapPluginManifest: PluginManifest = { + name: 'saucerswap', + version: '1.0.0', + displayName: 'SaucerSwap', + description: + 'Get swap quotes and execute HBAR ↔ token or token ↔ token swaps on SaucerSwap V2 (mainnet)', + commands: [ + { + name: 'quote', + summary: 'Get a swap quote (read-only)', + description: + 'Get expected output amount for an exact input swap. Use HBAR or token ID (0.0.x) for --in and --out.', + options: [ + { + name: 'in', + short: 'i', + type: OptionType.STRING, + required: true, + description: 'Input: HBAR or token ID (0.0.x)', + }, + { + name: 'out', + short: 'o', + type: OptionType.STRING, + required: true, + description: 'Output: HBAR or token ID (0.0.x)', + }, + { + name: 'amount', + short: 'a', + type: OptionType.STRING, + required: true, + description: + 'Amount of input (e.g. 10 for 10 HBAR, or 100t for 100 tinybar)', + }, + ], + handler: swapQuoteHandler, + output: { + schema: SwapQuoteOutputSchema, + humanTemplate: SWAP_QUOTE_TEMPLATE, + }, + }, + { + name: 'execute', + summary: 'Execute a swap', + description: + 'Execute a swap: HBAR → token or token → HBAR. Applies slippage to minimum output.', + options: [ + { + name: 'in', + short: 'i', + type: OptionType.STRING, + required: true, + description: 'Input: HBAR or token ID (0.0.x)', + }, + { + name: 'out', + short: 'o', + type: OptionType.STRING, + required: true, + description: 'Output: HBAR or token ID (0.0.x)', + }, + { + name: 'amount', + short: 'a', + type: OptionType.STRING, + required: true, + description: 'Amount of input', + }, + { + name: 'slippage', + short: 's', + type: OptionType.STRING, + required: false, + default: '0.5', + description: 'Slippage tolerance in percent (e.g. 0.5 for 0.5%)', + }, + ], + handler: swapExecuteHandler, + output: { + schema: SwapExecuteOutputSchema, + humanTemplate: SWAP_EXECUTE_TEMPLATE, + }, + }, + ], +}; + +export default saucerswapPluginManifest; diff --git a/src/plugins/saucerswap/utils/path-encoding.ts b/src/plugins/saucerswap/utils/path-encoding.ts new file mode 100644 index 000000000..b6fa92749 --- /dev/null +++ b/src/plugins/saucerswap/utils/path-encoding.ts @@ -0,0 +1,86 @@ +/** + * Encode SaucerSwap V2 path: [tokenIn (20 bytes), fee (3 bytes), tokenOut (20 bytes)]. + * Fee is uint24 e.g. 500 for 0.05%. + * All addresses use ContractId.toSolidityAddress() (shard.realm.num → 20 bytes). + * WHBAR uses its proxy contract ID so the pool is found. + */ +import { ContractId } from '@hashgraph/sdk'; +import { getBytes, hexlify } from 'ethers'; + +const FEE_BYTES = 3; +const ADDR_BYTES = 20; +const PATH_BYTES = ADDR_BYTES + FEE_BYTES + ADDR_BYTES; + +function entityIdToEvmAddressBytes(entityId: string): Uint8Array { + const solidity = ContractId.fromString(entityId).toSolidityAddress(); + const hex = solidity.startsWith('0x') ? solidity : `0x${solidity}`; + const bytes = getBytes(hex); + if (bytes.length !== ADDR_BYTES) { + throw new Error( + `Path address must be ${ADDR_BYTES} bytes, got ${bytes.length} for ${entityId}`, + ); + } + return bytes; +} + +function feeToBytes(fee: number): Uint8Array { + const buf = new Uint8Array(FEE_BYTES); + buf[2] = fee & 0xff; + buf[1] = (fee >> 8) & 0xff; + buf[0] = (fee >> 16) & 0xff; + return buf; +} + +/** + * Resolve the entity ID to use for the 20-byte address in the path. + * Pools use the token's proxy contract address; for WHBAR, token ID and contract ID differ. + * whbarPathEntityId: when token is HBAR or WHBAR token id, use this (contract id) in the path. + */ +function pathEntityId( + tokenId: string, + whbarTokenId: string, + whbarPathEntityId: string, +): string { + if (tokenId.toUpperCase() === 'HBAR' || tokenId === whbarTokenId) { + return whbarPathEntityId; + } + return tokenId; +} + +/** + * Build path bytes for a single-hop swap: tokenIn -> fee -> tokenOut. + * tokenInId / tokenOutId: Hedera entity ID (0.0.x) or "HBAR" for WHBAR. + * whbarPathEntityId: entity ID to use in path for WHBAR (use token's proxy contract ID so pool is found). + */ +export function encodePath( + tokenInId: string, + tokenOutId: string, + feeTier: number, + whbarTokenId: string, + whbarPathEntityId?: string, +): Uint8Array { + const whbarForPath = whbarPathEntityId ?? whbarTokenId; + const inResolved = pathEntityId(tokenInId, whbarTokenId, whbarForPath); + const outResolved = pathEntityId(tokenOutId, whbarTokenId, whbarForPath); + const inAddr = entityIdToEvmAddressBytes(inResolved); + const outAddr = entityIdToEvmAddressBytes(outResolved); + const fee = feeToBytes(feeTier); + const path = new Uint8Array(PATH_BYTES); + path.set(inAddr, 0); + path.set(fee, ADDR_BYTES); + path.set(outAddr, ADDR_BYTES + FEE_BYTES); + return path; +} + +/** Same as encodePath but returns hex string for ethers encodeFunctionData. */ +export function encodePathHex( + tokenInId: string, + tokenOutId: string, + feeTier: number, + whbarTokenId: string, + whbarPathEntityId?: string, +): string { + return hexlify( + encodePath(tokenInId, tokenOutId, feeTier, whbarTokenId, whbarPathEntityId), + ); +} diff --git a/src/plugins/split-payments/README.md b/src/plugins/split-payments/README.md new file mode 100644 index 000000000..c7e903239 --- /dev/null +++ b/src/plugins/split-payments/README.md @@ -0,0 +1,94 @@ +# Split Payments Plugin + +Batch HBAR transfers from a single CSV file so you don’t have to run `hbar transfer` many times. + +## What it does + +- Reads a CSV with **recipient** and **amount** (one transfer per row). +- Uses your configured **operator** (or `--from`) as the payer for all transfers. +- Runs each transfer one after another and reports success/failure per row. +- Optional **dry run** to validate the file and list planned transfers without sending. + +## Command + +```bash +hcli split-payments transfer --file +``` + +### Options + +| Option | Short | Required | Description | +| --------------- | ----- | -------- | ---------------------------------------------------------- | +| `--file` | `-f` | Yes | Path to CSV file (see format below). | +| `--from` | `-F` | No | Payer: alias or `accountId:privateKey`. Default: operator. | +| `--key-manager` | `-k` | No | `local` or `local_encrypted`. Default: config. | +| `--dry-run` | — | No | Validate and list planned transfers only; no transactions. | + +### CSV format + +- **Columns:** `to`, `amount` (order matters). +- **Separator:** comma (`,`) or semicolon (`;`). +- **Header (optional):** first line can be `to,amount` or `address,amount`; it’s skipped. +- **Amount:** HBAR (e.g. `10`, `1.5`) or **tinybars** with a trailing `t` (e.g. `100t` = 100 tinybars; `500t` = 500 tinybars). Without `t`, the value is in HBAR (1 HBAR = 100,000,000 tinybars). +- **To:** Hedera account ID (`0.0.123`), EVM address, or CLI account alias. + +**Where to put the file:** You can put the CSV anywhere. Pass the path with `--file` (absolute or relative to your current working directory). Examples: + +- Project root: `hcli split-payments transfer --file payments.csv` (from repo root) +- Full path: `hcli split-payments transfer --file C:\Users\you\payments.csv` +- Subfolder: `hcli split-payments transfer --file data/payments.csv` + +Example `payments.csv`: + +```csv +to,amount +0.0.100,1.5 +0.0.101,2 +alice,10 +0.0.102,500t +``` + +## When to run what + +1. **First-time / check setup** + - Set network: `hcli network use -g testnet` + - Set operator: `hcli network set-operator -o ` + - Optional: `hcli config set -o default_key_manager -v local_encrypted` + +2. **Validate CSV without sending** + + ```bash + hcli split-payments transfer --file payments.csv --dry-run + ``` + +3. **Run batch transfer** + + ```bash + hcli split-payments transfer --file payments.csv + ``` + +4. **Use another payer or network** + ```bash + hcli split-payments transfer --file payments.csv --from my-other-account + hcli split-payments transfer --file payments.csv --network mainnet + ``` + +## Output + +- **Human (default):** Summary (total / success / failed) and per-row result with HashScan links for successful transfers. +- **JSON:** `--format json` for scriptable output with the same data. + +## Requirements + +- Operator (or `--from`) must have enough HBAR for all transfers and fees. +- Each transfer is a separate Hedera transaction (one per row). +- Failed rows are reported; successful rows are still committed. + +## Troubleshooting + +- **"unknown command 'split-payments'"** + Run the CLI from the **built** project, not a globally installed package: + ```bash + node dist/hiero-cli.js split-payments transfer --file payments.csv + ``` + From the repo root after `npm run build`. If you had run the CLI before this plugin was added, the fix in the core plugin manager now merges new default plugins into existing state, so a fresh run should register `split-payments` automatically. diff --git a/src/plugins/split-payments/commands/transfer/handler.ts b/src/plugins/split-payments/commands/transfer/handler.ts new file mode 100644 index 000000000..7727cf186 --- /dev/null +++ b/src/plugins/split-payments/commands/transfer/handler.ts @@ -0,0 +1,266 @@ +/** + * Split Payments Transfer Command Handler + * Reads a CSV of (to, amount) and executes multiple HBAR transfers in one command. + */ +/// +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import type { KeyManagerName } from '@/core/services/kms/kms-types.interface'; +import type { SplitPaymentsTransferOutput, TransferItemResult } from './output'; + +import * as fs from 'fs'; +import * as path from 'path'; + +import { EntityIdSchema } from '@/core/schemas'; +import { HBAR_DECIMALS, Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; +import { processBalanceInput } from '@/core/utils/process-balance-input'; + +import { SplitPaymentsTransferInputSchema } from './input'; + +/** Parse a single line of CSV (handles quoted values) */ +function parseCsvLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === '"') { + inQuotes = !inQuotes; + } else if (inQuotes) { + current += c; + } else if (c === ',' || c === ';') { + result.push(current.trim()); + current = ''; + } else { + current += c; + } + } + result.push(current.trim()); + return result; +} + +/** Check if first line looks like a header */ +function isHeader(row: string[]): boolean { + if (row.length < 2) return false; + const first = row[0].toLowerCase(); + const second = row[1].toLowerCase(); + return ( + (first === 'to' || first === 'address' || first === 'account') && + (second === 'amount' || second === 'amount_hbar' || second === 'value') + ); +} + +/** + * Parse CSV file into { to, amount } rows. + * Expected columns: to (address or alias), amount (HBAR or e.g. 100t for tinybars). + */ +function parseCsvFile(filePath: string): Array<{ to: string; amount: string }> { + const resolved = path.resolve(filePath); + if (!fs.existsSync(resolved)) { + throw new Error(`File not found: ${resolved}`); + } + const content = fs.readFileSync(resolved, 'utf-8'); + const lines = content + .split(/\r?\n/) + .map((l: string) => l.trim()) + .filter((l: string) => l.length > 0); + + if (lines.length === 0) { + throw new Error('CSV file is empty'); + } + + const rows = lines.map(parseCsvLine); + const startIndex = rows.length > 0 && isHeader(rows[0]) ? 1 : 0; + const result: Array<{ to: string; amount: string }> = []; + + for (let i = startIndex; i < rows.length; i++) { + const row = rows[i]; + if (row.length < 2) { + throw new Error( + `Row ${i + 1}: expected "to,amount" (got ${row.length} columns)`, + ); + } + const to = row[0].replace(/^["']|["']$/g, '').trim(); + const amount = row[1].replace(/^["']|["']$/g, '').trim(); + if (!to || !amount) { + throw new Error(`Row ${i + 1}: empty to or amount`); + } + result.push({ to, amount }); + } + + return result; +} + +export async function splitPaymentsTransferHandler( + args: CommandHandlerArgs, +): Promise { + const { api, logger } = args; + + const validArgs = SplitPaymentsTransferInputSchema.parse(args.args); + const keyManager: KeyManagerName = + validArgs.keyManager ?? + api.config.getOption('default_key_manager'); + + let rows: Array<{ to: string; amount: string }>; + try { + rows = parseCsvFile(validArgs.file); + } catch (err) { + return { + status: Status.Failure, + errorMessage: formatError('Invalid CSV file', err), + }; + } + + if (rows.length === 0) { + return { + status: Status.Failure, + errorMessage: 'CSV file has no data rows (only header or empty).', + }; + } + + const from = await api.keyResolver.getOrInitKeyWithFallback( + validArgs.from, + keyManager, + ['split-payments:transfer'], + ); + + const currentNetwork = api.network.getCurrentNetwork(); + const transfers: TransferItemResult[] = []; + let successCount = 0; + let failureCount = 0; + + const resolveToAccountId = (to: string): string => { + const alias = api.alias.resolve(to, 'account', currentNetwork); + if (alias?.entityId) return alias.entityId; + const parsed = EntityIdSchema.safeParse(to); + if (parsed.success) return parsed.data; + throw new Error(`Invalid account: ${to} is not a valid ID or alias`); + }; + + if (validArgs.dryRun) { + for (const { to, amount } of rows) { + try { + const amountTinybar = processBalanceInput(amount, HBAR_DECIMALS); + if (amountTinybar <= 0n) throw new Error('Amount must be positive'); + const toAccountId = resolveToAccountId(to); + if (from.accountId === toAccountId) + throw new Error('Cannot transfer to self'); + transfers.push({ + toAccountId, + amountTinybar, + status: 'success', + }); + successCount++; + } catch (e) { + transfers.push({ + toAccountId: to as string, + amountTinybar: 0n, + status: 'failure', + errorMessage: e instanceof Error ? e.message : String(e), + }); + failureCount++; + } + } + const outputData: SplitPaymentsTransferOutput = { + network: currentNetwork, + fromAccountId: from.accountId, + totalTransfers: rows.length, + successCount, + failureCount, + dryRun: true, + transfers, + }; + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; + } + + for (const { to, amount } of rows) { + let toAccountId: string; + let amountTinybar: bigint; + try { + amountTinybar = processBalanceInput(amount, HBAR_DECIMALS); + if (amountTinybar <= 0n) throw new Error('Amount must be positive'); + toAccountId = resolveToAccountId(to); + if (from.accountId === toAccountId) { + transfers.push({ + toAccountId, + amountTinybar, + status: 'failure', + errorMessage: 'Cannot transfer to the same account', + }); + failureCount++; + continue; + } + } catch (e) { + transfers.push({ + toAccountId: to as string, + amountTinybar: 0n, + status: 'failure', + errorMessage: e instanceof Error ? e.message : String(e), + }); + failureCount++; + continue; + } + + try { + const transferResult = await api.hbar.transferTinybar({ + amount: amountTinybar, + from: from.accountId, + to: toAccountId, + }); + const result = await api.txExecution.signAndExecuteWith( + transferResult.transaction, + [from.keyRefId], + ); + + if (result.success && result.transactionId) { + transfers.push({ + toAccountId, + amountTinybar, + transactionId: result.transactionId, + status: 'success', + }); + successCount++; + logger.info( + `[split-payments] Transferred to ${toAccountId}: ${amountTinybar} tinybars (${result.transactionId})`, + ); + } else { + transfers.push({ + toAccountId, + amountTinybar, + status: 'failure', + errorMessage: result.receipt?.status?.status?.toString() ?? 'UNKNOWN', + }); + failureCount++; + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + transfers.push({ + toAccountId, + amountTinybar, + status: 'failure', + errorMessage: msg, + }); + failureCount++; + logger.warn( + `[split-payments] Failed to transfer to ${toAccountId}: ${msg}`, + ); + } + } + + const outputData: SplitPaymentsTransferOutput = { + network: currentNetwork, + fromAccountId: from.accountId, + totalTransfers: rows.length, + successCount, + failureCount, + transfers, + }; + + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; +} diff --git a/src/plugins/split-payments/commands/transfer/index.ts b/src/plugins/split-payments/commands/transfer/index.ts new file mode 100644 index 000000000..671340dfd --- /dev/null +++ b/src/plugins/split-payments/commands/transfer/index.ts @@ -0,0 +1,6 @@ +export { splitPaymentsTransferHandler } from './handler'; +export type { SplitPaymentsTransferOutput, TransferItemResult } from './output'; +export { + SplitPaymentsTransferOutputSchema, + SPLIT_PAYMENTS_TRANSFER_TEMPLATE, +} from './output'; diff --git a/src/plugins/split-payments/commands/transfer/input.ts b/src/plugins/split-payments/commands/transfer/input.ts new file mode 100644 index 000000000..0594e2964 --- /dev/null +++ b/src/plugins/split-payments/commands/transfer/input.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { KeyManagerTypeSchema, KeyOrAccountAliasSchema } from '@/core/schemas'; + +/** + * Input schema for split-payments transfer command. + * Validates: file path (CSV), optional payer (from), key-manager, dry-run. + */ +export const SplitPaymentsTransferInputSchema = z.object({ + file: z + .string() + .min(1, 'CSV file path is required') + .describe( + 'Path to CSV file with columns: to (address or alias), amount (HBAR or amount with t for tinybars)', + ), + from: KeyOrAccountAliasSchema.optional().describe( + 'Payer account: alias or accountId:privateKey. Defaults to operator.', + ), + keyManager: KeyManagerTypeSchema.optional().describe( + 'Key manager: local or local_encrypted (defaults to config)', + ), + dryRun: z + .boolean() + .optional() + .default(false) + .describe('If true, only validate and list planned transfers; do not send'), +}); + +export type SplitPaymentsTransferInput = z.infer< + typeof SplitPaymentsTransferInputSchema +>; diff --git a/src/plugins/split-payments/commands/transfer/output.ts b/src/plugins/split-payments/commands/transfer/output.ts new file mode 100644 index 000000000..33805e322 --- /dev/null +++ b/src/plugins/split-payments/commands/transfer/output.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +import { + EntityIdSchema, + NetworkSchema, + TinybarSchema, + TransactionIdSchema, +} from '@/core/schemas/common-schemas'; + +/** Single transfer result (success or failure) */ +export const TransferItemResultSchema = z.object({ + toAccountId: z + .string() + .describe('Recipient account ID or raw input if invalid'), + amountTinybar: TinybarSchema, + transactionId: TransactionIdSchema.optional(), + status: z.enum(['success', 'failure']), + errorMessage: z.string().optional(), +}); + +export type TransferItemResult = z.infer; + +/** Full batch output */ +export const SplitPaymentsTransferOutputSchema = z.object({ + network: NetworkSchema, + fromAccountId: EntityIdSchema, + totalTransfers: z.number(), + successCount: z.number(), + failureCount: z.number(), + dryRun: z.boolean().optional(), + transfers: z.array(TransferItemResultSchema), +}); + +export type SplitPaymentsTransferOutput = z.infer< + typeof SplitPaymentsTransferOutputSchema +>; + +export const SPLIT_PAYMENTS_TRANSFER_TEMPLATE = ` +{{#if dryRun}} +✅ Dry run — no transfers sent + +Network: {{network}} +From: {{hashscanLink fromAccountId "account" network}} +Total: {{totalTransfers}} | Valid: {{successCount}} | Invalid: {{failureCount}} + +{{#each transfers}} +{{#if_eq status "success"}} + ✓ Would send {{amountTinybar}} tinybars → {{toAccountId}} +{{else}} + ✗ {{toAccountId}} — {{amountTinybar}} tinybars — {{errorMessage}} +{{/if_eq}} +{{/each}} +{{else}} +✅ Split payments completed + +Network: {{network}} +From: {{hashscanLink fromAccountId "account" network}} +Total: {{totalTransfers}} | Success: {{successCount}} | Failed: {{failureCount}} + +{{#each transfers}} +{{#if_eq status "success"}} + ✓ {{toAccountId}} — {{amountTinybar}} tinybars — {{#if transactionId}}{{hashscanLink transactionId "transaction" ../network}}{{else}}-{{/if}} +{{else}} + ✗ {{toAccountId}} — {{amountTinybar}} tinybars — {{errorMessage}} +{{/if_eq}} +{{/each}} +{{/if}} +`.trim(); diff --git a/src/plugins/split-payments/index.ts b/src/plugins/split-payments/index.ts new file mode 100644 index 000000000..e0c1a011e --- /dev/null +++ b/src/plugins/split-payments/index.ts @@ -0,0 +1,5 @@ +export { + splitPaymentsPluginManifest, + splitPaymentsPluginManifest as default, +} from './manifest'; +export { splitPaymentsTransferHandler } from './commands/transfer'; diff --git a/src/plugins/split-payments/manifest.ts b/src/plugins/split-payments/manifest.ts new file mode 100644 index 000000000..afa3dc26b --- /dev/null +++ b/src/plugins/split-payments/manifest.ts @@ -0,0 +1,69 @@ +/** + * Split Payments Plugin Manifest + * Batch HBAR transfers from a CSV file in a single command. + */ +import type { PluginManifest } from '@/core'; +import { OptionType } from '@/core/types/shared.types'; + +import { + splitPaymentsTransferHandler, + SplitPaymentsTransferOutputSchema, + SPLIT_PAYMENTS_TRANSFER_TEMPLATE, +} from './commands/transfer'; + +export const splitPaymentsPluginManifest: PluginManifest = { + name: 'split-payments', + version: '1.0.0', + displayName: 'Split Payments', + description: + 'Batch HBAR transfers from a CSV file (address, amount) in a single command', + commands: [ + { + name: 'transfer', + summary: 'Batch transfer HBAR from a CSV file', + description: + 'Read a CSV file with columns (to, amount) and execute one HBAR transfer per row. Use --dry-run to validate without sending.', + options: [ + { + name: 'file', + short: 'f', + type: OptionType.STRING, + required: true, + description: + 'Path to CSV file. Columns: to (address or alias), amount (e.g. 10 or 100t for tinybars). Optional header: to,amount', + }, + { + name: 'from', + short: 'F', + type: OptionType.STRING, + required: false, + description: + 'Payer: account alias or accountId:privateKey. Defaults to operator.', + }, + { + name: 'key-manager', + short: 'k', + type: OptionType.STRING, + required: false, + description: + 'Key manager: local or local_encrypted (defaults to config)', + }, + { + name: 'dry-run', + type: OptionType.BOOLEAN, + required: false, + default: false, + description: + 'Validate CSV and list planned transfers without sending', + }, + ], + handler: splitPaymentsTransferHandler, + output: { + schema: SplitPaymentsTransferOutputSchema, + humanTemplate: SPLIT_PAYMENTS_TRANSFER_TEMPLATE, + }, + }, + ], +}; + +export default splitPaymentsPluginManifest; diff --git a/src/plugins/subgraph/README.md b/src/plugins/subgraph/README.md new file mode 100644 index 000000000..8f9226de3 --- /dev/null +++ b/src/plugins/subgraph/README.md @@ -0,0 +1,54 @@ +# Subgraph Plugin + +Create and deploy **Hedera testnet** subgraphs from the CLI using [The Graph](https://thegraph.com/) and the [Hedera JSON-RPC relay](https://docs.hedera.com/hedera/core-concepts/smart-contracts/json-rpc-relay) (e.g. Hashio testnet). + +## Prerequisites + +- **Docker** – for running the local graph node (IPFS + Postgres + graph-node). +- **Node.js** – the plugin uses `npx @graphprotocol/graph-cli` for deploy; no global install required. + +**Note:** The first `npm install` in a new subgraph project can take several minutes; the Graph CLI and its dependencies are large. If it seems stuck, try `npm cache clean --force` and run `npm install` again, or use `yarn` instead. + +## Commands + +### `hcli subgraph create` + +Scaffolds a new subgraph project (Greeter example) for Hedera testnet. + +```bash +hcli subgraph create --dir ./my-subgraph +# With contract and start block (replace after deploying your Greeter contract): +hcli subgraph create --dir ./my-subgraph --contract-address 0x... --start-block 12345 --name MyGreeter +``` + +Creates: `subgraph.yaml`, `schema.graphql`, `src/mappings.ts`, `abis/IGreeter.json`, `config/testnet.json`, `graph-node/docker-compose.yaml`, `package.json`. + +### `hcli subgraph start-node` + +Starts the local graph node (Docker). Run from the subgraph project directory or pass `--dir`. + +```bash +cd my-subgraph +hcli subgraph start-node +# Or: hcli subgraph start-node --dir ./my-subgraph +``` + +### `hcli subgraph deploy` + +Runs `graph codegen`, `graph build`, and deploys to your local graph node. Start the node first with `subgraph start-node`. + +```bash +cd my-subgraph +npm install +hcli subgraph start-node +# Wait ~1 minute, then: +hcli subgraph deploy --dir . --name Greeter --version-label v0.0.1 +``` + +Options: `--dir`, `--name`, `--version-label`, `--skip-codegen`, `--skip-build`. + +## References + +- [Hedera: Deploy a Subgraph Using The Graph and JSON-RPC](https://docs.hedera.com/hedera/tutorials/smart-contracts/deploy-a-subgraph-using-the-graph-and-json-rpc) +- [hedera-subgraph-example](https://github.com/hashgraph/hedera-subgraph-example) +- [The Graph docs](https://thegraph.com/docs/) diff --git a/src/plugins/subgraph/commands/create/handler.ts b/src/plugins/subgraph/commands/create/handler.ts new file mode 100644 index 000000000..1f240f1e4 --- /dev/null +++ b/src/plugins/subgraph/commands/create/handler.ts @@ -0,0 +1,171 @@ +/** + * Subgraph create command - scaffold a Hedera testnet subgraph project. + */ +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; + +import { DEFAULT_START_BLOCK, PLACEHOLDER_CONTRACT } from '../../constants'; +import { + DOCKER_COMPOSE_YAML, + IGREETER_ABI, + MAPPINGS_TS, + PACKAGE_JSON, + SCHEMA_GRAPHQL, + SUBGRAPH_YAML, + TESTNET_JSON, +} from '../../templates-data'; +import type { SubgraphCreateOutput } from './output'; +import { SubgraphCreateInputSchema } from './input'; + +function replacePlaceholders( + content: string, + contractAddress: string, + startBlock: number, +): string { + return content + .replace(/\{\{contractAddress\}\}/g, contractAddress) + .replace(/\{\{startBlock\}\}/g, String(startBlock)); +} + +export async function subgraphCreateHandler( + args: CommandHandlerArgs, +): Promise { + const { logger } = args; + + const parsed = SubgraphCreateInputSchema.safeParse(args.args); + if (!parsed.success) { + return { + status: Status.Failure, + errorMessage: formatError('Invalid arguments', parsed.error), + }; + } + + const { + dir, + name, + contractAddress = PLACEHOLDER_CONTRACT, + startBlock = DEFAULT_START_BLOCK, + } = parsed.data; + + const baseDir = path.resolve(dir); + const filesCreated: string[] = []; + + try { + if (fs.existsSync(baseDir)) { + const stat = fs.statSync(baseDir); + if (!stat.isDirectory()) { + return { + status: Status.Failure, + errorMessage: `Path exists and is not a directory: ${baseDir}`, + }; + } + const existing = fs.readdirSync(baseDir); + if (existing.length > 0) { + return { + status: Status.Failure, + errorMessage: `Directory is not empty: ${baseDir}. Use an empty directory or a new path.`, + }; + } + } else { + fs.mkdirSync(baseDir, { recursive: true }); + } + + const dirs = [ + path.join(baseDir, 'abis'), + path.join(baseDir, 'config'), + path.join(baseDir, 'src'), + path.join(baseDir, 'graph-node'), + ]; + for (const d of dirs) { + fs.mkdirSync(d, { recursive: true }); + } + + const subgraphYaml = replacePlaceholders( + SUBGRAPH_YAML, + contractAddress, + startBlock, + ); + const subgraphPath = path.join(baseDir, 'subgraph.yaml'); + fs.writeFileSync(subgraphPath, subgraphYaml, 'utf-8'); + filesCreated.push('subgraph.yaml'); + + fs.writeFileSync( + path.join(baseDir, 'schema.graphql'), + SCHEMA_GRAPHQL, + 'utf-8', + ); + filesCreated.push('schema.graphql'); + + fs.writeFileSync( + path.join(baseDir, 'src', 'mappings.ts'), + MAPPINGS_TS, + 'utf-8', + ); + filesCreated.push('src/mappings.ts'); + + fs.writeFileSync( + path.join(baseDir, 'abis', 'IGreeter.json'), + IGREETER_ABI, + 'utf-8', + ); + filesCreated.push('abis/IGreeter.json'); + + const testnetJson = replacePlaceholders( + TESTNET_JSON, + contractAddress, + startBlock, + ); + fs.writeFileSync( + path.join(baseDir, 'config', 'testnet.json'), + testnetJson, + 'utf-8', + ); + filesCreated.push('config/testnet.json'); + + fs.writeFileSync( + path.join(baseDir, 'graph-node', 'docker-compose.yaml'), + DOCKER_COMPOSE_YAML, + 'utf-8', + ); + filesCreated.push('graph-node/docker-compose.yaml'); + + const packageJson = PACKAGE_JSON(name); + fs.writeFileSync(path.join(baseDir, 'package.json'), packageJson, 'utf-8'); + filesCreated.push('package.json'); + + const nextSteps = [ + `cd ${baseDir}`, + 'npm install (first run may take several minutes — Graph CLI has many dependencies)', + 'Start local graph node: npm run graph-node (requires Docker)', + 'graph codegen && graph build', + `graph create --node http://localhost:8020/ ${name}`, + `graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 ${name}`, + `Or use: hcli subgraph deploy --dir . --name ${name}`, + ]; + + const outputData: SubgraphCreateOutput = { + dir: baseDir, + name, + contractAddress, + startBlock, + filesCreated, + nextSteps, + }; + + logger.info(`[subgraph] Created project at ${baseDir}`); + + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; + } catch (err) { + return { + status: Status.Failure, + errorMessage: formatError('Failed to create subgraph project', err), + }; + } +} diff --git a/src/plugins/subgraph/commands/create/index.ts b/src/plugins/subgraph/commands/create/index.ts new file mode 100644 index 000000000..093c1374e --- /dev/null +++ b/src/plugins/subgraph/commands/create/index.ts @@ -0,0 +1,4 @@ +export { subgraphCreateHandler } from './handler'; +export type { SubgraphCreateOutput } from './output'; +export { SubgraphCreateOutputSchema, SUBGRAPH_CREATE_TEMPLATE } from './output'; +export { SubgraphCreateInputSchema } from './input'; diff --git a/src/plugins/subgraph/commands/create/input.ts b/src/plugins/subgraph/commands/create/input.ts new file mode 100644 index 000000000..c279967b1 --- /dev/null +++ b/src/plugins/subgraph/commands/create/input.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +/** + * Input schema for subgraph create command. + */ +export const SubgraphCreateInputSchema = z.object({ + dir: z + .string() + .min(1, 'Output directory is required') + .describe('Directory where the subgraph project will be created'), + name: z + .string() + .min(1) + .max(100) + .regex( + /^[a-zA-Z][a-zA-Z0-9_-]*$/, + 'Subgraph name must be alphanumeric with optional - or _', + ) + .optional() + .default('Greeter') + .describe('Subgraph name (used in graph create/deploy)'), + contractAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, 'Contract address must be 0x + 40 hex chars') + .optional() + .describe( + 'Deployed Greeter contract address (0x...). Omit to use placeholder.', + ), + startBlock: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Start block for indexing. Omit to use 1 (replace after deployment).', + ), +}); + +export type SubgraphCreateInput = z.infer; diff --git a/src/plugins/subgraph/commands/create/output.ts b/src/plugins/subgraph/commands/create/output.ts new file mode 100644 index 000000000..187af9445 --- /dev/null +++ b/src/plugins/subgraph/commands/create/output.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +export const SubgraphCreateOutputSchema = z.object({ + dir: z.string().describe('Absolute path where the subgraph was created'), + name: z.string().describe('Subgraph name'), + contractAddress: z.string().describe('Contract address configured'), + startBlock: z.number().describe('Start block configured'), + filesCreated: z.array(z.string()).describe('Paths of created files'), + nextSteps: z.array(z.string()).describe('Suggested next steps'), +}); + +export type SubgraphCreateOutput = z.infer; + +export const SUBGRAPH_CREATE_TEMPLATE = ` +✅ Subgraph project created + +Directory: {{dir}} +Name: {{name}} +Contract: {{contractAddress}} +Start block: {{startBlock}} + +Files created: +{{#each filesCreated}} + • {{this}} +{{/each}} + +Next steps: +{{#each nextSteps}} + • {{this}} +{{/each}} +`.trim(); diff --git a/src/plugins/subgraph/commands/deploy/handler.ts b/src/plugins/subgraph/commands/deploy/handler.ts new file mode 100644 index 000000000..de33603b7 --- /dev/null +++ b/src/plugins/subgraph/commands/deploy/handler.ts @@ -0,0 +1,128 @@ +/** + * Subgraph deploy command - codegen, build, and deploy to local graph node (Hedera testnet). + */ +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; + +import { + GRAPH_IPFS_URL, + GRAPH_NODE_URL, + GRAPH_QUERY_URL, +} from '../../constants'; +import type { SubgraphDeployOutput } from './output'; +import { SubgraphDeployInputSchema } from './input'; + +const GRAPH_CLI = 'npx'; +const GRAPH_CLI_ARGS = ['@graphprotocol/graph-cli']; + +function runGraph( + args: string[], + cwd: string, + logger: { info: (s: string) => void }, +): void { + const cmd = [GRAPH_CLI, ...GRAPH_CLI_ARGS, ...args].join(' '); + logger.info(`[subgraph] Running: ${cmd}`); + const opts: import('child_process').ExecSyncOptions = { + cwd, + stdio: 'inherit', + shell: + process.platform === 'win32' + ? (process.env.COMSPEC ?? 'cmd.exe') + : '/bin/sh', + }; + execSync(cmd, opts); +} + +export async function subgraphDeployHandler( + args: CommandHandlerArgs, +): Promise { + const { logger } = args; + + const parsed = SubgraphDeployInputSchema.safeParse(args.args); + if (!parsed.success) { + return { + status: Status.Failure, + errorMessage: formatError('Invalid arguments', parsed.error), + }; + } + + const { dir, name, versionLabel, skipCodegen, skipBuild } = parsed.data; + const baseDir = path.resolve(dir); + + if (!fs.existsSync(baseDir)) { + return { + status: Status.Failure, + errorMessage: `Directory not found: ${baseDir}`, + }; + } + + const subgraphYaml = path.join(baseDir, 'subgraph.yaml'); + if (!fs.existsSync(subgraphYaml)) { + return { + status: Status.Failure, + errorMessage: `subgraph.yaml not found in ${baseDir}. Run 'hcli subgraph create' first or point --dir to a subgraph project.`, + }; + } + + const stepsCompleted: string[] = []; + + try { + if (!skipCodegen) { + runGraph(['codegen'], baseDir, logger); + stepsCompleted.push('graph codegen'); + } + if (!skipBuild) { + runGraph(['build'], baseDir, logger); + stepsCompleted.push('graph build'); + } + + try { + runGraph(['create', '--node', GRAPH_NODE_URL, name], baseDir, logger); + stepsCompleted.push(`graph create ${name}`); + } catch { + // create may fail if subgraph already exists; continue to deploy + stepsCompleted.push(`graph create ${name} (skipped - may already exist)`); + } + + runGraph( + [ + 'deploy', + '--node', + GRAPH_NODE_URL, + '--ipfs', + GRAPH_IPFS_URL, + '--version-label', + versionLabel, + name, + ], + baseDir, + logger, + ); + stepsCompleted.push(`graph deploy ${name} @ ${versionLabel}`); + + const graphqlUrl = `${GRAPH_QUERY_URL}/${name}/graphql`; + + const outputData: SubgraphDeployOutput = { + dir: baseDir, + name, + version: versionLabel, + graphqlUrl, + stepsCompleted, + }; + + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; + } catch (err) { + return { + status: Status.Failure, + errorMessage: formatError('Subgraph deploy failed', err), + }; + } +} diff --git a/src/plugins/subgraph/commands/deploy/index.ts b/src/plugins/subgraph/commands/deploy/index.ts new file mode 100644 index 000000000..09ff5c768 --- /dev/null +++ b/src/plugins/subgraph/commands/deploy/index.ts @@ -0,0 +1,4 @@ +export { subgraphDeployHandler } from './handler'; +export type { SubgraphDeployOutput } from './output'; +export { SubgraphDeployOutputSchema, SUBGRAPH_DEPLOY_TEMPLATE } from './output'; +export { SubgraphDeployInputSchema } from './input'; diff --git a/src/plugins/subgraph/commands/deploy/input.ts b/src/plugins/subgraph/commands/deploy/input.ts new file mode 100644 index 000000000..a77da0e50 --- /dev/null +++ b/src/plugins/subgraph/commands/deploy/input.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const SubgraphDeployInputSchema = z.object({ + dir: z + .string() + .min(1) + .optional() + .default('.') + .describe('Subgraph project directory (must contain subgraph.yaml)'), + name: z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/) + .optional() + .default('Greeter') + .describe('Subgraph name (must match dataSources in subgraph.yaml)'), + versionLabel: z + .string() + .min(1) + .optional() + .default('v0.0.1') + .describe('Version label for this deployment (e.g. v0.0.1)'), + skipCodegen: z + .boolean() + .optional() + .default(false) + .describe('Skip graph codegen (use if already generated)'), + skipBuild: z + .boolean() + .optional() + .default(false) + .describe('Skip graph build (use if already built)'), +}); + +export type SubgraphDeployInput = z.infer; diff --git a/src/plugins/subgraph/commands/deploy/output.ts b/src/plugins/subgraph/commands/deploy/output.ts new file mode 100644 index 000000000..f911f6c31 --- /dev/null +++ b/src/plugins/subgraph/commands/deploy/output.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const SubgraphDeployOutputSchema = z.object({ + dir: z.string().describe('Project directory'), + name: z.string().describe('Subgraph name'), + version: z.string().describe('Version label deployed'), + graphqlUrl: z.string().describe('GraphQL endpoint URL'), + stepsCompleted: z.array(z.string()).describe('Steps that ran successfully'), +}); + +export type SubgraphDeployOutput = z.infer; + +export const SUBGRAPH_DEPLOY_TEMPLATE = ` +✅ Subgraph deployed + +Project: {{dir}} +Name: {{name}} +Version: {{version}} + +GraphQL endpoint: {{graphqlUrl}} + +Steps completed: +{{#each stepsCompleted}} + • {{this}} +{{/each}} +`.trim(); diff --git a/src/plugins/subgraph/commands/start-node/handler.ts b/src/plugins/subgraph/commands/start-node/handler.ts new file mode 100644 index 000000000..25441fcd5 --- /dev/null +++ b/src/plugins/subgraph/commands/start-node/handler.ts @@ -0,0 +1,72 @@ +/** + * Subgraph start-node command - start local graph node (Docker) for Hedera testnet. + */ +import type { CommandExecutionResult, CommandHandlerArgs } from '@/core'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { Status } from '@/core/shared/constants'; +import { formatError } from '@/core/utils/errors'; + +import type { SubgraphStartNodeOutput } from './output'; +import { SubgraphStartNodeInputSchema } from './input'; + +export async function subgraphStartNodeHandler( + args: CommandHandlerArgs, +): Promise { + const { logger } = args; + + const parsed = SubgraphStartNodeInputSchema.safeParse(args.args); + if (!parsed.success) { + return { + status: Status.Failure, + errorMessage: formatError('Invalid arguments', parsed.error), + }; + } + + const { dir } = parsed.data; + const baseDir = path.resolve(dir); + const composePath = path.join(baseDir, 'graph-node', 'docker-compose.yaml'); + + if (!fs.existsSync(composePath)) { + return { + status: Status.Failure, + errorMessage: `docker-compose not found: ${composePath}. Run 'hcli subgraph create' first or use --dir.`, + }; + } + + try { + logger.info( + `[subgraph] Starting graph node: docker-compose -f ${composePath} up -d`, + ); + const execOpts: import('child_process').ExecSyncOptions = { + stdio: 'inherit', + shell: + process.platform === 'win32' + ? (process.env.COMSPEC ?? 'cmd.exe') + : '/bin/sh', + }; + execSync(`docker-compose -f "${composePath}" up -d`, execOpts); + + const outputData: SubgraphStartNodeOutput = { + dir: baseDir, + composeFile: composePath, + message: + 'Graph node, IPFS, and Postgres are starting. Wait a minute then run: hcli subgraph deploy --dir .', + }; + + return { + status: Status.Success, + outputJson: JSON.stringify(outputData), + }; + } catch (err) { + return { + status: Status.Failure, + errorMessage: formatError( + 'Failed to start graph node. Ensure Docker is running and docker-compose is available.', + err, + ), + }; + } +} diff --git a/src/plugins/subgraph/commands/start-node/index.ts b/src/plugins/subgraph/commands/start-node/index.ts new file mode 100644 index 000000000..aae5a2dca --- /dev/null +++ b/src/plugins/subgraph/commands/start-node/index.ts @@ -0,0 +1,7 @@ +export { subgraphStartNodeHandler } from './handler'; +export type { SubgraphStartNodeOutput } from './output'; +export { + SubgraphStartNodeOutputSchema, + SUBGRAPH_START_NODE_TEMPLATE, +} from './output'; +export { SubgraphStartNodeInputSchema } from './input'; diff --git a/src/plugins/subgraph/commands/start-node/input.ts b/src/plugins/subgraph/commands/start-node/input.ts new file mode 100644 index 000000000..db7201ae7 --- /dev/null +++ b/src/plugins/subgraph/commands/start-node/input.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const SubgraphStartNodeInputSchema = z.object({ + dir: z + .string() + .min(1) + .optional() + .default('.') + .describe( + 'Subgraph project directory containing graph-node/docker-compose.yaml', + ), +}); + +export type SubgraphStartNodeInput = z.infer< + typeof SubgraphStartNodeInputSchema +>; diff --git a/src/plugins/subgraph/commands/start-node/output.ts b/src/plugins/subgraph/commands/start-node/output.ts new file mode 100644 index 000000000..3d6372e07 --- /dev/null +++ b/src/plugins/subgraph/commands/start-node/output.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const SubgraphStartNodeOutputSchema = z.object({ + dir: z.string().describe('Project directory'), + composeFile: z.string().describe('Path to docker-compose.yaml'), + message: z.string().describe('Status message'), +}); + +export type SubgraphStartNodeOutput = z.infer< + typeof SubgraphStartNodeOutputSchema +>; + +export const SUBGRAPH_START_NODE_TEMPLATE = ` +✅ Graph node starting + +Project: {{dir}} +Compose file: {{composeFile}} + +{{message}} +`.trim(); diff --git a/src/plugins/subgraph/constants.ts b/src/plugins/subgraph/constants.ts new file mode 100644 index 000000000..fde4b09ff --- /dev/null +++ b/src/plugins/subgraph/constants.ts @@ -0,0 +1,12 @@ +/** + * Subgraph plugin constants - Hedera testnet + */ +export const HEDERA_TESTNET_RPC = 'https://testnet.hashio.io/api'; +export const DEFAULT_SUBGRAPH_NAME = 'Greeter'; +export const DEFAULT_START_BLOCK = 1; +/** Placeholder contract for template; user should replace with deployed Greeter address */ +export const PLACEHOLDER_CONTRACT = + '0x0000000000000000000000000000000000000000'; +export const GRAPH_NODE_URL = 'http://localhost:8020/'; +export const GRAPH_IPFS_URL = 'http://localhost:5001'; +export const GRAPH_QUERY_URL = 'http://localhost:8000/subgraphs/name'; diff --git a/src/plugins/subgraph/index.ts b/src/plugins/subgraph/index.ts new file mode 100644 index 000000000..93bde727a --- /dev/null +++ b/src/plugins/subgraph/index.ts @@ -0,0 +1,4 @@ +export { + subgraphPluginManifest, + subgraphPluginManifest as default, +} from './manifest'; diff --git a/src/plugins/subgraph/manifest.ts b/src/plugins/subgraph/manifest.ts new file mode 100644 index 000000000..9d6b9ac02 --- /dev/null +++ b/src/plugins/subgraph/manifest.ts @@ -0,0 +1,152 @@ +/** + * Subgraph Plugin Manifest + * Create and deploy Hedera testnet subgraphs (The Graph) from the CLI. + */ +import type { PluginManifest } from '@/core'; +import { OptionType } from '@/core/types/shared.types'; + +import { + subgraphCreateHandler, + SubgraphCreateOutputSchema, + SUBGRAPH_CREATE_TEMPLATE, +} from './commands/create'; +import { + subgraphDeployHandler, + SubgraphDeployOutputSchema, + SUBGRAPH_DEPLOY_TEMPLATE, +} from './commands/deploy'; +import { + subgraphStartNodeHandler, + SubgraphStartNodeOutputSchema, + SUBGRAPH_START_NODE_TEMPLATE, +} from './commands/start-node'; + +export const subgraphPluginManifest: PluginManifest = { + name: 'subgraph', + version: '1.0.0', + displayName: 'Subgraph', + description: + 'Create and deploy Hedera testnet subgraphs using The Graph (create, deploy, start local graph node)', + commands: [ + { + name: 'create', + summary: 'Scaffold a Hedera testnet subgraph project', + description: + 'Create a new subgraph project (Greeter example) for Hedera testnet. Configure contract address and start block, then deploy with subgraph deploy.', + options: [ + { + name: 'dir', + short: 'd', + type: OptionType.STRING, + required: true, + description: + 'Output directory for the new subgraph project (must be empty or not exist)', + }, + { + name: 'name', + short: 'n', + type: OptionType.STRING, + required: false, + default: 'Greeter', + description: + 'Subgraph name (alphanumeric, used in graph create/deploy)', + }, + { + name: 'contract-address', + type: OptionType.STRING, + required: false, + description: + 'Deployed Greeter contract address (0x...). Omit to use placeholder.', + }, + { + name: 'start-block', + type: OptionType.NUMBER, + required: false, + description: + 'Start block for indexing (default 1; replace after contract deployment)', + }, + ], + handler: subgraphCreateHandler, + output: { + schema: SubgraphCreateOutputSchema, + humanTemplate: SUBGRAPH_CREATE_TEMPLATE, + }, + }, + { + name: 'deploy', + summary: 'Deploy subgraph to local graph node', + description: + 'Run graph codegen, build, and deploy to local graph node. Start the node first with subgraph start-node (Docker required).', + options: [ + { + name: 'dir', + short: 'd', + type: OptionType.STRING, + required: false, + default: '.', + description: + 'Subgraph project directory (default: current directory)', + }, + { + name: 'name', + short: 'n', + type: OptionType.STRING, + required: false, + default: 'Greeter', + description: 'Subgraph name (must match subgraph.yaml)', + }, + { + name: 'version-label', + short: 'v', + type: OptionType.STRING, + required: false, + default: 'v0.0.1', + description: 'Version label for this deployment (e.g. v0.0.1)', + }, + { + name: 'skip-codegen', + type: OptionType.BOOLEAN, + required: false, + default: false, + description: 'Skip graph codegen', + }, + { + name: 'skip-build', + type: OptionType.BOOLEAN, + required: false, + default: false, + description: 'Skip graph build', + }, + ], + handler: subgraphDeployHandler, + output: { + schema: SubgraphDeployOutputSchema, + humanTemplate: SUBGRAPH_DEPLOY_TEMPLATE, + }, + }, + { + name: 'start-node', + summary: 'Start local graph node (Docker)', + description: + 'Start the local graph node, IPFS, and Postgres via Docker. Run from the subgraph project directory or pass --dir.', + options: [ + { + name: 'dir', + short: 'd', + type: OptionType.STRING, + required: false, + default: '.', + description: + 'Subgraph project directory containing graph-node/docker-compose.yaml', + }, + ], + handler: subgraphStartNodeHandler, + output: { + schema: SubgraphStartNodeOutputSchema, + humanTemplate: SUBGRAPH_START_NODE_TEMPLATE, + }, + }, + ], +}; + +export default subgraphPluginManifest; diff --git a/src/plugins/subgraph/templates-data.ts b/src/plugins/subgraph/templates-data.ts new file mode 100644 index 000000000..dd3caa0f0 --- /dev/null +++ b/src/plugins/subgraph/templates-data.ts @@ -0,0 +1,157 @@ +/** + * Embedded subgraph template content (Hedera testnet Greeter example). + * Placeholders: {{contractAddress}}, {{startBlock}} + */ +export const SUBGRAPH_YAML = `specVersion: 0.0.4 +description: Graph for Greeter contracts on Hedera testnet +repository: https://github.com/hashgraph/hedera-subgraph-example +schema: + file: ./schema.graphql +dataSources: + - kind: ethereum/contract + name: Greeter + network: testnet + source: + address: "{{contractAddress}}" + abi: IGreeter + startBlock: {{startBlock}} + mapping: + kind: ethereum/events + apiVersion: 0.0.6 + language: wasm/assemblyscript + entities: + - Greeting + abis: + - name: IGreeter + file: ./abis/IGreeter.json + eventHandlers: + - event: GreetingSet(string) + handler: handleGreetingSet + file: ./src/mappings.ts +`; + +export const SCHEMA_GRAPHQL = `type Greeting @entity { + id: ID! + currentGreeting: String! +} +`; + +export const MAPPINGS_TS = `/* + * Hedera Subgraph Example - Hedera testnet + * Scaffolded by hiero-cli subgraph plugin. + */ + +import { GreetingSet } from '../generated/Greeter/IGreeter'; +import { Greeting } from '../generated/schema'; + +export function handleGreetingSet(event: GreetingSet): void { + let entity = Greeting.load(event.transaction.hash.toHexString()); + + if (!entity) { + entity = new Greeting(event.transaction.hash.toHex()); + } + + entity.currentGreeting = event.params._greeting; + entity.save(); +} +`; + +export const TESTNET_JSON = `{ + "startBlock": "{{startBlock}}", + "Greeter": "{{contractAddress}}" +} +`; + +export const DOCKER_COMPOSE_YAML = `version: '3' +services: + graph-node: + image: graphprotocol/graph-node:v0.27.0 + ports: + - '8000:8000' + - '8001:8001' + - '8020:8020' + - '8030:8030' + - '8040:8040' + depends_on: + - ipfs + - postgres + extra_hosts: + - host.docker.internal:host-gateway + environment: + postgres_host: postgres + postgres_user: 'graph-node' + postgres_pass: 'let-me-in' + postgres_db: 'graph-node' + ipfs: 'ipfs:5001' + ethereum: 'testnet:https://testnet.hashio.io/api' + GRAPH_LOG: info + GRAPH_ETHEREUM_GENESIS_BLOCK_NUMBER: 1 + ipfs: + image: ipfs/go-ipfs:v0.10.0 + ports: + - '5001:5001' + volumes: + - ./data/ipfs:/data/ipfs + postgres: + image: postgres + ports: + - '5432:5432' + command: ['postgres', '-cshared_preload_libraries=pg_stat_statements'] + environment: + POSTGRES_USER: 'graph-node' + POSTGRES_PASSWORD: 'let-me-in' + POSTGRES_DB: 'graph-node' + PGDATA: '/data/postgres' + volumes: + - ./data/postgres:/var/lib/postgresql/data +`; + +export const IGREETER_ABI = `[ + { + "inputs": [{"internalType": "string", "name": "_greeting", "type": "string"}], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + {"indexed": false, "internalType": "string", "name": "_greeting", "type": "string"} + ], + "name": "GreetingSet", + "type": "event" + }, + { + "inputs": [], + "name": "greet", + "outputs": [{"internalType": "string", "name": "", "type": "string"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{"internalType": "string", "name": "_greeting", "type": "string"}], + "name": "setGreeting", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] +`; + +export const PACKAGE_JSON = (subgraphName: string) => `{ + "name": "hedera-subgraph-${subgraphName.toLowerCase()}", + "version": "1.0.0", + "description": "Hedera subgraph on testnet - scaffolded by hiero-cli", + "scripts": { + "codegen": "graph codegen", + "build": "graph build", + "create-local": "graph create --node http://localhost:8020/ ${subgraphName}", + "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 ${subgraphName}", + "graph-node": "docker-compose -f ./graph-node/docker-compose.yaml up -d", + "graph-node-down": "docker-compose -f ./graph-node/docker-compose.yaml down" + }, + "dependencies": { + "@graphprotocol/graph-cli": "0.33.0", + "@graphprotocol/graph-ts": "0.27.0" + } +} +`;