From a9a44c37ec5f9932d6d304d34164f812b2936b34 Mon Sep 17 00:00:00 2001 From: okjodom Date: Mon, 2 Dec 2024 17:57:52 +0300 Subject: [PATCH] feat: solowallet withdraw --- .../src/solowallet/solowallet.controller.ts | 22 +- apps/api/src/solowallet/solowallet.service.ts | 11 +- apps/solowallet/src/db/solowallet.schema.ts | 13 +- apps/solowallet/src/solowallet.controller.ts | 12 +- apps/solowallet/src/solowallet.service.ts | 237 +++++++++++++++--- bun.lockb | Bin 331630 -> 331654 bytes libs/common/src/dto/solowallet.dto.ts | 44 +++- libs/common/src/dto/swap.dto.ts | 2 +- libs/common/src/fedimint/fedimint.service.ts | 39 ++- libs/common/src/types/proto/solowallet.ts | 83 +++--- package.json | 1 + proto/solowallet.proto | 63 +++-- 12 files changed, 422 insertions(+), 105 deletions(-) diff --git a/apps/api/src/solowallet/solowallet.controller.ts b/apps/api/src/solowallet/solowallet.controller.ts index d00baf6..2a68f39 100644 --- a/apps/api/src/solowallet/solowallet.controller.ts +++ b/apps/api/src/solowallet/solowallet.controller.ts @@ -1,6 +1,7 @@ import { DepositFundsRequestDto, - FindUserTxsRequestDto, + UserTxsRequestDto, + WithdrawFundsRequestDto, } from '@bitsacco/common'; import { Body, Controller, Logger, Post } from '@nestjs/common'; import { ApiOperation, ApiBody } from '@nestjs/swagger'; @@ -23,12 +24,21 @@ export class SolowalletController { return this.walletService.depositFunds(req); } - @Post('user-deposits') - @ApiOperation({ summary: 'Find Solowallet user deposits' }) + @Post('withdraw') + @ApiOperation({ summary: 'Withdraw funds from Solowallet' }) @ApiBody({ - type: FindUserTxsRequestDto, + type: WithdrawFundsRequestDto, }) - findUserDeposits(@Body() req: FindUserTxsRequestDto) { - return this.walletService.findUserDeposits(req); + withdrawFunds(@Body() req: WithdrawFundsRequestDto) { + return this.walletService.withdrawFunds(req); + } + + @Post('transactions') + @ApiOperation({ summary: 'Find Solowallet user transactions' }) + @ApiBody({ + type: UserTxsRequestDto, + }) + userTransactions(@Body() req: UserTxsRequestDto) { + return this.walletService.userTransactions(req); } } diff --git a/apps/api/src/solowallet/solowallet.service.ts b/apps/api/src/solowallet/solowallet.service.ts index 7db612d..0a83338 100644 --- a/apps/api/src/solowallet/solowallet.service.ts +++ b/apps/api/src/solowallet/solowallet.service.ts @@ -1,8 +1,9 @@ import { DepositFundsRequestDto, - FindUserTxsRequestDto, + UserTxsRequestDto, SOLOWALLET_SERVICE_NAME, SolowalletServiceClient, + WithdrawFundsRequestDto, } from '@bitsacco/common'; import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { type ClientGrpc } from '@nestjs/microservices'; @@ -25,7 +26,11 @@ export class SolowalletService implements OnModuleInit { return this.client.depositFunds(req); } - findUserDeposits(req: FindUserTxsRequestDto) { - return this.client.findUserDeposits(req); + userTransactions(req: UserTxsRequestDto) { + return this.client.userTransactions(req); + } + + withdrawFunds(req: WithdrawFundsRequestDto) { + return this.client.withdrawFunds(req); } } diff --git a/apps/solowallet/src/db/solowallet.schema.ts b/apps/solowallet/src/db/solowallet.schema.ts index 8a5f120..490f01d 100644 --- a/apps/solowallet/src/db/solowallet.schema.ts +++ b/apps/solowallet/src/db/solowallet.schema.ts @@ -1,5 +1,9 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { AbstractDocument, TransactionStatus } from '@bitsacco/common'; +import { + AbstractDocument, + TransactionStatus, + TransactionType, +} from '@bitsacco/common'; @Schema({ versionKey: false }) export class SolowalletDocument extends AbstractDocument { @@ -12,6 +16,13 @@ export class SolowalletDocument extends AbstractDocument { @Prop({ type: Number, required: false }) amountFiat?: number; + @Prop({ + type: String, + required: true, + enum: Object.values(TransactionType), + }) + type: TransactionType; + @Prop({ type: String, required: true, diff --git a/apps/solowallet/src/solowallet.controller.ts b/apps/solowallet/src/solowallet.controller.ts index 7baacb1..12e66fa 100644 --- a/apps/solowallet/src/solowallet.controller.ts +++ b/apps/solowallet/src/solowallet.controller.ts @@ -3,7 +3,8 @@ import { GrpcMethod } from '@nestjs/microservices'; import { SolowalletServiceControllerMethods, DepositFundsRequestDto, - FindUserTxsRequestDto, + UserTxsRequestDto, + WithdrawFundsRequestDto, } from '@bitsacco/common'; import { SolowalletService } from './solowallet.service'; @@ -18,7 +19,12 @@ export class SolowalletController { } @GrpcMethod() - findUserDeposits(request: FindUserTxsRequestDto) { - return this.solowalletService.findUserDeposits(request); + userTransactions(request: UserTxsRequestDto) { + return this.solowalletService.userTransactions(request); + } + + @GrpcMethod() + withdrawFunds(request: WithdrawFundsRequestDto) { + return this.solowalletService.withdrawFunds(request); } } diff --git a/apps/solowallet/src/solowallet.service.ts b/apps/solowallet/src/solowallet.service.ts index 259bde3..89eea10 100644 --- a/apps/solowallet/src/solowallet.service.ts +++ b/apps/solowallet/src/solowallet.service.ts @@ -3,14 +3,13 @@ import { CreateOnrampSwapDto, Currency, DepositFundsRequestDto, - DepositFundsResponse, - DepositsMeta, + WalletMeta, fedimint_receive_failure, fedimint_receive_success, FedimintService, fiatToBtc, - FindUserDepositTxsResponse, - FindUserTxsRequestDto, + UserTxsResponse, + UserTxsRequestDto, FmInvoice, PaginatedSolowalletTxsResponse, QuoteDto, @@ -23,11 +22,14 @@ import { SwapResponse, SwapServiceClient, TransactionStatus, + TransactionType, + WithdrawFundsRequestDto, + CreateOfframpSwapDto, } from '@bitsacco/common'; import { type ClientGrpc } from '@nestjs/microservices'; import { catchError, firstValueFrom, map, of, tap } from 'rxjs'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; -import { SolowalletRepository } from './db'; +import { SolowalletDocument, SolowalletRepository } from './db'; @Injectable() export class SolowalletService { @@ -97,7 +99,7 @@ export class SolowalletService { ); } - private async initiateSwap(fiatDeposit: CreateOnrampSwapDto): Promise<{ + private async initiateOnrampSwap(fiatDeposit: CreateOnrampSwapDto): Promise<{ status: TransactionStatus; amountMsats: number; amountFiat: number; @@ -141,10 +143,56 @@ export class SolowalletService { ); } - private async getPaginatedUserDeposits({ + private async initiateOfframpSwap( + fiatWithdraw: CreateOfframpSwapDto, + ): Promise<{ + status: TransactionStatus; + amountMsats: number; + amountFiat: number; + reference: string; + }> { + const reference = fiatWithdraw.reference; + const amountFiat = Number(fiatWithdraw.amountFiat); + + return firstValueFrom( + this.swapService + .createOfframpSwap(fiatWithdraw) + .pipe( + tap((swap: SwapResponse) => { + this.logger.log(`Swap: ${swap}`); + }), + map((swap: SwapResponse) => { + const { amountMsats } = fiatToBtc({ + amountFiat, + btcToFiatRate: Number(swap.rate), + }); + + return { + status: swap.status, + amountMsats, + amountFiat, + reference, + }; + }), + ) + .pipe( + catchError((error) => { + this.logger.error('Error in swap:', error); + return of({ + status: TransactionStatus.FAILED, + amountMsats: 0, + amountFiat, + reference, + }); + }), + ), + ); + } + + private async getPaginatedUserTxLedger({ userId, pagination, - }: FindUserTxsRequestDto): Promise { + }: UserTxsRequestDto): Promise { const allDeposits = await this.wallet.find({ userId }, { createdAt: -1 }); const { page, size } = pagination; @@ -171,12 +219,20 @@ export class SolowalletService { try { status = Number(deposit.status) as TransactionStatus; } catch (error) { - this.logger.warn('Error parsing deposit status', error); + this.logger.warn('Error parsing transaction status', error); + } + + let type = TransactionType.UNRECOGNIZED; + try { + type = Number(deposit.type) as TransactionType; + } catch (error) { + this.logger.warn('Error parsing transaction type', error); } return { ...deposit, status, + type, lightning, paymentTracker: lightning.operationId, id: deposit._id, @@ -193,36 +249,73 @@ export class SolowalletService { }; } - private async getDepositsMeta( - userId: string, - ): Promise { - let meta: DepositsMeta = undefined; + private async aggregateUserDeposits(userId: string): Promise { + let deposits: number = 0; + try { + deposits = await this.wallet + .aggregate([ + { + $match: { + userId: userId, + status: TransactionStatus.COMPLETE.toString(), + type: TransactionType.DEPOSIT.toString(), + }, + }, + { + $group: { + _id: '$userId', + totalMsats: { $sum: '$amountMsats' }, + }, + }, + ]) + .then((result) => { + return result[0].totalMsats || 0; + }); + } catch (e) { + this.logger.error('Error aggregating deposits', e); + } + + return deposits; + } + + private async aggregateUserWithdrawals(userId: string): Promise { + let withdrawals: number = 0; try { - meta = await this.wallet + withdrawals = await this.wallet .aggregate([ { $match: { userId: userId, status: TransactionStatus.COMPLETE.toString(), + type: TransactionType.WITHDRAW.toString(), }, }, { $group: { _id: '$userId', totalMsats: { $sum: '$amountMsats' }, - avgMsats: { $avg: '$amountMsats' }, - count: { $sum: 1 }, }, }, ]) .then((result) => { - return result[0]; + return result[0].totalMsats || 0; }); } catch (e) { - this.logger.error('Error getting deposits meta', e); + this.logger.error('Error aggregating withdrawals', e); } - return meta; + return withdrawals; + } + + private async getWalletMeta(userId: string): Promise { + const totalDeposits = await this.aggregateUserDeposits(userId); + const totalWithdrawals = await this.aggregateUserWithdrawals(userId); + + return { + totalDeposits, + totalWithdrawals, + currentBalance: totalDeposits - totalWithdrawals, + }; } async depositFunds({ @@ -230,7 +323,7 @@ export class SolowalletService { amountFiat, reference, onramp, - }: DepositFundsRequestDto): Promise { + }: DepositFundsRequestDto): Promise { const { quote, amountMsats } = await this.getQuote({ from: onramp?.currency || Currency.KES, to: Currency.BTC, @@ -243,7 +336,7 @@ export class SolowalletService { ); const { status } = onramp - ? await this.initiateSwap({ + ? await this.initiateOnrampSwap({ quote, amountFiat: amountFiat.toString(), reference, @@ -263,6 +356,7 @@ export class SolowalletService { amountFiat, lightning: JSON.stringify(lightning), paymentTracker: lightning.operationId, + type: TransactionType.DEPOSIT, status, reference, }); @@ -273,36 +367,121 @@ export class SolowalletService { lightning.operationId, ); - const deposits = await this.getPaginatedUserDeposits({ + const ledger = await this.getPaginatedUserTxLedger({ userId, pagination: { page: 0, size: 10 }, }); - const meta = await this.getDepositsMeta(userId); + const meta = await this.getWalletMeta(userId); return { txId: deposit._id, - deposits, + ledger, meta, + userId, }; } - async findUserDeposits({ + async userTransactions({ userId, pagination, - }: FindUserTxsRequestDto): Promise { - const deposits = await this.getPaginatedUserDeposits({ + }: UserTxsRequestDto): Promise { + const ledger = await this.getPaginatedUserTxLedger({ userId, pagination, }); - const meta = await this.getDepositsMeta(userId); + const meta = await this.getWalletMeta(userId); return { - deposits, + userId, + ledger, meta, }; } + async withdrawFunds({ + userId, + amountFiat, + reference, + offramp, + lightning, + }: WithdrawFundsRequestDto): Promise { + const { quote, amountMsats } = await this.getQuote({ + from: Currency.BTC, + to: offramp?.currency || Currency.KES, + amount: amountFiat.toString(), + }); + + const { currentBalance } = await this.getWalletMeta(userId); + if (amountMsats > currentBalance) { + throw new Error('Insufficient funds'); + } + + let withdrawal: SolowalletDocument; + + if (lightning) { + this.logger.log(lightning); + const inv = await this.fedimintService.decode(lightning.invoice); + const invoiceMsats = Number(inv.amountMsats); + + if (invoiceMsats > amountMsats || invoiceMsats > currentBalance) { + throw new Error( + 'Invoice amount exceeds withdrawal amount or available balance', + ); + } + + const { operationId, fee } = await this.fedimintService.pay( + lightning.invoice, + ); + this.logger.log('paid invoice'); + this.logger.log(operationId); + + withdrawal = await this.wallet.create({ + userId, + amountMsats: invoiceMsats + fee * 2, + amountFiat, + lightning: JSON.stringify(lightning), + paymentTracker: operationId, + type: TransactionType.WITHDRAW, + status: TransactionStatus.COMPLETE, + reference, + }); + } else if (offramp) { + const { status, amountMsats: offrampMsats } = + await this.initiateOfframpSwap({ + quote, + amountFiat: amountFiat.toString(), + reference, + target: offramp, + }); + + withdrawal = await this.wallet.create({ + userId, + amountMsats: offrampMsats * 1.01, + amountFiat, + lightning: JSON.stringify(lightning), + type: TransactionType.WITHDRAW, + status, + reference, + }); + } else { + throw new Error('No offramp or lightning withdrawal path provided'); + } + + const ledger = await this.getPaginatedUserTxLedger({ + userId, + pagination: { page: 0, size: 10 }, + }); + const meta = await this.getWalletMeta(userId); + + return { + txId: withdrawal._id, + ledger, + meta, + userId, + }; + } + @OnEvent(fedimint_receive_success) private async handleSuccessfulReceive({ context, diff --git a/bun.lockb b/bun.lockb index 45df185890139924b0be6a348347b439804fa63d..dec3f490cbae4797355b172381d6b5439dec621e 100755 GIT binary patch delta 26260 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 delta 26335 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 J#k{ote*jSj$7KKj diff --git a/libs/common/src/dto/solowallet.dto.ts b/libs/common/src/dto/solowallet.dto.ts index d7ed1db..cd8544e 100644 --- a/libs/common/src/dto/solowallet.dto.ts +++ b/libs/common/src/dto/solowallet.dto.ts @@ -7,8 +7,16 @@ import { ValidateNested, } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -import { OnrampSwapSourceDto } from './swap.dto'; -import { DepositFundsRequest, FindUserTxsRequest } from '../types'; +import { + Bolt11InvoiceDto, + OfframpSwapTargetDto, + OnrampSwapSourceDto, +} from './swap.dto'; +import { + DepositFundsRequest, + WithdrawFundsRequest, + UserTxsRequest, +} from '../types'; import { PaginatedRequestDto } from './lib.dto'; export class DepositFundsRequestDto implements DepositFundsRequest { @@ -36,7 +44,37 @@ export class DepositFundsRequestDto implements DepositFundsRequest { onramp?: OnrampSwapSourceDto; } -export class FindUserTxsRequestDto implements FindUserTxsRequest { +export class WithdrawFundsRequestDto implements WithdrawFundsRequest { + @IsNotEmpty() + @IsString() + @Type(() => String) + @ApiProperty({ example: '7b158dfd-cb98-40b1-9ed2-a13006a9f670' }) + userId: string; + + @ApiProperty() + @IsNumber() + @Min(1) + @ApiProperty({ example: 2 }) + amountFiat: number; + + @IsNotEmpty() + @IsString() + @Type(() => String) + @ApiProperty() + reference: string; + + @ValidateNested() + @Type(() => OfframpSwapTargetDto) + @ApiProperty({ type: OfframpSwapTargetDto }) + offramp?: OfframpSwapTargetDto; + + @ValidateNested() + @Type(() => Bolt11InvoiceDto) + @ApiProperty({ type: Bolt11InvoiceDto }) + lightning?: Bolt11InvoiceDto; +} + +export class UserTxsRequestDto implements UserTxsRequest { @IsNotEmpty() @IsString() @Type(() => String) diff --git a/libs/common/src/dto/swap.dto.ts b/libs/common/src/dto/swap.dto.ts index 6872e69..f431114 100644 --- a/libs/common/src/dto/swap.dto.ts +++ b/libs/common/src/dto/swap.dto.ts @@ -115,7 +115,7 @@ export class CreateOnrampSwapDto implements OnrampSwapRequest { target: OnrampSwapTargetDto; } -class OfframpSwapTargetDto implements OfframpSwapTarget { +export class OfframpSwapTargetDto implements OfframpSwapTarget { @IsEnum(Currency) @TransformToCurrency() @ApiProperty({ enum: SupportedCurrencies, enumName: 'SupportedCurrencyType' }) diff --git a/libs/common/src/fedimint/fedimint.service.ts b/libs/common/src/fedimint/fedimint.service.ts index b193031..45346e7 100644 --- a/libs/common/src/fedimint/fedimint.service.ts +++ b/libs/common/src/fedimint/fedimint.service.ts @@ -15,6 +15,7 @@ import { WithFederationId, WithGatewayId, } from '@bitsacco/common'; +import { decode } from 'light-bolt11-decoder'; @Injectable() export class FedimintService { @@ -100,18 +101,45 @@ export class FedimintService { }; } + async decode(invoice: string): Promise { + try { + const decodedInvoice = decode(invoice); + // this.logger.log(decodedInvoice); + + return { + paymentHash: decodedInvoice.sections.find( + (s) => s.name === 'payment_hash', + )?.value, + amountMsats: decodedInvoice.sections.find((s) => s.name === 'amount') + ?.value, + description: decodedInvoice.sections.find( + (s) => s.name === 'description', + )?.value, + timestamp: decodedInvoice.sections.find((s) => s.name === 'timestamp') + ?.value, + }; + } catch (error) { + console.error('Error decoding invoice:', error); + throw new Error('Failed to decode invoice'); + } + } + async pay(invoice: string): Promise<{ operationId: string; fee: number }> { this.logger.log(`Paying Invoice : ${invoice}`); - const { operationId, fee }: LightningPayResponse = await this.post< + const foo = await this.post< { paymentInfo: string } & WithFederationId & WithGatewayId, - LightningPayResponse + unknown >('/ln/pay', { paymentInfo: invoice, federationId: this.federationId, gatewayId: this.gatewayId, }); + this.logger.log(foo); + const { operationId, fee }: LightningPayResponse = + foo as LightningPayResponse; + this.logger.log(`Paid Invoice : ${invoice} : ${operationId}`); return { operationId, @@ -168,3 +196,10 @@ export class FedimintService { }); } } + +interface FlatDecodedInvoice { + paymentHash: string; + amountMsats: string; + description: string; + timestamp: number; +} diff --git a/libs/common/src/types/proto/solowallet.ts b/libs/common/src/types/proto/solowallet.ts index 6d95420..a779353 100644 --- a/libs/common/src/types/proto/solowallet.ts +++ b/libs/common/src/types/proto/solowallet.ts @@ -9,7 +9,13 @@ import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices'; import { Observable } from 'rxjs'; import { PaginatedRequest, TransactionStatus } from './lib'; import { Bolt11 } from './lightning'; -import { OnrampSwapSource } from './swap'; +import { OfframpSwapTarget, OnrampSwapSource } from './swap'; + +export enum TransactionType { + DEPOSIT = 0, + WITHDRAW = 1, + UNRECOGNIZED = -1, +} export interface DepositFundsRequest { userId: string; @@ -18,10 +24,24 @@ export interface DepositFundsRequest { onramp?: OnrampSwapSource | undefined; } -export interface DepositFundsResponse { - txId: string; - deposits: PaginatedSolowalletTxsResponse | undefined; - meta?: DepositsMeta | undefined; +export interface WithdrawFundsRequest { + userId: string; + amountFiat: number; + reference: string; + offramp?: OfframpSwapTarget | undefined; + lightning?: Bolt11 | undefined; +} + +export interface UserTxsRequest { + userId: string; + pagination: PaginatedRequest | undefined; +} + +export interface UserTxsResponse { + txId?: string | undefined; + ledger: PaginatedSolowalletTxsResponse | undefined; + meta?: WalletMeta | undefined; + userId: string; } export interface SolowalletTx { @@ -31,21 +51,12 @@ export interface SolowalletTx { amountMsats: number; amountFiat?: number | undefined; lightning: Bolt11 | undefined; + type: TransactionType; reference: string; createdAt: string; updatedAt?: string | undefined; } -export interface FindUserTxsRequest { - userId: string; - pagination: PaginatedRequest | undefined; -} - -export interface FindUserDepositTxsResponse { - deposits: PaginatedSolowalletTxsResponse | undefined; - meta?: DepositsMeta | undefined; -} - export interface PaginatedSolowalletTxsResponse { /** List of onramp swaps */ transactions: SolowalletTx[]; @@ -57,39 +68,41 @@ export interface PaginatedSolowalletTxsResponse { pages: number; } -export interface DepositsMeta { - totalMsats: number; - avgMsats: number; - count: number; +export interface WalletMeta { + totalDeposits: number; + totalWithdrawals: number; + currentBalance: number; } export interface SolowalletServiceClient { - depositFunds(request: DepositFundsRequest): Observable; + depositFunds(request: DepositFundsRequest): Observable; + + withdrawFunds(request: WithdrawFundsRequest): Observable; - findUserDeposits( - request: FindUserTxsRequest, - ): Observable; + userTransactions(request: UserTxsRequest): Observable; } export interface SolowalletServiceController { depositFunds( request: DepositFundsRequest, - ): - | Promise - | Observable - | DepositFundsResponse; - - findUserDeposits( - request: FindUserTxsRequest, - ): - | Promise - | Observable - | FindUserDepositTxsResponse; + ): Promise | Observable | UserTxsResponse; + + withdrawFunds( + request: WithdrawFundsRequest, + ): Promise | Observable | UserTxsResponse; + + userTransactions( + request: UserTxsRequest, + ): Promise | Observable | UserTxsResponse; } export function SolowalletServiceControllerMethods() { return function (constructor: Function) { - const grpcMethods: string[] = ['depositFunds', 'findUserDeposits']; + const grpcMethods: string[] = [ + 'depositFunds', + 'withdrawFunds', + 'userTransactions', + ]; for (const method of grpcMethods) { const descriptor: any = Reflect.getOwnPropertyDescriptor( constructor.prototype, diff --git a/package.json b/package.json index e819928..ceeea37 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "intasend-node": "^1.1.2", "ioredis": "^5.4.1", "joi": "^17.13.3", + "light-bolt11-decoder": "^3.2.0", "mongoose": "^8.8.0", "nestjs-pino": "^4.1.0", "nostr-tools": "^2.10.3", diff --git a/proto/solowallet.proto b/proto/solowallet.proto index ba743cb..007012c 100644 --- a/proto/solowallet.proto +++ b/proto/solowallet.proto @@ -7,9 +7,11 @@ import "lightning.proto"; package solowallet; service SolowalletService { - rpc DepositFunds(DepositFundsRequest) returns (DepositFundsResponse){} + rpc DepositFunds(DepositFundsRequest) returns (UserTxsResponse){} - rpc FindUserDeposits(FindUserTxsRequest) returns (FindUserDepositTxsResponse){} + rpc WithdrawFunds(WithdrawFundsRequest) returns (UserTxsResponse){} + + rpc UserTransactions(UserTxsRequest) returns (UserTxsResponse){} } message DepositFundsRequest { @@ -21,13 +23,38 @@ message DepositFundsRequest { optional swap.OnrampSwapSource onramp = 5; - // Add more otional funding sources, like direct lightning deposit + // Add more optional funding sources, like direct lightning deposit +} + +message WithdrawFundsRequest { + string user_id = 1; + + int32 amountFiat = 2; + + string reference = 3; + + optional swap.OfframpSwapTarget offramp = 4; + + optional lightning.Bolt11 lightning = 5; + + // Add more optional withdrawal targets, like bolt12 or lnurl address } -message DepositFundsResponse { - string tx_id = 1; - PaginatedSolowalletTxsResponse deposits = 2; - optional DepositsMeta meta = 3; +message UserTxsRequest { + string user_id = 1; + lib.PaginatedRequest pagination = 2; +} + +message UserTxsResponse { + optional string tx_id = 1; + PaginatedSolowalletTxsResponse ledger = 2; + optional WalletMeta meta = 3; + string user_id = 4; +} + +enum TransactionType { + DEPOSIT = 0; + WITHDRAW = 1; } message SolowalletTx { @@ -43,7 +70,9 @@ message SolowalletTx { lightning.Bolt11 lightning = 6; - reserved 7, 8, 9; + TransactionType type = 7; + + reserved 8, 9; string reference = 10; @@ -52,16 +81,6 @@ message SolowalletTx { optional string updatedAt = 12; } -message FindUserTxsRequest { - string user_id = 1; - lib.PaginatedRequest pagination = 2; -} - -message FindUserDepositTxsResponse { - PaginatedSolowalletTxsResponse deposits = 1; - optional DepositsMeta meta = 2; -} - message PaginatedSolowalletTxsResponse { // List of onramp swaps repeated SolowalletTx transactions = 1; @@ -73,8 +92,8 @@ message PaginatedSolowalletTxsResponse { int32 pages = 4; } -message DepositsMeta { - int32 total_msats = 1; - float avg_msats = 2; - int32 count = 3; +message WalletMeta { + int32 total_deposits = 1; + float total_withdrawals = 2; + int32 current_balance = 3; }