Skip to content

Replace the simulate-only Soroban billing client with a real build, sign, submit and confirm flow against the vault ABI #1487

Description

@greatest0fallt1me

Summary

SorobanRpcBillingClient never moves money. It posts a made-up simulateTransaction payload with an invocation object, which the real Soroban RPC does not accept. It passes the user id as a String where the vault expects an Address, sends the idempotency key as a String where the vault expects a u64, and reads a "transaction hash" from a simulation response that has none. This issue implements the client with @stellar/stellar-sdk (already a dependency). It builds the vault deduct(caller, amount, request_id) call, simulates and assembles it, signs with the backend's authorized-caller key, submits it, and polls for the final status. It also maps the vault's typed errors to stable categories, and it is exercised against the deployed testnet vault.

Background and evidence

  • Not a real RPC request. src/services/sorobanBilling.ts, invoke (about lines 300-363), sends { method: 'simulateTransaction', params: { invocation, sourceAccount, networkPassphrase } }. The real method takes { transaction: <base64 XDR> }.
  • Nothing is ever submitted. deductBalance (about line 280) only calls invoke, so it only simulates. sendTransaction and getTransaction are never called anywhere in src/.
  • Arguments do not match the vault ABI. buildSorobanDeductInvocation (about line 157) sends [{type:'string', value:userId}, {type:'i128', value:amount}, {type:'string', value:idempotencyKey}].
    • The deployed vault (CalloraOrg/Callora-Contracts contracts/vault/src/lib.rs) is deduct(env, caller: Address, amount: i128, request_id: u64) -> Result<(), VaultError>. It calls caller.require_auth() and stores request_id for replay protection; 0 means "no idempotency".
    • balance(env) -> i128 takes no arguments, yet buildSorobanBalanceInvocation passes userId.
  • The hash is invented. normalizeTxHash reads transactionHash ?? txHash ?? hash from the simulation result. Simulation responses carry none of these, so either every deduct fails or a stub hash is persisted as if the deduction happened.
  • The signing key is unused. SOROBAN_BILLING_BACKEND_SECRET_KEY is defined in src/config/env.ts (line 218) and .env.example, but nothing reads it.
  • Testnet deployment (from Callora-Contracts docs/DEPLOYMENTS.md):
    • Vault CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ, initialised with authorized_caller = GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ, max_deduct = 1000000000.
    • RPC https://soroban-testnet.stellar.org, passphrase Test SDF Network ; September 2015.

Scope

In scope

  1. New client SorobanVaultBillingClient implementing the existing SorobanClient interface in src/services/billing.ts:
    • getBalance(userId) resolves the user's vault contract id (from VaultRepository.findByUserId, falling back to SOROBAN_BILLING_CONTRACT_ID for single-vault deployments). It simulates balance() with no arguments and decodes the i128 with scValToNative.
    • deductBalance(userId, amount, idempotencyKey) does the following:
      1. builds deduct(caller: Address(backend), amount: i128, request_id: u64);
      2. calls server.prepareTransaction (simulate and assemble the footprint and resource fee);
      3. signs with Keypair.fromSecret(SOROBAN_BILLING_BACKEND_SECRET_KEY);
      4. calls sendTransaction;
      5. polls getTransaction with a bounded deadline;
      6. returns the real hash only on SUCCESS.
  2. Deterministic u64 request id.
    • Add a BIGINT onchain_request_id allocated from a Postgres sequence when the Phase 1 row is inserted, and reuse it for every retry of that row.
    • Never send 0.
    • Document the mapping in docs/sdk/billing-deduct.md.
  3. Error mapping.
    • Decode VaultError contract codes from the simulation or transaction result into SorobanRpcError categories: InsufficientBalance (5) → INSUFFICIENT_BALANCE; Paused (4), Unauthorized (3), ExceedsMaxDeduct (7) → CONTRACT_ERROR, with the numeric code in simulationDetails.
    • Transport failures map to NETWORK_ERROR or TIMEOUT.
  4. Wiring. createSorobanBillingService uses the new client when SOROBAN_BILLING_BACKEND_SECRET_KEY and a contract id are configured. The old JSON shape is removed, not kept as a fallback.
  5. Docs. Update docs/fee-abstraction.md, docs/soroban-simulation-diagnostics.md and .env.example with the required keys and the testnet values above.

Out of scope

  • Resolving rows that are left pending (a separate issue).
  • Settlement and revenue-pool payouts.
  • Startup validation of key and contract-id formats (a separate issue).

