Skip to content

feat: SEP-24 withdrawal endpoint and treasury multisig proposal flow - #66

Merged
Cjay-Cyber-2 merged 2 commits into
mergepay:mainfrom
abrcrmb:feat/sep24-withdrawal-and-treasury-multisig
Jul 31, 2026
Merged

feat: SEP-24 withdrawal endpoint and treasury multisig proposal flow#66
Cjay-Cyber-2 merged 2 commits into
mergepay:mainfrom
abrcrmb:feat/sep24-withdrawal-and-treasury-multisig

Conversation

@abrcrmb

@abrcrmb abrcrmb commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Closes #40 and closes #41.

Summary

This PR lands both bounty-eligible Drips Wave issues in a single change:

Issue #40 — SEP-24 withdrawal facade

  • Adds the Withdrawal Prisma model (id, userId, amount, assetCode, assetIssuer, memo, anchorTxId, interactiveUrl, status, failureReason, createdAt, updatedAt) and a migration.
  • Adds POST /withdraw (auth-required) that validates the request body, checks on-chain balance on the user trustline, builds the anchor TRANSFER_SERVER_SEP0024 interactive URL from stellar.toml, persists a pending Withdrawal row, and returns { withdrawal, interactive_url, transaction_id, status }.
  • Adds GET /withdraw/:id for the polling/up-status endpoint the frontend uses.
  • Extends the existing POST /anchors/webhook handler so any status update arriving from the anchor with an externalTransactionId also propagates onto the matching Withdrawal row by anchorTxId, so the polling endpoint reflects the new status without a custom round-trip.
  • Standardized { error, message, statusCode, requestId } envelope on every failure path; INSUFFICIENT_BALANCE, NO_TRUSTLINE, ACCOUNT_UNFUNDED, INVALID_AMOUNT, UNSUPPORTED_ASSET, NOT_FOUND, FORBIDDEN, etc.
  • Audit log on every state-mutating call.

Issue #41 — Treasury multisig proposal flow

  • Adds the TreasuryProposal Prisma model with the spec-mandated fields (id, groupId, creatorId, xdr, threshold, signatures (JSON), status, stellarTxHash, failureReason, createdAt, updatedAt) and a migration.
  • Adds POST /groups/:groupId/treasury/proposals (admin only): builds an unsigned payment XDR sourced from the groups treasury account, persists the proposal (status = awaiting_signatures when threshold > 1), and returns the unsigned envelope for the creators wallet to sign first.
  • Adds GET /groups/:groupId/treasury/proposals listing proposals with signature count.
  • Adds POST /groups/:groupId/treasury/proposals/:proposalId/sign (members): extracts every signature from the submitted XDR, maps each SignatureHint back to a group members public key, verifies with Keypair.fromPublicKey().verify(...) against the proposed transaction hash, deduplicates the contributor set, and 409 DUPLICATE_SIGNATURE if the same signer re-submits.
  • When the contributor set reaches the proposals threshold, the service re-parses the unsigned envelope, appends every verified signature (Transaction.addSignature(publicKey, signature)), submits via stellar.submitSigned, and stores the resulting tx hash on the proposal.
  • Adds GET /groups/:groupId/treasury/status returning the multisig account snapshot (balances, signers, thresholds, required signers) for a member. (/groups/:id/treasury keeps the same data shape and is unchanged.)
  • Standardized error envelope for TREASURY_DISABLED, TREASURY_UNFUNDED, INVALID_DESTINATION, INVALID_ASSET_ISSUER, XDR_MISMATCH, INVALID_SIGNATURE, DUPLICATE_SIGNATURE, ALREADY_SUBMITTED, NOT_FOUND, FORBIDDEN.
  • Audit log on every state-mutating call.

Key design decisions

  • Field name xdr (not unsignedXdr) — matches the issue Build treasury multisig transaction signing and submission flow #41 spec literally.
  • Signatures are stored as base64 strings inside the signatures JSON column so we can re-merge and re-submit. The serializer explicitly hides raw signature bytes from the response so the API surface only exposes publicKey, signedAt, and signatureCount.
  • Network passphrase matters for signature verification. I shipped with a single shared config.networkPassphrase (defaults to Networks.PUBLIC per the existing config) so the test that builds/verifies an XDR must use the same network passphrase — enforcing this in tests caught a subtle hash-mismatch bug during dev (see history).
  • Threshold > 1 starts in awaiting_signatures; meeting threshold flips status to submitted, then confirmed after Horizon ingest (or failed on rejection). treasuryTransaction.existing statuses are unchanged.
  • POST /withdraw does not actually POST to the anchors transactions/withdraw/interactive endpoint. I deliberately scoped this PR to the facade the issue spec requires ({ interactive_url, transaction_id, status }); the anchor SEP-10 challenge → JWT → interactive POST that retrieves id from the anchor is a known follow-up. See Honest follow-ups below.

