Skip to content

fix(trades): convert sell amounts to stroops explicitly (issue #292) - #426

Merged
dark-sarge merged 4 commits into
arflexx:mainfrom
CodingBabe-1:fix/292-explicit-stroop-conversion
Oct 1, 2026
Merged

dark-sarge merged 4 commits into
arflexx:mainfrom
CodingBabe-1:fix/292-explicit-stroop-conversion

Conversation

@CodingBabe-1

Copy link
Copy Markdown
Contributor

Summary

The sell form collected a naira amount and services/stellar.ts multiplied it
by 1_000_000 on the way to the escrow contract, so the unit conversion was
implicit and buried in the service. A second conversion anywhere on the path
would silently create a listing for a million times the intended amount.

This makes the conversion explicit and shared: the client converts once with
toStroops() from packages/shared, the create-listing API validates the
amount as a positive integer already in stroops, and the service forwards
that exact integer to the contract. There is no scale factor left on the
contract path.

Closes #292


Type of Change

  • feat — new feature
  • fix — bug fix
  • refactor — code change with no behaviour change
  • docs — documentation only
  • chore — build, deps, config
  • contract — Soroban smart contract change

What Changed.

File Change
packages/shared/units.js / units.d.ts New dependency-free CJS + .d.ts module: toStroops(amount: number): bigint (pure converter, Math.round(amount * 1e6), rejects non-finite), fromStroops() (accepts bigint/number/Postgres NUMERIC string), asStroops(), and STROOPS_PER_UNIT/MAX_TRADE_UNITS/MAX_TRADE_STROOPS constants.
packages/shared/package.json Adds an exports map exposing @airflex/shared/units (plus ".") so the Next app and the Express server resolve the same implementation without a build step.
frontend/app/sell/page.tsx Sends Number(toStroops(parseFloat(fields.amount))) — the naira the seller typed converted to stroops exactly once.
server/src/schemas/trade.schemas.ts amount is now z.number().int().positive().max(MAX_TRADE_STROOPS) — a positive integer already in stroops (ceiling = the sell form's ₦1,000,000 cap).
server/src/routes/trades.ts POST passes the validated amount straight to createListing (asStroops(amount), no * 1_000_000) and persists the naira equivalent with fromStroops; buy/prepare derives the deposit amount with toStroops(Number(trade.amount)).
server/src/services/stellar.ts createListing / buildEscrowDepositXdr take amountStroops: bigint and pass it to nativeToScVal(..., "i128") unchanged; both * 1_000_000 calls are gone and the span attribute is now trade.amount_stroops.
server/src/schemas/trade.schemas.test.ts, server/src/utils/units.test.ts Cover 0, negative and fractional inputs, the ₦1,000,000 cap in stroops, and round-tripping.
server/src/routes/trades.create.test.ts New route tests: the contract receives the exact stroop integer (no scaling), the ledger stores naira, and fractional/zero amounts are rejected with 422.
frontend/app/sell/sell.test.tsx Asserts the POST body carries 5_000_000_000 for a ₦5,000 listing.
server/src/openapi.ts, server/openapi.json, docs/api-reference.md, docs/getting-started.md Document amount as stroops on POST /api/v1/trades and note the naira ledger unit.

How to Test

# 1. Server unit + route tests
cd server
cp .env.test .env
NODE_ENV=test DOTENV_CONFIG_PATH=.env.test \
  NODE_OPTIONS="--require=dotenv/config" npx jest --silent
# → 27 suites / 198 tests pass (includes the new units + trades.create suites)

# 2. Typecheck
cd server && npx tsc --noEmit
cd ../frontend && npx tsc --noEmit

# 3. Frontend tests + build
cd frontend && npx jest          # 19 suites / 119 tests pass
NEXT_PUBLIC_API_URL=http://localhost:3001 npx next build

Manual check (server running, authenticated seller with verified KYC):

# ₦500 listing = 500,000,000 stroops; the contract call must receive exactly that.
curl -X POST http://localhost:3001/api/v1/trades \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"assetType":"MTN_AIRTIME","amount":500000000,"expiresInHours":24}'

# Fractional stroops are rejected before any contract call:
#   {"amount": 500.5} → 422 "amount must be a whole number of stroops"

Checklist

General

  • Code compiles / builds without errors
  • No new TypeScript errors (tsc --noEmit)
  • Follows existing code style and patterns
  • No secrets, keys, or credentials committed
  • .env.example updated if new env vars were added (none added)

API changes

  • Request/response shapes documented in docs/api-reference.md
  • Zod validation added for all request inputs
  • Correct HTTP status codes returned
  • Auth middleware applied where required

Smart contract changes

  • N/A — no contract source changed

Database changes

  • N/A — no migration; trade_offers.amount keeps its existing naira meaning

Docs changes

  • Relevant doc in docs/ updated or created

Notes for Reviewer

Unit boundary — please read. POST /api/v1/trades now takes amount in
stroops (the escrow contract's unit, per the issue) while responses keep
reporting amount in naira. The platform ledger — trade_offers.amount,
fee_amount, seller_net_amount, wallets and transactions — is denominated
in naira, so the route stores fromStroops(amount) rather than rewriting the
column's unit (which would have misread every existing row and needed a
migration to backfill). Only the request body and the contract call speak
stroops; fromStroops() is used on the way back in for the deposit.

toStroops is a pure converter. It does not reject 0 or negatives —
validation lives in the schema (int().positive().max()) and in the sell
form's validate(). The unit tests pin the 0 / negative / fractional behaviour
so it cannot drift.

Base commit. This branch sits on a65afac (CI repair for the duplicate
getServerKeypair and related breakage left by recent merges), which is also
the base of #423. That commit is unrelated to #292 and is included only because
main does not currently build; once either merges the shared commit drops out
of the diff.

Follow-ups worth considering (out of scope here): normalising the amount
filters (GET /trades?minAmount=) and the admin analytics volume sums if the
ledger is ever switched wholesale to stroops.

…sell path

The sell form sent the raw naira figure and services/stellar.ts multiplied by
1_000_000 on the way to the escrow contract, so the conversion was implicit and
a future call site could easily scale the amount a second time.

Add `toStroops`/`fromStroops`/`asStroops` to packages/shared, have the sell form
convert once, and require a positive integer already in stroops at the API. The
service forwards that integer to the contract untouched, while the naira the
seller quoted is still what lands in the naira-denominated platform ledger.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@drips-wave

drips-wave Bot commented Sep 30, 2026

Copy link
Copy Markdown

@CodingBabe-1 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Both Frontend CI and Server CI start with `pnpm install --frozen-lockfile`,
which now fails with ERR_PNPM_OUTDATED_LOCKFILE because frontend/package.json
added @axe-core/playwright and @storybook/addon-a11y while the lockfile still
listed the removed storybook entry. Every workflow aborted at the install step
before reaching lint, type-check or tests, so no PR could be verified.

Regenerated with the repo's pinned pnpm 10.28.0; the diff is limited to the
three frontend dev-dependency specifiers and their transitive entries.
`pnpm test -- --run --json --outputFile=test-results.json` expands to
`jest --runInBand --forceExit --run ...`, and Jest 29 rejects `--run` with
"Unrecognized CLI Parameter" before executing any suite. No test-results.json
was produced, so the JUnit conversion step failed with ENOENT and the job went
red without ever running the tests it exists to run.

Verified locally with the CI environment (NODE_ENV=test, .env.test): 27 suites
/ 198 tests execute and test-results.xml is written.
@dark-sarge
dark-sarge merged commit bbcdd75 into arflexx:main Oct 1, 2026
3 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[frontend] - Sell form sends amount without explicit stroop conversion making unit handling ambiguous

2 participants