Case study · Full-stack product

PULSO — a non-custodial exchange, and the bug we caught before you could

Eleven modules, three languages, four test layers — but the part worth reading is the post-mortem: a real economic bug in the v1 staking contract, found by adversarial self-audit, quantified on-chain, and fixed with a two-layer defense before v2. That loop — ship, attack your own work, redeploy — is the product.

React/ViteFastAPISolidity/FoundrywagmiGroq LLMi18n ×3Playwrightpytest

What it is

A demo-grade, non-custodial exchange platform: live market data (top 100 with sparklines and candlestick detail), a crypto↔USD↔ARS converter with MEP/CCL/USDT dollar rates for the Argentine market, wallet portfolio valuation, price alerts, a DeFi protocol comparator, an Argentina-focused yield table, on-chain staking with a real wallet, paper-trading bots (DCA / Grid / SMA crossover) running on a backend engine, a trends dashboard, an 8-lesson education course with quizzes, and a context-aware AI assistant. Every module states plainly what's testnet and what's paper — no fabricated numbers anywhere.

The post-mortem: a Synthetix-pattern edge case, found at home

The v1 deploy script called notifyRewardAmount in the same broadcast as the deploy — before any staker existed. In the Synthetix staking pattern, with totalSupply == 0, rewardPerToken() doesn't advance but lastUpdateTime does: every token dripped until the first stake() is credited to no one. And because recoverERC20 excludes the staking token entirely (a deliberate safety rule), those tokens are irrecoverable by design.

Quantified on-chain with cast during the internal audit: ~917 PULSO orphaned in ~6.6 hours (3.334 PULSO/day drip rate). Not a hypothesis — a measured number against the live v1 contract.

The v2 fix, two layers:

  1. notifyRewardAmount now reverts with NoStakers() when totalSupply == 0 — the contract refuses the foot-gun.
  2. The deploy script anchors 1 PULSO of deployer stake before funding rewards — the operational cause can't recur.

Alternative considered and rejected: freezing lastUpdateTime while supply is zero (a variant some Synthetix forks use) gifts the entire accrued span to whoever stakes first — front-runnable, and worse than requiring the anchor. The residual case (everyone exits mid-period) is quantified by a dedicated test and accepted as a known trade-off rather than papered over with gas-heavy checkpoints.

Other deliberate contract decisions documented in DESIGN.md: an asymmetric pause (pause() can stop staking and claiming, but unstake is never pausable — a "non-custodial" system whose pause traps principal is a contradiction), and notifyRewardAmount doing an atomic pull with a solvency check instead of trusting a prior transfer.

Engineering that doesn't show in screenshots

LayerWhat's there
ContractsPulsoToken + PulsoStaking v2 verified on Sepolia, 20 Foundry tests including an adversarial suite and a solvency invariant born from the post-mortem.
BackendFastAPI with a background paper-trading engine (aiosqlite, WAL, single-flight TTL caches per router), global + per-endpoint rate limiting, and graceful degradation everywhere: if CriptoYa / DefiLlama / alternative.me are down, the API serves what it can instead of failing. When CoinGecko started rejecting datacenter IPs in production, /top100 grew a keyless CoinPaprika fallback validated against the same pydantic model. 57 pytest tests, all upstreams mocked.
FrontendReact + Vite + wagmi/RainbowKit, 11 lazy-loaded modules, shared rate helpers, defensive localStorage (broken JSON degrades, never crashes). 23 vitest unit tests on the pure logic.
E2E6 Playwright flows against the real production build with the API fully stubbed via route interception — deterministic, no upstream flakes: market table, converter math end-to-end, alerts firing, i18n switch.
i18nES/EN/PT at 421 keys ×3 with a CI parity guard — plus the full education course (8 lessons + 32 quiz questions) editorially translated, with its own content-parity check (same IDs, same question counts, same correct answers across languages).
CIThree jobs (Foundry / web / api) on every push: build, lint, both parity guards, all four test layers.

Lessons

  • Audit your own deploys like an attacker — the v1 bug wasn't in the contract logic, it was in the orchestration (deploy script ordering). The fix hardened both.
  • Degrade, don't die: every external dependency (four data upstreams + an LLM) has a tested failure path that keeps the product usable.
  • Guards over discipline: i18n and lesson parity are CI checks, not review checklist items — they can't silently rot.