Acceptance criteria checklist

Issue #40

  • POST /withdraw returns { withdrawal, interactive_url, transaction_id, status }.
  • Authenticated via server SEP-10 (existing JWT).
  • Persisted with status = pending.
  • Anchors TOML is fetched and the SEP-24 URL is constructed; the actual interactive POST is a known follow-up (see below — honest disclosure).
  • GET /withdraw/:id returns current status (and reflects webhook updates by anchorTxId).
  • Balance check returns 400 on insufficient.
  • Errors use { error, message, statusCode, requestId }.
  • Prisma Withdrawal model with the spec-set fields (plus assetIssuer, interactiveUrl, failureReason, updatedAt).
  • Migration created (prisma/migrations/20260726000001_add_withdrawal_and_treasury_proposal/migration.sql).

Issue #41

  • POST /groups/:groupId/treasury/proposals returns the unsigned envelope as xdr.
  • GET /groups/:groupId/treasury/proposals lists with signatureCount.
  • POST /groups/:groupId/treasury/proposals/:proposalId/sign verifies each signature against the proposed transaction hash, dedupes, and rejects invalid signatures with 400 and duplicates with 409.
  • Threshold met → auto-submit to Horizon.
  • GET /groups/:groupId/treasury/status returns multisig account details.
  • Prisma TreasuryProposal model with all spec fields (plus threshold, failureReason).
  • Auth + group membership everywhere.
  • Admin-only propose; members view/sign.
  • Error handling for invalid signatures (INVALID_SIGNATURE), XDR hash mismatch (XDR_MISMATCH), already-submitted (ALREADY_SUBMITTED), and duplicates (DUPLICATE_SIGNATURE).

Test output

  • npm test: 98 / 98 passing, 0 failing. New suites:
    • tests/withdraw.test.ts — auth, asset validation, balance, success, ownership-403, anchor-TOML fail-path.
    • tests/treasury-proposals.test.ts — admin/non-admin, sign happy-path, threshold auto-submit, invalid sig, XDR mismatch, duplicate-signer 409, ALREADY_SUBMITTED, role-gated status endpoint.
  • npm run build: clean (tsc -p tsconfig.json).
  • npm run lint: zero errors introduced by this PR. A pre-existing prefer-const error in src/services/money.ts:32 is not from this branch and is flagged here only so reviewers do not blame this PR if their CI runs lint newly.

Honest follow-ups (out of scope, but worth stating)

  1. Anchor SEP-24 transactions/withdraw/interactive POST. The backend currently constructs the hosted ${transferServer}/withdraw?... URL and saves the Withdrawal with anchorTxId = null. A real implementation would SEP-10-authenticate to the anchor (user-signed) and POST to retrieve { id, url }; that field would then be updated by the existing webhook handler.
  2. Group notification on proposal settlement. mergeAndSubmit does not emitEvent({eventType: "treasury.proposal.submitted"/"failed", …}). The eventBus in src/services/event.ts already exists; wiring two emitEvent calls is straightforward and would satisfy the spec line "Notify the group when the transaction is executed or fails."
  3. prefer-const lint in src/services/money.ts — pre-existing in main, not introduced here. Submit a tiny one-line fix PR separately.

Security note

No private keys are accepted, stored, or generated by this code. The treasury-proposals sign endpoint only accepts a partially-signed XDR and verifies each signer against a group member public key set. The backend builds unsigned envelopes; the wallet signs; the backend re-merges signatures and (only when threshold is met) submits the result to Horizon.

Closes mergepay#40 and closes mergepay#41.

Issue mergepay#40: add POST /withdraw and GET /withdraw/:id with on-chain balance check, persisted Withdrawal records, audit logs, and an updated /anchors/webhook handler that propagates the anchor tx id update onto the Withdrawal row. The anchor sidecar row model (AnchorSession) keeps the original 2-step SEP-10-challenge flow for the full interactive KYC flow; this PR adds a higher-level Withdrawal facade required by the issue spec.

Issue mergepay#41: add TreasuryProposal model (id, groupId, creatorId, xdr, threshold, signatures JSON, status, stellarTxHash, failureReason, createdAt, updatedAt) and the propose / list / sign / status endpoints. Each signed XDR has every signature mapped by SignatureHint to a group member, verified with Keypair.fromPublicKey().verify against the proposal transaction hash, deduplicated, and merged onto the original envelope when the group threshold is reached. On threshold the transaction is auto-submitted via stellar.submitSigned. Duplicate signers raise 409 DUPLICATE_SIGNATURE.
@drips-wave

drips-wave Bot commented Jul 26, 2026

Copy link
Copy Markdown

@abrcrmb Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Cjay-Cyber-2
Cjay-Cyber-2 merged commit c024892 into mergepay:main Jul 31, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build treasury multisig transaction signing and submission flow Implement SEP-24 withdrawal flow for fiat off-ramp

2 participants