Skip to content

[Enhancement] Stellar claimable balances: gift-a-course and trustline-free receiving with expiry reclaim #43

Description

@zeemscript

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

  • 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 buyercreator 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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).
  6. 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

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

Metadata

Metadata

Labels

GrantFox OSSPart of the GrantFox OSS programMaybe RewardedPotential reward for completionOfficial Campaign | FWC26GrantFox official campaign FWC26complexity:highMaps to Drips Wave High tier (200 pts)enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions