-
Notifications
You must be signed in to change notification settings - Fork 12
Feat/Deposit Account Pool Service (Stellar Keypair Management for Deposit-Based Payments) #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
codebestia
merged 6 commits into
ShadeProtocol:main
from
DioChuks:feat/deposit-pool-service
Jul 29, 2026
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
303ca71
feat: add Deposit migration & model
DioChuks df27027
feat: add stellar horizon & deposit key
DioChuks 8df5ab8
feat: add AES-256-GCM encryption & test
DioChuks fde7d43
feat: add deposit account with Horizon.server instance along with it …
DioChuks 3533981
chore: add skip lib or test checks
DioChuks 7d16f96
fix: Align the DepositAccount FK with the intended deletion behavior.
DioChuks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
41 changes: 41 additions & 0 deletions
41
prisma/migrations/20260728064714_add_deposit_account/migration.sql
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| -- CreateTable | ||
| CREATE TABLE "PaymentConfirmation" ( | ||
| "id" TEXT NOT NULL, | ||
| "invoiceId" TEXT NOT NULL, | ||
| "merchantId" TEXT NOT NULL, | ||
| "payerAddress" TEXT NOT NULL, | ||
| "txHash" TEXT, | ||
| "idempotencyKey" TEXT NOT NULL, | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
|
||
| CONSTRAINT "PaymentConfirmation_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| -- CreateTable | ||
| CREATE TABLE "DepositAccount" ( | ||
| "id" TEXT NOT NULL, | ||
| "address" TEXT NOT NULL, | ||
| "encryptedSecret" TEXT NOT NULL, | ||
| "invoiceId" TEXT, | ||
| "inUse" BOOLEAN NOT NULL DEFAULT false, | ||
| "lastUsedAt" TIMESTAMP(3), | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| "updatedAt" TIMESTAMP(3) NOT NULL, | ||
|
|
||
| CONSTRAINT "DepositAccount_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "PaymentConfirmation_idempotencyKey_key" ON "PaymentConfirmation"("idempotencyKey"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "DepositAccount_address_key" ON "DepositAccount"("address"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "DepositAccount_invoiceId_key" ON "DepositAccount"("invoiceId"); | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "PaymentConfirmation" ADD CONSTRAINT "PaymentConfirmation_invoiceId_merchantId_fkey" FOREIGN KEY ("invoiceId", "merchantId") REFERENCES "Invoice"("id", "merchantId") ON DELETE RESTRICT ON UPDATE CASCADE; | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "DepositAccount" ADD CONSTRAINT "DepositAccount_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE; |
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { Horizon, Keypair } from '@stellar/stellar-sdk'; | ||
| import type { DepositAccount } from '@prisma/client'; | ||
| import prisma from '../config/prisma.js'; | ||
| import { encrypt } from '../utils/encryption.js'; | ||
| import { AppError } from '../utils/errors.js'; | ||
|
|
||
| export interface DepositAccountSummary { | ||
| id: string; | ||
| address: string; | ||
| invoiceId: string | null; | ||
| inUse: boolean; | ||
| lastUsedAt: Date | null; | ||
| createdAt: Date; | ||
| updatedAt: Date; | ||
| } | ||
|
|
||
| export function sanitizeDepositAccount(account: DepositAccount): DepositAccountSummary { | ||
| return { | ||
| id: account.id, | ||
| address: account.address, | ||
| invoiceId: account.invoiceId, | ||
| inUse: account.inUse, | ||
| lastUsedAt: account.lastUsedAt, | ||
| createdAt: account.createdAt, | ||
| updatedAt: account.updatedAt, | ||
| }; | ||
| } | ||
|
|
||
| export class DepositAccountService { | ||
| constructor(private horizon: Horizon.Server) {} | ||
|
|
||
| async createAccount(): Promise<DepositAccountSummary> { | ||
| const keypair = Keypair.random(); | ||
| const encryptedSecret = encrypt(keypair.secret()); | ||
|
|
||
| const account = await prisma.depositAccount.create({ | ||
| data: { | ||
| address: keypair.publicKey(), | ||
| encryptedSecret, | ||
| inUse: false, | ||
| invoiceId: null, | ||
| }, | ||
| }); | ||
|
|
||
| return sanitizeDepositAccount(account); | ||
| } | ||
|
|
||
| async getAllAccounts(): Promise<DepositAccountSummary[]> { | ||
| const accounts = await prisma.depositAccount.findMany({ | ||
| orderBy: { createdAt: 'desc' }, | ||
| }); | ||
| return accounts.map(sanitizeDepositAccount); | ||
| } | ||
|
|
||
| async getAccountBalance( | ||
| address: string, | ||
| ): Promise<Awaited<ReturnType<Horizon.Server['loadAccount']>>['balances']> { | ||
| try { | ||
| const account = await this.horizon.loadAccount(address); | ||
| return account.balances; | ||
| } catch (error: unknown) { | ||
| const err = error as { response?: { status?: number }; status?: number; name?: string }; | ||
| if (err?.response?.status === 404 || err?.status === 404 || err?.name === 'NotFoundError') { | ||
| return []; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| async getAvailableAccounts(): Promise<DepositAccountSummary[]> { | ||
| const accounts = await prisma.depositAccount.findMany({ | ||
| where: { inUse: false }, | ||
| orderBy: { createdAt: 'asc' }, | ||
| }); | ||
| return accounts.map(sanitizeDepositAccount); | ||
| } | ||
|
|
||
| async assignAccount(invoiceId: string): Promise<DepositAccountSummary> { | ||
| while (true) { | ||
| const candidates = await prisma.depositAccount.findMany({ | ||
| where: { inUse: false }, | ||
| orderBy: { createdAt: 'asc' }, | ||
| }); | ||
|
|
||
| if (candidates.length === 0) { | ||
| throw new AppError(404, 'No available deposit accounts'); | ||
| } | ||
|
|
||
| const now = new Date(); | ||
|
|
||
| for (const candidate of candidates) { | ||
| try { | ||
| const result = await prisma.depositAccount.updateMany({ | ||
| where: { | ||
| id: candidate.id, | ||
| inUse: false, | ||
| }, | ||
| data: { | ||
| inUse: true, | ||
| invoiceId, | ||
| lastUsedAt: now, | ||
| }, | ||
| }); | ||
|
|
||
| if (result.count === 1) { | ||
| const updated = await prisma.depositAccount.findUnique({ | ||
| where: { id: candidate.id }, | ||
| }); | ||
| return sanitizeDepositAccount(updated!); | ||
| } | ||
| } catch (error: unknown) { | ||
| const err = error as { code?: string }; | ||
| if (err?.code === 'P2002') { | ||
| throw new AppError(409, 'Invoice already has an assigned deposit account'); | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async releaseAccount(accountId: string): Promise<DepositAccountSummary> { | ||
| const account = await prisma.depositAccount.findUnique({ | ||
| where: { id: accountId }, | ||
| }); | ||
|
|
||
| if (!account) { | ||
| throw new AppError(404, 'Deposit account not found'); | ||
| } | ||
|
|
||
| const updated = await prisma.depositAccount.update({ | ||
| where: { id: accountId }, | ||
| data: { | ||
| invoiceId: null, | ||
| inUse: false, | ||
| lastUsedAt: new Date(), | ||
| }, | ||
| }); | ||
|
|
||
| return sanitizeDepositAccount(updated); | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import crypto from 'node:crypto'; | ||
| import { environment } from '../config/environment.js'; | ||
|
|
||
| const ALGORITHM = 'aes-256-gcm'; | ||
| const IV_LENGTH = 12; | ||
|
|
||
| export function getEncryptionKey(): Buffer { | ||
| const keyHex = | ||
| process.env.DEPOSIT_ACCOUNT_ENCRYPTION_KEY || environment.depositAccountEncryptionKey; | ||
| if ( | ||
| !keyHex || | ||
| typeof keyHex !== 'string' || | ||
| keyHex.length !== 64 || | ||
| !/^[0-9a-fA-F]{64}$/.test(keyHex) | ||
| ) { | ||
| throw new Error('DEPOSIT_ACCOUNT_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)'); | ||
| } | ||
| return Buffer.from(keyHex, 'hex'); | ||
| } | ||
|
|
||
| export function encrypt(plaintext: string): string { | ||
| const key = getEncryptionKey(); | ||
| const iv = crypto.randomBytes(IV_LENGTH); | ||
| const cipher = crypto.createCipheriv(ALGORITHM, key, iv); | ||
| const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); | ||
| const tag = cipher.getAuthTag(); | ||
|
|
||
| return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`; | ||
| } | ||
|
|
||
| export function decrypt(ciphertext: string): string { | ||
| const key = getEncryptionKey(); | ||
| const parts = ciphertext.split(':'); | ||
| if (parts.length !== 3) { | ||
| throw new Error('Invalid ciphertext format'); | ||
| } | ||
| const [ivHex, tagHex, encryptedHex] = parts; | ||
| const iv = Buffer.from(ivHex, 'hex'); | ||
| const tag = Buffer.from(tagHex, 'hex'); | ||
| const encrypted = Buffer.from(encryptedHex, 'hex'); | ||
|
|
||
| const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); | ||
| decipher.setAuthTag(tag); | ||
|
|
||
| const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); | ||
| return decrypted.toString('utf8'); | ||
| } |
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.