How the two contracts in this workspace fit together, where state lives, how a request travels from the network boundary down to business logic, and why the protocol is shaped the way it is. Read this before Workspace and Crate Layout or the component pattern if you want the "why" before the "how."
The workspace has two deployable contract crates under contracts/:
| Contract | Role |
|---|---|
shade |
The entry point and hub: merchants, invoices, fee routing, refunds, analytics and access control live here as components (see the component pattern). |
account |
Per-merchant vault, deployed by shade's account_factory component. Holds merchant balances and services withdrawals and refunds. |
The hub calls two kinds of external contract: the merchant's account, through narrow traits declared at their call sites, and SEP-41 tokens. invoice.rs also calls a PriceOracleClient for fiat-priced invoices, an admin-configured oracle that is not part of this repository.
| Component | Account functions it calls |
|---|---|
invoice |
refund |
auto_withdrawal |
sweep_to |
merchant_account |
restrict_account, verify_account, add_token, set_withdrawal_threshold, approve_withdrawal |
account_factory |
initialize |
Contracts outside the gateway, such as escrow, subscriptions, ticketing and crowdfunding, are in example/. See ADR 0007.
Every contract owns its own persistent storage; Soroban gives each deployed contract address its own isolated ledger-entry namespace, so there is no shared database between shade, account, and the standalone contracts. Within shade:
types.rsdefines the storage-key enum,DataKey. Soroban caps every#[contracttype]enum at 50 cases.- Each component reads and writes only the key variants that belong to its domain (
pausableownsDataKey::Paused,invoiceownsDataKey::Invoice/DataKey::InvoiceCount, and so on). Cross-component reads go through the owning component's public getters — see the component pattern's boundary rules. - See Storage model for each key.
A client invokes a shade contract function (directly, or via a Soroban RPC simulateTransaction/sendTransaction); the Soroban host loads the contract's WASM, resolves the function against the exported #[contractimpl] methods, and calls into shade.rs. From there:
flowchart TD
Client[Client / wallet] -->|invoke function| Host[Soroban host / VM]
Host -->|dispatch to exported fn| ShadeImpl["shade.rs\n#[contractimpl] impl ShadeTrait for Shade"]
ShadeImpl -->|"pausable_component::assert_not_paused(&env)\n(most state-changing methods)"| Guard{Paused?}
Guard -->|yes: panic ContractPaused| Host
Guard -->|no| Component["components/*.rs\n(auth, validation, storage, events)"]
Component -->|storage read/write| Ledger[(Persistent storage\nfor this contract address)]
Component -->|cross-contract call, where needed| Account["account contract\n(MerchantAccountRefund / \nMerchantAccountContract / withdraw_to)"]
Component -->|emit| Events[[contractevent]]
Component -->|return value or panic| ShadeImpl
ShadeImpl --> Host
Host --> Client
shade_interface.rs's ShadeTrait is what makes the WASM's exported function list and the shade.rs implementation provably consistent — the trait is the single source of truth for the public surface, and #[contractimpl] impl ShadeTrait for Shade is a compile error if shade.rs doesn't implement every trait method with a matching signature.
Tracing an actual state-changing call end to end, verified against contracts/shade/src/shade.rs and contracts/shade/src/components/invoice.rs — this is the exact guard and step order, not an assumed one:
- Client calls
pay_invoice(payer, invoice_id). shade.rs::pay_invoicecallspausable_component::assert_not_paused(&env)— panics withContractPaused(error 9) if the contract is paused. This is the only guardshade.rsapplies itself before delegating.shade.rs::pay_invoicedelegates toinvoice_component::pay_invoice(&env, &payer, invoice_id).invoice::pay_invoicecallspayer.require_auth()— the payer must have authorized this specific invocation.invoice::pay_invoicecallspay_invoice_inner, which loads the invoice, checks its status isPendingorPartiallyPaid(else panicsInvalidInvoiceStatus), computes the remaining amount viaresolve_invoice_amount(which, for a fiat-priced invoice, re-queries the oracle), and callspay_invoice_partial_innerwith that remaining amount.pay_invoice_partial_innerre-validates: amount positive, invoice not expired, status still payable, amount does not exceed the invoice total, token still globally accepted. It re-resolves the fiat quote (refresh_fiat_invoice_quote) if applicable.- It resolves the merchant's address and merchant-account address, then calls
platform_fee::route_from_payer, which computes the merchant/platform split (compute_split) and executes the token transfers — this is where funds actually move, via the SEP-41 token contract'stransfer. - It updates the
Invoicerecord (amount_paid,status,payer,date_paidif now fully paid) and writes it back to storage. - It emits
InvoicePaidEvent, thenPaymentSplitRoutedEvent(viafinalize_routeinsideroute_from_payer, which itself emitsPlatformFeeRoutedEventbefore returning — so a singlepay_invoicecall can emitPlatformFeeRoutedEvent, thenInvoicePaidEvent, thenPaymentSplitRoutedEvent, in that order). - It calls
history::record_transaction, appending aTransactionto the payer's log (no event). - It calls
auto_withdrawal::check_and_trigger_auto_withdrawal, which may itself move funds again and emitAutoWithdrawalTriggeredEventif the merchant's configured threshold was just crossed, orAutoWithdrawalFailedEventif the sweep failed. A failed sweep does not revert the payment.
Guard order for pay_invoice specifically: pause check (step 2) → require_auth (step 4) → status/amount/expiry/token validation (steps 5–6) → fee computation and token transfer (step 7) → storage write (step 8) → events (step 9) → history (step 10) → auto-withdrawal sweep (step 11). pay_invoice does not call the reentrancy guard (reentrancy::enter/exit) — that guard is used selectively by a different set of functions (see the component pattern's guard discussion); do not assume it wraps every fund-moving path.
| Layer | Owns | A PR touching it should... |
|---|---|---|
shade_interface.rs |
The complete public function signature list (ShadeTrait) |
Add exactly one line per new public function; never contain logic. |
shade.rs |
Wiring: the pause guard (where applicable) plus delegation to one component call | Stay at "guard, then delegate" — if a method body does anything else, that logic belongs in a component. |
components/ |
All actual logic: auth, validation, storage access, cross-contract calls, event emission | Own only its domain's storage keys; reach other domains through the owning component's public functions, never raw storage access. |
types.rs |
Every #[contracttype] record and storage-key enum |
New persisted fields/variants only ever added, never renamed/retyped/moved — see Upgradeability's storage compatibility rules. |
events.rs |
Every #[contractevent] struct and its publish_* helper |
New events added here, never constructed ad hoc inside a component. |
errors.rs |
Every #[contracterror] enum, partitioned into non-overlapping numeric ranges per domain |
New error variants appended within the domain's assigned range; never renumber an existing one. |
One hub contract with components, rather than many small contracts, for the core payment domain. Invoices, merchants and fees all need to agree on shared concepts — a merchant's ID and active/verified state, the accepted-token whitelist, the platform fee split — cheaply and atomically. Keeping them in one contract with one storage namespace means pay_invoice can read the merchant's account address, compute the fee, transfer tokens, and record the payment in a single atomic call with no cross-contract round trip for the shared state. The component pattern (see component-pattern.md) buys the maintainability of separate modules without paying the deployment, cross-contract-call, and atomicity cost of separate contracts for logic that is this interdependent.
A factory for per-merchant contracts, where isolation matters more than shared state. A merchant's account isolates that merchant's balance under its own address, so a restriction or bug in one instance cannot touch another's funds. account_factory deploys one instance per merchant from a registered WASM hash. See Cross-Contract Calls and the Factory Pattern for the mechanics.
Events as the off-chain integration surface. Nothing in this contract exposes a webhook or push mechanism; every state transition that matters to an integrator is instead published as a #[contractevent] (see Events reference). An indexer or backend is expected to watch the event stream rather than polling every query function, which is both cheaper for the integrator and keeps the contract itself free of any notion of "who is listening."
- The component pattern — the module-level structure inside
shade, with a full inventory of the components. - Workspace and Crate Layout — the Cargo workspace, directory tree, and per-crate file conventions.
- Cross-Contract Calls and the Factory Pattern — factory deployment mechanics, SEP-41 token interactions, and authorization propagation across contract boundaries.
- Upgradeability, WASM hash management, and versioning — how the hub's code (and, separately, factory-deployed WASM) can change after deployment, and the storage-compatibility rules that govern it.
ShadeTraitfunction reference- Events reference