Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -233,18 +233,32 @@ SOROBAN_RPC_ENABLED=false
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
SOROBAN_RPC_TIMEOUT=2000

# Billing RPC: contract ID is required in production. In development/test,
# omit it if billing deduction routes are unused or inject a fake client.
# Billing RPC: SorobanVaultBillingClient builds, signs, submits and confirms
# real vault deduct(caller, amount, request_id) transactions with stellar-sdk.
# Required keys: SOROBAN_BILLING_BACKEND_SECRET_KEY (the authorized caller's
# S... secret; the service logs a config warning and disables billing without
# it) and a contract id (SOROBAN_BILLING_CONTRACT_ID, required in production).
# Contract ID is required in production. In development/test, omit it if
# billing deduction routes are unused or inject a fake client.
# Billing RPC URL falls back to SOROBAN_RPC_URL, then http://localhost:8000.
# Deployed testnet vault (Callora-Contracts docs/DEPLOYMENTS.md):
# vault = CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ
# caller = GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ (authorized_caller, max_deduct = 1000000000)
# RPC = https://soroban-testnet.stellar.org
# passphrase = Test SDF Network ; September 2015
SOROBAN_BILLING_RPC_URL=https://soroban-testnet.stellar.org
SOROBAN_BILLING_CONTRACT_ID=your-vault-contract-id
SOROBAN_BILLING_CONTRACT_ID=CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ
SOROBAN_BILLING_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
SOROBAN_BILLING_SOURCE_ACCOUNT=your-backend-source-account
# Reserved for a future signing integration; current billing RPC client does not read it.
# Must equal the public key derived from SOROBAN_BILLING_BACKEND_SECRET_KEY
# (validated at startup) and be the vault's authorized_caller.
SOROBAN_BILLING_SOURCE_ACCOUNT=GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ
# Required: signs the vault deduct. When unset the billing service is
# disabled (config warning, no partial client) instead of failing at runtime.
SOROBAN_BILLING_BACKEND_SECRET_KEY=your-backend-secret-key
SOROBAN_BILLING_BALANCE_FN=balance
SOROBAN_BILLING_DEDUCT_FN=deduct
# Positive integer timeout in milliseconds.
# Positive integer timeout in milliseconds. Also the polling budget for one
# deduct call: polling finishes within SOROBAN_BILLING_RPC_TIMEOUT_MS x attempts.
SOROBAN_BILLING_RPC_TIMEOUT_MS=5000
# Stellar secret key used to submit fee-bumped transactions. Default: unset; fee bumping fails closed when missing.
FEE_BUMPER_SECRET_KEY=
Expand Down
35 changes: 35 additions & 0 deletions docs/fee-abstraction.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,38 @@ This event can trigger downstream webhook deliveries if the developer has subscr
| `FEE_BUMPER_SECRET_KEY` | **Yes** | Stellar secret key (`S...`) for the platform fee account |
| `STELLAR_BASE_FEE` | No (default `100`) | Base fee per operation in stroops |
| `STELLAR_NETWORK` | No (default `testnet`) | Active network: `testnet` or `mainnet` |

---

## Soroban billing (vault deduct) configuration

Fee abstraction covers classic Stellar fee bumps; the billing deduction itself
runs on Soroban through `SorobanVaultBillingClient`, which builds, signs,
submits and confirms real `vault.deduct(caller, amount, request_id)`
transactions. These keys are required for that flow:

| Variable | Required | Description |
|---|---|---|
| `SOROBAN_BILLING_BACKEND_SECRET_KEY` | **Yes** | Stellar secret (`S...`) of the vault's `authorized_caller`; signs every deduct. When unset, the billing service logs a config warning and stays disabled (no partial client). |
| `SOROBAN_BILLING_SOURCE_ACCOUNT` | **Yes** (validated) | Public key derived from the secret above; must also equal the vault's `authorized_caller`. |
| `SOROBAN_BILLING_CONTRACT_ID` | **Yes** (required in production) | Default vault contract id; per-user vaults from `VaultRepository` take precedence. |
| `SOROBAN_BILLING_RPC_URL` | Recommended | Soroban RPC endpoint (falls back to `SOROBAN_RPC_URL`, then `http://localhost:8000` outside production). |
| `SOROBAN_BILLING_NETWORK_PASSPHRASE` | Recommended | Must match the active `STELLAR_NETWORK`. |
| `SOROBAN_BILLING_RPC_TIMEOUT_MS` | No (default `5000`) | Total RPC + polling budget for one deduct call; polling always finishes within `SOROBAN_BILLING_RPC_TIMEOUT_MS × attempts`. |
| `SOROBAN_BILLING_BALANCE_FN` / `SOROBAN_BILLING_DEDUCT_FN` | No (defaults `balance` / `deduct`) | Vault entrypoints. |

Deployed testnet values (from `Callora-Contracts` `docs/DEPLOYMENTS.md`):

```bash
SOROBAN_BILLING_RPC_URL=https://soroban-testnet.stellar.org
SOROBAN_BILLING_CONTRACT_ID=CBJZLUKQBWDMZPKNJAVONZ7GCCWADSETRXWOPN5Z3XWAUNI4F2R7XDYQ
SOROBAN_BILLING_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
SOROBAN_BILLING_SOURCE_ACCOUNT=GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ
SOROBAN_BILLING_BACKEND_SECRET_KEY=<secret deriving the account above>
```

The testnet vault is initialised with `authorized_caller =
GDXDZNSTNORCX4373EGGYS4Q3U3XVZMBSIMMRHG5S5GCCF7JHC7QZWAZ` and
`max_deduct = 1000000000`. The on-chain `request_id` is the row-scoped
`usage_events.onchain_request_id` (Postgres sequence, never 0) — see
`docs/sdk/billing-deduct.md` for the idempotency contract.
1 change: 1 addition & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ Tracks every proxied API invocation and Soroban billing deduction. Hash-partitio
| `developer_id` | VARCHAR(255) | NOT NULL DEFAULT '' | API owner and hash partition key |
| `amount_usdc` | NUMERIC(30, 0) | NOT NULL, `CHECK (amount_usdc > 0)` | **Integer contract units** ("stroops", 7 decimals). 1 USDC = 10,000,000 contract units. |
| `request_id` | VARCHAR(255) | Part of UNIQUE `(request_id, developer_id)` | Idempotency correlation ID |
| `onchain_request_id` | BIGINT | NULLABLE, nextval(`usage_events_onchain_request_id_seq`) at insert | u64 sent as the vault `deduct` `request_id`. Allocated from a Postgres sequence when the Phase 1 row is inserted and reused for every retry of that row, so the vault's replay protection makes double charging impossible. Never 0 (the vault's "no idempotency" sentinel). See `docs/sdk/billing-deduct.md`. |
| `stellar_tx_hash` | VARCHAR(64) | NULLABLE | On-chain Soroban deduction transaction hash |
| `created_at` | TIMESTAMP | NOT NULL DEFAULT NOW() | Creation timestamp |