Design constraints

  • Idempotent retries. A retried deductBalance for the same row reuses the same onchain_request_id. If the vault reports the id as already processed, return the stored hash or look it up; never double charge.
  • Polling budget. Polling must finish within SOROBAN_BILLING_RPC_TIMEOUT_MS × attempts, so it never outlives the HTTP request deadline. A NOT_FOUND past the deadline is TIMEOUT, not success.
  • No secrets in logs. Never log the secret key or the full signed XDR; reuse redactSimulationDetails.
  • Testable. Inject rpc.Server (or a thin interface) so tests can stub the RPC without the network.

Test plan

Unit tests in src/services/sorobanVaultBillingClient.test.ts with a stubbed rpc.Server, plus one opt-in live test.

# Scenario Expected
1 deduct happy path the operation encodes Address, i128, u64; signed by the backend key; returns the getTransaction hash
2 Simulation returns Error(Contract, #5) SorobanRpcError with category INSUFFICIENT_BALANCE; nothing submitted
3 Simulation returns Error(Contract, #4) (paused) CONTRACT_ERROR with code 4 in diagnostics
4 sendTransaction returns TRY_AGAIN_LATER treated as transient and retried with the same request_id
5 getTransaction stays NOT_FOUND past the deadline TIMEOUT; no hash returned
6 getTransaction returns FAILED CONTRACT_ERROR; no hash returned
7 balance() invoked with zero args; i128 decoded to a bigint string
8 Retry after a submitted-but-unknown result the same onchain_request_id is sent both times
9 Secret key missing createSorobanBillingService returns undefined and logs a config warning; no partial client
10 Live (opt-in SOROBAN_LIVE_TESTNET=1) balance() simulate against CBJZLUKQ...XDYQ on testnet returns a non-negative integer

Regression proof. Row 1 fails on current main: the old client never calls sendTransaction and sends String args.

Acceptance criteria

  • grep -n "invocation" src/services/sorobanBilling.ts returns no request-building matches; the client uses @stellar/stellar-sdk rpc.Server.
  • All test-plan rows pass; row 10 is documented and skipped by default in CI.
  • npm run lint, npm run typecheck, npm run build and npm test pass, and CI is green.
  • At least 90% line coverage on the new client (npm run test:coverage).
  • docs/openapi.json error examples for /api/billing/deduct list the new contract error codes, and npm run validate:openapi passes.

Getting started

git checkout -b feat/soroban-vault-billing-client
npm ci && npx prisma generate
npm run test:unit -- src/services/sorobanBilling src/services/billing
# read-only probe of the testnet vault:
stellar contract invoke --id CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ --network testnet --send=no -- balance

Read first: src/services/sorobanBilling.ts, src/services/createSorobanBillingService.ts, executeDeductWithRetry in src/services/billing.ts, and the deduct rustdoc in Callora-Contracts contracts/vault/src/lib.rs.

PR requirements

  • Link this issue (Closes #<n>).
  • Describe the request_id allocation and why retries cannot double charge.
  • Paste unit test output and, if you ran it, the live testnet probe output with the tx hash.
  • Keep the diff focused on the client and its wiring.
  • Example commit: feat(billing): submit vault deduct via stellar-sdk with u64 request ids.

Priority rationale

High. Today the billing path cannot charge anyone on-chain, yet it can persist fabricated hashes as proof of payment. This is the core money-moving integration. It needs Soroban transaction assembly, auth, idempotency and error decoding.

Activity

  1. esegbueadam-sys commented on Oct 18, 2026

    @esegbueadam-sys
    Contributor

    @esegbueadam-sys has applied to work on this issue as part of the Stellar Wave Program's 10th wave.

    Hi, maintainer. I would like to work on your issue.

    ℹ️ Repo Maintainers: To accept this application, review their application or assign @esegbueadam-sys to this issue.

  2. drips-wave commented on Oct 19, 2026

    @drips-wave

    Congratulations, @esegbueadam-sys! 🎉 Your application was accepted by the repo's maintainers, and the issue is due on October 22, 2026.

    🧑‍💻 @esegbueadam-sys: Please resolve the issue such that the repo's maintainers have enough time to review your contribution before the due date. You'll earn Points for completing the issue on-time, which will make you eligible for a share of the Stellar Wave Program's reward pool.

    Warning

    When opening a PR, please link it to this issue to ensure it gets tracked accurately. Points are awarded when this issue is marked as completed by the maintainer.

    🤠 Repo maintainers: Please keep an eye on the contributor's progress and review their work before the due date. You can manage this issue, including adjusting its complexity and points, here.

    🌊 Happy Wave 🌊

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions