From 2b01570247c6f8238795108fb5f97b5813b22405 Mon Sep 17 00:00:00 2001 From: Habnark Date: Wed, 29 Jul 2026 12:54:52 +0100 Subject: [PATCH] fix(security): correct RPC simulation shape, warn on secrets, label admin examples checkWhitelist and the portfolio balance lookup called simulateTransaction with a raw contract-call operation wrapped in `{ transaction: ... } as any`, rather than a built Transaction. rpc.Server.simulateTransaction expects a Transaction directly; both call sites are fixed via a shared buildSimulationTransaction helper, with regression tests asserting the real Transaction shape now used. Also removes the unnecessary signing keypair from the README's read-only Quickstart (it was labeled "adminKeypair" for a call that needs no signer), adds an explicit Privileged Operations section for mint/transfer, and replaces every hardcoded Keypair.fromSecret('S...') in README/docs/examples with an env-var pattern plus a "never hardcode a real secret" warning. Updates the reviewer checklist's security section to reference these examples so future ones are held to the same bar. Closes #66 --- README.md | 37 +++++- docs/api-reference.md | 2 +- docs/migration-guide.md | 84 ++++++++++--- docs/reviewer-checklist.md | 5 +- examples/migration/compliance-before-after.ts | 28 ++++- .../migration/error-handling-before-after.ts | 42 +++++-- .../migration/mint-transfer-before-after.ts | 6 +- examples/migration/portfolio-before-after.ts | 28 ++++- src/compliance.ts | 7 +- src/investor/portfolio.ts | 6 +- src/utils/simulation.ts | 37 ++++++ tests/compliance.test.ts | 117 ++++++++++++++++++ tests/investor.test.ts | 30 ++++- 13 files changed, 375 insertions(+), 54 deletions(-) create mode 100644 src/utils/simulation.ts create mode 100644 tests/compliance.test.ts diff --git a/README.md b/README.md index 2af57cd..58441d6 100644 --- a/README.md +++ b/README.md @@ -10,16 +10,13 @@ npm install @aegis/sdk ## Quickstart Initialize the client with a typed environment preset and query the compliance module. +This is a **read-only** call — no signing keypair is needed or used here. ```TypeScript import { AegisClient } from '@aegis/sdk'; -import { Keypair } from '@stellar/stellar-sdk'; - -const adminKeypair = Keypair.fromSecret('S...'); const aegis = new AegisClient({ environment: 'testnet', // or 'local'; see docs/environments.md contractId: 'C_YOUR_CONTRACT_ID', - keypair: adminKeypair // Optional for read-only calls }); async function main() { @@ -30,6 +27,38 @@ async function main() { main(); ``` + +## Privileged Operations (Admin / Issuer) +⚠️ **`mint` and `transfer` are privileged, state-changing operations.** They require an +`AegisClient` configured with a signing `keypair`, and that keypair's authority is +whatever the deployed contract grants it (typically issuer/admin authority for `mint`). +Never hardcode a real secret key in source code. Load it from an environment variable +or secret manager that is excluded from version control — the string below is a +placeholder, not something to paste a real secret into. +```TypeScript +import { AegisClient } from '@aegis/sdk'; +import { Keypair } from '@stellar/stellar-sdk'; + +// NEVER hardcode a real secret key. Load it from a secret manager or an +// environment variable that is git-ignored (e.g. via a local .env file). +const issuerKeypair = Keypair.fromSecret(process.env.AEGIS_ISSUER_SECRET!); + +const aegis = new AegisClient({ + environment: 'testnet', + contractId: 'C_YOUR_CONTRACT_ID', + keypair: issuerKeypair, // required for mint/transfer; omit for read-only usage +}); + +async function mintExample() { + const txHash = await aegis.asset.mint('G_RECIPIENT_PUBLIC_KEY', 1000); + console.log('Mint submitted, tx hash:', txHash); +} + +mintExample(); +``` +See [API Reference: `AssetModule`](docs/api-reference.md#assetmodule) for the open +caveats (sequence-number placeholder, no pre-submission simulation) before using this +against a real account. ## Role Discovery & Capability Checks Check what an address is classified as, and what it can currently attempt through the SDK. This is a client-side convenience for UI gating, not on-chain authorization — see the diff --git a/docs/api-reference.md b/docs/api-reference.md index e047534..66b76d2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -68,7 +68,7 @@ try { } ``` -> **Open note:** `checkWhitelist` passes the raw invocation object returned by `contract.call(...)` directly as the `transaction` field to `simulateTransaction` (cast through `as any`), rather than assembling a full `Transaction` via `TransactionBuilder` the way `AssetModule.mint`/`transfer` do. The source itself flags this with a comment ("Cast required depending on SDK version wrapper"), so the exact request shape expected by `simulateTransaction` across `@stellar/stellar-sdk` versions is not fully confirmed — verify against the installed SDK version rather than assuming it's stable. +> **Note:** `checkWhitelist` builds a full `Transaction` via the shared `buildSimulationTransaction` helper (`src/utils/simulation.ts`) before calling `simulateTransaction`, the same way `AssetModule.mint`/`transfer` build theirs — `simulateTransaction` takes a built `Transaction` directly, not a `{ transaction: ... }` wrapper around a bare, unbuilt operation. Since simulation never signs or submits anything, the source account does not need to be real: the client's configured signer is reused when present, otherwise an ephemeral keypair supplies a structurally valid source. --- diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 512c9bf..f6870b9 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -32,19 +32,31 @@ Before migrating, ensure you have: ### Before (Raw Soroban) ```typescript -import { rpc, Contract, nativeToScVal, Keypair, Networks, xdr, scValToNative } from '@stellar/stellar-sdk'; +import { rpc, Contract, nativeToScVal, Keypair, Networks, Account, TransactionBuilder, xdr, scValToNative } from '@stellar/stellar-sdk'; const rpcServer = new rpc.Server('https://soroban-testnet.stellar.org'); const contractId = 'C_YOUR_CONTRACT_ID'; const networkPassphrase = Networks.TESTNET; -// Read-only call: check whitelist +// Read-only call: check whitelist. +// simulateTransaction takes a built Transaction, not a bare operation — and +// since simulation never signs or submits, the source account doesn't need +// to be real; any structurally valid keypair works as a placeholder. const contract = new Contract(contractId); const call = contract.call('is_whitelisted', nativeToScVal(userAddress, { type: 'address' })); -const result = await rpcServer.simulateTransaction({ transaction: call as any } as any); +const simSourceAccount = new Account(Keypair.random().publicKey(), '0'); +const simTx = new TransactionBuilder(simSourceAccount, { fee: '100', networkPassphrase }) + .addOperation(call) + .setTimeout(30) + .build(); +const result = await rpcServer.simulateTransaction(simTx); const isWhitelisted = scValToNative(xdr.ScVal.fromXDR(result.result.retval, 'base64')); -// Write call: mint tokens (you must build everything manually) +// ⚠️ Write call: mint tokens — a privileged, state-changing operation. +// `adminKeypair` here must hold issuer/admin authority on the deployed +// contract. NEVER hardcode a real secret key; load it from a secret manager +// or an environment variable excluded from version control, e.g.: +// const adminKeypair = Keypair.fromSecret(process.env.AEGIS_ISSUER_SECRET!); const sourceAccount = new Account(adminKeypair.publicKey(), '0'); const mintCall = contract.call( 'mint_asset', @@ -69,17 +81,21 @@ const response = await rpcServer.sendTransaction(tx); import { AegisClient } from '@aegis/sdk'; import { Keypair, Networks } from '@stellar/stellar-sdk'; +// ⚠️ Privileged operation: `mint` requires a signer with issuer/admin +// authority on the deployed contract. NEVER hardcode a real secret key — +// load it from a secret manager or an environment variable that is +// excluded from version control. const aegis = new AegisClient({ rpcUrl: 'https://soroban-testnet.stellar.org', networkPassphrase: Networks.TESTNET, contractId: 'C_YOUR_CONTRACT_ID', - keypair: Keypair.fromSecret('S...'), + keypair: Keypair.fromSecret(process.env.AEGIS_ISSUER_SECRET!), }); -// Read-only: check whitelist +// Read-only: check whitelist — no keypair required for this call. const isWhitelisted = await aegis.compliance.checkWhitelist(userAddress); -// Write: mint tokens +// Write: mint tokens (privileged — see warning above) const txHash = await aegis.asset.mint(recipientAddress, 1000000000); ``` @@ -99,15 +115,26 @@ const txHash = await aegis.asset.mint(recipientAddress, 1000000000); ### Before ```typescript -import { rpc, Contract, nativeToScVal, xdr, scValToNative } from '@stellar/stellar-sdk'; +import { rpc, Contract, nativeToScVal, xdr, scValToNative, Account, TransactionBuilder, Keypair } from '@stellar/stellar-sdk'; -async function checkWhitelist(rpcServer: rpc.Server, contractId: string, address: string): Promise { +async function checkWhitelist( + rpcServer: rpc.Server, + contractId: string, + networkPassphrase: string, + address: string +): Promise { const contract = new Contract(contractId); const call = contract.call('is_whitelisted', nativeToScVal(address, { type: 'address' })); - const result = await rpcServer.simulateTransaction({ - transaction: call as any, - } as any); + // simulateTransaction takes a built Transaction, not a bare operation. + // Simulation never signs or submits, so a real account isn't required — + // any structurally valid source account works, e.g. a throwaway keypair. + const sourceAccount = new Account(Keypair.random().publicKey(), '0'); + const tx = new TransactionBuilder(sourceAccount, { fee: '100', networkPassphrase }) + .addOperation(call) + .setTimeout(30) + .build(); + const result = await rpcServer.simulateTransaction(tx); if (rpc.Api.isSimulationSuccess(result) && result.result) { const parsed = scValToNative(xdr.ScVal.fromXDR(result.result.retval, 'base64')); @@ -134,6 +161,10 @@ const isWhitelisted = await aegis.compliance.checkWhitelist(address); ## Minting Tokens +⚠️ **Privileged operation.** `signer` below must hold issuer/admin authority on +the deployed contract. Never hardcode a real secret key — load it from a +secret manager or an environment variable excluded from version control. + ### Before ```typescript @@ -193,6 +224,11 @@ const txHash = await aegis.asset.mint(recipientAddress, amount); ## Transferring Tokens +⚠️ **Privileged operation.** `signer` below must be the token holder, or must +otherwise be authorized to move the asset per the deployed contract's rules. +Never hardcode a real secret key — load it from a secret manager or an +environment variable excluded from version control. + ### Before ```typescript @@ -253,17 +289,27 @@ Reading a full portfolio required multiple manual calls and assembly logic: async function getPortfolio( rpcServer: rpc.Server, contractId: string, + networkPassphrase: string, investorAddress: string ) { + // simulateTransaction takes a built Transaction, not a bare operation. + // Simulation never signs or submits, so a real account isn't required — + // any structurally valid source account works, e.g. a throwaway keypair. + const buildSimTx = (call: any) => { + const sourceAccount = new Account(Keypair.random().publicKey(), '0'); + return new TransactionBuilder(sourceAccount, { fee: '100', networkPassphrase }) + .addOperation(call) + .setTimeout(30) + .build(); + }; + // 1. Check KYC const contract = new Contract(contractId); const whitelistCall = contract.call( 'is_whitelisted', nativeToScVal(investorAddress, { type: 'address' }) ); - const whitelistResult = await rpcServer.simulateTransaction({ - transaction: whitelistCall as any, - } as any); + const whitelistResult = await rpcServer.simulateTransaction(buildSimTx(whitelistCall)); const isKycApproved = rpc.Api.isSimulationSuccess(whitelistResult) && whitelistResult.result ? scValToNative(xdr.ScVal.fromXDR(whitelistResult.result.retval, 'base64')) @@ -274,9 +320,7 @@ async function getPortfolio( 'balance', nativeToScVal(investorAddress, { type: 'address' }) ); - const balanceResult = await rpcServer.simulateTransaction({ - transaction: balanceCall as any, - } as any); + const balanceResult = await rpcServer.simulateTransaction(buildSimTx(balanceCall)); const balance = rpc.Api.isSimulationSuccess(balanceResult) && balanceResult.result ? scValToNative(xdr.ScVal.fromXDR(balanceResult.result.retval, 'base64')) @@ -323,7 +367,9 @@ Raw Soroban error handling requires manual checks at every step: ```typescript try { - const result = await rpcServer.simulateTransaction({ transaction: call } as any); + // `tx` here is a built Transaction (see the Compliance section above) — + // simulateTransaction does not accept a bare operation or a wrapper object. + const result = await rpcServer.simulateTransaction(tx); if (rpc.Api.isSimulationSuccess(result) && result.result) { return scValToNative(xdr.ScVal.fromXDR(result.result.retval, 'base64')); } diff --git a/docs/reviewer-checklist.md b/docs/reviewer-checklist.md index 1350bd9..61fa077 100644 --- a/docs/reviewer-checklist.md +++ b/docs/reviewer-checklist.md @@ -62,9 +62,12 @@ Maintainers and reviewers should use this guide to ensure high standards of qual ## 5. Security, Compliance & Safety -- **Secret Key Protection:** Secret keys or private seeds (`S...`) are NEVER committed to version control or hardcoded in tests/examples. +- **Secret Key Protection:** Secret keys or private seeds (`S...`) are NEVER committed to version control or hardcoded in tests/examples. Any example that constructs a `Keypair` for a signing/write operation must load it from an environment variable or secret manager (e.g. `Keypair.fromSecret(process.env.AEGIS_ISSUER_SECRET!)`), not a literal string, and must carry an explicit "never hardcode a real secret" warning comment. +- **Admin/Privileged Examples Are Labelled:** Any example demonstrating `mint`, `transfer`, or another operation that requires issuer/admin authority on the contract must be visibly marked as privileged (e.g. a `⚠️ Privileged operation` note) so it isn't mistaken for a safe default to copy into a read-only context. See the README's [Quickstart](../README.md#quickstart) (read-only, no keypair) vs. [Privileged Operations](../README.md#privileged-operations-admin--issuer) split for the pattern to follow. +- **RPC Request Shape:** Examples and source that call `simulateTransaction` pass a built `Transaction` (via `Account` + `TransactionBuilder`, see `src/utils/simulation.ts`) directly — never a bare contract-call operation or a `{ transaction: ... }` wrapper object cast through `as any`. - **RWA Protocol Compliance:** Compliance and whitelist-gated behaviors (e.g., KYC checks, transfer restrictions) maintain security guarantees and accurate disclaimers. - **Input Validation:** Public endpoints validate user inputs (public keys, contract IDs, transaction parameters) prior to RPC invocation. +- **Examples Audit Trail:** `README.md`, `docs/migration-guide.md`, and `examples/migration/*.ts` are the current canonical examples reviewed against the criteria above (see issue #66). When adding a new example, review it against this list before merging. --- diff --git a/examples/migration/compliance-before-after.ts b/examples/migration/compliance-before-after.ts index 97ab94f..3acdd78 100644 --- a/examples/migration/compliance-before-after.ts +++ b/examples/migration/compliance-before-after.ts @@ -3,7 +3,16 @@ * * Shows the equivalent raw Soroban call vs Aegis SDK usage. */ -import { rpc, Contract, nativeToScVal, xdr, scValToNative } from '@stellar/stellar-sdk'; +import { + rpc, + Contract, + nativeToScVal, + xdr, + scValToNative, + Account, + TransactionBuilder, + Keypair, +} from '@stellar/stellar-sdk'; import { AegisClient } from '@aegis/sdk'; // ============================================================ @@ -13,6 +22,7 @@ import { AegisClient } from '@aegis/sdk'; async function checkWhitelistRaw( rpcServer: rpc.Server, contractId: string, + networkPassphrase: string, address: string ): Promise { const contract = new Contract(contractId); @@ -22,9 +32,19 @@ async function checkWhitelistRaw( ); try { - const result = await rpcServer.simulateTransaction({ - transaction: call as any, - } as any); + // simulateTransaction takes a built Transaction, not a bare operation. + // Simulation never signs or submits, so a real account isn't required — + // any structurally valid source account works, e.g. a throwaway keypair. + const sourceAccount = new Account(Keypair.random().publicKey(), '0'); + const tx = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase, + }) + .addOperation(call) + .setTimeout(30) + .build(); + + const result = await rpcServer.simulateTransaction(tx); if (rpc.Api.isSimulationSuccess(result) && result.result) { const parsed = scValToNative( diff --git a/examples/migration/error-handling-before-after.ts b/examples/migration/error-handling-before-after.ts index 22c4f19..53aee14 100644 --- a/examples/migration/error-handling-before-after.ts +++ b/examples/migration/error-handling-before-after.ts @@ -4,10 +4,32 @@ * Shows how raw Soroban error handling compares to the Aegis SDK's * structured error approach. */ -import { rpc, Contract, nativeToScVal, xdr, scValToNative } from '@stellar/stellar-sdk'; +import { + rpc, + Contract, + nativeToScVal, + xdr, + scValToNative, + Account, + TransactionBuilder, + Keypair, +} from '@stellar/stellar-sdk'; import { AegisClient, PortfolioError } from '@aegis/sdk'; import type { InvestorPortfolio } from '@aegis/sdk'; +// Simulation never signs or submits, so a real account isn't required — any +// structurally valid source account works, e.g. a throwaway keypair. +function buildSimulationTx(networkPassphrase: string, call: any) { + const sourceAccount = new Account(Keypair.random().publicKey(), '0'); + return new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase, + }) + .addOperation(call) + .setTimeout(30) + .build(); +} + // ============================================================ // BEFORE: Raw Soroban — Manual Error Handling // ============================================================ @@ -15,6 +37,7 @@ import type { InvestorPortfolio } from '@aegis/sdk'; async function checkWhitelistRaw( rpcServer: rpc.Server, contractId: string, + networkPassphrase: string, address: string ): Promise { const contract = new Contract(contractId); @@ -24,9 +47,9 @@ async function checkWhitelistRaw( ); try { - const result = await rpcServer.simulateTransaction({ - transaction: call as any, - } as any); + // simulateTransaction takes a built Transaction, not a bare operation. + const tx = buildSimulationTx(networkPassphrase, call); + const result = await rpcServer.simulateTransaction(tx); if (rpc.Api.isSimulationSuccess(result) && result.result) { return scValToNative( @@ -69,6 +92,7 @@ async function checkWhitelistSDK( async function getPortfolioRaw( rpcServer: rpc.Server, contractId: string, + networkPassphrase: string, investorAddress: string ) { const contract = new Contract(contractId); @@ -78,9 +102,8 @@ async function getPortfolioRaw( 'is_whitelisted', nativeToScVal(investorAddress, { type: 'address' }) ); - const whitelistResult = await rpcServer.simulateTransaction({ - transaction: whitelistCall as any, - } as any); + const whitelistTx = buildSimulationTx(networkPassphrase, whitelistCall); + const whitelistResult = await rpcServer.simulateTransaction(whitelistTx); const isKycApproved = rpc.Api.isSimulationSuccess(whitelistResult) && whitelistResult.result @@ -93,9 +116,8 @@ async function getPortfolioRaw( 'balance', nativeToScVal(investorAddress, { type: 'address' }) ); - const balanceResult = await rpcServer.simulateTransaction({ - transaction: balanceCall as any, - } as any); + const balanceTx = buildSimulationTx(networkPassphrase, balanceCall); + const balanceResult = await rpcServer.simulateTransaction(balanceTx); const balance = rpc.Api.isSimulationSuccess(balanceResult) && balanceResult.result diff --git a/examples/migration/mint-transfer-before-after.ts b/examples/migration/mint-transfer-before-after.ts index 9a9f1fa..f5b33df 100644 --- a/examples/migration/mint-transfer-before-after.ts +++ b/examples/migration/mint-transfer-before-after.ts @@ -92,6 +92,10 @@ async function transferTokensRaw( // ============================================================ // AFTER: Aegis SDK — Mint & Transfer +// ⚠️ Privileged operation: mint/transfer require a signing keypair with +// issuer/admin authority on the deployed contract. NEVER hardcode a real +// secret key — load it from an environment variable or secret manager +// that is excluded from version control. // ============================================================ async function mintAndTransferSDK() { @@ -99,7 +103,7 @@ async function mintAndTransferSDK() { rpcUrl: 'https://soroban-testnet.stellar.org', networkPassphrase: Networks.TESTNET, contractId: 'C_YOUR_CONTRACT_ID', - keypair: Keypair.fromSecret('S...'), + keypair: Keypair.fromSecret(process.env.AEGIS_ISSUER_SECRET!), }); // Mint 1000 tokens (in base units) to a recipient diff --git a/examples/migration/portfolio-before-after.ts b/examples/migration/portfolio-before-after.ts index f4e057c..e7800c4 100644 --- a/examples/migration/portfolio-before-after.ts +++ b/examples/migration/portfolio-before-after.ts @@ -11,10 +11,26 @@ import { xdr, scValToNative, Networks, + Account, + TransactionBuilder, + Keypair, } from '@stellar/stellar-sdk'; import { AegisClient } from '@aegis/sdk'; import type { InvestorPortfolio, PortfolioStatus } from '@aegis/sdk'; +// Simulation never signs or submits, so a real account isn't required — any +// structurally valid source account works, e.g. a throwaway keypair. +function buildSimulationTx(networkPassphrase: string, call: any) { + const sourceAccount = new Account(Keypair.random().publicKey(), '0'); + return new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase, + }) + .addOperation(call) + .setTimeout(30) + .build(); +} + // ============================================================ // BEFORE: Raw Soroban — Manual Portfolio Assembly // ============================================================ @@ -22,6 +38,7 @@ import type { InvestorPortfolio, PortfolioStatus } from '@aegis/sdk'; async function getPortfolioRaw( rpcServer: rpc.Server, contractId: string, + networkPassphrase: string, investorAddress: string ) { const contract = new Contract(contractId); @@ -31,9 +48,9 @@ async function getPortfolioRaw( 'is_whitelisted', nativeToScVal(investorAddress, { type: 'address' }) ); - const whitelistResult = await rpcServer.simulateTransaction({ - transaction: whitelistCall as any, - } as any); + // simulateTransaction takes a built Transaction, not a bare operation. + const whitelistTx = buildSimulationTx(networkPassphrase, whitelistCall); + const whitelistResult = await rpcServer.simulateTransaction(whitelistTx); const isKycApproved = rpc.Api.isSimulationSuccess(whitelistResult) && whitelistResult.result ? scValToNative( @@ -46,9 +63,8 @@ async function getPortfolioRaw( 'balance', nativeToScVal(investorAddress, { type: 'address' }) ); - const balanceResult = await rpcServer.simulateTransaction({ - transaction: balanceCall as any, - } as any); + const balanceTx = buildSimulationTx(networkPassphrase, balanceCall); + const balanceResult = await rpcServer.simulateTransaction(balanceTx); const balance = rpc.Api.isSimulationSuccess(balanceResult) && balanceResult.result ? scValToNative( diff --git a/src/compliance.ts b/src/compliance.ts index 04e0be7..38dbc7a 100644 --- a/src/compliance.ts +++ b/src/compliance.ts @@ -1,6 +1,7 @@ import { Contract, nativeToScVal, rpc } from '@stellar/stellar-sdk'; import { AegisClient } from './client'; import { parseSorobanResult } from './utils/xdr-parser'; +import { buildSimulationTransaction } from './utils/simulation'; export class ComplianceModule { private client: AegisClient; @@ -19,12 +20,10 @@ constructor(client: AegisClient) { // Create the invocation for the read-only 'is_whitelisted' function const call = contract.call('is_whitelisted', nativeToScVal(address, { type: 'address' })); + const tx = buildSimulationTransaction(this.client, call); const result = await this.client.runNetworkOperation(() => - this.client.rpcServer.simulateTransaction({ - // Dummy transaction for simulation purposes - transaction: call as any, // Cast required depending on SDK version wrapper - } as any) + this.client.rpcServer.simulateTransaction(tx) ); // rpc.Api.isSimulationSuccess acts as a type guard here diff --git a/src/investor/portfolio.ts b/src/investor/portfolio.ts index 7574b78..04c5e71 100644 --- a/src/investor/portfolio.ts +++ b/src/investor/portfolio.ts @@ -1,5 +1,6 @@ import { Contract, nativeToScVal, rpc } from '@stellar/stellar-sdk'; import { AegisClient } from '../client'; +import { buildSimulationTransaction } from '../utils/simulation'; import { InvestorPortfolio, PortfolioStatus, @@ -146,9 +147,8 @@ export class InvestorModule { let balanceRaw = '0'; try { - const result = await this.client.rpcServer.simulateTransaction({ - transaction: call as any, - } as any); + const tx = buildSimulationTransaction(this.client, call); + const result = await this.client.rpcServer.simulateTransaction(tx); if (rpc.Api.isSimulationSuccess(result) && result.result) { const parsed = parseSorobanResult(result.result.retval as any); diff --git a/src/utils/simulation.ts b/src/utils/simulation.ts new file mode 100644 index 0000000..3e8789e --- /dev/null +++ b/src/utils/simulation.ts @@ -0,0 +1,37 @@ +import { Account, Keypair, Operation, Transaction, TransactionBuilder } from '@stellar/stellar-sdk'; +import { AegisClient } from '../client'; + +/** + * Builds a properly-typed `Transaction` for a read-only simulation call. + * + * `rpc.Server.simulateTransaction` takes a `Transaction` (or `FeeBumpTransaction`) + * directly as its first argument — it does not accept a bare contract-call + * `Operation`, and it does not accept a `{ transaction: ... }` wrapper object. + * Passing either of those (as this SDK previously did via `as any` casts) relies + * on undocumented, version-dependent leniency in the underlying RPC client + * rather than the documented request shape. + * + * Simulation never signs or submits anything, so the source account does not + * need to be real or have a live sequence number: sequence `"0"` is a safe + * placeholder here, same as the one `AssetModule` already uses for write + * transactions before they're actually submitted. When the client has a + * configured signer, its public key is reused as the source account so a + * simulation reflects the account that would actually sign; otherwise an + * ephemeral keypair's public key is used purely to satisfy the transaction + * envelope's structural requirement for a source account. + */ +export function buildSimulationTransaction( + client: AegisClient, + operation: ReturnType +): Transaction { + const sourcePublicKey = client.keypair?.publicKey() ?? Keypair.random().publicKey(); + const sourceAccount = new Account(sourcePublicKey, '0'); + + return new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: client.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); +} diff --git a/tests/compliance.test.ts b/tests/compliance.test.ts new file mode 100644 index 0000000..b4abd80 --- /dev/null +++ b/tests/compliance.test.ts @@ -0,0 +1,117 @@ +import { AegisClient } from '../src/client'; +import { Networks, Keypair, rpc, xdr, Transaction } from '@stellar/stellar-sdk'; + +jest.mock('@stellar/stellar-sdk', () => { + const original = jest.requireActual('@stellar/stellar-sdk'); + return { + ...original, + rpc: { + ...original.rpc, + Server: jest.fn().mockImplementation(() => ({ + simulateTransaction: jest.fn(), + })), + Api: { + ...original.rpc.Api, + isSimulationSuccess: jest.fn(), + }, + }, + }; +}); + +describe('ComplianceModule', () => { + let client: AegisClient; + let mockRpcServer: any; + + const mockContractId = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + const mockUserAddress = Keypair.random().publicKey(); + + beforeEach(() => { + jest.clearAllMocks(); + + client = new AegisClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId: mockContractId, + }); + + mockRpcServer = client.rpcServer; + }); + + it('returns true when simulation succeeds and decodes to true', async () => { + (rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + const trueScValBase64 = xdr.ScVal.scvBool(true).toXDR('base64'); + mockRpcServer.simulateTransaction.mockResolvedValueOnce({ + result: { retval: trueScValBase64 }, + }); + + await expect(client.compliance.checkWhitelist(mockUserAddress)).resolves.toBe(true); + }); + + it('returns false when simulation succeeds and decodes to false', async () => { + (rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + const falseScValBase64 = xdr.ScVal.scvBool(false).toXDR('base64'); + mockRpcServer.simulateTransaction.mockResolvedValueOnce({ + result: { retval: falseScValBase64 }, + }); + + await expect(client.compliance.checkWhitelist(mockUserAddress)).resolves.toBe(false); + }); + + it('returns false, not throw, when the simulation does not succeed', async () => { + (rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(false); + mockRpcServer.simulateTransaction.mockResolvedValueOnce({ result: undefined }); + + await expect(client.compliance.checkWhitelist(mockUserAddress)).resolves.toBe(false); + }); + + it('re-throws when simulateTransaction itself rejects', async () => { + mockRpcServer.simulateTransaction.mockRejectedValueOnce(new Error('RPC unreachable')); + + await expect(client.compliance.checkWhitelist(mockUserAddress)).rejects.toThrow(); + }); + + /** + * Regression test for the "incorrect RPC formatting" bug (issue #66): + * `checkWhitelist` used to call `simulateTransaction({ transaction: call as + * any } as any)` — a `{ transaction }` wrapper around a bare, unbuilt + * contract-call operation. `rpc.Server.simulateTransaction` actually takes a + * built `Transaction` directly as its first argument. Both the wrapper shape + * and the missing transaction envelope (fee, source account, sequence + * number, network passphrase) were wrong; assert the real shape here so this + * can't silently regress. + */ + it('calls simulateTransaction with a real built Transaction, not a wrapper object', async () => { + (rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + mockRpcServer.simulateTransaction.mockResolvedValueOnce({ + result: { retval: xdr.ScVal.scvBool(true).toXDR('base64') }, + }); + + await client.compliance.checkWhitelist(mockUserAddress); + + expect(mockRpcServer.simulateTransaction).toHaveBeenCalledTimes(1); + const passedArg = mockRpcServer.simulateTransaction.mock.calls[0][0]; + expect(passedArg).toBeInstanceOf(Transaction); + expect(passedArg.networkPassphrase).toBe(Networks.TESTNET); + }); + + it('uses the configured signer as the simulation source account when one is set', async () => { + const signer = Keypair.random(); + const signedClient = new AegisClient({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Networks.TESTNET, + contractId: mockContractId, + keypair: signer, + }); + const signedMockRpcServer = signedClient.rpcServer as any; + + (rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + signedMockRpcServer.simulateTransaction.mockResolvedValueOnce({ + result: { retval: xdr.ScVal.scvBool(true).toXDR('base64') }, + }); + + await signedClient.compliance.checkWhitelist(mockUserAddress); + + const passedArg = signedMockRpcServer.simulateTransaction.mock.calls[0][0]; + expect(passedArg.source).toBe(signer.publicKey()); + }); +}); diff --git a/tests/investor.test.ts b/tests/investor.test.ts index a8f5276..16db793 100644 --- a/tests/investor.test.ts +++ b/tests/investor.test.ts @@ -1,6 +1,6 @@ import { AegisClient } from '../src/client'; import { InvestorModule } from '../src/investor/portfolio'; -import { Networks, Keypair, rpc, xdr, nativeToScVal, StrKey } from '@stellar/stellar-sdk'; +import { Networks, Keypair, rpc, xdr, nativeToScVal, StrKey, Transaction } from '@stellar/stellar-sdk'; jest.mock('@stellar/stellar-sdk', () => { const original = jest.requireActual('@stellar/stellar-sdk'); @@ -153,4 +153,32 @@ describe('InvestorModule (Portfolio Read Model)', () => { expect(portfolio.error).toContain('Invalid investor address'); }); }); + + describe('Balance simulation request shape', () => { + /** + * Regression test for issue #66 ("incorrect RPC formatting"): the balance + * lookup used to call `simulateTransaction({ transaction: call as any } + * as any)` — a wrapper object around an unbuilt operation — instead of a + * real built `Transaction`. Assert the real shape so it can't regress. + */ + it('calls simulateTransaction with a real built Transaction for the balance query', async () => { + (rpc.Api.isSimulationSuccess as unknown as jest.Mock).mockReturnValue(true); + + const trueScValBase64 = xdr.ScVal.scvBool(true).toXDR('base64'); + const balanceScValBase64 = xdr.ScVal.scvI128( + new xdr.Int128Parts({ hi: xdr.Int64.fromString('0'), lo: xdr.Uint64.fromString('0') }) + ).toXDR('base64'); + + mockRpcServer.simulateTransaction + .mockResolvedValueOnce({ result: { retval: trueScValBase64 } }) + .mockResolvedValueOnce({ result: { retval: balanceScValBase64 } }); + + await client.investor.getPortfolio(mockInvestorAddress); + + expect(mockRpcServer.simulateTransaction).toHaveBeenCalledTimes(2); + const balanceCallArg = mockRpcServer.simulateTransaction.mock.calls[1][0]; + expect(balanceCallArg).toBeInstanceOf(Transaction); + expect(balanceCallArg.networkPassphrase).toBe(Networks.TESTNET); + }); + }); });