diff --git a/src/app/rosetta/examples/README.md b/src/app/rosetta/examples/README.md new file mode 100644 index 000000000000..aa498aa0422f --- /dev/null +++ b/src/app/rosetta/examples/README.md @@ -0,0 +1,44 @@ +# Mina Rosetta integration examples + +Runnable scripts demonstrating common integration patterns against Mina's [Rosetta (Mesh) API](https://docs.cdp.coinbase.com/mesh/docs/welcome) implementation. + +## Prerequisites + +A running Mina Rosetta endpoint. The fastest way is the Docker Compose stack in `../docker-compose/`: + +```bash +cd ../docker-compose +make devnet +``` + +This exposes Rosetta at `http://localhost:3087`. + +## Available examples + +### TypeScript (`ts/`) + +Uses [`@o1-labs/mina-rosetta-sdk`](https://www.npmjs.com/package/@o1-labs/mina-rosetta-sdk) for the typed Rosetta HTTP surface and [`mina-signer`](https://www.npmjs.com/package/mina-signer) for Pallas-curve transaction signing. + +| Script | What it does | +| --- | --- | +| `account-balance.ts` | Query a single account balance (smoke test) | +| `scan-blocks.ts` | Poll `/network/status` and fetch new blocks as they arrive | +| `track-deposits.ts` | Watch an address for incoming MINA deposits | +| `send-transaction.ts` | Full Construction API flow: derive → preprocess → metadata → payloads → sign → combine → submit | +| `offline-sign.ts` | Same flow split for cold-signing setups: metadata online, signing offline | + +See `ts/README.md` for setup and run instructions. + +### Go (`go/`) + +Read-side examples using [`coinbase/mesh-sdk-go`](https://github.com/coinbase/mesh-sdk-go) (the canonical Go SDK). Send-transaction is intentionally not included because Pallas signing has no pure-Go implementation today — see `go/README.md` for options. + +| Program | What it does | +| --- | --- | +| `account-balance/` | Query a single account balance | +| `scan-blocks/` | Poll for new blocks via `fetcher` | +| `track-deposits/` | Filter `payment_receiver_inc` operations for an address | + +## Adding examples in other languages + +Drop a sibling directory (`py/`, `rust/`, etc.). Keep each language self-contained with its own `README.md` and follow the same pattern: thin client wrappers (or generated client over the [Mesh OpenAPI spec](https://github.com/coinbase/mesh-specifications)), one program per integration scenario, runnable against a live Rosetta endpoint. diff --git a/src/app/rosetta/examples/go/README.md b/src/app/rosetta/examples/go/README.md new file mode 100644 index 000000000000..8f9821094d7c --- /dev/null +++ b/src/app/rosetta/examples/go/README.md @@ -0,0 +1,35 @@ +# Go Rosetta examples + +Read-side Rosetta integration examples in Go using [`coinbase/mesh-sdk-go`](https://github.com/coinbase/mesh-sdk-go) — the canonical Go SDK Coinbase ships and uses internally. + +These examples are intentionally minimal. For Go, the upstream `mesh-sdk-go/examples/` directory already covers the generic Rosetta flow well. The point here is to show the **Mina-specific** bits (operation types, transfer layout, default token ID) wired up against `fetcher.New(...)`. + +## Setup + +```bash +cd src/app/rosetta/examples/go +go mod download +``` + +## Run + +Each example is a separate `main` package. Pass configuration via environment variables (same names as the TypeScript examples): + +```bash +export ROSETTA_URL=http://localhost:3087 +export NETWORK=devnet +export TEST_ADDRESS=B62q... + +go run ./account-balance +go run ./scan-blocks +go run ./track-deposits +``` + +## Why no send-transaction in Go + +Mina uses the Pallas curve for signatures, and there is no pure-Go Pallas signer. To send transactions from Go you would either: + +- Shell out to the [`mina-ocaml-signer`](../../../rosetta/ocaml-signer) CLI shipped with the Rosetta image, or +- Run the TypeScript [`offline-sign.ts`](../ts/offline-sign.ts) example for the signing step and submit from Go + +For most exchange integrators this is a non-issue — signing usually happens in a separate cold-signing service. The Go examples here cover the read-side patterns (`/account/balance`, `/block`, `/network/status`) that real integrators run from their main service. diff --git a/src/app/rosetta/examples/go/account-balance/main.go b/src/app/rosetta/examples/go/account-balance/main.go new file mode 100644 index 000000000000..fd5662660ac1 --- /dev/null +++ b/src/app/rosetta/examples/go/account-balance/main.go @@ -0,0 +1,42 @@ +// Smoke test: fetch a single account's balance from Mina Rosetta. +package main + +import ( + "context" + "fmt" + "log" + + "github.com/MinaProtocol/mina/src/app/rosetta/examples/go/internal/config" + "github.com/coinbase/rosetta-sdk-go/fetcher" + "github.com/coinbase/rosetta-sdk-go/types" +) + +func main() { + address, err := config.Required("TEST_ADDRESS") + if err != nil { + log.Fatal(err) + } + + env := config.Load() + f := fetcher.New(env.URL) + + block, balances, _, ferr := f.AccountBalance( + context.Background(), + env.Network, + &types.AccountIdentifier{ + Address: address, + Metadata: map[string]interface{}{"token_id": config.DefaultTokenID}, + }, + nil, + nil, + ) + if ferr != nil { + log.Fatalf("AccountBalance: %s", ferr.Err) + } + + fmt.Printf("Address: %s\n", address) + fmt.Printf("As of block %d (%s)\n", block.Index, block.Hash) + for _, b := range balances { + fmt.Printf(" %s %s (%d decimals)\n", b.Value, b.Currency.Symbol, b.Currency.Decimals) + } +} diff --git a/src/app/rosetta/examples/go/go.mod b/src/app/rosetta/examples/go/go.mod new file mode 100644 index 000000000000..2f1c31d6f198 --- /dev/null +++ b/src/app/rosetta/examples/go/go.mod @@ -0,0 +1,5 @@ +module github.com/MinaProtocol/mina/src/app/rosetta/examples/go + +go 1.21 + +require github.com/coinbase/rosetta-sdk-go v0.8.6 diff --git a/src/app/rosetta/examples/go/internal/config/config.go b/src/app/rosetta/examples/go/internal/config/config.go new file mode 100644 index 000000000000..15aef05163b4 --- /dev/null +++ b/src/app/rosetta/examples/go/internal/config/config.go @@ -0,0 +1,51 @@ +// Package config centralizes the Mina-specific Rosetta knobs and shared env +// parsing the example programs need. +package config + +import ( + "fmt" + "os" + + "github.com/coinbase/rosetta-sdk-go/types" +) + +const ( + Blockchain = "mina" + CurveType = types.Pallas + DefaultTokenID = "wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf" + DefaultURL = "http://localhost:3087" + DefaultNetwork = "devnet" +) + +var MinaCurrency = &types.Currency{Symbol: "MINA", Decimals: 9} + +// Env returns the example-script configuration sourced from environment +// variables. Defaults match the TypeScript examples. +type Env struct { + URL string + Network *types.NetworkIdentifier +} + +func Load() *Env { + url := os.Getenv("ROSETTA_URL") + if url == "" { + url = DefaultURL + } + network := os.Getenv("NETWORK") + if network == "" { + network = DefaultNetwork + } + return &Env{ + URL: url, + Network: &types.NetworkIdentifier{Blockchain: Blockchain, Network: network}, + } +} + +// Required reads a required env var, returning a friendly error if unset. +func Required(name string) (string, error) { + v := os.Getenv(name) + if v == "" { + return "", fmt.Errorf("missing env var: %s", name) + } + return v, nil +} diff --git a/src/app/rosetta/examples/go/scan-blocks/main.go b/src/app/rosetta/examples/go/scan-blocks/main.go new file mode 100644 index 000000000000..aeef6de57123 --- /dev/null +++ b/src/app/rosetta/examples/go/scan-blocks/main.go @@ -0,0 +1,59 @@ +// Poll Mina Rosetta for new blocks and print transaction hashes. +package main + +import ( + "context" + "fmt" + "log" + "os" + "strconv" + "time" + + "github.com/MinaProtocol/mina/src/app/rosetta/examples/go/internal/config" + "github.com/coinbase/rosetta-sdk-go/fetcher" + "github.com/coinbase/rosetta-sdk-go/types" +) + +const pollInterval = 10 * time.Second + +func main() { + env := config.Load() + f := fetcher.New(env.URL) + ctx := context.Background() + + height := startHeight(ctx, f, env.Network) + fmt.Printf("Scanning from block %d\n", height) + + for { + block, ferr := f.Block(ctx, env.Network, &types.PartialBlockIdentifier{Index: &height}) + if ferr != nil { + log.Fatalf("Block %d: %s", height, ferr.Err) + } + if block == nil { + time.Sleep(pollInterval) + continue + } + + fmt.Printf("[%s] block %d (%s) — %d tx\n", + time.UnixMilli(block.Timestamp).UTC().Format(time.RFC3339), + block.BlockIdentifier.Index, block.BlockIdentifier.Hash, + len(block.Transactions)) + for _, tx := range block.Transactions { + fmt.Printf(" %s\n", tx.TransactionIdentifier.Hash) + } + height++ + } +} + +func startHeight(ctx context.Context, f *fetcher.Fetcher, network *types.NetworkIdentifier) int64 { + if v := os.Getenv("START_HEIGHT"); v != "" { + if h, err := strconv.ParseInt(v, 10, 64); err == nil && h > 0 { + return h + } + } + status, ferr := f.NetworkStatus(ctx, network, nil) + if ferr != nil { + log.Fatalf("NetworkStatus: %s", ferr.Err) + } + return status.CurrentBlockIdentifier.Index +} diff --git a/src/app/rosetta/examples/go/track-deposits/main.go b/src/app/rosetta/examples/go/track-deposits/main.go new file mode 100644 index 000000000000..2f8fdde26971 --- /dev/null +++ b/src/app/rosetta/examples/go/track-deposits/main.go @@ -0,0 +1,100 @@ +// Watch a Mina address for incoming MINA deposits. +package main + +import ( + "context" + "fmt" + "log" + "os" + "strconv" + "time" + + "github.com/MinaProtocol/mina/src/app/rosetta/examples/go/internal/config" + "github.com/coinbase/rosetta-sdk-go/fetcher" + "github.com/coinbase/rosetta-sdk-go/types" +) + +const ( + pollInterval = 10 * time.Second + depositOp = "payment_receiver_inc" +) + +func main() { + address, err := config.Required("TEST_ADDRESS") + if err != nil { + log.Fatal(err) + } + + env := config.Load() + f := fetcher.New(env.URL) + ctx := context.Background() + + height := startHeight(ctx, f, env.Network) + fmt.Printf("Watching %s for deposits starting at block %d\n", address, height) + + for { + block, ferr := f.Block(ctx, env.Network, &types.PartialBlockIdentifier{Index: &height}) + if ferr != nil { + log.Fatalf("Block %d: %s", height, ferr.Err) + } + if block == nil { + time.Sleep(pollInterval) + continue + } + + for _, deposit := range findDeposits(block, address) { + fmt.Printf("DEPOSIT block=%d tx=%s amount=%s nanomina\n", + deposit.height, deposit.txHash, deposit.amount) + } + height++ + } +} + +type deposit struct { + height int64 + txHash string + amount string +} + +func findDeposits(block *types.Block, address string) []deposit { + var out []deposit + for _, tx := range block.Transactions { + for _, op := range tx.Operations { + if !isDeposit(op, address) { + continue + } + out = append(out, deposit{ + height: block.BlockIdentifier.Index, + txHash: tx.TransactionIdentifier.Hash, + amount: op.Amount.Value, + }) + } + } + return out +} + +func isDeposit(op *types.Operation, address string) bool { + if op.Type != depositOp { + return false + } + if op.Status != nil && *op.Status == "Failed" { + return false + } + if op.Account == nil || op.Account.Address != address { + return false + } + return op.Amount != nil +} + +func startHeight(ctx context.Context, f *fetcher.Fetcher, network *types.NetworkIdentifier) int64 { + if v := os.Getenv("START_HEIGHT"); v != "" { + if h, err := strconv.ParseInt(v, 10, 64); err == nil && h > 0 { + return h + } + } + status, ferr := f.NetworkStatus(ctx, network, nil) + if ferr != nil { + log.Fatalf("NetworkStatus: %s", ferr.Err) + } + return status.CurrentBlockIdentifier.Index +} diff --git a/src/app/rosetta/examples/ts/.gitignore b/src/app/rosetta/examples/ts/.gitignore new file mode 100644 index 000000000000..69b063dcf986 --- /dev/null +++ b/src/app/rosetta/examples/ts/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +dist/ +*.log +.cold-signing/ diff --git a/src/app/rosetta/examples/ts/README.md b/src/app/rosetta/examples/ts/README.md new file mode 100644 index 000000000000..39f0611b6bac --- /dev/null +++ b/src/app/rosetta/examples/ts/README.md @@ -0,0 +1,71 @@ +# TypeScript Rosetta examples + +Runnable scripts integrating with Mina's [Rosetta (Mesh) API](https://docs.cdp.coinbase.com/mesh/docs/welcome). + +The examples use [`@o1-labs/mina-rosetta-sdk`](https://github.com/o1-labs/mina-rosetta-sdk-js) for the typed Rosetta HTTP surface and [`mina-signer`](https://www.npmjs.com/package/mina-signer) for Pallas-curve transaction signing. The SDK is a light wrapper — it doesn't reimplement the full `mesh-sdk` typed-client stack, just the endpoints Mina actually exposes plus a few Mina-specific operation builders. + +## Setup + +```bash +cd src/app/rosetta/examples/ts +npm install +cp env.example .env +# edit .env with your Rosetta URL, network, addresses +``` + +Make sure a Rosetta endpoint is running. From this repo: + +```bash +cd ../../docker-compose +make rosetta-up # devnet by default +``` + +## Run an example + +```bash +npm run account-balance # query a balance (smoke test) +npm run scan-blocks # poll for new blocks +npm run track-deposits # watch an address for incoming MINA +npm run send-transaction # full Construction API flow +npm run offline-sign # cold-signing variant +``` + +Type-check without running: + +```bash +npm run typecheck +``` + +## Layout + +| File | Purpose | +| --- | --- | +| `account-balance.ts` | One-shot balance query against `/account/balance` | +| `scan-blocks.ts` | Polling loop that fetches blocks sequentially from chain tip | +| `track-deposits.ts` | Same loop, filtering operations for `payment_receiver_inc` to a target address | +| `send-transaction.ts` | preprocess → metadata → payloads → sign → combine → submit | +| `offline-sign.ts` | Splits the same flow across hot/cold environments via on-disk handoff files | + +## Mina-specific knobs + +The SDK exports these constants: + +- `BLOCKCHAIN = "mina"` +- `CURVE_TYPE = "pallas"` +- `MINA_CURRENCY = { symbol: "MINA", decimals: 9 }` — amounts are in nanomina +- `DEFAULT_TOKEN_ID` — the canonical MINA token ID; override for custom tokens +- `OperationType.{FeePayment, PaymentSourceDec, PaymentReceiverInc, ...}` — the operation-type strings the Rosetta server uses + +The three-operation MINA transfer (`fee_payment` → `payment_source_dec` → `payment_receiver_inc`) is built by `buildTransferOperations()`; stake delegations by `buildDelegationOperations()`. See `send-transaction.ts` for usage of the transfer helper end-to-end. + +## Adapting these to your codebase + +Install the SDK directly — no need to copy any files out of this directory: + +```bash +npm install @o1-labs/mina-rosetta-sdk mina-signer +``` + +Then use `account-balance.ts` and `send-transaction.ts` as templates for the read-side and write-side patterns. + +For a deeper walkthrough of what each step does and the Mina-specific spec deltas, see the [Rosetta integration guide](https://docs.minaprotocol.com/node-operators/rosetta) on the docs portal. diff --git a/src/app/rosetta/examples/ts/account-balance.ts b/src/app/rosetta/examples/ts/account-balance.ts new file mode 100644 index 000000000000..e5ccccb48f8b --- /dev/null +++ b/src/app/rosetta/examples/ts/account-balance.ts @@ -0,0 +1,31 @@ +/** + * Smoke test for /account/balance. The simplest example of the SDK in use. + */ +import 'dotenv/config'; +import { RosettaClient } from '@o1-labs/mina-rosetta-sdk'; + +async function main() { + const address = requireEnv('TEST_ADDRESS'); + const client = new RosettaClient({ + baseUrl: process.env.ROSETTA_URL ?? 'http://localhost:3087', + network: process.env.NETWORK ?? 'devnet', + }); + + const { block_identifier, balances } = await client.accountBalance({ address }); + console.log(`Address: ${address}`); + console.log(`As of block ${block_identifier.index} (${block_identifier.hash})`); + for (const b of balances) { + console.log(` ${b.value} ${b.currency.symbol} (${b.currency.decimals} decimals)`); + } +} + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing env var: ${name}`); + return v; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/rosetta/examples/ts/env.example b/src/app/rosetta/examples/ts/env.example new file mode 100644 index 000000000000..b772beefcb2b --- /dev/null +++ b/src/app/rosetta/examples/ts/env.example @@ -0,0 +1,35 @@ +# Copy to .env and fill in values for the examples that need them. + +# Rosetta endpoint. Default port from docker-compose stack is 3087. +ROSETTA_URL=http://localhost:3087 + +# Network name advertised by your Rosetta. Must match what /network/list returns. +NETWORK=devnet + +# An address you want to query. Used by account-balance.ts and track-deposits.ts. +TEST_ADDRESS=B62qiy32p8kAKnny8ZFwoMhyUjYbuQ7nYHcSEqCvrR7sM4f9F4eNJMW + +# Where to start scanning. Used by scan-blocks.ts and track-deposits.ts. +# Set to 0 to start from chain tip. +START_HEIGHT=0 + +# --- send-transaction.ts / offline-sign.ts --- + +# Hex-encoded private key in mina-signer format. +# Generate one with: node -e "console.log(new (require('mina-signer'))({network:'testnet'}).genKeys())" +SENDER_PRIVATE_KEY= + +# Base58 sender address (derived from SENDER_PRIVATE_KEY). +SENDER_ADDRESS= + +# Hex-encoded sender public key. Required for offline-sign.ts (so the online +# host can build payloads without seeing the private key). +# Derive from SENDER_ADDRESS via mina-signer's publicKeyToRaw. +SENDER_PUBLIC_KEY= + +# Base58 receiver address. +RECEIVER_ADDRESS= + +# Amounts in nanomina (1 MINA = 1_000_000_000 nanomina). +TRANSFER_AMOUNT=1000000000 +TRANSFER_FEE=10000000 diff --git a/src/app/rosetta/examples/ts/offline-sign.ts b/src/app/rosetta/examples/ts/offline-sign.ts new file mode 100644 index 000000000000..9646cd3bec1b --- /dev/null +++ b/src/app/rosetta/examples/ts/offline-sign.ts @@ -0,0 +1,118 @@ +/** + * Cold-signing flow. Splits the Construction API across an online and + * offline environment: + * + * ONLINE (hot host with Rosetta access): + * preprocess → metadata → payloads → save unsigned to disk + * + * OFFLINE (cold host, no network): + * read unsigned → rosettaCombinePayload(payloads, privateKey) → save to disk + * + * ONLINE: + * read combine input → /construction/combine → /construction/submit + * + * For demo purposes everything happens in one process and persists through + * temp files, but the comments mark the boundary you would split in + * production. + */ +import 'dotenv/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import Client from 'mina-signer'; +import { + CURVE_TYPE, + RosettaClient, + buildTransferOperations, + type Signature, +} from '@o1-labs/mina-rosetta-sdk'; + +const WORK_DIR = path.resolve(__dirname, '.cold-signing'); +const PAYLOADS_FILE = path.join(WORK_DIR, 'payloads.json'); +const COMBINE_INPUT_FILE = path.join(WORK_DIR, 'combine-input.json'); + +const NETWORK = process.env.NETWORK ?? 'devnet'; + +function makeRosetta() { + return new RosettaClient({ + baseUrl: process.env.ROSETTA_URL ?? 'http://localhost:3087', + network: NETWORK, + }); +} + +async function buildPayloads() { + const senderAddress = requireEnv('SENDER_ADDRESS'); + const senderPublicKey = requireEnv('SENDER_PUBLIC_KEY'); + const receiverAddress = requireEnv('RECEIVER_ADDRESS'); + const amount = process.env.TRANSFER_AMOUNT ?? '1000000000'; + const fee = process.env.TRANSFER_FEE ?? '10000000'; + + const rosetta = makeRosetta(); + const operations = buildTransferOperations({ + sender: senderAddress, + receiver: receiverAddress, + amountNanomina: amount, + feeNanomina: fee, + }); + const publicKeys = [{ hex_bytes: senderPublicKey, curve_type: CURVE_TYPE }]; + + const { options } = await rosetta.constructionPreprocess({ operations }); + const { metadata } = await rosetta.constructionMetadata({ + options: options ?? {}, + publicKeys, + }); + const payloadsResponse = await rosetta.constructionPayloads({ + operations, + metadata, + publicKeys, + }); + + fs.mkdirSync(WORK_DIR, { recursive: true }); + fs.writeFileSync(PAYLOADS_FILE, JSON.stringify(payloadsResponse)); + console.log(`Wrote payloads to ${PAYLOADS_FILE}`); +} + +function signOffline() { + const senderPrivateKey = requireEnv('SENDER_PRIVATE_KEY'); + const signer = new Client({ + network: NETWORK === 'mainnet' ? 'mainnet' : 'testnet', + }); + + const payloadsResponse = JSON.parse(fs.readFileSync(PAYLOADS_FILE, 'utf8')); + const combinePayload = signer.rosettaCombinePayload(payloadsResponse, senderPrivateKey); + + fs.writeFileSync(COMBINE_INPUT_FILE, JSON.stringify(combinePayload)); + console.log(`Wrote combine input to ${COMBINE_INPUT_FILE}`); +} + +async function submit() { + const rosetta = makeRosetta(); + const combinePayload = JSON.parse(fs.readFileSync(COMBINE_INPUT_FILE, 'utf8')); + const { signed_transaction } = await rosetta.constructionCombine({ + unsignedTransaction: combinePayload.unsigned_transaction, + signatures: combinePayload.signatures as Signature[], + }); + const result = await rosetta.constructionSubmit(signed_transaction); + console.log(`Submitted: ${result.transaction_identifier.hash}`); +} + +async function main() { + console.log('=== ONLINE: build unsigned payloads ==='); + await buildPayloads(); + + console.log('=== OFFLINE: sign ==='); + signOffline(); + + console.log('=== ONLINE: combine + submit ==='); + await submit(); +} + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing env var: ${name}`); + return v; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/rosetta/examples/ts/package.json b/src/app/rosetta/examples/ts/package.json new file mode 100644 index 000000000000..a1750a04a3ad --- /dev/null +++ b/src/app/rosetta/examples/ts/package.json @@ -0,0 +1,25 @@ +{ + "name": "mina-rosetta-examples-ts", + "version": "0.0.0", + "private": true, + "description": "Runnable TypeScript examples for integrating with Mina's Rosetta API via @o1-labs/mina-rosetta-sdk", + "license": "Apache-2.0", + "scripts": { + "account-balance": "ts-node account-balance.ts", + "scan-blocks": "ts-node scan-blocks.ts", + "track-deposits": "ts-node track-deposits.ts", + "send-transaction": "ts-node send-transaction.ts", + "offline-sign": "ts-node offline-sign.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@o1-labs/mina-rosetta-sdk": "^0.0.1", + "dotenv": "^16.4.0", + "mina-signer": "^3.0.7" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.4.0" + } +} diff --git a/src/app/rosetta/examples/ts/scan-blocks.ts b/src/app/rosetta/examples/ts/scan-blocks.ts new file mode 100644 index 000000000000..fd9a88448696 --- /dev/null +++ b/src/app/rosetta/examples/ts/scan-blocks.ts @@ -0,0 +1,54 @@ +/** + * Walk forward from chain tip, printing each block as it arrives. + * Indexers and exchange deposit watchers follow the same pattern. + */ +import 'dotenv/config'; +import { RosettaClient } from '@o1-labs/mina-rosetta-sdk'; + +const POLL_INTERVAL_MS = 10_000; + +async function main() { + const client = new RosettaClient({ + baseUrl: process.env.ROSETTA_URL ?? 'http://localhost:3087', + network: process.env.NETWORK ?? 'devnet', + }); + const startEnv = parseInt(process.env.START_HEIGHT ?? '0', 10); + + let height = startEnv; + if (!height) { + const status = await client.networkStatus(); + height = status.current_block_identifier.index; + console.log(`Starting from chain tip: ${height}`); + } else { + console.log(`Starting from height: ${height}`); + } + + // eslint-disable-next-line no-constant-condition + while (true) { + const { block } = await client.block({ index: height }); + + if (!block) { + await sleep(POLL_INTERVAL_MS); + continue; + } + + console.log( + `[${new Date(block.timestamp).toISOString()}] block ${block.block_identifier.index} ` + + `(${block.block_identifier.hash}) — ${block.transactions.length} tx`, + ); + for (const tx of block.transactions) { + console.log(` ${tx.transaction_identifier.hash}`); + } + + height += 1; + } +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/rosetta/examples/ts/send-transaction.ts b/src/app/rosetta/examples/ts/send-transaction.ts new file mode 100644 index 000000000000..cf980d501428 --- /dev/null +++ b/src/app/rosetta/examples/ts/send-transaction.ts @@ -0,0 +1,81 @@ +/** + * Full Construction API flow: build operations → preprocess → metadata → + * payloads → sign → combine → submit. + * + * Uses `mina-signer`'s `rosettaCombinePayload` helper, which takes the + * response from `/construction/payloads` and returns the bytes that go + * into `/construction/combine`. See `offline-sign.ts` for the + * cold-signing variant. + */ +import 'dotenv/config'; +import Client from 'mina-signer'; +import { + CURVE_TYPE, + RosettaClient, + buildTransferOperations, + type Signature, +} from '@o1-labs/mina-rosetta-sdk'; + +async function main() { + const senderPrivateKey = requireEnv('SENDER_PRIVATE_KEY'); + const senderAddress = requireEnv('SENDER_ADDRESS'); + const receiverAddress = requireEnv('RECEIVER_ADDRESS'); + const amount = process.env.TRANSFER_AMOUNT ?? '1000000000'; + const fee = process.env.TRANSFER_FEE ?? '10000000'; + const network = process.env.NETWORK ?? 'devnet'; + + const rosetta = new RosettaClient({ + baseUrl: process.env.ROSETTA_URL ?? 'http://localhost:3087', + network, + }); + const signer = new Client({ network: network === 'mainnet' ? 'mainnet' : 'testnet' }); + + const operations = buildTransferOperations({ + sender: senderAddress, + receiver: receiverAddress, + amountNanomina: amount, + feeNanomina: fee, + }); + + const senderPublicKey = signer.derivePublicKey(senderPrivateKey); + const senderPublicKeyHex = signer.publicKeyToRaw(senderPublicKey); + const publicKeys = [{ hex_bytes: senderPublicKeyHex, curve_type: CURVE_TYPE }]; + + console.log('[1/5] /construction/preprocess'); + const { options } = await rosetta.constructionPreprocess({ operations }); + + console.log('[2/5] /construction/metadata'); + const { metadata } = await rosetta.constructionMetadata({ + options: options ?? {}, + publicKeys, + }); + + console.log('[3/5] /construction/payloads'); + const payloadsResponse = await rosetta.constructionPayloads({ + operations, + metadata, + publicKeys, + }); + + console.log('[4/5] sign + /construction/combine'); + const combinePayload = signer.rosettaCombinePayload(payloadsResponse, senderPrivateKey); + const { signed_transaction } = await rosetta.constructionCombine({ + unsignedTransaction: combinePayload.unsigned_transaction, + signatures: combinePayload.signatures as unknown as Signature[], + }); + + console.log('[5/5] /construction/submit'); + const result = await rosetta.constructionSubmit(signed_transaction); + console.log(`Submitted: ${result.transaction_identifier.hash}`); +} + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing env var: ${name}`); + return v; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/rosetta/examples/ts/track-deposits.ts b/src/app/rosetta/examples/ts/track-deposits.ts new file mode 100644 index 000000000000..757067d77fb6 --- /dev/null +++ b/src/app/rosetta/examples/ts/track-deposits.ts @@ -0,0 +1,93 @@ +/** + * Watch a target address for incoming MINA. Filters `payment_receiver_inc` + * operations — the canonical exchange-deposit-monitoring pattern. + */ +import 'dotenv/config'; +import { + type Block, + type Operation, + OperationType, + RosettaClient, +} from '@o1-labs/mina-rosetta-sdk'; + +const POLL_INTERVAL_MS = 10_000; + +interface Deposit { + blockHeight: number; + blockHash: string; + txHash: string; + amountNanomina: string; +} + +function findDeposits(block: Block, address: string): Deposit[] { + const deposits: Deposit[] = []; + for (const tx of block.transactions) { + for (const op of tx.operations) { + if (isDeposit(op, address)) { + deposits.push({ + blockHeight: block.block_identifier.index, + blockHash: block.block_identifier.hash, + txHash: tx.transaction_identifier.hash, + amountNanomina: op.amount!.value, + }); + } + } + } + return deposits; +} + +function isDeposit(op: Operation, address: string): boolean { + return ( + op.type === OperationType.PaymentReceiverInc && + op.status !== 'Failed' && + op.account?.address === address && + !!op.amount + ); +} + +async function main() { + const address = requireEnv('TEST_ADDRESS'); + const client = new RosettaClient({ + baseUrl: process.env.ROSETTA_URL ?? 'http://localhost:3087', + network: process.env.NETWORK ?? 'devnet', + }); + const startEnv = parseInt(process.env.START_HEIGHT ?? '0', 10); + + let height = startEnv; + if (!height) { + const status = await client.networkStatus(); + height = status.current_block_identifier.index; + } + console.log(`Watching ${address} for deposits starting at block ${height}`); + + // eslint-disable-next-line no-constant-condition + while (true) { + const { block } = await client.block({ index: height }); + if (!block) { + await sleep(POLL_INTERVAL_MS); + continue; + } + + for (const d of findDeposits(block, address)) { + console.log( + `DEPOSIT block=${d.blockHeight} tx=${d.txHash} amount=${d.amountNanomina} nanomina`, + ); + } + height += 1; + } +} + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing env var: ${name}`); + return v; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/rosetta/examples/ts/tsconfig.json b/src/app/rosetta/examples/ts/tsconfig.json new file mode 100644 index 000000000000..ae2542659e02 --- /dev/null +++ b/src/app/rosetta/examples/ts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true + }, + "include": ["*.ts"] +}