Problem
SorobanAdapter signs every submitted transaction with a single admin keypair, which the aid_escrow contract's require_auth() checks will reject for the two operations that must be signed by someone else. In soroban.adapter.ts:
private getKeypair(): Keypair {
if (!this.keypair) this.keypair = Keypair.fromSecret(this.adminSecretKey);
return this.keypair;
}
// ...
preparedTx.sign(kp); // ← always the SOROBAN_ADMIN_SECRET_KEY keypair
The contract requires the recipient to authorise claims and the operator to authorise package creation:
// app/onchain/contracts/aid_escrow/src/lib.rs
package.recipient.require_auth(); // in claim()
Self::require_admin_or_distributor(&env, &operator)?; // operator.require_auth() in create_package()
Consequence: the backend can only successfully submit operations where the admin is the required signer (disburse, revoke, refund, fund, config). claimAidPackage submits claim signed by the admin, which fails recipient.require_auth() for any real recipient; createAidPackage with a distributor operatorAddress fails require_admin_or_distributor because the admin keypair is not that distributor. There is no method to accept or inject a recipient/distributor-signed transaction (no XDR submission path, no fee-bump/sponsorship option), so the backend cannot orchestrate recipient claims or distributor-created packages for end users who hold their own keys. By contrast, tools/testnet-smoke/index.js only completes the claim flow by holding the recipient's secret itself (SOROBAN_RECIPIENT_SECRET_KEY), confirming the gap in the production adapter.
Root cause
The adapter assumes a single server-side key for all flows, but the contract's auth model is per-caller: recipient claims and distributor operations require the caller's signature, which the backend does not possess.
Why this is architecturally hard
- This is a signing-orchestration design, not a key swap. The backend must either (a) accept an already-built, user-signed transaction XDR and submit/sponsor it, or (b) return an unsigned/stub transaction for the client (mobile WalletConnect / Freighter) to sign. Each choice changes the
OnchainAdapter interface, which today returns { transactionHash, status } from a server-side submit.
- It crosses the trust boundary. Holding recipient keys server-side would defeat the wallet model; the fix must define a sponsorship/fee-bump strategy (who pays fees) and a nonce/sequence management story for client-built transactions.
- The interface is the contract.
OnchainAdapter.claimAidPackage/createAidPackage signatures would need a new "unsigned XDR" or "submit signed XDR" mode, rippling into OnchainService, AidEscrowService, the BullMQ OnchainProcessor, and every ONCHAIN_ADAPTER_TOKEN mock in app/backend/test/.
- Parity with the smoke harness.
tools/testnet-smoke/index.js already models the correct recipient-signed claim; the production path must converge with it or the two will keep diverging.
Proposed design
Extend the adapter with a client-signing seam: e.g. buildUnsignedClaimTx(packageId, recipient) -> xdr and submitSignedTx(xdr), or a claimAidPackage overload that accepts a recipient-signed envelope. Sponsor fees via the admin account (fee bump) rather than requiring the recipient to hold XLM. Document the sequence for mobile WalletConnect and Freighter callers. Keep the existing admin-signed methods for admin-only operations.
Downstream impact
OnchainAdapter (app/backend/src/onchain/onchain.adapter.ts) is consumed by OnchainService, AidEscrowService, OnchainProcessor, ClaimsService, and every test mock under app/backend/test/. Any interface change must update those mocks. The mobile app (app/mobile/src/services/walletConnect.ts) and frontend (app/frontend/src/lib/walletStore.ts) are the eventual signers.
Acceptance criteria
Service
Tests
Documentation
Out of scope
Deleting the broken SorobanOnchainAdapter stub and ledger reconciliation are separate issues.
Getting started
Files: app/backend/src/onchain/soroban.adapter.ts, app/backend/src/onchain/onchain.adapter.ts, app/backend/src/onchain/onchain.service.ts, tools/testnet-smoke/index.js.
Good first files to read: soroban.adapter.ts submitContractOp/claimAidPackage, then tools/testnet-smoke/index.js for the recipient-keypair claim pattern to generalise.
Problem
SorobanAdaptersigns every submitted transaction with a single admin keypair, which theaid_escrowcontract'srequire_auth()checks will reject for the two operations that must be signed by someone else. Insoroban.adapter.ts:The contract requires the recipient to authorise claims and the operator to authorise package creation:
Consequence: the backend can only successfully submit operations where the admin is the required signer (
disburse,revoke,refund,fund, config).claimAidPackagesubmitsclaimsigned by the admin, which failsrecipient.require_auth()for any real recipient;createAidPackagewith a distributoroperatorAddressfailsrequire_admin_or_distributorbecause the admin keypair is not that distributor. There is no method to accept or inject a recipient/distributor-signed transaction (no XDR submission path, no fee-bump/sponsorship option), so the backend cannot orchestrate recipient claims or distributor-created packages for end users who hold their own keys. By contrast,tools/testnet-smoke/index.jsonly completes the claim flow by holding the recipient's secret itself (SOROBAN_RECIPIENT_SECRET_KEY), confirming the gap in the production adapter.Root cause
The adapter assumes a single server-side key for all flows, but the contract's auth model is per-caller: recipient claims and distributor operations require the caller's signature, which the backend does not possess.
Why this is architecturally hard
OnchainAdapterinterface, which today returns{ transactionHash, status }from a server-side submit.OnchainAdapter.claimAidPackage/createAidPackagesignatures would need a new "unsigned XDR" or "submit signed XDR" mode, rippling intoOnchainService,AidEscrowService, the BullMQOnchainProcessor, and everyONCHAIN_ADAPTER_TOKENmock inapp/backend/test/.tools/testnet-smoke/index.jsalready models the correct recipient-signed claim; the production path must converge with it or the two will keep diverging.Proposed design
Extend the adapter with a client-signing seam: e.g.
buildUnsignedClaimTx(packageId, recipient) -> xdrandsubmitSignedTx(xdr), or aclaimAidPackageoverload that accepts a recipient-signed envelope. Sponsor fees via the admin account (fee bump) rather than requiring the recipient to hold XLM. Document the sequence for mobile WalletConnect and Freighter callers. Keep the existing admin-signed methods for admin-only operations.Downstream impact
OnchainAdapter(app/backend/src/onchain/onchain.adapter.ts) is consumed byOnchainService,AidEscrowService,OnchainProcessor,ClaimsService, and every test mock underapp/backend/test/. Any interface change must update those mocks. The mobile app (app/mobile/src/services/walletConnect.ts) and frontend (app/frontend/src/lib/walletStore.ts) are the eventual signers.Acceptance criteria
Service
claimAidPackagecan submit a recipient-signedclaimtransaction without the backend holding the recipient's secret; the resulting package transitions toClaimed.createAidPackagecan submit a distributor-signedcreate_packagefor a non-adminoperatorAddress.disburse,revoke,refund) still sign with the admin keypair.Tests
Documentation
SOROBAN_INTEGRATION.md) documents the signing model and the fee-sponsorship strategy.Out of scope
Deleting the broken
SorobanOnchainAdapterstub and ledger reconciliation are separate issues.Getting started
Files:
app/backend/src/onchain/soroban.adapter.ts,app/backend/src/onchain/onchain.adapter.ts,app/backend/src/onchain/onchain.service.ts,tools/testnet-smoke/index.js.Good first files to read:
soroban.adapter.tssubmitContractOp/claimAidPackage, thentools/testnet-smoke/index.jsfor the recipient-keypair claim pattern to generalise.