You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Use Stellar claimable balances to unlock two flows the current direct-payment design cannot do: (1) gift a course — a buyer pays for a course for another user, and (2) trustline-free receiving — when a payment destination has no USDC trustline (today this hard-fails with op_no_trust), the sender instead creates a claimable balance the recipient can claim whenever they are ready, with a reclaim-after-expiry predicate so funds are never stranded. All signing stays client-side: the server only builds unsigned XDR, tracks balance IDs, and verifies claims on-chain before granting access.
Current state
A payment to an account without a USDC trustline is a dead end: submitTransaction in src/services/stellar/stellarService.js parses Horizon result_codes and throws "Recipient does not have a USDC trustline. They need to add USDC to their wallet first." on op_no_trust, and "Destination account does not exist" on op_no_destination. The purchase simply fails and the buyer must come back later.
initializePayment (src/controllers/stellar/paymentController.js) refuses to start at all if the creator has no wallet ("Creator has not connected their Stellar wallet yet"), so an educator who hasn't finished Stellar onboarding cannot sell anything.
There is no gifting concept anywhere: Transaction (src/models/Transaction.js) models exactly buyer → creator for an itemType of book|course, and access is granted to the payer themselves in submitPayment (pushes into buyer.purchasedBooks/buyer.purchasedCourses and Course.enrolledUsers).
The building blocks are present: stellarService.js exports server (Horizon), USDC, networkPassphrase, and the build-XDR → client-sign → submitTransaction pattern; @stellar/stellar-sdk v16 (package.json) ships Operation.createClaimableBalance, Operation.claimClaimableBalance, Claimant with predicate helpers, and server.claimableBalances().
Nothing on the platform ever holds user funds — claimable balances preserve that: the balance lives on-ledger, claimable only by the named claimants.
What to build
Model (src/models/GiftClaim.js or extend Transaction with a discriminator): sender, recipient user (and/or recipient wallet), itemType/itemId/itemTitle, amount, balanceId (Horizon claimable balance ID), status (pending_signature, open, claimed, reclaimed, expired), expiresAt, creation/claim tx hashes, network. Index balanceId unique-sparse.
Service (src/services/stellar/claimableBalanceService.js):
buildCreateClaimableBalanceTx({ sourcePublicKey, claimantPublicKey, amount, expiresAt }) — one createClaimableBalance op with two claimants: the recipient with Claimant.predicateBeforeAbsoluteTime(expiresAt) and the sender with Claimant.predicateNot(predicateBeforeAbsoluteTime(expiresAt)) so the sender can reclaim after expiry. Returns unsigned XDR (same shape as buildPaymentTransaction).
buildClaimTx({ claimantPublicKey, balanceId }) — a claimClaimableBalance op; if the claimant lacks the USDC trustline, prepend a changeTrust op for USDC in the same transaction so claiming is one signature.
resolveBalanceId(txHash) — after submission, obtain the balance ID (parse the transaction result XDR or query server.claimableBalances().forClaimant(...); document which and why).
getClaimableBalance(balanceId) — Horizon lookup for status checks.
Gift-a-course flow (src/routes/stellar/giftRoutes.js mounted at /api/stellar/gifts in app.js, all behind protect):
POST /api/stellar/gifts/initialize — body { itemType, itemId, recipientUserId }; validates recipient exists, has a connected wallet (User.stellarWallet.publicKey), and doesn't already own the item; then either builds a direct payment to the creator plus recipient access grant (recipient has trustline-independent access; payment still goes to the creator as in the normal flow) or — when the creator lacks a trustline/wallet — a claimable balance payable to the creator. Reuse the duplicate-pending and already-purchased guards from initializePayment.
POST /api/stellar/gifts/submit — verify the signed tx on-chain (amount, asset, claimants/destination — extend the checks in the spirit of issue [Enhancement] Verify signed Stellar transaction contents before granting item access #18), persist balanceId, mark open or grant access immediately for the direct-payment variant, and record who the access goes to (the recipient, not the payer).
GET /api/stellar/gifts (sent/received) and GET /api/stellar/gifts/:id with live Horizon status.
Claim flow: POST /api/stellar/gifts/:id/claim/initialize (recipient- or sender-only depending on expiry; returns claim XDR, auto-including changeTrust when needed) and POST /api/stellar/gifts/:id/claim/submit (submit, verify the claim_claimable_balance succeeded, set claimed/reclaimed). Course/book access for the recipient is granted at gift confirmation, not claim time — the claimable-balance leg is the creator's money; make this explicit in code comments.
Expiry sweep: a small interval job that marks open gifts past expiresAt as expired and surfaces "reclaim available" to the sender (do not auto-move funds — the sender must sign the reclaim).
Fallback in the normal purchase flow: when initializePayment currently 400s on a missing creator trustline, offer the claimable-balance variant in the response ({ fallback: "claimable_balance" }) so the frontend can route the buyer there instead of a dead end.
Acceptance criteria
Creating a gift for a recipient with a wallet produces valid unsigned XDR; after client-signing and submit, the stored record has a real Horizon balanceId and status open (claimable-balance variant) or claimed-equivalent access grant (direct variant).
The recipient (before expiry) and only the sender (after expiry) can obtain a claim XDR; anyone else gets 403. Predicates on-ledger match: recipient before_absolute_time, sender not(before_absolute_time).
Claiming with no existing USDC trustline works in a single transaction (changeTrust + claimClaimableBalance).
Submitted gift transactions are verified on-chain (correct asset, amount, claimants) before any DB state changes or access grants; a tampered signed XDR is rejected.
Recipient — not the payer — receives item access (purchasedCourses/purchasedBooks, Course.enrolledUsers); the payer does not gain access.
Duplicate-gift and already-owned guards mirror initializePayment behavior; a gift to a recipient with no connected wallet fails with a clear 400.
Jest tests: XDR operation shape (ops, claimants, predicates decoded from XDR — no live Horizon needed), authorization on claim endpoints, expiry state transitions, and the trustline-fallback response from initialize.
Pointers
src/services/stellar/stellarService.js (the op_no_trust failure this replaces; exported server, USDC, networkPassphrase, build/submit/verify helpers), src/controllers/stellar/paymentController.js (guards and grant-access logic to reuse), src/models/Transaction.js, src/models/User.js (stellarWallet, purchasedCourses), src/routes/stellar/paymentRoutes.js, app.js.
Gotchas: the claimable balance ID is not the transaction hash — it must come from the result XDR or a Horizon query after inclusion. Claimant predicate helpers are static methods on Claimant in stellar-sdk v16. Transaction.expiresAt has a TTL index that deletes documents (issue [Bug] Transaction TTL index deletes confirmed purchase records after 30 minutes #6) — do not reuse that field/collection semantics blindly for gift records. CI (.github/workflows/ci.yml) has no Horizon access, so on-chain paths must be mocked in tests. PRs target dev.
Difficulty
High — multi-party predicate design, balance-ID plumbing, on-chain verification before state changes, and access-grant semantics that deliberately differ from the existing buyer-centric flow.
🏆 GrantFox OSS — Official Campaign | FWC26. Apply for this issue through the GrantFox campaign page. The maintainer assigns one contributor before work starts; unassigned PRs may not be reviewed. PRs target the dev branch. Quality bar: CI must stay green.
💬 Questions or need help? Reach the maintainers and other contributors on the DeenBridge Telegram: https://t.me/+nst9lXNj1wc4ZDE0
Summary
Use Stellar claimable balances to unlock two flows the current direct-payment design cannot do: (1) gift a course — a buyer pays for a course for another user, and (2) trustline-free receiving — when a payment destination has no USDC trustline (today this hard-fails with
op_no_trust), the sender instead creates a claimable balance the recipient can claim whenever they are ready, with a reclaim-after-expiry predicate so funds are never stranded. All signing stays client-side: the server only builds unsigned XDR, tracks balance IDs, and verifies claims on-chain before granting access.Current state
submitTransactioninsrc/services/stellar/stellarService.jsparses Horizonresult_codesand throws"Recipient does not have a USDC trustline. They need to add USDC to their wallet first."onop_no_trust, and"Destination account does not exist"onop_no_destination. The purchase simply fails and the buyer must come back later.initializePayment(src/controllers/stellar/paymentController.js) refuses to start at all if the creator has no wallet ("Creator has not connected their Stellar wallet yet"), so an educator who hasn't finished Stellar onboarding cannot sell anything.Transaction(src/models/Transaction.js) models exactlybuyer→creatorfor anitemTypeofbook|course, and access is granted to the payer themselves insubmitPayment(pushes intobuyer.purchasedBooks/buyer.purchasedCoursesandCourse.enrolledUsers).stellarService.jsexportsserver(Horizon),USDC,networkPassphrase, and the build-XDR → client-sign →submitTransactionpattern;@stellar/stellar-sdkv16 (package.json) shipsOperation.createClaimableBalance,Operation.claimClaimableBalance,Claimantwith predicate helpers, andserver.claimableBalances().What to build
src/models/GiftClaim.jsor extendTransactionwith a discriminator): sender, recipient user (and/or recipient wallet),itemType/itemId/itemTitle, amount,balanceId(Horizon claimable balance ID),status(pending_signature,open,claimed,reclaimed,expired),expiresAt, creation/claim tx hashes, network. IndexbalanceIdunique-sparse.src/services/stellar/claimableBalanceService.js):buildCreateClaimableBalanceTx({ sourcePublicKey, claimantPublicKey, amount, expiresAt })— onecreateClaimableBalanceop with two claimants: the recipient withClaimant.predicateBeforeAbsoluteTime(expiresAt)and the sender withClaimant.predicateNot(predicateBeforeAbsoluteTime(expiresAt))so the sender can reclaim after expiry. Returns unsigned XDR (same shape asbuildPaymentTransaction).buildClaimTx({ claimantPublicKey, balanceId })— aclaimClaimableBalanceop; if the claimant lacks the USDC trustline, prepend achangeTrustop forUSDCin the same transaction so claiming is one signature.resolveBalanceId(txHash)— after submission, obtain the balance ID (parse the transaction result XDR or queryserver.claimableBalances().forClaimant(...); document which and why).getClaimableBalance(balanceId)— Horizon lookup for status checks.src/routes/stellar/giftRoutes.jsmounted at/api/stellar/giftsinapp.js, all behindprotect):POST /api/stellar/gifts/initialize— body{ itemType, itemId, recipientUserId }; validates recipient exists, has a connected wallet (User.stellarWallet.publicKey), and doesn't already own the item; then either builds a direct payment to the creator plus recipient access grant (recipient has trustline-independent access; payment still goes to the creator as in the normal flow) or — when the creator lacks a trustline/wallet — a claimable balance payable to the creator. Reuse the duplicate-pending and already-purchased guards frominitializePayment.POST /api/stellar/gifts/submit— verify the signed tx on-chain (amount, asset, claimants/destination — extend the checks in the spirit of issue [Enhancement] Verify signed Stellar transaction contents before granting item access #18), persistbalanceId, markopenor grant access immediately for the direct-payment variant, and record who the access goes to (the recipient, not the payer).GET /api/stellar/gifts(sent/received) andGET /api/stellar/gifts/:idwith live Horizon status.POST /api/stellar/gifts/:id/claim/initialize(recipient- or sender-only depending on expiry; returns claim XDR, auto-includingchangeTrustwhen needed) andPOST /api/stellar/gifts/:id/claim/submit(submit, verify theclaim_claimable_balancesucceeded, setclaimed/reclaimed). Course/book access for the recipient is granted at gift confirmation, not claim time — the claimable-balance leg is the creator's money; make this explicit in code comments.opengifts pastexpiresAtasexpiredand surfaces "reclaim available" to the sender (do not auto-move funds — the sender must sign the reclaim).initializePaymentcurrently 400s on a missing creator trustline, offer the claimable-balance variant in the response ({ fallback: "claimable_balance" }) so the frontend can route the buyer there instead of a dead end.Acceptance criteria
balanceIdand statusopen(claimable-balance variant) orclaimed-equivalent access grant (direct variant).before_absolute_time, sendernot(before_absolute_time).changeTrust+claimClaimableBalance).purchasedCourses/purchasedBooks,Course.enrolledUsers); the payer does not gain access.initializePaymentbehavior; a gift to a recipient with no connected wallet fails with a clear 400.Pointers
src/services/stellar/stellarService.js(theop_no_trustfailure this replaces; exportedserver,USDC,networkPassphrase, build/submit/verify helpers),src/controllers/stellar/paymentController.js(guards and grant-access logic to reuse),src/models/Transaction.js,src/models/User.js(stellarWallet,purchasedCourses),src/routes/stellar/paymentRoutes.js,app.js.Claimantpredicate helpers are static methods onClaimantin stellar-sdk v16.Transaction.expiresAthas a TTL index that deletes documents (issue [Bug] Transaction TTL index deletes confirmed purchase records after 30 minutes #6) — do not reuse that field/collection semantics blindly for gift records. CI (.github/workflows/ci.yml) has no Horizon access, so on-chain paths must be mocked in tests. PRs targetdev.Difficulty
High — multi-party predicate design, balance-ID plumbing, on-chain verification before state changes, and access-grant semantics that deliberately differ from the existing buyer-centric flow.
🏆 GrantFox OSS — Official Campaign | FWC26. Apply for this issue through the GrantFox campaign page. The maintainer assigns one contributor before work starts; unassigned PRs may not be reviewed. PRs target the
devbranch. Quality bar: CI must stay green.💬 Questions or need help? Reach the maintainers and other contributors on the DeenBridge Telegram: https://t.me/+nst9lXNj1wc4ZDE0