Repository navigation
Replace the simulate-only Soroban billing client with a real build, sign, submit and confirm flow against the vault ABI #1487
Description
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 callsinvoke, so it only simulates.sendTransactionandgetTransactionare never called anywhere insrc/. - 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) isdeduct(env, caller: Address, amount: i128, request_id: u64) -> Result<(), VaultError>. It callscaller.require_auth()and storesrequest_idfor replay protection;0means "no idempotency". balance(env) -> i128takes no arguments, yetbuildSorobanBalanceInvocationpassesuserId.
- The deployed vault (CalloraOrg/Callora-Contracts
- The hash is invented.
normalizeTxHashreadstransactionHash ?? txHash ?? hashfrom 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_KEYis defined insrc/config/env.ts(line 218) and.env.example, but nothing reads it. - Testnet deployment (from Callora-Contracts
docs/DEPLOYMENTS.md):- Vault
CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ, initialised withauthorized_caller = GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ,max_deduct = 1000000000. - RPC
https://soroban-testnet.stellar.org, passphraseTest SDF Network ; September 2015.
- Vault
Scope
In scope
- New client
SorobanVaultBillingClientimplementing the existingSorobanClientinterface insrc/services/billing.ts:getBalance(userId)resolves the user's vault contract id (fromVaultRepository.findByUserId, falling back toSOROBAN_BILLING_CONTRACT_IDfor single-vault deployments). It simulatesbalance()with no arguments and decodes thei128withscValToNative.deductBalance(userId, amount, idempotencyKey)does the following:- builds
deduct(caller: Address(backend), amount: i128, request_id: u64); - calls
server.prepareTransaction(simulate and assemble the footprint and resource fee); - signs with
Keypair.fromSecret(SOROBAN_BILLING_BACKEND_SECRET_KEY); - calls
sendTransaction; - polls
getTransactionwith a bounded deadline; - returns the real hash only on
SUCCESS.
- builds
- Deterministic
u64request id.- Add a
BIGINTonchain_request_idallocated 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.
- Add a
- Error mapping.
- Decode
VaultErrorcontract codes from the simulation or transaction result intoSorobanRpcErrorcategories:InsufficientBalance (5)→INSUFFICIENT_BALANCE;Paused (4),Unauthorized (3),ExceedsMaxDeduct (7)→CONTRACT_ERROR, with the numeric code insimulationDetails. - Transport failures map to
NETWORK_ERRORorTIMEOUT.
- Decode
- Wiring.
createSorobanBillingServiceuses the new client whenSOROBAN_BILLING_BACKEND_SECRET_KEYand a contract id are configured. The old JSON shape is removed, not kept as a fallback. - Docs. Update
docs/fee-abstraction.md,docs/soroban-simulation-diagnostics.mdand.env.examplewith 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
deductBalancefor the same row reuses the sameonchain_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. ANOT_FOUNDpast the deadline isTIMEOUT, 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.tsreturns no request-building matches; the client uses@stellar/stellar-sdkrpc.Server. - All test-plan rows pass; row 10 is documented and skipped by default in CI.
-
npm run lint,npm run typecheck,npm run buildandnpm testpass, and CI is green. - At least 90% line coverage on the new client (
npm run test:coverage). -
docs/openapi.jsonerror examples for/api/billing/deductlist the new contract error codes, andnpm run validate:openapipasses.
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 -- balanceRead 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_idallocation 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
esegbueadam-sys commentedon Oct 18, 2026
@esegbueadam-sys has applied to work on this issue as part of the Stellar Wave Program's 10th wave.
ℹ️ Repo Maintainers: To accept this application, review their application or assign @esegbueadam-sys to this issue.
drips-wave commentedon Oct 19, 2026
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 🌊