Skip to content

SorobanAdapter signs every transaction with the admin keypair: recipient and distributor auth cannot be satisfied #428

Description

@kilodesodiq-arch

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

  1. 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.
  2. 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.
  3. 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/.
  4. 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

  • claimAidPackage can submit a recipient-signed claim transaction without the backend holding the recipient's secret; the resulting package transitions to Claimed.
  • createAidPackage can submit a distributor-signed create_package for a non-admin operatorAddress.
  • Admin-only operations (disburse, revoke, refund) still sign with the admin keypair.

Tests

  • A test proves a claim signed only by the admin keypair is rejected, and one signed by the recipient succeeds (mock the RPC/sign boundary).

Documentation

  • The adapter README (SOROBAN_INTEGRATION.md) documents the signing model and the fee-sponsorship strategy.

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.

cd app/backend
npm test

Good first files to read: soroban.adapter.ts submitContractOp/claimAidPackage, then tools/testnet-smoke/index.js for the recipient-keypair claim pattern to generalise.

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third Campaignarea:backendBackend (NestJS) areabugSomething isn't workinghighHigh severity issues

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions