Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ jobs:
REDIS_PORT: '6379'
run: pnpm --filter backend run test:cov

- name: Test signing specs (real SDK, no mock)
env:
API_KEY: test-api-key-123
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/chainforge_test?schema=public
NODE_ENV: test
REDIS_HOST: 127.0.0.1
REDIS_PORT: '6379'
run: pnpm --filter backend run test:signing

- name: Build
run: pnpm --filter backend run build

Expand Down
1 change: 1 addition & 0 deletions app/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --config ./test/jest-coverage.js --coverage",
"test:signing": "jest --testPathPatterns='soroban\\.adapter\\.signing\\.spec'",
"coverage:baseline": "node ./test/generate-coverage-baseline.js",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
Expand Down
47 changes: 47 additions & 0 deletions app/backend/src/onchain/SOROBAN_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,53 @@ ONCHAIN_ADAPTER=mock
ONCHAIN_ADAPTER=soroban
```

## Signing model (recipient / distributor auth)

The `aid_escrow` contract's auth is **per-caller**, not per-backend: `claim(id, claimer)` calls `claimer.require_auth()`, and `create_package` calls `require_admin_or_distributor(&operator)`. A backend that signs every envelope with a single `SOROBAN_ADMIN_SECRET_KEY` keypair can only ever satisfy these checks when the admin *is* the caller (e.g. the admin acting as its own recipient). Real recipients and distributors hold their own keys, so the adapter exposes a **client-signing seam** in two phases:

1. `buildUnsignedClaimTx({ packageId, recipientAddress })` / `buildUnsignedCreatePackageTx({ ... })` — simulate the contract call and return an **unsigned envelope XDR** whose Soroban auth entries demand the caller's signature. The envelope *source account is the admin account*, so the admin sponsors all fees; the recipient/distributor never needs to hold XLM or even have an account. A `transactionHash` (informational) and an `expiresAt` freshness window are returned alongside.
2. The client (mobile WalletConnect / Freighter / a backend service holding the distributor key) signs the auth entries and returns the new envelope XDR.
3. `submitSignedTx({ signedXdr, expectedSigner })` — cryptographically verifies that every auth entry was signed by the account the contract requires (and, when `expectedSigner` is set, exactly that account), signs the envelope with the admin keypair, submits, and polls until confirmed. A claim whose auth entry was signed by the admin keypair — or by any other account — is **rejected before submission**.

Admin-only operations (`disburse`, `revoke`, `refund`, config, `init`) are unchanged: they still sign with the admin keypair via the internal admin-signed path. The legacy `claimAidPackage` / `createAidPackage` admin-signed methods also remain for the admin-as-caller case; for real end-user keys, use the seam.

### Fee sponsorship strategy

Fees are sponsored by making the **admin account the transaction source**. This is strictly simpler than a fee-bump: a fee-bump still requires the inner (recipient-source) transaction to have a valid source account and sequence number, which a fresh recipient cannot provide, and it adds a second envelope to construct. With the admin as source, one envelope covers both the caller's auth entry and the admin's fee payment. The trade-off is sequence-number management: the unsigned envelope pins the admin's sequence at build time, so a concurrent admin-signed submission between `build*` and `submitSignedTx` can invalidate it (`tx_bad_seq`). Submit promptly and retry by rebuilding the unsigned envelope; a shared nonce/sequence manager is a deliberate follow-up.

### Client-side signing sequence

The client signs the auth entries (not the envelope — the admin signs that server-side). Using the Stellar JS SDK:

```ts
import {
TransactionBuilder,
Operation,
SorobanDataBuilder,
authorizeEntry,
} from '@stellar/stellar-sdk';

const tx = TransactionBuilder.fromXDR(transactionXdr, networkPassphrase) as Transaction;
const op = tx.operations[0];
const expiration = op.auth![0].credentials().address().signatureExpirationLedger();
const signedEntries = await Promise.all(
op.auth!.map(entry => authorizeEntry(entry, recipientKeypair, expiration, networkPassphrase)),
);
const signedXdr = TransactionBuilder.cloneFrom(tx, {
fee: tx.fee,
networkPassphrase,
sorobanData: tx.sorobanData,
})
.clearOperations()
.addOperation(Operation.invokeHostFunction({ source: op.source, func: op.func, auth: signedEntries }))
.build()
.toXDR();

// POST signedXdr (+ expectedSigner) back to the backend → submitSignedTx
```

The signature payload is `sha256(HashIdPreimageSorobanAuthorization{ networkId, nonce, invocation, signatureExpirationLedger })` — it does **not** cover the envelope, so the backend can safely re-sign the envelope and the client can rebuild timebounds without invalidating the recipient's signature. The on-chain `signature_expiration_ledger` (set during simulation) is the authoritative expiry, enforced by the network.

## Error Handling

All errors follow the global error format:
Expand Down
49 changes: 49 additions & 0 deletions app/backend/src/onchain/onchain.adapter.mock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,53 @@ describe('MockOnchainAdapter', () => {
expect(result.metadata?.recipientAddress).toBe(recipientAddress);
});
});

describe('client-signing seam', () => {
const RECIPIENT =
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF';
const OPERATOR =
'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB';

it('buildUnsignedClaimTx returns an unsigned envelope stub', async () => {
const result = await adapter.buildUnsignedClaimTx({
packageId: '1',
recipientAddress: RECIPIENT,
});

expect(result.packageId).toBe('1');
expect(result.recipientAddress).toBe(RECIPIENT);
expect(result.transactionXdr).toContain('mock-unsigned-claim-xdr');
expect(result.transactionHash).toHaveLength(64);
expect(result.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000));
expect(result.timestamp).toBeInstanceOf(Date);
});

it('buildUnsignedCreatePackageTx returns an unsigned envelope stub', async () => {
const result = await adapter.buildUnsignedCreatePackageTx({
operatorAddress: OPERATOR,
packageId: '42',
recipientAddress: RECIPIENT,
amount: '250',
tokenAddress: MOCK_TOKEN_ADDRESS,
expiresAt: 1767225600,
});

expect(result.packageId).toBe('42');
expect(result.operatorAddress).toBe(OPERATOR);
expect(result.transactionXdr).toContain('mock-unsigned-create-package');
expect(result.transactionHash).toHaveLength(64);
});

it('submitSignedTx accepts a signed envelope and reports success', async () => {
const result = await adapter.submitSignedTx({
signedXdr: 'mock-signed-xdr-123',
expectedSigner: RECIPIENT,
});

expect(result.status).toBe('success');
expect(result.transactionHash).toHaveLength(64);
expect(result.metadata?.signer).toBe(RECIPIENT);
expect(result.metadata?.adapter).toBe('mock');
});
});
});
58 changes: 58 additions & 0 deletions app/backend/src/onchain/onchain.adapter.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import {
GetTransactionStatusParams,
GetTransactionStatusResult,
TxStatus,
BuildUnsignedClaimTxParams,
BuildUnsignedClaimTxResult,
BuildUnsignedCreatePackageParams,
BuildUnsignedCreatePackageResult,
SubmitSignedTxParams,
SubmitSignedTxResult,
} from './onchain.adapter';
import { createHash } from 'crypto';

Expand Down Expand Up @@ -149,6 +155,58 @@ export class MockOnchainAdapter implements OnchainAdapter {
};
}

async buildUnsignedClaimTx(
params: BuildUnsignedClaimTxParams,
): Promise<BuildUnsignedClaimTxResult> {
await Promise.resolve();
return {
packageId: params.packageId,
recipientAddress: params.recipientAddress,
transactionXdr: `mock-unsigned-claim-xdr-${params.packageId}-${params.recipientAddress}`,
transactionHash: this.generateMockHash(
`unsigned-claim-${params.packageId}-${params.recipientAddress}`,
),
expiresAt: Math.floor(Date.now() / 1000) + 300,
timestamp: new Date(),
};
}

async buildUnsignedCreatePackageTx(
params: BuildUnsignedCreatePackageParams,
): Promise<BuildUnsignedCreatePackageResult> {
await Promise.resolve();
return {
packageId: params.packageId,
operatorAddress: params.operatorAddress,
transactionXdr: `mock-unsigned-create-package-xdr-${params.packageId}-${params.operatorAddress}`,
transactionHash: this.generateMockHash(
`unsigned-create-package-${params.packageId}-${params.operatorAddress}`,
),
expiresAt: Math.floor(Date.now() / 1000) + 300,
timestamp: new Date(),
};
}

async submitSignedTx(
params: SubmitSignedTxParams,
): Promise<SubmitSignedTxResult> {
await Promise.resolve();
const transactionHash = this.generateMockHash(
`signed-tx-${params.signedXdr.slice(0, 32)}-${Date.now()}`,
);

return {
transactionHash,
status: 'success',
timestamp: new Date(),
metadata: {
contractId: this.mockEscrowAddress,
signer: params.expectedSigner,
adapter: 'mock',
},
};
}

async disburseAidPackage(
params: DisburseAidPackageParams,
): Promise<DisburseAidPackageResult> {
Expand Down
102 changes: 102 additions & 0 deletions app/backend/src/onchain/onchain.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,82 @@ export interface ClaimAidPackageParams {
recipientAddress: string;
}

/**
* Client-signing seam: parameters for building an unsigned, simulated
* `claim` transaction. The returned XDR carries the Soroban auth entry the
* recipient must sign; the envelope source is the backend admin account, so
* fees are sponsored by the operator (no XLM required from the recipient).
*/
export interface BuildUnsignedClaimTxParams {
packageId: string;
recipientAddress: string;
}

export interface BuildUnsignedClaimTxResult {
packageId: string;
recipientAddress: string;
/**
* Base64 XDR of the unsigned envelope. The client signs the Soroban auth
* entries inside it with the recipient key and hands the result back to
* `submitSignedTx`.
*/
transactionXdr: string;
/** Hash of the unsigned envelope, hex-encoded (informational). */
transactionHash: string;
/** Unix seconds after which the envelope should be treated as stale. */
expiresAt: number;
timestamp: Date;
}

/**
* Client-signing seam: parameters for building an unsigned, simulated
* `create_package` transaction whose Soroban auth entry must be signed by the
* operator (distributor) — enabling package creation for a non-admin
* `operatorAddress`.
*/
export interface BuildUnsignedCreatePackageParams {
operatorAddress: string;
packageId: string;
recipientAddress: string;
amount: string;
tokenAddress: string;
expiresAt: number;
metadata?: Record<string, string>;
}

export interface BuildUnsignedCreatePackageResult {
packageId: string;
operatorAddress: string;
transactionXdr: string;
transactionHash: string;
expiresAt: number;
timestamp: Date;
}

/**
* Client-signing seam: submit an envelope whose Soroban auth entries were
* signed off-chain by the required account (recipient for `claim`, operator
* for `create_package`). The adapter verifies the auth-entry signatures,
* signs the envelope with the admin keypair (fee sponsorship), then submits
* and confirms the transaction.
*/
export interface SubmitSignedTxParams {
/** Base64 XDR of the client-signed envelope. */
signedXdr: string;
/**
* G… public key that must have signed the auth entries. When set, any auth
* entry signed by a different account is rejected before submission.
*/
expectedSigner?: string;
}

export interface SubmitSignedTxResult {
transactionHash: string;
status: 'success' | 'failed';
timestamp: Date;
metadata?: Record<string, unknown>;
}

export interface ClaimAidPackageResult {
packageId: string;
transactionHash: string;
Expand Down Expand Up @@ -233,6 +309,32 @@ export interface OnchainAdapter {
params: ClaimAidPackageParams,
): Promise<ClaimAidPackageResult>;

/**
* Build an unsigned, simulated `claim` transaction whose Soroban auth entry
* requires the recipient's signature. The recipient signs the returned XDR
* and hands it back to {@link submitSignedTx}.
*/
buildUnsignedClaimTx(
params: BuildUnsignedClaimTxParams,
): Promise<BuildUnsignedClaimTxResult>;

/**
* Build an unsigned, simulated `create_package` transaction whose Soroban
* auth entry requires the operator's signature — the distributor-created
* package path for a non-admin `operatorAddress`.
*/
buildUnsignedCreatePackageTx(
params: BuildUnsignedCreatePackageParams,
): Promise<BuildUnsignedCreatePackageResult>;

/**
* Submit a client-signed transaction envelope. Auth-entry signatures are
* verified against the required account, the envelope is signed by the
* admin keypair (fee sponsorship), and the transaction is submitted and
* confirmed before the result is returned.
*/
submitSignedTx(params: SubmitSignedTxParams): Promise<SubmitSignedTxResult>;

/**
* Disburse an aid package by admin
*/
Expand Down
Loading
Loading