Expand Down
38 changes: 38 additions & 0 deletions docs/soroban-simulation-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,41 @@ API responses expose only a redacted summary:
```

Full account identifiers, contract identifiers, balances, XDR, keys, signatures, and hashes are redacted before diagnostics are returned to callers. Server-side warning logs retain the structured internal diagnostics for support debugging.

## Vault contract error codes

The billing deduct client (`SorobanVaultBillingClient`) decodes the numeric
discriminant from `Error(Contract, #N)` strings returned by simulation or by a
failed transaction, stores it as `simulationDetails.errorCode`, and maps it to
a stable category that drives the HTTP status:

| Code | `VaultError` | Category | Billing outcome | Typical HTTP response |
|---|---|---|---|---|
| 5 | `InsufficientBalance` | `INSUFFICIENT_BALANCE` | Row voided (definitive). Detected earlier by the balance pre-check → 402 without contract details. | 502 `SIMULATION_FAILED` with `errorCode: 5`, or 402 `INSUFFICIENT_BALANCE` |
| 3 | `Unauthorized` | `CONTRACT_ERROR` | Row voided (definitive) — the backend key is not the vault's `authorized_caller` | 502 `SIMULATION_FAILED` with `errorCode: 3` |
| 4 | `Paused` | `CONTRACT_ERROR` | Row voided (definitive) — the vault is paused | 502 `SIMULATION_FAILED` with `errorCode: 4` |
| 7 | `ExceedsMaxDeduct` | `CONTRACT_ERROR` | Row voided (definitive) — amount above the vault's `max_deduct` cap | 502 `SIMULATION_FAILED` with `errorCode: 7` |
| 29 | `DuplicateRequestId` | resolved or `TIMEOUT` | The request id was already processed: the client returns the original transaction hash when it can confirm it, otherwise reports an unknown outcome so the row stays **pending** (never voided, never double charged) | 200 with the original `stellarTxHash`, or 409 `RECONCILIATION_REQUIRED` |

Failures with no decodable code keep the message-based classification
(`simulation failed`/`contract` → `CONTRACT_ERROR`, transport errors →
`NETWORK_ERROR`, deadline exhausted → `TIMEOUT`). `NETWORK_ERROR` and
`TIMEOUT` are transient for `BillingService`: the deduction is retried with
the same `onchain_request_id`, and when the attempts run out the row stays
pending (409 `RECONCILIATION_REQUIRED`) instead of being voided.

Because every coded contract failure carries `simulationDetails`, the billing
route renders it through the canonical redacted envelope — the numeric code
survives redaction while addresses, XDR, keys and hashes do not:

```json
{
"code": "SIMULATION_FAILED",
"simulationDetails": {
"errorCode": 4,
"errorMessage": "io.callora: Error(Contract, #4)",
"eventCount": 0,
"footprintPresent": false
}
}
```
7 changes: 7 additions & 0 deletions migrations/0033_usage_events_onchain_request_id.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- dialect: postgres
-- Rollback: drops the on-chain request id column and its sequence.

ALTER TABLE usage_events
DROP COLUMN IF EXISTS onchain_request_id;

DROP SEQUENCE IF EXISTS usage_events_onchain_request_id_seq;
19 changes: 19 additions & 0 deletions migrations/0033_usage_events_onchain_request_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- dialect: postgres
-- Deterministic on-chain request ids for Soroban vault deductions.
--
-- billing.ts allocates usage_events.onchain_request_id from this sequence in
-- the same transaction that inserts the Phase 1 row, and re-sends that same
-- u64 on every retry of the row. The vault contract stores the id for replay
-- protection (VaultError::DuplicateRequestId), so retries can never
-- double charge. The sequence starts at 1: 0 is the vault's documented
-- "no idempotency" sentinel and is never sent.
--
-- The column is added WITHOUT a default on purpose: a volatile default
-- (nextval) would rewrite every partition of the live usage_events table.
-- Existing/legacy rows keep NULL and fall back to an id derived from their
-- idempotency key until re-armed.

CREATE SEQUENCE IF NOT EXISTS usage_events_onchain_request_id_seq START 1;

ALTER TABLE usage_events
ADD COLUMN IF NOT EXISTS onchain_request_id BIGINT;
10 changes: 7 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ import {
interface AppDependencies extends SorobanBillingDependencies {
usageEventsRepository?: UsageEventsRepository;
healthCheckConfig?: HealthCheckConfig;
vaultRepository?: VaultRepository;
vaultReader?: VaultStateReader;
apiRepository?: ApiRepository;
developerRepository?: DeveloperRepository;
Expand Down Expand Up @@ -97,11 +96,16 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
});
// Set database pool in locals for billing routes
app.locals.dbPool = pool;
app.locals.billingService = createSorobanBillingService(pool, dependencies);
const usageEventsRepository =
dependencies?.usageEventsRepository ?? new InMemoryUsageEventsRepository();
const vaultRepository =
// Resolve the vault repository first: the billing service uses it to map a
// user to their vault contract id when building vault deduct calls.
const vaultRepository: VaultRepository =
dependencies?.vaultRepository ?? new InMemoryVaultRepository();
app.locals.billingService = createSorobanBillingService(pool, {
...dependencies,
vaultRepository,
});
const lookupDeveloper = dependencies?.findDeveloperByUserId ?? findByUserId;
const persistApi = dependencies?.createApiWithEndpoints ?? createApi;
const { reportExporterService, scheduledExportsService } = createAppExports(usageEventsRepository);
Expand Down
Loading
Loading