diff --git a/src/mina-signer/tests/mina-signer-test-app/README.md b/src/mina-signer/tests/mina-signer-test-app/README.md new file mode 100644 index 0000000000..a5aef04239 --- /dev/null +++ b/src/mina-signer/tests/mina-signer-test-app/README.md @@ -0,0 +1,45 @@ +# Test Signer CLI + +Command-line helper for drafting, signing, and broadcasting Mina payments via +the public GraphQL API. It wraps the `mina-test-signer` library so you can submit +transactions without wiring up a full wallet or SDK. + +## Getting Started + +- **Prerequisites:** Node.js 18+ (for native `fetch`) and npm. +- **Install dependencies:** `npm install` +- **Quick run:** + `node mina-test-signer.js [graphql_url] [nonce]` + +The optional `graphql_url` flag lets you override the default target defined in +`config.js`. + +## Workflow + +1. `mina-test-signer.js` parses CLI arguments and wires the supporting services. +2. `graphql-client.js` sends the signed payload to the Mina daemon and can check + whether the transaction reached the pool. +3. `utils.js` provides small helpers for GraphQL string construction and CLI + validation. +4. `config.js` centralises network defaults and usage messaging. + +Check the console output for a transaction id; you can re-run the pool check or +the `getPooledUserCommands` helper to confirm inclusion. Provide a `nonce` +argument when you need to synchronise with on-chain account state manually. The +CLI prints emoji-enhanced step logs and a summary table so you can spot +successes and failures at a glance. GraphQL errors (including malformed +responses) cause the CLI to exit with a non-zero status so they can be surfaced +in scripts and CI. + +## Private key format + +For clarity, private key is in output format of: + +``` + mina advanced dump-keypair --privkey-path ... +``` + +## Safety Notes + +- Treat private keys in plain text with care. Prefer environment variables or a + secure secrets manager for real deployments. \ No newline at end of file diff --git a/src/mina-signer/tests/mina-signer-test-app/config.js b/src/mina-signer/tests/mina-signer-test-app/config.js new file mode 100644 index 0000000000..3948362c75 --- /dev/null +++ b/src/mina-signer/tests/mina-signer-test-app/config.js @@ -0,0 +1,24 @@ +/** + * Centralized configuration for Mina payment signing. + * Keeps network defaults and unit conversion helpers in one place so + * the rest of the code can remain declarative. + */ +export const CONFIG = { + NETWORK: 'testnet', + DEFAULT_GRAPHQL_URL: 'http://localhost:3085/graphql', + MINA_UNITS: { + ONE_MINA: 1000000000, + DEFAULT_AMOUNT_MULTIPLIER: 150, + DEFAULT_FEE_MULTIPLIER: 1 + } +}; + +/** + * Human-friendly CLI usage text that `mina-test-signer.js` displays when + * the caller provides incomplete arguments. + */ +export const USAGE_INFO = { + message: 'Usage: node mina-test-signer.js [graphql_url] [nonce]', + example: 'Example: node mina-test-signer.js EKErBK1KznrJJY3raJafSyxSayJ6viejaVrmjzXkSmoxXiJQsesU B62qp4wcxoJyFFyXZ2RVw8kGPpWn6ncK4RtsTz29jFf6fY2XYN42R1v http://172.17.0.3:3085/graphql 3', + defaultUrl: `Default GraphQL URL: ${CONFIG.DEFAULT_GRAPHQL_URL}` +}; diff --git a/src/mina-signer/tests/mina-signer-test-app/graphql-client.js b/src/mina-signer/tests/mina-signer-test-app/graphql-client.js new file mode 100644 index 0000000000..4595da702c --- /dev/null +++ b/src/mina-signer/tests/mina-signer-test-app/graphql-client.js @@ -0,0 +1,177 @@ +import { GraphQLUtils } from './utils.js'; + +/** + * Minimal GraphQL transport layer responsible for broadcasting signed + * payments to a Mina daemon and inspecting the transaction pool. + */ +export class GraphQLClient { + constructor(url) { + this.url = url; + } + + /** + * Posts a signed payment mutation to the configured GraphQL endpoint. + * Surfaces detailed errors while preserving the structured response + * the caller uses to confirm transaction submission. + */ + async sendPayment(signedPayment) { + const query = GraphQLUtils.createPaymentMutation(signedPayment); + + console.log('\nšŸš€ Sending payment via GraphQL'); + console.log(`🌐 Endpoint: ${this.url}`); + console.log('šŸ“ Mutation payload:'); + console.log(query); + + try { + const response = await fetch(this.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + }); + + return await this.handleResponse(response); + } catch (error) { + throw new Error(`Request error: ${error.message}`); + } + } + + /** + * Normalizes the GraphQL response shape by either returning JSON data + * or throwing a rich error that upstream callers can surface. + */ + async handleResponse(response) { + if (response.status === 200) { + const rawBody = await response.text(); + + let json; + try { + json = JSON.parse(rawBody); + } catch (parseError) { + throw new Error( + `Unexpected JSON payload: ${parseError.message}. Raw response: ${rawBody}` + ); + } + + if (json.errors?.length) { + const combinedErrors = json.errors + .map(error => error.message ?? JSON.stringify(error)) + .join(' | '); + throw new Error(`GraphQL errors: ${combinedErrors}`); + } + + console.log('šŸ“¦ GraphQL response payload:'); + console.dir(json, { depth: null }); + return json; + } else { + const text = await response.text(); + throw new Error(`GraphQL error (${response.status}): ${text}`); + } + } + + /** + * Queries the daemon's pooled commands and returns true when the given + * transaction ID is currently staged for inclusion in a block. + */ + async checkTransactionInPool(transactionId) { + const query = ` + query MyQuery { + pooledUserCommands { + id + } + } + `; + + try { + const response = await fetch(this.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operationName: 'MyQuery', + query, + variables: {} + }), + }); + + const rawBody = await response.text(); + if (response.status !== 200) { + throw new Error(`GraphQL error (${response.status}): ${rawBody}`); + } + + let json; + try { + json = JSON.parse(rawBody); + } catch (parseError) { + throw new Error( + `Unexpected JSON payload when checking pool: ${parseError.message}. Raw response: ${rawBody}` + ); + } + + if (json.errors?.length) { + const combinedErrors = json.errors + .map(error => error.message ?? JSON.stringify(error)) + .join(' | '); + throw new Error(`GraphQL errors while checking pool: ${combinedErrors}`); + } + + const pooledCommands = json.data?.pooledUserCommands || []; + return pooledCommands.some(command => command.id === transactionId); + } catch (error) { + console.error('Error checking transaction in pool:', error.message); + throw error; + } + } + + /** + * Convenience method that lists transaction IDs in the current pool. + * Useful for manual debugging or exploratory scripts. + */ + async getPooledUserCommands() { + const query = ` + query MyQuery { + pooledUserCommands { + id + } + } + `; + + try { + const response = await fetch(this.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operationName: 'MyQuery', + query, + variables: {} + }), + }); + + const rawBody = await response.text(); + if (response.status !== 200) { + throw new Error(`GraphQL error (${response.status}): ${rawBody}`); + } + + let json; + try { + json = JSON.parse(rawBody); + } catch (parseError) { + throw new Error( + `Unexpected JSON payload when fetching pooled commands: ${parseError.message}. Raw response: ${rawBody}` + ); + } + + if (json.errors?.length) { + const combinedErrors = json.errors + .map(error => error.message ?? JSON.stringify(error)) + .join(' | '); + throw new Error(`GraphQL errors while fetching pooled commands: ${combinedErrors}`); + } + + console.log('šŸ“¦ Pooled commands response payload:'); + console.dir(json, { depth: null }); + return json.data?.pooledUserCommands || []; + } catch (error) { + console.error('Error fetching pooled commands:', error.message); + throw error; + } + } +} diff --git a/src/mina-signer/tests/mina-signer-test-app/mina-test-signer.js b/src/mina-signer/tests/mina-signer-test-app/mina-test-signer.js new file mode 100644 index 0000000000..9a6c2c3e16 --- /dev/null +++ b/src/mina-signer/tests/mina-signer-test-app/mina-test-signer.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node + +/** + * CLI utility for signing a Mina payment and broadcasting it via GraphQL. + * + * Responsibilities: + * - Parse and validate CLI input (private key, recipient, optional GraphQL URL and nonce) + * - Construct and sign a payment using `mina-signer` + * - Submit the signed payload to a Mina daemon and verify it reached the pool + * + * Usage: + * node mina-test-signer.js --private-key --recipient
[--url ] [--nonce ] + */ +import { Command } from 'commander'; +import { GraphQLClient } from './graphql-client.js'; +import { CONFIG } from './config.js'; +import Client from 'mina-signer'; + +const DIVIDER = '────────────────────────────────────────'; + +const showKey = value => { + if (!value) return 'n/a'; + const normalized = String(value); + if (normalized.length <= 12) { + return normalized; + } + return `${normalized.slice(0, 6)}...${normalized.slice(-6)}`; +}; + +const logger = { + banner(title) { + console.log(`\n✨ ${title}`); + console.log(DIVIDER); + }, + step(message) { + console.log(`āž”ļø ${message}`); + }, + info(message) { + console.log(`ā„¹ļø ${message}`); + }, + success(message) { + console.log(`āœ… ${message}`); + }, + warn(message) { + console.warn(`āš ļø ${message}`); + }, + error(message) { + console.error(`āŒ ${message}`); + }, + summary(items) { + if (!items.length) { + return; + } + console.log(`\nšŸ“‹ Run Summary`); + console.log(DIVIDER); + items.forEach(({ label, success, detail }) => { + const icon = success ? 'āœ…' : 'āŒ'; + const suffix = detail ? ` — ${detail}` : ''; + console.log(`${icon} ${label}${suffix}`); + }); + console.log(DIVIDER); + }, +}; + +/** + * Orchestrates the CLI flow by coordinating validation, signing, submission, + * and follow-up checks against the daemon. + */ +class PaymentApp { + constructor() { + this.program = new Command(); + this.setupCLI(); + } + + /** Configure CLI options and arguments using commander */ + setupCLI() { + this.program + .name('mina-test-signer') + .description('CLI utility for signing and broadcasting Mina payments') + .version('1.0.0') + .requiredOption('-k, --private-key ', 'Private key for signing the payment') + .requiredOption('-r, --recipient
', 'Recipient public key address') + .option('-u, --url ', 'GraphQL endpoint URL', CONFIG.DEFAULT_GRAPHQL_URL) + .option('-n, --nonce ', 'Transaction nonce (optional, will be fetched if not provided)', parseInt) + .parse(); + } + + /** Prints user-friendly guidance for invoking the CLI correctly. */ + displayUsage() { + this.program.help(); + } + + /** + * Get parsed and validated CLI options from commander + */ + validateAndParseArgs() { + const options = this.program.opts(); + return { + privateKey: options.privateKey, + recipientAddress: options.recipient, + url: options.url, + nonce: options.nonce + }; + } + + /** + * Primary workflow: + * 1. Gather CLI parameters and fall back to defaults when possible + * 2. Construct and sign a payment payload + * 3. Send the signed transaction and verify it enters the pool + */ + async run() { + const summary = []; + const record = (label, success, detail) => summary.push({ label, success, detail }); + + logger.banner('Mina Payment Signer'); + + try { + logger.step('Parsing command line arguments...'); + const { privateKey, recipientAddress, url, nonce } = this.validateAndParseArgs(); + record('CLI arguments', true, `Recipient ${showKey(recipientAddress)} | Nonce ${nonce ?? 'auto'}`); + + logger.step('Creating unsigned payment payload...'); + const client = new Client({ network: CONFIG.NETWORK }); + const publicKey = client.derivePublicKey(privateKey); + const { ONE_MINA, DEFAULT_FEE_MULTIPLIER } = CONFIG.MINA_UNITS; + + const payment = { + from: publicKey, + to: recipientAddress, + amount: ONE_MINA, + nonce, + fee: ONE_MINA * DEFAULT_FEE_MULTIPLIER, + }; + + logger.info(`Sender public key: ${showKey(payment.from)}`); + logger.info(`Amount: ${payment.amount} nanomina | Fee: ${payment.fee} nanomina`); + record('Payment draft', true, `From ${showKey(payment.from)} → ${showKey(payment.to)} (nonce ${payment.nonce})`); + + logger.step('Signing payment...'); + const signedPayment = client.signPayment(payment, privateKey); + logger.info(`Signature field: ${showKey(signedPayment.signature?.field)} | scalar: ${showKey(signedPayment.signature?.scalar)}`); + record('Signature', true, `Field ${showKey(signedPayment.signature?.field)}...`); + + const graphqlClient = new GraphQLClient(url); + logger.step(`Submitting to GraphQL endpoint ${url}...`); + // The GraphQL client returns the parsed JSON response from the daemon. + const result = await graphqlClient.sendPayment(signedPayment); + + const paymentId = result?.data?.sendPayment?.payment?.id; + if (!paymentId) { + record('GraphQL submission', false, 'No payment id returned'); + throw new Error('GraphQL response did not include a payment id.'); + } + record('GraphQL submission', true, `Payment id ${paymentId}`); + logger.success(`GraphQL accepted payment id ${paymentId}.`); + + logger.step(`Verifying transaction ${paymentId} in pool...`); + try { + const isInPool = await graphqlClient.checkTransactionInPool(paymentId); + if (isInPool) { + logger.success(`Transaction ${paymentId} is currently in the pool.`); + record('Pool status', true, 'Present in pool'); + } else { + logger.warn(`Transaction ${paymentId} not found in the pool yet.`); + record('Pool status', false, 'Not yet in pool'); + } + } catch (poolError) { + record('Pool status', false, poolError.message); + throw poolError; + } + + logger.success('šŸŽ‰ All steps completed successfully.'); + record('Run status', true, 'All steps completed'); + logger.summary(summary); + } catch (error) { + logger.error(`Application error: ${error.message}`); + record('Run status', false, error.message); + logger.summary(summary); + process.exit(1); + } + } +} + +// Run the application +const app = new PaymentApp(); +app.run(); diff --git a/src/mina-signer/tests/mina-signer-test-app/package-lock.json b/src/mina-signer/tests/mina-signer-test-app/package-lock.json new file mode 100644 index 0000000000..533c26563f --- /dev/null +++ b/src/mina-signer/tests/mina-signer-test-app/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "mina-test-signer", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mina-test-signer", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "commander": "^14.0.2", + "json-to-graphql-query": "^2.3.0", + "mina-signer": "../../../mina-signer" + }, + "bin": { + "mina-test-signer": "mina-test-signer.js" + } + }, + "../..": { + "version": "3.1.0", + "license": "Apache-2.0", + "dependencies": { + "blakejs": "^1.2.1", + "js-sha256": "^0.9.0" + }, + "devDependencies": { + "pkg-pr-new": "^0.0.9" + } + }, + "../../mina-signer": { + "extraneous": true + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/json-to-graphql-query": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/json-to-graphql-query/-/json-to-graphql-query-2.3.0.tgz", + "integrity": "sha512-khZtaLLQ0HllFec+t89ZWduUZ0rmne/OpRm/39hyZUWDHNx9Yk4DgQzDtMeqd8zj2g5opBD4GHrdtH0JzKnN2g==", + "license": "MIT" + }, + "node_modules/mina-signer": { + "resolved": "../..", + "link": true + } + } +} diff --git a/src/mina-signer/tests/mina-signer-test-app/package.json b/src/mina-signer/tests/mina-signer-test-app/package.json new file mode 100644 index 0000000000..68ef90bef4 --- /dev/null +++ b/src/mina-signer/tests/mina-signer-test-app/package.json @@ -0,0 +1,21 @@ +{ + "name": "mina-test-signer", + "type": "module", + "version": "1.0.0", + "main": "mina-test-signer.js", + "bin": { + "mina-test-signer": "mina-test-signer.js" + }, + "scripts": { + "start": "node mina-test-signer.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "o1-labs", + "license": "ISC", + "description": "test app for mina-signer", + "dependencies": { + "commander": "^14.0.2", + "json-to-graphql-query": "^2.3.0", + "mina-signer": "../../../mina-signer" + } +} diff --git a/src/mina-signer/tests/mina-signer-test-app/utils.js b/src/mina-signer/tests/mina-signer-test-app/utils.js new file mode 100644 index 0000000000..2597468d8c --- /dev/null +++ b/src/mina-signer/tests/mina-signer-test-app/utils.js @@ -0,0 +1,26 @@ +import { jsonToGraphQLQuery } from 'json-to-graphql-query'; + +/** + * Utility helpers for constructing GraphQL payloads that the Mina GraphQL + * endpoint accepts. These keep the string manipulation away from the core + * payment workflow. + */ +export class GraphQLUtils { + static createPaymentMutation(signedPayment) { + const mutation = { + mutation: { + sendPayment: { + __args: { + input: signedPayment.data, + signature: signedPayment.signature + }, + payment: { + id: true + } + } + } + }; + + return jsonToGraphQLQuery(mutation, { pretty: true }); + } +} \ No newline at end of file