feat: SEP-24 withdrawal endpoint and treasury multisig proposal flow - #66
Merged
Cjay-Cyber-2 merged 2 commits intoJul 31, 2026
Merged
Conversation
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.
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
WithdrawalPrisma model (id, userId, amount, assetCode, assetIssuer, memo, anchorTxId, interactiveUrl, status, failureReason, createdAt, updatedAt) and a migration.POST /withdraw(auth-required) that validates the request body, checks on-chain balance on the user trustline, builds the anchorTRANSFER_SERVER_SEP0024interactive URL fromstellar.toml, persists apendingWithdrawalrow, and returns{ withdrawal, interactive_url, transaction_id, status }.GET /withdraw/:idfor the polling/up-status endpoint the frontend uses.POST /anchors/webhookhandler so any status update arriving from the anchor with anexternalTransactionIdalso propagates onto the matchingWithdrawalrow byanchorTxId, so the polling endpoint reflects the new status without a custom round-trip.{ error, message, statusCode, requestId }envelope on every failure path;INSUFFICIENT_BALANCE,NO_TRUSTLINE,ACCOUNT_UNFUNDED,INVALID_AMOUNT,UNSUPPORTED_ASSET,NOT_FOUND,FORBIDDEN, etc.Issue #41 — Treasury multisig proposal flow
TreasuryProposalPrisma model with the spec-mandated fields (id, groupId, creatorId, xdr, threshold, signatures (JSON), status, stellarTxHash, failureReason, createdAt, updatedAt) and a migration.POST /groups/:groupId/treasury/proposals(admin only): builds an unsigned payment XDR sourced from the groups treasury account, persists the proposal (status = awaiting_signatureswhenthreshold > 1), and returns the unsigned envelope for the creators wallet to sign first.GET /groups/:groupId/treasury/proposalslisting proposals with signature count.POST /groups/:groupId/treasury/proposals/:proposalId/sign(members): extracts every signature from the submitted XDR, maps eachSignatureHintback to a group members public key, verifies withKeypair.fromPublicKey().verify(...)against the proposed transaction hash, deduplicates the contributor set, and409 DUPLICATE_SIGNATUREif the same signer re-submits.threshold, the service re-parses the unsigned envelope, appends every verified signature (Transaction.addSignature(publicKey, signature)), submits viastellar.submitSigned, and stores the resulting tx hash on the proposal.GET /groups/:groupId/treasury/statusreturning the multisig account snapshot (balances, signers, thresholds, required signers) for a member. (/groups/:id/treasurykeeps the same data shape and is unchanged.)TREASURY_DISABLED,TREASURY_UNFUNDED,INVALID_DESTINATION,INVALID_ASSET_ISSUER,XDR_MISMATCH,INVALID_SIGNATURE,DUPLICATE_SIGNATURE,ALREADY_SUBMITTED,NOT_FOUND,FORBIDDEN.Key design decisions
xdr(notunsignedXdr) — matches the issue Build treasury multisig transaction signing and submission flow #41 spec literally.signaturesJSON column so we can re-merge and re-submit. The serializer explicitly hides raw signature bytes from the response so the API surface only exposespublicKey,signedAt, andsignatureCount.config.networkPassphrase(defaults toNetworks.PUBLICper 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).awaiting_signatures; meeting threshold flips status tosubmitted, thenconfirmedafter Horizon ingest (orfailedon rejection).treasuryTransaction.existingstatuses are unchanged.POST /withdrawdoes not actually POST to the anchorstransactions/withdraw/interactiveendpoint. 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 retrievesidfrom the anchor is a known follow-up. See Honest follow-ups below.Acceptance criteria checklist
Issue #40
POST /withdrawreturns{ withdrawal, interactive_url, transaction_id, status }.status = pending.GET /withdraw/:idreturns current status (and reflects webhook updates byanchorTxId).{ error, message, statusCode, requestId }.Withdrawalmodel with the spec-set fields (plusassetIssuer,interactiveUrl,failureReason,updatedAt).prisma/migrations/20260726000001_add_withdrawal_and_treasury_proposal/migration.sql).Issue #41
POST /groups/:groupId/treasury/proposalsreturns the unsigned envelope asxdr.GET /groups/:groupId/treasury/proposalslists withsignatureCount.POST /groups/:groupId/treasury/proposals/:proposalId/signverifies each signature against the proposed transaction hash, dedupes, and rejects invalid signatures with 400 and duplicates with 409.GET /groups/:groupId/treasury/statusreturns multisig account details.TreasuryProposalmodel with all spec fields (plusthreshold,failureReason).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-existingprefer-consterror insrc/services/money.ts:32is 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)
transactions/withdraw/interactivePOST. The backend currently constructs the hosted${transferServer}/withdraw?...URL and saves the Withdrawal withanchorTxId = 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.mergeAndSubmitdoes notemitEvent({eventType: "treasury.proposal.submitted"/"failed", …}). TheeventBusinsrc/services/event.tsalready exists; wiring twoemitEventcalls is straightforward and would satisfy the spec line "Notify the group when the transaction is executed or fails."prefer-constlint insrc/services/money.ts— pre-existing inmain, 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-proposalssign 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.