diff --git a/.github/checklists/issue-249.md b/.github/checklists/issue-249.md new file mode 100644 index 0000000..8173473 --- /dev/null +++ b/.github/checklists/issue-249.md @@ -0,0 +1,28 @@ +# Acceptance Criteria Checklist — Issue #249 + +> Generated for pre-PR verification. Confirm each item, then run: +> +> ```bash +> npm run verify:pr -- --checklist .github/checklists/issue-249.md +> ``` + +## Issue + +- **Number:** #249 +- **Title:** Add SDK transaction build validation pipeline + +## Acceptance Criteria + +- [x] Reusable transaction validators are added +- [x] Validation order is documented +- [x] Typed errors are returned +- [x] Tests cover invalid inputs +- [x] Existing flows use validators where practical + +## Contributor confirmations + +- [x] Automated checks passed (`npm run verify:pr`) +- [x] Tests added or updated for behaviour changes +- [x] Documentation updated when public behaviour changed +- [x] PR description maps each acceptance criterion to the change +- [x] No secrets or `.env` values committed diff --git a/docs/transaction-build-validation.md b/docs/transaction-build-validation.md new file mode 100644 index 0000000..44c09fc --- /dev/null +++ b/docs/transaction-build-validation.md @@ -0,0 +1,117 @@ +# Transaction build validation pipeline + +A reusable pipeline that validates the inputs a transaction is built from, in a +documented order, before anything is built or signed. + +```ts +import { validateTransactionBuild } from 'stellar-pocketpay-sdk'; + +const result = validateTransactionBuild({ sourceSecret, destination, amount, memo }); +if (!result.valid) { + for (const issue of result.issues) showFieldError(issue.field, issue.message); + return; +} +``` + +## Why it exists + +The duplication was literal. `sendXLM` hand-chained four validators, and +`sendAsset` repeated the same four with `validateAssetSpec` appended. Both now +call the pipeline instead. + +A non-throwing family already existed that the build path never used — but the +helpers could not simply be called in sequence, because they return **four +different shapes**: + +| Helper | Returns | +|---|---| +| `safeValidateMemo` | `{ valid } \| { valid, error }` | +| `safeParseAmount` | `{ valid, amount } \| { valid, error }` | +| `safeValidateDestination` | `Promise>` | +| `validateSendXLMParams` | `{ ok } \| { ok, errors: ValidationError[] }` | + +Normalising those into one result is what the pipeline does. + +## Validation order + +`VALIDATION_ORDER` is exported so the order is checkable, not just documented. +Local, zero-cost checks run first; anything that reads configuration or an +account runs last, so a malformed key is reported without resolving config at +all. + +| # | Stage | Reads | Notes | +|---|---|---|---| +| 1 | `sourceAccount` | — | The only stage that touches the secret | +| 2 | `destination` | — | Includes self-payment, which needs the derived source | +| 3 | `amount` | — | Format, positivity, 7-decimal precision | +| 4 | `asset` | — | Issued-asset spec shape | +| 5 | `memo` | — | Type and byte length | +| 6 | `network` | SDK config | Validates URLs, timeout, contract ID | +| 7 | `signerCapability` | account | See the limitation below | + +### Issues accumulate + +Stages are **not** short-circuited. Three malformed fields produce three issues, +so a form can show all of them at once instead of one per submit. +`assertTransactionBuildValid` is the throwing variant for call sites that want +the first failure only. + +### Running a subset + +```ts +validateTransactionBuild(input, { stages: ['sourceAccount', 'amount'] }); +``` + +`sendXLM` and `sendAsset` use this to skip the `network` stage, because they +resolve configuration themselves. Skipping it keeps *when* a configuration error +surfaces exactly where it was before the pipeline existed. + +## Error codes are transported, never merged + +An issue carries the code the originating validator already produced: + +```ts +{ stage: 'amount', code: 'INVALID_AMOUNT', field: 'amount', message: '…' } +``` + +`ValidationErrorCode` (`src/payments/validation.ts`) and the published error +registry are **separate taxonomies on purpose**. Both appear in the same +`issues` array unchanged; the pipeline does not unify them. + +This is also why the amount stage uses `validateAmount` rather than +`safeParseAmount`. The safe parser belongs to the safe-amount model and raises +that model's codes, while the payment surface publishes `INVALID_AMOUNT` and +`INVALID_AMOUNT_PRECISION`. Composing the safe parser would have been a silent +breaking change to published codes. + +## Adoption is not a breaking change + +`assertTransactionBuildValid` throws the **same error object** the underlying +validator produced. Every published code — `INVALID_SECRET_KEY`, +`SELF_PAYMENT`, `INVALID_AMOUNT`, `INVALID_AMOUNT_PRECISION` and the asset codes +— is unchanged by routing through the pipeline, and tests assert exactly that. + +The self-payment *message* differs between flows ("Cannot send XLM to yourself" +vs "Cannot send asset to yourself"). The check is shared; only the sentence is +passed in via `selfPaymentMessage`. + +## Limitation: the signer-capability stage + +`mapAuthRequirements` takes a **built** transaction, so full threshold analysis +cannot run before a transaction exists. The pipeline's `signerCapability` stage +validates what is knowable pre-build: whether the supplied account declares +signing capability. + +Full authorisation mapping — thresholds, signer weights, unsupported operations +— remains a post-build step via `mapAuthRequirements` and +`assertAuthFullyMapped`. The stage is skipped entirely when no `account` is +supplied. + +## API + +| Export | Purpose | +|---|---| +| `validateTransactionBuild(input, options?)` | Run the pipeline, collect all issues | +| `assertTransactionBuildValid(input, options?)` | Throw the first failure, unchanged | +| `VALIDATION_ORDER` | The documented stage order | +| `ValidationStage`, `TransactionValidationIssue`, `TransactionValidationResult`, `TransactionBuildInput`, `TransactionValidationOptions` | Types | diff --git a/src/index.ts b/src/index.ts index 12b45d2..d2db501 100644 --- a/src/index.ts +++ b/src/index.ts @@ -214,6 +214,21 @@ export type { LifecycleFailure, } from './types'; +// ─── Transaction build validation pipeline (issue #249) ───────────────────── +export { + validateTransactionBuild, + assertTransactionBuildValid, + VALIDATION_ORDER, +} from './transactions'; + +export type { + ValidationStage, + TransactionValidationIssue, + TransactionValidationResult, + TransactionBuildInput, + TransactionValidationOptions, +} from './transactions'; + // ─── Transactions ─────────────────────────────────────────────────────────── export { getTransactions, diff --git a/src/payments/index.ts b/src/payments/index.ts index b098706..75f1858 100644 --- a/src/payments/index.ts +++ b/src/payments/index.ts @@ -6,10 +6,11 @@ import * as StellarSDK from '@stellar/stellar-sdk'; import { getHorizonServer, getNetworkPassphrase, resolveConfig } from '../config'; import { SendXLMParams, SendAssetParams, PaymentResult, PocketPayError, SDKConfig, PocketPayResult, EnhancedPocketPayResult } from '../types'; -import { validateSecretKey, validatePublicKey, validateAmount, validateMemoInput, buildMemo, wrapError, toResult, toEnhancedSuccessResult, toEnhancedFailureResult, toEnhancedResult } from '../utils'; +import { buildMemo, wrapError, toResult, toEnhancedSuccessResult, toEnhancedFailureResult, toEnhancedResult } from '../utils'; import type { ResultWarning, RecoveryHint } from '../errors'; import { withTimeout } from '../network'; import { submitWithGuard } from '../transactions/guarded-submit'; +import { assertTransactionBuildValid } from '../transactions/build-validation'; import { validateAssetSpec, verifyPaymentTrustlineOrThrow } from './trustline'; /** @@ -31,21 +32,21 @@ export async function sendXLM( ): Promise { const { sourceSecret, destination, amount, memo } = params; // ─── Preflight validation (before any network call) ───────────────────────── - validateSecretKey(sourceSecret); - validatePublicKey(destination); - validateAmount(amount); - validateMemoInput(memo); + // Runs through the shared build-validation pipeline (issue #249) rather than + // hand-chaining the same four validators here and again in `sendAsset`. The + // pipeline rethrows the originating PocketPayError untouched, so every + // published code below is unchanged. `network` is excluded because this + // function resolves config itself inside the try block, and moving that would + // change when a configuration error surfaces relative to the handler below. + assertTransactionBuildValid( + { sourceSecret, destination, amount, memo }, + { + stages: ['sourceAccount', 'destination', 'amount', 'memo'], + selfPaymentMessage: 'Cannot send XLM to yourself', + }, + ); const sourceKeypair = StellarSDK.Keypair.fromSecret(sourceSecret); const sourcePublic = sourceKeypair.publicKey(); - if (sourcePublic === destination) { - throw new PocketPayError('Cannot send XLM to yourself', 'SELF_PAYMENT', { - validation: { - field: 'destination', - reason: 'same_as_source', - value: destination - } - }); - } try { const cfg = resolveConfig(config); const server = getHorizonServer(config); @@ -326,25 +327,21 @@ export async function sendAsset( const { sourceSecret, destination, amount, asset, memo, skipTrustlineCheck } = params; // ─── Preflight validation (synchronous, no network) ────────────────────── - validateSecretKey(sourceSecret); - validatePublicKey(destination); - validateAmount(amount); - validateMemoInput(memo); - validateAssetSpec(asset); + // Same shared pipeline as `sendXLM` (issue #249). This path is where the + // duplication was most visible: the identical four validators, plus the asset + // check. Only the self-payment wording differs between the two flows, so that + // sentence is passed in rather than the check being repeated. + assertTransactionBuildValid( + { sourceSecret, destination, amount, asset, memo }, + { + stages: ['sourceAccount', 'destination', 'amount', 'asset', 'memo'], + selfPaymentMessage: 'Cannot send asset to yourself', + }, + ); const sourceKeypair = StellarSDK.Keypair.fromSecret(sourceSecret); const sourcePublic = sourceKeypair.publicKey(); - if (sourcePublic === destination) { - throw new PocketPayError('Cannot send asset to yourself', 'SELF_PAYMENT', { - validation: { - field: 'destination', - reason: 'same_as_source', - value: destination, - }, - }); - } - const isNative = asset.code.toUpperCase() === 'XLM' || asset.code.toLowerCase() === 'native'; diff --git a/src/transactions/build-validation.ts b/src/transactions/build-validation.ts new file mode 100644 index 0000000..3ada4d3 --- /dev/null +++ b/src/transactions/build-validation.ts @@ -0,0 +1,349 @@ +/** + * Stellar PocketPay SDK — Transaction build validation pipeline. + * + * Validates the inputs a transaction is built from, in a documented order, + * before anything is built or signed. + * + * @remarks + * The duplication this replaces was literal, not hypothetical: + * `src/payments/index.ts` hand-chained `validateSecretKey`, + * `validatePublicKey`, `validateAmount` and `validateMemoInput`, and the same + * four calls reappeared in the issued-asset path with `validateAssetSpec` + * appended. + * + * A non-throwing family already existed that the build path never used, but it + * could not simply be called in sequence: the four helpers return **four + * different shapes** — + * + * - `safeValidateMemo` → `{ valid } | { valid, error }` + * - `safeParseAmount` → `{ valid, amount } | { valid, error }` + * - `safeValidateDestination` → `Promise>` + * - `validateSendXLMParams` → `{ ok } | { ok, errors: ValidationError[] }` + * + * Normalising those into one result is the work here. The error **codes are + * transported, never merged**: `ValidationErrorCode` and the published error + * registry are separate taxonomies on purpose, and collapsing them would break + * existing tests. + * + * @security Issues carry a code and a message. The input is never echoed back — + * a build input holds `sourceSecret`. + */ + +import { canSignTransaction, type AccountAbstraction } from '../account'; +import { resolveConfig } from '../config'; +import { validateAssetSpec } from '../payments/trustline'; +import { PocketPayError } from '../types'; +import type { SDKConfig, StellarAssetSpec } from '../types'; +import { safeValidateMemo, validateAmount, validatePublicKey, validateSecretKey } from '../utils'; +import * as StellarSDK from '@stellar/stellar-sdk'; +import type { MemoInput } from '../types'; + +/** + * The seven inputs a transaction is built from, in validation order. + * + * Named after the validators the issue lists so the mapping between the + * acceptance criteria and the code is one to one. + */ +export type ValidationStage = + | 'sourceAccount' + | 'destination' + | 'amount' + | 'asset' + | 'memo' + | 'network' + | 'signerCapability'; + +/** + * The documented order. Local, zero-cost checks run first; anything that reads + * configuration or an account runs last, so a malformed key is reported without + * resolving config at all. + */ +export const VALIDATION_ORDER: readonly ValidationStage[] = [ + 'sourceAccount', + 'destination', + 'amount', + 'asset', + 'memo', + 'network', + 'signerCapability', +]; + +/** One validation failure, attributed to the stage that produced it. */ +export interface TransactionValidationIssue { + /** Which input failed. */ + stage: ValidationStage; + /** + * The code the originating validator already produced. + * + * Carried verbatim from whichever taxonomy raised it. This field does not + * unify `ValidationErrorCode` with the published registry codes; both appear + * here unchanged. + */ + code: string; + /** The input field, when the validator identified one. */ + field?: string; + /** Human-readable message, safe to display. */ + message: string; +} + +/** Outcome of a full pipeline run. */ +export interface TransactionValidationResult { + /** True when no stage produced an issue. */ + valid: boolean; + /** Every issue found, in stage order. Empty when `valid`. */ + issues: TransactionValidationIssue[]; +} + +/** Inputs a transaction can be built from. Every field is optional: a stage is skipped when its input is absent. */ +export interface TransactionBuildInput { + /** Secret key of the source account. */ + sourceSecret?: string; + /** Destination account public key. */ + destination?: string; + /** Amount as a decimal string. */ + amount?: string; + /** Asset specification for issued-asset payments. */ + asset?: StellarAssetSpec; + /** Memo to attach. */ + memo?: string | MemoInput; + /** + * Account abstraction used for the signer-capability stage. + * + * @remarks + * This stage validates what can be known **before** a transaction exists: + * whether the account declares signing capability. Full threshold analysis + * needs a built envelope and belongs to `mapAuthRequirements`, which runs + * after building — see the module docs. + */ + account?: AccountAbstraction; +} + +/** Options for a pipeline run. */ +export interface TransactionValidationOptions { + /** SDK config overrides, used by the `network` stage. */ + config?: Partial; + /** + * Restricts the run to these stages, in {@link VALIDATION_ORDER} regardless + * of the order given here. + * + * @remarks + * Exists so a caller that already resolves configuration itself can run the + * input stages without resolving it twice — and, more importantly, without + * moving *when* a configuration error surfaces relative to the caller's own + * error handling. + */ + stages?: readonly ValidationStage[]; + /** + * Message for the self-payment failure. + * + * @remarks + * `sendXLM` and `sendAsset` word this differently ("Cannot send XLM to + * yourself" / "Cannot send asset to yourself") and both wordings are asserted + * by existing tests. The check itself is shared; only the sentence is the + * caller's. + */ + selfPaymentMessage?: string; +} + +/** Internal: a stage failure keeps the original error so the throwing variant can rethrow it unchanged. */ +interface StageFailure { + issue: TransactionValidationIssue; + error: PocketPayError; +} + +/** Runs a throwing validator and converts a `PocketPayError` into a stage failure. */ +function capture(stage: ValidationStage, run: () => void): StageFailure | undefined { + try { + run(); + return undefined; + } catch (error) { + const err = error as PocketPayError; + if (!err || typeof err.code !== 'string') throw error; + return { + error: err, + issue: { + stage, + code: err.code, + ...(err.validation?.field ? { field: err.validation.field } : {}), + message: err.safeMessage ?? err.message, + }, + }; + } +} + +/** Converts an already-caught error into a stage failure. */ +function fromError(stage: ValidationStage, error: PocketPayError): StageFailure { + return { + error, + issue: { + stage, + code: error.code, + ...(error.validation?.field ? { field: error.validation.field } : {}), + message: error.safeMessage ?? error.message, + }, + }; +} + +/** + * Runs every applicable stage in {@link VALIDATION_ORDER}. + * + * Stages are **not** short-circuited: a caller with three malformed fields gets + * three issues, so a form can show all of them at once instead of one per + * submit. + */ +function runStages( + input: TransactionBuildInput, + options: TransactionValidationOptions, +): StageFailure[] { + const failures: StageFailure[] = []; + const enabled = options.stages ? new Set(options.stages) : undefined; + const runs = (stage: ValidationStage): boolean => !enabled || enabled.has(stage); + + // 1 — source account. Local, and the only stage that touches the secret. + let sourceSecretUsable = input.sourceSecret !== undefined; + if (runs('sourceAccount') && input.sourceSecret !== undefined) { + const failure = capture('sourceAccount', () => { + validateSecretKey(input.sourceSecret as string); + }); + if (failure) { + failures.push(failure); + sourceSecretUsable = false; + } + } + + // 2 — destination, including self-payment, which needs the derived source. + if (runs('destination') && input.destination !== undefined) { + const failure = capture('destination', () => { + validatePublicKey(input.destination as string); + }); + if (failure) { + failures.push(failure); + } else if (sourceSecretUsable) { + // Only derive the public key once the secret is known to be well formed. + // `Keypair.fromSecret` throws a raw Error on a malformed secret, which + // would escape the pipeline and reach the caller as something other than + // a PocketPayError. + const selfPayment = capture('destination', () => { + const source = StellarSDK.Keypair.fromSecret(input.sourceSecret as string).publicKey(); + if (source === input.destination) { + throw new PocketPayError( + options.selfPaymentMessage ?? 'Cannot send to yourself', + 'SELF_PAYMENT', + { + validation: { + field: 'destination', + reason: 'same_as_source', + value: input.destination as string, + }, + }, + ); + } + }); + if (selfPayment) failures.push(selfPayment); + } + } + + // 3 — amount. + // + // Uses `validateAmount`, not `safeParseAmount`, and the reason is the same + // one that keeps the taxonomies apart. `safeParseAmount` belongs to the + // safe-amount model and raises that model's codes; the payment surface + // publishes `INVALID_AMOUNT` and `INVALID_AMOUNT_PRECISION`, which existing + // tests assert. Composing the safe parser here would have been a silent + // breaking change to published error codes. + if (runs('amount') && input.amount !== undefined) { + const failure = capture('amount', () => { + validateAmount(input.amount as string); + }); + if (failure) failures.push(failure); + } + + // 4 — asset shape, for issued-asset payments. + if (runs('asset') && input.asset !== undefined) { + const failure = capture('asset', () => { + validateAssetSpec(input.asset as StellarAssetSpec); + }); + if (failure) failures.push(failure); + } + + // 5 — memo. + if (runs('memo') && input.memo !== undefined) { + const memoResult = safeValidateMemo(input.memo); + if (!memoResult.valid) failures.push(fromError('memo', memoResult.error)); + } + + // 6 — network. Resolving config validates URLs, timeout and contract ID. + if (runs('network')) { + const networkFailure = capture('network', () => { + resolveConfig(options.config); + }); + if (networkFailure) failures.push(networkFailure); + } + + // 7 — signer capability. See TransactionBuildInput.account for the limit. + if (runs('signerCapability') && input.account !== undefined && !canSignTransaction(input.account)) { + failures.push({ + error: new PocketPayError( + 'The provided account cannot sign transactions.', + 'SIGNER_CANNOT_SIGN', + { validation: { field: 'account', reason: 'cannot_sign' } }, + ), + issue: { + stage: 'signerCapability', + code: 'SIGNER_CANNOT_SIGN', + field: 'account', + message: + 'The provided account cannot sign transactions. Full threshold analysis ' + + 'requires a built envelope and is performed by mapAuthRequirements.', + }, + }); + } + + return failures; +} + +/** + * Validates transaction build inputs without throwing. + * + * @param input - The inputs a transaction would be built from. + * @param options - SDK config overrides for the network stage. + * @returns Every issue found, in stage order. + * + * @example + * ```ts + * const result = validateTransactionBuild({ sourceSecret, destination, amount }); + * if (!result.valid) { + * for (const issue of result.issues) showFieldError(issue.field, issue.message); + * return; + * } + * ``` + */ +export function validateTransactionBuild( + input: TransactionBuildInput, + options: TransactionValidationOptions = {}, +): TransactionValidationResult { + const issues = runStages(input, options).map((failure) => failure.issue); + return { valid: issues.length === 0, issues }; +} + +/** + * Validates and throws the first failure, preserving the original error. + * + * @remarks + * The error thrown is the **same object** the underlying validator produced, so + * every published error code — `INVALID_SECRET_KEY`, `SELF_PAYMENT`, + * `INVALID_AMOUNT_PRECISION` and the rest — is unchanged by routing through the + * pipeline. This is what lets existing flows adopt it without a breaking + * change. + * + * @param input - The inputs a transaction would be built from. + * @param options - SDK config overrides for the network stage. + * @throws The originating `PocketPayError` of the first failing stage. + */ +export function assertTransactionBuildValid( + input: TransactionBuildInput, + options: TransactionValidationOptions = {}, +): void { + const failures = runStages(input, options); + if (failures.length > 0) throw failures[0]!.error; +} diff --git a/src/transactions/index.ts b/src/transactions/index.ts index 3a64e78..c8e4674 100644 --- a/src/transactions/index.ts +++ b/src/transactions/index.ts @@ -360,3 +360,18 @@ export { } from './orchestrator'; export type { GuardedSubmitOptions, SubmittableTransaction } from './orchestrator'; + +// ─── Transaction build validation pipeline (issue #249) ────────────────────── +export { + validateTransactionBuild, + assertTransactionBuildValid, + VALIDATION_ORDER, +} from './build-validation'; + +export type { + ValidationStage, + TransactionValidationIssue, + TransactionValidationResult, + TransactionBuildInput, + TransactionValidationOptions, +} from './build-validation'; diff --git a/tests/build-validation.test.ts b/tests/build-validation.test.ts new file mode 100644 index 0000000..a3774cd --- /dev/null +++ b/tests/build-validation.test.ts @@ -0,0 +1,250 @@ +/** + * Transaction build validation pipeline tests (issue #249). + * + * The duplication this replaces was literal: `src/payments/index.ts` hand-chained + * `validateSecretKey`, `validatePublicKey`, `validateAmount` and + * `validateMemoInput`, and the same four calls reappeared in the issued-asset + * path with `validateAssetSpec` appended. + * + * The tests that matter most are the last block: routing the payment helpers + * through the pipeline must not change a single published error code. That is + * what makes adoption a non-breaking change, and it is where a careless + * refactor silently breaks consumers. + */ + +import { describe, it, expect } from 'vitest'; +import * as StellarSDK from '@stellar/stellar-sdk'; +import { + assertTransactionBuildValid, + validateTransactionBuild, + VALIDATION_ORDER, + type ValidationStage, +} from '../src/transactions/build-validation'; +import { sendXLM, sendAsset } from '../src/payments'; +import { PocketPayError } from '../src/types'; + +const SECRET = StellarSDK.Keypair.random().secret(); +const PUBLIC = StellarSDK.Keypair.random().publicKey(); +const OTHER = StellarSDK.Keypair.random().publicKey(); +const NATIVE = { code: 'XLM' } as const; + +/** Only the local stages, so no test resolves configuration. */ +const LOCAL: readonly ValidationStage[] = [ + 'sourceAccount', + 'destination', + 'amount', + 'asset', + 'memo', +]; + +describe('stage coverage — one invalid input per stage', () => { + it('reports a malformed source secret', () => { + const result = validateTransactionBuild({ sourceSecret: 'not-a-secret' }, { stages: LOCAL }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.stage).toBe('sourceAccount'); + }); + + it('reports a malformed destination', () => { + const result = validateTransactionBuild({ destination: 'not-a-key' }, { stages: LOCAL }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.stage).toBe('destination'); + }); + + it('reports self-payment on the destination stage', () => { + const kp = StellarSDK.Keypair.random(); + const result = validateTransactionBuild( + { sourceSecret: kp.secret(), destination: kp.publicKey() }, + { stages: LOCAL }, + ); + expect(result.valid).toBe(false); + expect(result.issues[0]).toMatchObject({ stage: 'destination', code: 'SELF_PAYMENT' }); + }); + + it('reports a bad amount', () => { + const result = validateTransactionBuild({ amount: '-5' }, { stages: LOCAL }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.stage).toBe('amount'); + }); + + it('reports a bad asset spec', () => { + const result = validateTransactionBuild({ asset: { code: '' } as never }, { stages: LOCAL }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.stage).toBe('asset'); + }); + + it('reports an over-long memo', () => { + const result = validateTransactionBuild({ memo: 'x'.repeat(64) }, { stages: LOCAL }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.stage).toBe('memo'); + }); + + it('reports an account that cannot sign', () => { + const result = validateTransactionBuild( + { account: { canSign: false } as never }, + { stages: ['signerCapability'] }, + ); + expect(result.valid).toBe(false); + expect(result.issues[0]).toMatchObject({ + stage: 'signerCapability', + code: 'SIGNER_CANNOT_SIGN', + }); + }); + + it('skips the signer stage when no account is supplied', () => { + const result = validateTransactionBuild({ amount: '10' }, { stages: LOCAL }); + expect(result.valid).toBe(true); + }); +}); + +describe('ordering', () => { + it('publishes the documented order, source account first and signer last', () => { + expect(VALIDATION_ORDER).toEqual([ + 'sourceAccount', + 'destination', + 'amount', + 'asset', + 'memo', + 'network', + 'signerCapability', + ]); + }); + + it('reports issues in stage order, not input order', () => { + const result = validateTransactionBuild( + { memo: 'x'.repeat(64), amount: 'abc', sourceSecret: 'nope' }, + { stages: LOCAL }, + ); + expect(result.issues.map((i) => i.stage)).toEqual(['sourceAccount', 'amount', 'memo']); + }); + + it('does not short-circuit — three bad fields produce three issues', () => { + const result = validateTransactionBuild( + { sourceSecret: 'nope', destination: 'nope', amount: 'nope' }, + { stages: LOCAL }, + ); + expect(result.issues).toHaveLength(3); + }); + + it('does not derive the source key when the secret is already invalid', () => { + // `Keypair.fromSecret` throws a raw Error on a malformed secret. Running the + // self-payment check anyway would let a non-PocketPayError escape. + const result = validateTransactionBuild( + { sourceSecret: 'nope', destination: PUBLIC }, + { stages: LOCAL }, + ); + expect(result.issues.map((i) => i.stage)).toEqual(['sourceAccount']); + }); +}); + +describe('valid input', () => { + it('returns valid with no issues', () => { + const result = validateTransactionBuild( + { sourceSecret: SECRET, destination: PUBLIC, amount: '10.5', asset: NATIVE, memo: 'hi' }, + { stages: LOCAL }, + ); + expect(result).toEqual({ valid: true, issues: [] }); + }); + + it('never echoes the source secret into an issue', () => { + const result = validateTransactionBuild( + { sourceSecret: SECRET, destination: 'nope', amount: 'nope' }, + { stages: LOCAL }, + ); + expect(JSON.stringify(result)).not.toContain(SECRET); + }); +}); + +describe('taxonomies are transported, not merged', () => { + it('keeps each validator’s own code in the same result', () => { + const result = validateTransactionBuild( + { amount: '-5', asset: { code: '' } as never }, + { stages: LOCAL }, + ); + + const amountIssue = result.issues.find((i) => i.stage === 'amount'); + const assetIssue = result.issues.find((i) => i.stage === 'asset'); + + // Two different taxonomies coexisting in one array, each unchanged. + expect(amountIssue?.code).toBe('INVALID_AMOUNT'); + expect(assetIssue?.code).not.toBe(amountIssue?.code); + expect(assetIssue?.code).toMatch(/ASSET/); + }); +}); + +describe('assertTransactionBuildValid', () => { + it('throws the originating PocketPayError of the first failing stage', () => { + try { + assertTransactionBuildValid( + { sourceSecret: 'nope', amount: '-5' }, + { stages: LOCAL }, + ); + throw new Error('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(PocketPayError); + expect((error as PocketPayError).code).toBe('INVALID_SECRET_KEY'); + } + }); + + it('does not throw for valid input', () => { + expect(() => + assertTransactionBuildValid({ sourceSecret: SECRET, amount: '1' }, { stages: LOCAL }), + ).not.toThrow(); + }); +}); + +describe('adoption is not a breaking change — published codes are unchanged', () => { + // The payment helpers now route through the pipeline. Every code below was + // produced by the hand-chained validators before this issue. + it('sendXLM still reports INVALID_SECRET_KEY', async () => { + const error = await sendXLM({ + sourceSecret: 'nope', + destination: PUBLIC, + amount: '10', + }).catch((e) => e); + expect(error).toBeInstanceOf(PocketPayError); + expect(error.code).toBe('INVALID_SECRET_KEY'); + }); + + it('sendXLM still reports INVALID_AMOUNT', async () => { + const error = await sendXLM({ + sourceSecret: SECRET, + destination: PUBLIC, + amount: '-5', + }).catch((e) => e); + expect(error.code).toBe('INVALID_AMOUNT'); + }); + + it('sendXLM keeps its own self-payment wording', async () => { + const kp = StellarSDK.Keypair.random(); + const error = await sendXLM({ + sourceSecret: kp.secret(), + destination: kp.publicKey(), + amount: '10', + }).catch((e) => e); + expect(error.code).toBe('SELF_PAYMENT'); + expect(error.message).toBe('Cannot send XLM to yourself'); + }); + + it('sendAsset keeps its own, different self-payment wording', async () => { + const kp = StellarSDK.Keypair.random(); + const error = await sendAsset({ + sourceSecret: kp.secret(), + destination: kp.publicKey(), + amount: '10', + asset: NATIVE, + }).catch((e) => e); + expect(error.code).toBe('SELF_PAYMENT'); + expect(error.message).toBe('Cannot send asset to yourself'); + }); + + it('sendAsset still validates the asset spec', async () => { + const error = await sendAsset({ + sourceSecret: SECRET, + destination: OTHER, + amount: '10', + asset: { code: '' } as never, + }).catch((e) => e); + expect(error).toBeInstanceOf(PocketPayError); + expect(error.code).toMatch(/ASSET/); + }); +});