Soroban (Stellar) smart contracts for the Invoisio invoice payment platform.
Initialized with stellar contract init following the official Soroban template.
This workspace uses the recommended structure for a Soroban project:
soroban/
├── Cargo.toml # Workspace manifest (soroban-sdk = "25")
├── README.md # This file
├── build.sh # Build contract WASM
├── deploy.sh # Deploy to testnet + initialize
├── invoke-record-payment.sh # Record invoice payment
├── invoke-get-payment.sh # Query payment record
├── invoke-config.sh # Query high-level contract config
├── invoke-has-payment.sh # Check payment existence
├── invoke-payment-history.sh # Page through payment history
├── invoke-propose-admin.sh # Step 1 of admin handoff: propose next admin
├── invoke-accept-admin.sh # Step 2 of admin handoff: accept and become admin
└── contracts/
└── invoice-payment/ # ← Main Invoisio contract
├── src/lib.rs # Contract logic + inline docs
├── src/test.rs # Unit tests
├── src/storage.rs # Persistent storage helpers
├── src/events.rs # Event definitions / emitters
├── src/errors.rs # Contract error types
├── Cargo.toml
├── Makefile # build / test / deploy / invoke targets
└── examples/ # Demo scripts
| Tool | Version | Install |
|---|---|---|
| Rust | stable | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
| wasm32v1-none target | — | rustup target add wasm32v1-none |
| Stellar CLI | ≥ 22 | cargo install --locked stellar-cli --features opt |
| Testnet XLM | — | Auto-funded by deploy script via Friendbot |
- macOS / Linux: All scripts work natively with bash
- Windows: Use WSL 2 (Windows Subsystem for Linux) to run the shell scripts
- Install WSL:
wsl --installin PowerShell (as Administrator) - The scripts will NOT work in PowerShell or CMD directly
- Install WSL:
- Git Bash (Windows): Should work but WSL 2 is recommended for best compatibility
All commands run from the soroban/ directory.
./build.shExpected output:
=========================================
Building Invoisio Invoice Payment Contract
=========================================
🔍 Checking prerequisites...
✅ stellar CLI: stellar 25.1.0 (a048a57...)
✅ Rust: rustc 1.93.1 (01f6ddf75 2026-02-11)
✅ wasm32v1-none target installed
🔨 Building contract...
Compiling invoice-payment v0.1.0
Finished `release` profile [optimized] target(s) in 1m 41s
✅ Build complete!
📦 WASM output:
-rwxrwxrwx 1 user user 9.9K invoice_payment.wasm
Next steps:
./deploy.sh - Deploy to Stellar testnet
./deploy.shExpected output:
=========================================
Deploying Invoisio Contract
=========================================
Network: testnet
Identity: invoisio-admin
🔑 Step 1/4: Setting up identity 'invoisio-admin'...
✅ Identity created
Address: GAIC6UD7QYAYHJ3Q5LLXWRBWGNLNKAZBFIN4CEH77CQASDOCTDRIHENL
💰 Step 2/4: Funding account from Friendbot...
✅ Account funded successfully
🚀 Step 3/4: Deploying contract to testnet...
✅ Contract deployed!
Contract ID: CA5KFRYL64YTI5Y4OWCLVJRM6UJB3D37WXGV7VVFPGYERBREF6BWOWD2
💾 Contract ID saved to contracts/invoice-payment/.contract-id
⚙️ Step 4/4: Initializing contract...
✅ Contract initialized with admin: GAIC6UD7QYAYHJ3Q5LLXWRBWGNLNKAZBFIN4CEH77CQASDOCTDRIHENL
=========================================
🎉 Deployment Complete!
=========================================
Contract ID: CA5KFRYL64YTI5Y4OWCLVJRM6UJB3D37WXGV7VVFPGYERBREF6BWOWD2
Admin: GAIC6UD7QYAYHJ3Q5LLXWRBWGNLNKAZBFIN4CEH77CQASDOCTDRIHENL
Network: testnet
XLM payment (1 XLM = 10,000,000 stroops):
./invoke-record-payment.sh \
invoisio-demo-001 \
GAIC6UD7QYAYHJ3Q5LLXWRBWGNLNKAZBFIN4CEH77CQASDOCTDRIHENL \
XLM "" 10000000Expected output:
=========================================
Recording Payment
=========================================
Contract ID: CA5KFRYL64YTI5Y4OWCLVJRM6UJB3D37WXGV7VVFPGYERBREF6BWOWD2
Invoice ID: invoisio-demo-001
Payer: GAIC6UD7QYAYHJ3Q5LLXWRBWGNLNKAZBFIN4CEH77CQASDOCTDRIHENL
Asset Code: XLM
Asset Issuer: <native XLM>
Amount: 10000000
Network: testnet
🚀 Invoking record_payment...
null
✅ Payment recorded successfully!
USDC payment (5 USDC with 7 decimals = 50,000,000):
./invoke-record-payment.sh \
invoisio-usdc-001 \
GAIC6UD7QYAYHJ3Q5LLXWRBWGNLNKAZBFIN4CEH77CQASDOCTDRIHENL \
USDC \
GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 \
50000000./invoke-get-payment.sh invoisio-demo-001Expected output:
=========================================
Retrieving Payment Record
=========================================
Contract ID: CA5KFRYL64YTI5Y4OWCLVJRM6UJB3D37WXGV7VVFPGYERBREF6BWOWD2
Invoice ID: invoisio-demo-001
Network: testnet
{
"amount": "10000000",
"asset": "Native",
"invoice_id": "invoisio-demo-001",
"payer": "GAIC6UD7QYAYHJ3Q5LLXWRBWGNLNKAZBFIN4CEH77CQASDOCTDRIHENL",
"timestamp": 1772475360
}
Use one permissionless call to read admin ownership, initialization status, version metadata, and current allowlist policy:
./invoke-config.shExpected output:
{
"admin": "GAIC6UD7QYAYHJ3Q5LLXWRBWGNLNKAZBFIN4CEH77CQASDOCTDRIHENL",
"pending_admin": null,
"initialized": true,
"version": {
"contract_version": 1000000,
"storage_schema_version": 1
},
"allowlist_mode": {
"native_allowed": false,
"requires_token_allowlist": true
}
}Builds the contract WASM file for deployment.
- Prerequisites check: Validates Rust, Stellar CLI, and wasm32v1-none target
- Auto-installs wasm32v1-none if missing
- Output:
target/wasm32v1-none/release/invoice_payment.wasm(~10KB)
Deploys the contract to Stellar testnet and initializes it.
Environment variables:
STELLAR_NETWORK— Network to use (default:testnet)STELLAR_IDENTITY— Identity name (default:invoisio-admin)
What it does:
- Creates/verifies the identity exists
- Funds the account from Friendbot (testnet only)
- Deploys the WASM to the network
- Initializes the contract with the admin address
- Saves
CONTRACT_IDtocontracts/invoice-payment/.contract-id
Network configuration is stored in manifests/ as TOML files. deploy.sh
reads the correct manifest automatically based on STELLAR_NETWORK.
| File | Purpose |
|---|---|
manifests/testnet.toml |
Testnet (default) — Friendbot-funded, SDF RPC |
manifests/mainnet.toml |
Mainnet — pre-funded admin required |
Each manifest covers:
[network]— passphrase, RPC URL, Horizon URL[identity]— local keys identity name and the env var that holds the secret key[contract]— WASM path and where to write the deployed contract ID[assets]— allowlist of accepted payment assets (CODE:ISSUERor"native")
Secrets are never stored in the manifest — they are referenced by env var name only.
Testnet (default):
./build.sh
./deploy.sh
# or explicitly:
STELLAR_NETWORK=testnet ./deploy.shMainnet:
./build.sh
STELLAR_NETWORK=mainnet INVOISIO_ADMIN_SECRET=S... ./deploy.shAdding a new environment (e.g. futurenet): copy manifests/testnet.toml,
rename it manifests/futurenet.toml, update the [network] block, and run:
STELLAR_NETWORK=futurenet ./deploy.shRecords an invoice payment on-chain.
Usage:
./invoke-record-payment.sh <invoice_id> <payer> <asset_code> <asset_issuer> <amount>Arguments:
invoice_id— Unique invoice identifier (e.g.,invoisio-abc123)payer— Stellar account that made the payment (G...)asset_code—XLMfor native, orUSDC,EURT, etc.asset_issuer— Issuer address for tokens (use""for XLM)amount— Amount in smallest unit (stroops for XLM)
Environment variables:
STELLAR_NETWORK— Network (default:testnet)STELLAR_IDENTITY— Signing identity (default:invoisio-admin)CONTRACT_ID— Override contract ID
Examples:
# XLM payment (1 XLM)
./invoke-record-payment.sh invoisio-001 GB7TAYRUZGE6T... XLM "" 10000000
# USDC payment (5 USDC)
./invoke-record-payment.sh invoisio-002 GB7TAYRUZGE6T... USDC GBBD47IF6LWK... 50000000Retrieves a payment record from the contract.
Usage:
./invoke-get-payment.sh <invoice_id>Returns: JSON payment record with invoice_id, payer, asset, amount, timestamp
Returns a stable JSON snapshot with:
admin— current admin address, ornullbefore initializationpending_admin— address proposed as next admin viapropose_admin, ornullwhen no transfer is in flightinitialized— whetherinitialize(admin)has been calledversion.contract_version— packed semver for the state-writing contract buildversion.storage_schema_version— storage layout versionallowlist_mode.native_allowed— whether native XLM is acceptedallowlist_mode.requires_token_allowlist— whether issued assets must be explicitly allowlisted
Checks if a payment exists for an invoice (non-panicking).
Usage:
./invoke-has-payment.sh <invoice_id>Returns: true if payment exists, false otherwise
Retrieves a bounded page of payment history.
Usage:
./invoke-payment-history.sh <cursor> [limit]Returns: a page of payment records with next_cursor and has_more
Tracks invoice payments on Soroban so any off-chain indexer can reconcile
on-chain activity with native Stellar Payment operations observed via Horizon.
Every call to record_payment both persists the record and emits a Soroban
event, giving the Invoisio backend two independent reconciliation paths:
- Horizon polling — watch for native
Paymentops with memoinvoisio-<id>. - Soroban event streaming — subscribe to
invoice_payment_recordedevents viagetEvents.
| Decision | Rationale |
|---|---|
| Admin-gated writes | Only the backend service account (admin) may call record_payment |
| Two-step admin handoff | propose_admin (current admin) + accept_admin (proposed admin) — no single transaction can change the admin, so a lost/compromised key can never hand off alone |
One record per invoice_id |
Idempotent; prevents double-counting in reconciliation |
| Persistent storage | Records survive ledger archival windows |
| Soroban events | Full PaymentRecord in each event; subscribers don't need to poll state |
Admin rights are transferred in two explicit steps, replacing the old
single-step set_admin:
- Propose — the current admin calls
propose_admin(new_admin)(current admin authorises). The proposal is staged in instance storage; the admin does not change yet. Emitsadmin_transfer_proposed. - Accept — the proposed address calls
accept_admin(caller)(the proposed address authorises). The role transfers and the proposal is cleared. Emitsadmin_transfer_accepted.
Each step can happen in a separate transaction, in any order from a signing
perspective, and the pending state is observable via the permissionless
pending_admin() view (also surfaced as ContractConfig.pending_admin).
The CLI scripts invoke-propose-admin.sh and invoke-accept-admin.sh drive
the flow; the TS client exposes proposeAdmin() / acceptAdmin() /
getPendingAdmin().
The contract uses a practical hybrid strategy:
- Semver for contract code (
CONTRACT_VERSION). - Explicit on-chain schema metadata (
ContractMeta { contract_version, storage_schema_version }). - Versioned storage keys for records (
PaymentV1(invoice_id)), while retaining legacy read support (Payment(invoice_id)).
- Major breaking changes (new required fields, behavioral changes): deploy a new contract address.
- Backward-compatible updates (bug fixes, additive methods): code can be upgraded in place, and metadata tracks state/schema.
- Legacy safety: reads still accept old keys and lazily migrate them to the current key namespace.
- Keep the existing
PaymentRecordedevent and topic (payment_recorded) stable for v1 consumers. - For future breaking event payload changes, emit a new event name (for example,
payment_recorded_v2) instead of mutating the old one. - During migrations, optionally emit both events for one release window so indexers can cut over safely.
| Change type | Address strategy | State strategy |
|---|---|---|
| Patch/minor, no schema break | Same contract address (WASM update) | Keep schema version, or increment only if data layout changes |
| Additive schema change with fallback | Same address possible | Increment schema, keep legacy read path |
| Breaking schema/API change | New contract address | Migrate data off-chain and repopulate new contract |
Contract v1 (C1) live
-> freeze new writes in backend
-> export invoice/payment records from C1 (state + events)
-> deploy v2 contract (C2) and initialize admin
-> replay/import records into C2 (idempotent write path)
-> backend dual-read (C1 + C2), write only to C2
-> switch indexers/clients to C2 as primary
-> retire C1 after verification window
- Clients should call
contract_version()andversion_info()to detect runtime compatibility. - Prefer bindings generated from the exact contract artifact version in use.
- Keep a per-network contract registry in backend config, for example:
invoice_payment_v1_contract_idinvoice_payment_v2_contract_id
| Method | Auth | Description |
|---|---|---|
initialize(admin) |
— | One-time setup; registers the admin address. |
record_payment(invoice_id, payer, asset_code, asset_issuer, amount, settlement_ref) |
admin | Persist record + emit event. settlement_ref is a non-empty hash/reference ID (≤ 128 chars) for backend deduplication. |
get_payment(invoice_id) → PaymentRecord |
— | Return stored record. Errors: InvalidInvoiceId (empty id), PaymentNotFound (no record). |
has_payment(invoice_id) → bool |
— | Returns true if a payment exists; false if invoice_id is empty or no record. |
payment_count() → u32 |
— | Total payments recorded. |
payment_history(cursor, limit) → PaymentHistoryPage |
— | Return a bounded, cursor-friendly page of payment history. limit is capped on-chain. |
contract_version() → u32 |
— | Current WASM code version (packed semver). |
version_info() → ContractMeta |
— | On-chain state metadata (contract_version, storage_schema_version). |
admin() → Address |
— | Current admin. |
| `pending_admin() → Address | null` | — |
propose_admin(new_admin) |
admin | Step 1 of two-step admin handoff: propose the next admin (current admin signs). |
accept_admin(caller) |
proposed_admin | Step 2 of two-step admin handoff: the proposed address accepts and becomes admin. |
payment_history(cursor, limit) pages the append-only indexed history maintained by the contract, and the contract caps limit on-chain so the read remains bounded.
The contract uses #[contracterror]; these codes are returned as ScError::Contract(code) in Horizon and when invoking via stellar contract invoke. They are stable part of the on-chain ABI — do not reorder or remove.
| Code (u32) | Name | Description |
|---|---|---|
| 1 | AlreadyInitialized | initialize() was called on a contract that is already set up. |
| 2 | NotInitialized | A method requiring admin was called before initialize(). |
| 3 | PaymentAlreadyRecorded | record_payment() was called with an invoice_id already recorded. |
| 4 | PaymentNotFound | get_payment() was called for an invoice_id that has no record. |
| 5 | InvalidAmount | amount was zero or negative; payments must be strictly positive. |
| 6 | InvalidInvoiceId | invoice_id was empty or otherwise invalid. |
| 7 | InvalidAsset | asset_code empty, or non-XLM asset without asset_issuer; or invalid allowlist args. |
| 8 | AssetNotAllowed | The asset (code, issuer) is not in the admin-controlled allowlist. |
| 9 | Unauthorized | The caller is not authorized to perform the operation. |
| 10 | StorageSchemaTooNew | upgrade_storage() called on a deployment whose storage_schema_version is newer than this WASM knows about. |
| 11 | StorageSchemaTooOld | upgrade_storage() called but the schema is already at or beyond the version this WASM implements. |
| 12 | ContractPaused | The contract is paused and cannot perform the requested operation. |
| 13 | InvalidSettlementRef | settlement_ref was empty or exceeded the maximum allowed length. |
| 14 | NoPendingAdmin | accept_admin() was called but no admin transfer proposal is pending. |
| 15 | PendingAdminExists | propose_admin() was called while an admin transfer proposal is already pending. |
| 16 | InvalidProposedAdmin | propose_admin() was called with the current admin (or another invalid address). |
The TypeScript client ships a typed, single-source-of-truth manifest at
soroban/client/src/error-manifest.ts (CONTRACT_ERROR_MANIFEST), mirroring
errors.rs exactly: each entry carries the stable code, the name matching
the Rust variant, and a meaning. ContractErrorCode, CONTRACT_ERROR_CODES,
and getContractErrorCode() are derived from it, and parseContractError
resolves host error strings against it — see
soroban/client/src/error-manifest.test.ts for the regression coverage.
import { CONTRACT_ERROR_MANIFEST, parseContractError } from '@invoisio/soroban-client';
// Full typed reference for off-chain error handling
CONTRACT_ERROR_MANIFEST // [{ code: 1, name: 'AlreadyInitialized', meaning: ... }, ...]
// Parse a host/simulation error string into a typed SorobanContractError
try {
await client.recordPayment(params);
} catch (err) {
if (err instanceof SorobanContractError) {
console.error(err.code); // 'PaymentAlreadyRecorded' | 'Unknown' | ...
}
}Evolving the manifest. Error codes are permanent once deployed. When a new Soroban error is introduced:
- Append the new variant at the end of
ContractErrorinsoroban/contracts/invoice-payment/src/errors.rs— never reorder, remove, or reuse codes. - Append the matching entry (same numeric
code, identical camelCasename) toCONTRACT_ERROR_MANIFESTinsoroban/client/src/error-manifest.ts. - Extend the regression tests in
soroban/client/src/error-manifest.test.tsso the new code is covered by theparseContractErrormapping tests. - Update this table, then rebuild the client (
cd soroban/client && npm run build) so the committeddist/ships the new codes to downstream consumers.
pub struct PaymentRecord {
pub invoice_id: String, // e.g. "invoisio-abc123"
pub payer: Address, // Stellar account that paid
pub asset: Asset, // Native XLM or Token(code, issuer)
pub amount: i128, // stroops for XLM; token-specific decimals
pub timestamp: u64, // ledger Unix timestamp at recording time
pub settlement_ref: String, // normalised settlement reference (≤ 128 chars)
}
pub enum Asset {
Native, // XLM
Token(String, String), // (asset_code, issuer_address)
}Multi-Asset Support: The contract supports both native XLM and any Stellar-issued token (USDC, EURT, etc.) through the Asset enum.
Every record_payment call publishes a flattened event payload so off-chain indexers and backends can parse it reliably:
Topics : (Symbol "invoice_payment_recorded")
Data : InvoicePaymentRecorded { schema_version, invoice_id, payer, asset_code, asset_issuer, amount, settlement_ref }
Note: The tx_hash is not directly inside the payload, but is automatically included by Horizon in the event envelope when fetching via RPC.
The leading schema_version field (currently 1, see EVENT_SCHEMA_VERSION in events.rs) lets off-chain indexers detect the event payload shape and stay forward-compatible. Consumers should read schema_version first and branch on it; when the payload changes in a breaking way the version is bumped (and, per the event compatibility policy above, a new event name may also be introduced).
Subscribe and decode via CLI:
stellar events \
--id <CONTRACT_ID> \
--network testnet \
--type contract \
--start-ledger 1The CLI automatically deserializes the XDR payload into human-readable JSON. A backend client can directly consume these events using generated TypeScript bindings (stellar contract bindings typescript).
Cause: Stellar CLI not installed
Fix:
cargo install --locked stellar-cli --features optVerify installation:
stellar --versionCause: Missing WASM compilation target
Fix:
rustup target add wasm32v1-noneThe build.sh script auto-installs this if missing.
Cause: Contract not built before deploying
Fix:
./build.sh
./deploy.shCause: Network issues with Friendbot or account already exists
Fix:
- Check internet connection
- Friendbot may be rate-limited; wait 1 minute and retry
- For existing accounts, the script continues automatically
Cause: Old script version or incorrect usage
Fix: For XLM payments, pass empty string as "":
./invoke-record-payment.sh invoice-001 G... XLM "" 10000000
# ↑↑
# Empty string for XLMCause: Scripts not executable (shouldn't happen on Windows)
Fix:
chmod +x *.shCause: Bash scripts require a Unix-like environment
Fix: Use WSL 2:
# In PowerShell as Administrator
wsl --install
# Then access your project in WSL
wsl
cd /mnt/c/Users/YourName/path/to/Invoisio/soroban
./build.shcargo testTests run locally without network access using soroban-sdk test utilities.
From contracts/invoice-payment/:
make build # Build contract
make test # Run tests
make deploy # Deploy (requires env setup)
make invoke-record-payment \
CONTRACT_ID=<id> \
INVOICE_ID=invoisio-001 \
PAYER=G... \
ASSET_CODE=XLM \
ASSET_ISSUER="" \
AMOUNT=10000000# Deploy to different network
STELLAR_NETWORK=futurenet ./deploy.sh
# Use custom identity
STELLAR_IDENTITY=my-admin ./deploy.sh
# Override contract ID for invocations
CONTRACT_ID=CXXXXXXXXX... ./invoke-get-payment.sh invoisio-001See contracts/invoice-payment/examples/multi_asset_demo.sh for a complete demo of XLM and USDC payments.
Aligned with the backend .env described in the root README.md:
| Variable | Testnet value |
|---|---|
STELLAR_NETWORK_PASSPHRASE |
"Test SDF Network ; September 2015" |
| Horizon URL | https://horizon-testnet.stellar.org |
| Soroban RPC | https://soroban-testnet.stellar.org |
| Friendbot | https://friendbot.stellar.org |
For mainnet use "Public Global Stellar Network ; September 2015" and the mainnet RPC.
A minimal TypeScript client library lives in soroban/client/. It is the
reference implementation for any service that needs to interact with the
deployed contract from Node.js.
| Requirement | Notes |
|---|---|
| Node.js ≥ 18 | LTS recommended |
@stellar/stellar-sdk ^14.6.0 |
Bundled as a dependency |
| Funded Stellar account | Source for simulation transactions |
| Admin secret key | Required only for write operations |
# 1. Install and build the client library
cd soroban/client
npm install
npm run build
# 2. Copy and configure environment variables
cp .env.example .env
# Edit .env — fill in SOROBAN_RPC_URL, SOROBAN_CONTRACT_ID, etc.| Variable | Description |
|---|---|
SOROBAN_RPC_URL |
Soroban RPC endpoint (testnet: https://soroban-testnet.stellar.org) |
STELLAR_NETWORK_PASSPHRASE |
Network passphrase |
SOROBAN_CONTRACT_ID |
Deployed contract ID from .contract-id |
ADMIN_SECRET_KEY |
Admin secret key — write operations only; never commit |
SOURCE_PUBLIC_KEY |
Any funded public key — read-only operations |
import { SorobanInvoiceClient, SorobanContractError } from '@invoisio/soroban-client';
const client = new SorobanInvoiceClient({
rpcUrl: process.env.SOROBAN_RPC_URL!,
networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE!,
contractId: process.env.SOROBAN_CONTRACT_ID!,
signerSecretKey: process.env.ADMIN_SECRET_KEY, // write operations
sourcePublicKey: process.env.SOURCE_PUBLIC_KEY, // read-only fallback
});
// ── Write (admin-gated) ──────────────────────────────────────────────────────
// Call this after confirming the companion Stellar Payment on Horizon.
// 150 USDC: Stellar tokens use 7 decimal places → 150 × 10_000_000 = 1_500_000_000
const result = await client.recordPayment({
invoiceId: 'invoisio-abc123',
payer: 'GCEZ...NYJH',
assetCode: 'USDC',
assetIssuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
amount: 1_500_000_000n,
settlementRef: 'sha256-abcdef...', // required: normalised settlement reference
});
console.log(`Confirmed — hash: ${result.hash}, ledger: ${result.ledger}`);
// ── Admin handoff (two-step) ─────────────────────────────────────────────────
// Step 1: current admin proposes a successor (signed by ADMIN_SECRET_KEY).
await client.proposeAdmin('GCEZ...NEWADMIN');
const pending = await client.getPendingAdmin(); // "GCEZ...NEWADMIN"
console.log('Awaiting acceptance from', pending);
// Step 2: the proposed admin accepts. Construct the client with the NEW
// admin's secret key so acceptAdmin() signs with the proposed address.
const newAdminClient = new SorobanInvoiceClient({ /* ... signerSecretKey: NEW_ADMIN_SECRET_KEY */ });
await newAdminClient.acceptAdmin();
console.log('New admin:', (await newAdminClient.getConfig()).admin);
// ── Read (permissionless) ────────────────────────────────────────────────────
const config = await client.getConfig();
console.log(config.initialized, config.admin, config.allowlistMode.nativeAllowed);
const exists = await client.hasPayment('invoisio-abc123');
if (exists) {
const record = await client.getPayment('invoisio-abc123');
console.log(record.invoiceId, record.amount, record.timestamp);
}
const total = await client.getPaymentCount();
console.log(`Total payments on-chain: ${total}`);cd soroban/client
# Record a payment (requires ADMIN_SECRET_KEY + PAYER_PUBLIC_KEY in .env)
npm run example:record
# Query a payment (requires SOURCE_PUBLIC_KEY in .env)
npm run example:query
# Query high-level contract config (requires SOURCE_PUBLIC_KEY + CONTRACT_ID)
npm run example:configSample output — record:
Recording payment for invoice invoisio-demo-001 ...
✓ Transaction confirmed
Hash : e7a4b2c1d9f83a56b0e2c4d7f1a3b8e9c0d2f4a6b8c1d3e5f7a9b0c2d4e6f8a0
Ledger : 588412
Sample output — query:
Checking invoice invoisio-demo-001 ...
Payment recorded: true
PaymentRecord {
invoiceId : invoisio-demo-001
payer : GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKRFAXMSYF6AEQYEOJ2NYJH
asset : USDC (GA5ZSEJ...K4KZVN)
amount : 1500000000 (150.0000000 USDC)
timestamp : 1741910400 (2025-03-14T00:00:00.000Z)
}
Total payments on-chain: 1
All contract-level rejections are thrown as SorobanContractError, with code
resolved against the typed manifest (CONTRACT_ERROR_MANIFEST) described in
Contract error codes:
import { SorobanContractError } from '@invoisio/soroban-client';
try {
await client.recordPayment({ invoiceId: 'invoisio-abc123', ... });
} catch (err) {
if (err instanceof SorobanContractError) {
// err.code: 'AlreadyInitialized' | 'PaymentAlreadyRecorded' | 'Unknown' | ...
console.error(`Rejected by contract [${err.code}] (${err.numericCode})`);
}
}Unknown numeric codes are surfaced as code: 'Unknown' (numeric code preserved)
so forward-compatible clients degrade gracefully when the contract grows new
errors before the client manifest is updated.
The Invoisio backend (backend/) integrates via the SorobanModule at
backend/src/soroban/. It imports @invoisio/soroban-client as a local
package reference and exposes a NestJS-injectable SorobanService.
# Build the client library first (one-time step)
cd soroban/client && npm install && npm run build
# Install backend dependencies (picks up the file: reference)
cd ../../backend && npm installAdd to backend/.env:
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
SOROBAN_CONTRACT_ID=<from .contract-id>
ADMIN_SECRET_KEY=<admin secret>
// Injected automatically via SorobanModule → InvoicesModule
constructor(private readonly sorobanService: SorobanService) {}
// Idempotency-safe reconciliation after Horizon confirms a Payment
await invoicesService.reconcilePayment(
invoiceId, payerAddress, 'USDC',
'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
'1500000000',
);The backend exposes two on-chain interaction paths:
- Write path — after confirming a native
Paymenton Horizon (matched by memoinvoisio-<invoiceId>), callrecordInvoicePaymentto anchor the data on-chain. - Event path — subscribe to
getEventson the Soroban RPC, filtering onCONTRACT_IDand topicpayment_recordedfor push-based reconciliation without polling Horizon.
Both paths are independent; the backend can start with just the Horizon watcher and add the Soroban write path later without breaking existing invoices.
When adding new functionality:
- Add tests in
contracts/invoice-payment/src/test.rs - Run
cargo testto verify - Update this README if adding new public methods
- Consider updating shell scripts if the contract API changes