Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,44 @@ grid sandbox receive \
--currency USD
```

### Exchange Rates & Lookups

```bash
# Exchange rates (--destination-currency is repeatable)
grid exchange-rates --source-currency USD --destination-currency EUR --destination-currency MXN

# Estimate a crypto withdrawal fee
grid crypto estimate-fee \
--internal-account-id <id> --currency USDC --crypto-network SOLANA \
--amount 5000 --destination-address <address>

# Discover receiving institutions
grid discoveries --country PH --currency PHP

# List counterparty (UMA) providers
grid uma-providers [--country-code US] [--currency-code USD]
```

### API Tokens

```bash
grid tokens list [--name <name>]
grid tokens get <tokenId>
# clientSecret is returned only once at creation — store it securely
grid tokens create --name "CI token" --permissions VIEW,TRANSACT
grid tokens revoke <tokenId>
```

### Internal Account Management

```bash
# Both are signed-retry operations — run once to get the 202 challenge, then
# re-run with --wallet-signature <stamp> --request-id <id> (see the signing note
# under Auth). The CLI does not compute the stamp, generate keys, or decrypt.
grid internal-accounts update <id> --private-enabled true
grid internal-accounts export <id> --client-public-key <hex>
```

## Output Format

All commands output JSON:
Expand Down
17 changes: 13 additions & 4 deletions cli/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export interface PaginatedResponse<T> {
totalCount?: number;
}

export type QueryParams = Record<
string,
string | number | boolean | string[] | undefined
>;

export class GridClient {
private config: GridConfig;
private timeoutMs: number;
Expand All @@ -34,7 +39,7 @@ export class GridClient {

private buildUrl(
path: string,
params?: Record<string, string | number | boolean | undefined>
params?: QueryParams
): string {
const baseUrl = this.config.baseUrl.endsWith("/")
? this.config.baseUrl
Expand All @@ -43,7 +48,11 @@ export class GridClient {
const url = new URL(fullPath, baseUrl);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
if (value === undefined) return;
// Array values become repeated query params (e.g. destinationCurrency).
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, String(v)));
} else {
url.searchParams.append(key, String(value));
}
});
Expand All @@ -55,7 +64,7 @@ export class GridClient {
method: string,
path: string,
options?: {
params?: Record<string, string | number | boolean | undefined>;
params?: QueryParams;
body?: unknown;
headers?: Record<string, string | undefined>;
}
Expand Down Expand Up @@ -141,7 +150,7 @@ export class GridClient {

async get<T>(
path: string,
params?: Record<string, string | number | boolean | undefined>,
params?: QueryParams,
headers?: Record<string, string | undefined>
): Promise<ApiResponse<T>> {
return this.request<T>("GET", path, { params, headers });
Expand Down
62 changes: 62 additions & 0 deletions cli/src/commands/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Command } from "commander";
import { GridClient } from "../client";
import { outputResponse, formatError, output } from "../output";
import { GlobalOptions } from "../index";

interface EstimateCryptoWithdrawalFeeResponse {
networkFee: number;
networkFeeAsset: string;
applicationFee: number;
totalFee: number;
netAmount: number;
}

export function registerCryptoCommand(
program: Command,
getClient: (opts: GlobalOptions) => GridClient | null
): void {
const cryptoCmd = program
.command("crypto")
.description("Crypto helper commands");

cryptoCmd
.command("estimate-fee")
.description("Estimate the fee for a crypto withdrawal")
.requiredOption("--internal-account-id <id>", "Source internal account ID")
.requiredOption("--currency <code>", "Currency code (e.g. USDC)")
.requiredOption("--crypto-network <network>", "Crypto network (e.g. SOLANA)")
.requiredOption("--amount <number>", "Amount in the smallest unit")
.requiredOption("--destination-address <address>", "Destination address")
.action(async (options) => {
const opts = program.opts<GlobalOptions>();
const client = getClient(opts);
if (!client) return;

// A crypto amount must be a positive integer in the smallest unit;
// reject trailing garbage (e.g. "10foo") and out-of-range values that
// parseInt would otherwise accept or truncate.
if (!/^\d+$/.test(options.amount)) {
output(formatError("--amount must be a positive integer"));
process.exitCode = 1;
return;
}
const amount = Number(options.amount);
if (!Number.isSafeInteger(amount) || amount < 1) {
output(formatError("--amount is out of range"));
process.exitCode = 1;
return;
}

const response = await client.post<EstimateCryptoWithdrawalFeeResponse>(
"/crypto/estimate-withdrawal-fee",
{
internalAccountId: options.internalAccountId,
currency: options.currency,
cryptoNetwork: options.cryptoNetwork,
amount,
destinationAddress: options.destinationAddress,
}
);
outputResponse(response);
});
}
33 changes: 33 additions & 0 deletions cli/src/commands/discoveries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Command } from "commander";
import { GridClient } from "../client";
import { outputResponse } from "../output";
import { GlobalOptions } from "../index";

interface Discovery {
bankName: string;
displayName: string;
country: string;
currency: string;
}

export function registerDiscoveriesCommand(
program: Command,
getClient: (opts: GlobalOptions) => GridClient | null
): void {
program
.command("discoveries")
.description("List available receiving institutions")
.option("--country <code>", "Filter by country (ISO 3166-1 alpha-2)")
.option("--currency <code>", "Filter by currency (ISO 4217)")
.action(async (options) => {
const opts = program.opts<GlobalOptions>();
const client = getClient(opts);
if (!client) return;

const response = await client.get<{ data: Discovery[] }>("/discoveries", {
country: options.country,
currency: options.currency,
});
outputResponse(response);
});
}
66 changes: 66 additions & 0 deletions cli/src/commands/exchange-rates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Command } from "commander";
import { GridClient } from "../client";
import { outputResponse, formatError, output } from "../output";
import { GlobalOptions } from "../index";

interface Currency {
code: string;
name?: string;
symbol?: string;
decimals?: number;
}

interface ExchangeRate {
sourceCurrency: Currency;
sendingAmount: number;
destinationCurrency: Currency;
destinationPaymentRail: string;
receivingAmount: number;
exchangeRate: number;
updatedAt: string;
}

function collect(value: string, previous: string[] = []): string[] {
return [...previous, value];
}

export function registerExchangeRatesCommand(
program: Command,
getClient: (opts: GlobalOptions) => GridClient | null
): void {
program
.command("exchange-rates")
.description("Get exchange rates")
.option("--source-currency <code>", "Source currency code")
.option(
"--destination-currency <code>",
"Destination currency code (repeatable)",
collect
)
.option("--sending-amount <number>", "Sending amount in the smallest unit")
.action(async (options) => {
const opts = program.opts<GlobalOptions>();
const client = getClient(opts);
if (!client) return;

let sendingAmount: number | undefined;
if (options.sendingAmount !== undefined) {
sendingAmount = parseInt(options.sendingAmount, 10);
if (Number.isNaN(sendingAmount)) {
output(formatError("--sending-amount must be a number"));
process.exitCode = 1;
return;
}
Comment on lines +47 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 parseInt silently truncates non-integer inputs

parseInt("10.5", 10) returns 10 and parseInt("-50", 10) returns -50 — both are accepted without any error, even though the help text says "Sending amount in the smallest unit" (implying a positive integer). The result is that a user passing --sending-amount 10.5 gets rate quotes for 10 instead of an error, and a negative amount is forwarded to the API. The crypto estimate-fee command in the same PR guards against exactly this with a /^\d+$/ regex plus an amount >= 1 check — the same approach should be used here for consistency and correctness.

Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/commands/exchange-rates.ts
Line: 47-53

Comment:
**`parseInt` silently truncates non-integer inputs**

`parseInt("10.5", 10)` returns `10` and `parseInt("-50", 10)` returns `-50` — both are accepted without any error, even though the help text says "Sending amount in the smallest unit" (implying a positive integer). The result is that a user passing `--sending-amount 10.5` gets rate quotes for `10` instead of an error, and a negative amount is forwarded to the API. The `crypto estimate-fee` command in the same PR guards against exactly this with a `/^\d+$/` regex plus an `amount >= 1` check — the same approach should be used here for consistency and correctness.

How can I resolve this? If you propose a fix, please make it concise.

}

const response = await client.get<{ data: ExchangeRate[] }>(
"/exchange-rates",
{
sourceCurrency: options.sourceCurrency,
destinationCurrency: options.destinationCurrency,
sendingAmount,
}
);
outputResponse(response);
});
}
66 changes: 66 additions & 0 deletions cli/src/commands/internal-accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Command } from "commander";
import { GridClient } from "../client";
import { outputResponse, formatError, output } from "../output";
import { GlobalOptions } from "../index";
import { addSignedOptions, signedHeaders } from "../signed";

export function registerInternalAccountsCommand(
program: Command,
getClient: (opts: GlobalOptions) => GridClient | null
): void {
const internalAccountsCmd = program
.command("internal-accounts")
.description("Internal account update/export commands");

addSignedOptions(
internalAccountsCmd
.command("update <internalAccountId>")
.description("Update an internal account")
.option("--private-enabled <true|false>", "Enable/disable Embedded Wallet privacy")
).action(async (internalAccountId: string, options) => {
const opts = program.opts<GlobalOptions>();
const client = getClient(opts);
if (!client) return;

if (options.privateEnabled === undefined) {
output(formatError("Provide --private-enabled <true|false>"));
process.exitCode = 1;
return;
}
if (options.privateEnabled !== "true" && options.privateEnabled !== "false") {
output(formatError("--private-enabled must be true or false"));
process.exitCode = 1;
return;
}

const response = await client.patch(
`/internal-accounts/${internalAccountId}`,
{ privateEnabled: options.privateEnabled === "true" },
signedHeaders(options)
);
outputResponse(response);
});

addSignedOptions(
internalAccountsCmd
.command("export <internalAccountId>")
.description(
"Export an internal account's wallet credentials — returns an HPKE-sealed ciphertext you decrypt client-side with the matching private key"
)
.requiredOption(
"--client-public-key <hex>",
"Ephemeral client P-256 public key (uncompressed SEC1 hex); discard the private key after decrypting"
)
).action(async (internalAccountId: string, options) => {
const opts = program.opts<GlobalOptions>();
const client = getClient(opts);
if (!client) return;

const response = await client.post(
`/internal-accounts/${internalAccountId}/export`,
{ clientPublicKey: options.clientPublicKey },
signedHeaders(options)
);
outputResponse(response);
});
}
Loading
Loading