diff --git a/datasource.js b/datasource.js index 7d4baa01..08082474 100644 --- a/datasource.js +++ b/datasource.js @@ -21,6 +21,7 @@ import { ManagementGrant } from "./build/src/services/storage/entity/ManagementG import { AppUserDevice } from "./build/src/services/storage/entity/AppUserDevice.js" import { UserAccess } from "./build/src/services/storage/entity/UserAccess.js" import { AdminSettings } from "./build/src/services/storage/entity/AdminSettings.js" +import { TransactionSwap } from "./build/src/services/storage/entity/TransactionSwap.js" import { Initial1703170309875 } from './build/src/services/storage/migrations/1703170309875-initial.js' import { LspOrder1718387847693 } from './build/src/services/storage/migrations/1718387847693-lsp_order.js' @@ -41,6 +42,7 @@ import { AppUserDevice1753285173175 } from './build/src/services/storage/migrati import { UserAccess1759426050669 } from './build/src/services/storage/migrations/1759426050669-user_access.js' import { AddBlindToUserOffer1760000000000 } from './build/src/services/storage/migrations/1760000000000-add_blind_to_user_offer.js' import { ApplicationAvatarUrl1761000001000 } from './build/src/services/storage/migrations/1761000001000-application_avatar_url.js' +import { AdminSettings1761683639419 } from './build/src/services/storage/migrations/1761683639419-admin_settings.js' export default new DataSource({ type: "better-sqlite3", @@ -49,10 +51,11 @@ export default new DataSource({ migrations: [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264, DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, - AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000], + AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419], + entities: [User, UserReceivingInvoice, UserReceivingAddress, AddressReceivingTransaction, UserInvoicePayment, UserTransactionPayment, UserBasicAuth, UserEphemeralKey, Product, UserToUserPayment, Application, ApplicationUser, UserToUserPayment, LspOrder, LndNodeInfo, - TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice, UserAccess, AdminSettings], + TrackedProvider, InviteToken, DebitAccess, UserOffer, ManagementGrant, AppUserDevice, UserAccess, AdminSettings, TransactionSwap], // synchronize: true, }) -//npx typeorm migration:generate ./src/services/storage/migrations/admin_settings -d ./datasource.js \ No newline at end of file +//npx typeorm migration:generate ./src/services/storage/migrations/tx_swap -d ./datasource.js \ No newline at end of file diff --git a/proto/autogenerated/client.md b/proto/autogenerated/client.md index a4116e6c..17dcc2de 100644 --- a/proto/autogenerated/client.md +++ b/proto/autogenerated/client.md @@ -198,6 +198,11 @@ The nostr server will send back a message response, and inside the body there wi - input: [SingleMetricReq](#SingleMetricReq) - output: [UsageMetricTlv](#UsageMetricTlv) +- GetTransactionSwapQuote + - auth type: __User__ + - input: [TransactionSwapRequest](#TransactionSwapRequest) + - output: [TransactionSwapQuote](#TransactionSwapQuote) + - GetUsageMetrics - auth type: __Metrics__ - input: [LatestUsageMetricReq](#LatestUsageMetricReq) @@ -708,6 +713,13 @@ The nostr server will send back a message response, and inside the body there wi - input: [SingleMetricReq](#SingleMetricReq) - output: [UsageMetricTlv](#UsageMetricTlv) +- GetTransactionSwapQuote + - auth type: __User__ + - http method: __post__ + - http route: __/api/user/swap/quote__ + - input: [TransactionSwapRequest](#TransactionSwapRequest) + - output: [TransactionSwapQuote](#TransactionSwapQuote) + - GetUsageMetrics - auth type: __Metrics__ - http method: __post__ @@ -1450,6 +1462,7 @@ The nostr server will send back a message response, and inside the body there wi - __address__: _string_ - __amoutSats__: _number_ - __satsPerVByte__: _number_ + - __swap_operation_id__: _string_ *this field is optional ### PayAddressResponse - __network_fee__: _number_ @@ -1554,6 +1567,16 @@ The nostr server will send back a message response, and inside the body there wi - __page__: _number_ - __request_id__: _number_ *this field is optional +### TransactionSwapQuote + - __chain_fee_sats__: _number_ + - __invoice_amount_sats__: _number_ + - __swap_fee_sats__: _number_ + - __swap_operation_id__: _string_ + - __transaction_amount_sats__: _number_ + +### TransactionSwapRequest + - __transaction_amount_sats__: _number_ + ### UpdateChannelPolicyRequest - __policy__: _[ChannelPolicy](#ChannelPolicy)_ - __update__: _[UpdateChannelPolicyRequest_update](#UpdateChannelPolicyRequest_update)_ diff --git a/proto/autogenerated/go/http_client.go b/proto/autogenerated/go/http_client.go index 8c20a0a6..5b0fa1f2 100644 --- a/proto/autogenerated/go/http_client.go +++ b/proto/autogenerated/go/http_client.go @@ -101,6 +101,7 @@ type Client struct { GetSeed func() (*LndSeed, error) GetSingleBundleMetrics func(req SingleMetricReq) (*BundleData, error) GetSingleUsageMetrics func(req SingleMetricReq) (*UsageMetricTlv, error) + GetTransactionSwapQuote func(req TransactionSwapRequest) (*TransactionSwapQuote, error) GetUsageMetrics func(req LatestUsageMetricReq) (*UsageMetrics, error) GetUserInfo func() (*UserInfo, error) GetUserOffer func(req OfferId) (*OfferConfig, error) @@ -1285,6 +1286,35 @@ func NewClient(params ClientParams) *Client { } return &res, nil }, + GetTransactionSwapQuote: func(req TransactionSwapRequest) (*TransactionSwapQuote, error) { + auth, err := params.RetrieveUserAuth() + if err != nil { + return nil, err + } + finalRoute := "/api/user/swap/quote" + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + resBody, err := doPostRequest(params.BaseURL+finalRoute, body, auth) + if err != nil { + return nil, err + } + result := ResultError{} + err = json.Unmarshal(resBody, &result) + if err != nil { + return nil, err + } + if result.Status == "ERROR" { + return nil, fmt.Errorf(result.Reason) + } + res := TransactionSwapQuote{} + err = json.Unmarshal(resBody, &res) + if err != nil { + return nil, err + } + return &res, nil + }, GetUsageMetrics: func(req LatestUsageMetricReq) (*UsageMetrics, error) { auth, err := params.RetrieveMetricsAuth() if err != nil { diff --git a/proto/autogenerated/go/types.go b/proto/autogenerated/go/types.go index e8e90df5..2c85c8c0 100644 --- a/proto/autogenerated/go/types.go +++ b/proto/autogenerated/go/types.go @@ -532,9 +532,10 @@ type OperationsCursor struct { Ts int64 `json:"ts"` } type PayAddressRequest struct { - Address string `json:"address"` - Amoutsats int64 `json:"amoutSats"` - Satspervbyte int64 `json:"satsPerVByte"` + Address string `json:"address"` + Amoutsats int64 `json:"amoutSats"` + Satspervbyte int64 `json:"satsPerVByte"` + Swap_operation_id string `json:"swap_operation_id"` } type PayAddressResponse struct { Network_fee int64 `json:"network_fee"` @@ -639,6 +640,16 @@ type SingleMetricReq struct { Page int64 `json:"page"` Request_id int64 `json:"request_id"` } +type TransactionSwapQuote struct { + Chain_fee_sats int64 `json:"chain_fee_sats"` + Invoice_amount_sats int64 `json:"invoice_amount_sats"` + Swap_fee_sats int64 `json:"swap_fee_sats"` + Swap_operation_id string `json:"swap_operation_id"` + Transaction_amount_sats int64 `json:"transaction_amount_sats"` +} +type TransactionSwapRequest struct { + Transaction_amount_sats int64 `json:"transaction_amount_sats"` +} type UpdateChannelPolicyRequest struct { Policy *ChannelPolicy `json:"policy"` Update *UpdateChannelPolicyRequest_update `json:"update"` diff --git a/proto/autogenerated/ts/express_server.ts b/proto/autogenerated/ts/express_server.ts index 18102374..3b08f9a4 100644 --- a/proto/autogenerated/ts/express_server.ts +++ b/proto/autogenerated/ts/express_server.ts @@ -477,6 +477,18 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) } break + case 'GetTransactionSwapQuote': + if (!methods.GetTransactionSwapQuote) { + throw new Error('method GetTransactionSwapQuote not found' ) + } else { + const error = Types.TransactionSwapRequestValidate(operation.req) + opStats.validate = process.hrtime.bigint() + if (error !== null) throw error + const res = await methods.GetTransactionSwapQuote({...operation, ctx}); responses.push({ status: 'OK', ...res }) + opStats.handle = process.hrtime.bigint() + callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) + } + break case 'GetUserInfo': if (!methods.GetUserInfo) { throw new Error('method GetUserInfo not found' ) @@ -1317,6 +1329,28 @@ export default (methods: Types.ServerMethods, opts: ServerOptions) => { opts.metricsCallback([{ ...info, ...stats, ...authContext }]) } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } }) + if (!opts.allowNotImplementedMethods && !methods.GetTransactionSwapQuote) throw new Error('method: GetTransactionSwapQuote is not implemented') + app.post('/api/user/swap/quote', async (req, res) => { + const info: Types.RequestInfo = { rpcName: 'GetTransactionSwapQuote', batch: false, nostr: false, batchSize: 0} + const stats: Types.RequestStats = { startMs:req.startTimeMs || 0, start:req.startTime || 0n, parse: process.hrtime.bigint(), guard: 0n, validate: 0n, handle: 0n } + let authCtx: Types.AuthContext = {} + try { + if (!methods.GetTransactionSwapQuote) throw new Error('method: GetTransactionSwapQuote is not implemented') + const authContext = await opts.UserAuthGuard(req.headers['authorization']) + authCtx = authContext + stats.guard = process.hrtime.bigint() + const request = req.body + const error = Types.TransactionSwapRequestValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authContext }, opts.metricsCallback) + const query = req.query + const params = req.params + const response = await methods.GetTransactionSwapQuote({rpcName:'GetTransactionSwapQuote', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res.json({status: 'OK', ...response}) + opts.metricsCallback([{ ...info, ...stats, ...authContext }]) + } catch (ex) { const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } + }) if (!opts.allowNotImplementedMethods && !methods.GetUsageMetrics) throw new Error('method: GetUsageMetrics is not implemented') app.post('/api/reports/usage', async (req, res) => { const info: Types.RequestInfo = { rpcName: 'GetUsageMetrics', batch: false, nostr: false, batchSize: 0} diff --git a/proto/autogenerated/ts/http_client.ts b/proto/autogenerated/ts/http_client.ts index 9c37e1b3..c48d2ef7 100644 --- a/proto/autogenerated/ts/http_client.ts +++ b/proto/autogenerated/ts/http_client.ts @@ -603,6 +603,20 @@ export default (params: ClientParams) => ({ } return { status: 'ERROR', reason: 'invalid response' } }, + GetTransactionSwapQuote: async (request: Types.TransactionSwapRequest): Promise => { + const auth = await params.retrieveUserAuth() + if (auth === null) throw new Error('retrieveUserAuth() returned null') + let finalRoute = '/api/user/swap/quote' + const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.TransactionSwapQuoteValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, GetUsageMetrics: async (request: Types.LatestUsageMetricReq): Promise => { const auth = await params.retrieveMetricsAuth() if (auth === null) throw new Error('retrieveMetricsAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_client.ts b/proto/autogenerated/ts/nostr_client.ts index 38795d01..9bcbfe00 100644 --- a/proto/autogenerated/ts/nostr_client.ts +++ b/proto/autogenerated/ts/nostr_client.ts @@ -536,6 +536,21 @@ export default (params: NostrClientParams, send: (to:string, message: NostrRequ } return { status: 'ERROR', reason: 'invalid response' } }, + GetTransactionSwapQuote: async (request: Types.TransactionSwapRequest): Promise => { + const auth = await params.retrieveNostrUserAuth() + if (auth === null) throw new Error('retrieveNostrUserAuth() returned null') + const nostrRequest: NostrRequest = {} + nostrRequest.body = request + const data = await send(params.pubDestination, {rpcName:'GetTransactionSwapQuote',authIdentifier:auth, ...nostrRequest }) + if (data.status === 'ERROR' && typeof data.reason === 'string') return data + if (data.status === 'OK') { + const result = data + if(!params.checkResult) return { status: 'OK', ...result } + const error = Types.TransactionSwapQuoteValidate(result) + if (error === null) { return { status: 'OK', ...result } } else return { status: 'ERROR', reason: error.message } + } + return { status: 'ERROR', reason: 'invalid response' } + }, GetUsageMetrics: async (request: Types.LatestUsageMetricReq): Promise => { const auth = await params.retrieveNostrMetricsAuth() if (auth === null) throw new Error('retrieveNostrMetricsAuth() returned null') diff --git a/proto/autogenerated/ts/nostr_transport.ts b/proto/autogenerated/ts/nostr_transport.ts index cd9e708f..3d4614fc 100644 --- a/proto/autogenerated/ts/nostr_transport.ts +++ b/proto/autogenerated/ts/nostr_transport.ts @@ -359,6 +359,18 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => { callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) } break + case 'GetTransactionSwapQuote': + if (!methods.GetTransactionSwapQuote) { + throw new Error('method not defined: GetTransactionSwapQuote') + } else { + const error = Types.TransactionSwapRequestValidate(operation.req) + opStats.validate = process.hrtime.bigint() + if (error !== null) throw error + const res = await methods.GetTransactionSwapQuote({...operation, ctx}); responses.push({ status: 'OK', ...res }) + opStats.handle = process.hrtime.bigint() + callsMetrics.push({ ...opInfo, ...opStats, ...ctx }) + } + break case 'GetUserInfo': if (!methods.GetUserInfo) { throw new Error('method not defined: GetUserInfo') @@ -962,6 +974,22 @@ export default (methods: Types.ServerMethods, opts: NostrOptions) => { opts.metricsCallback([{ ...info, ...stats, ...authContext }]) }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } break + case 'GetTransactionSwapQuote': + try { + if (!methods.GetTransactionSwapQuote) throw new Error('method: GetTransactionSwapQuote is not implemented') + const authContext = await opts.NostrUserAuthGuard(req.appId, req.authIdentifier) + stats.guard = process.hrtime.bigint() + authCtx = authContext + const request = req.body + const error = Types.TransactionSwapRequestValidate(request) + stats.validate = process.hrtime.bigint() + if (error !== null) return logErrorAndReturnResponse(error, 'invalid request body', res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback) + const response = await methods.GetTransactionSwapQuote({rpcName:'GetTransactionSwapQuote', ctx:authContext , req: request}) + stats.handle = process.hrtime.bigint() + res({status: 'OK', ...response}) + opts.metricsCallback([{ ...info, ...stats, ...authContext }]) + }catch(ex){ const e = ex as any; logErrorAndReturnResponse(e, e.message || e, res, logger, { ...info, ...stats, ...authCtx }, opts.metricsCallback); if (opts.throwErrors) throw e } + break case 'GetUsageMetrics': try { if (!methods.GetUsageMetrics) throw new Error('method: GetUsageMetrics is not implemented') diff --git a/proto/autogenerated/ts/types.ts b/proto/autogenerated/ts/types.ts index 239d0686..71ad89fc 100644 --- a/proto/autogenerated/ts/types.ts +++ b/proto/autogenerated/ts/types.ts @@ -35,8 +35,8 @@ export type UserContext = { app_user_id: string user_id: string } -export type UserMethodInputs = AddProduct_Input | AddUserOffer_Input | AuthorizeManage_Input | BanDebit_Input | DecodeInvoice_Input | DeleteUserOffer_Input | EditDebit_Input | EnrollAdminToken_Input | EnrollMessagingToken_Input | GetDebitAuthorizations_Input | GetHttpCreds_Input | GetLNURLChannelLink_Input | GetLnurlPayLink_Input | GetLnurlWithdrawLink_Input | GetManageAuthorizations_Input | GetPaymentState_Input | GetUserInfo_Input | GetUserOffer_Input | GetUserOfferInvoices_Input | GetUserOffers_Input | GetUserOperations_Input | NewAddress_Input | NewInvoice_Input | NewProductInvoice_Input | PayAddress_Input | PayInvoice_Input | ResetDebit_Input | ResetManage_Input | RespondToDebit_Input | UpdateCallbackUrl_Input | UpdateUserOffer_Input | UserHealth_Input -export type UserMethodOutputs = AddProduct_Output | AddUserOffer_Output | AuthorizeManage_Output | BanDebit_Output | DecodeInvoice_Output | DeleteUserOffer_Output | EditDebit_Output | EnrollAdminToken_Output | EnrollMessagingToken_Output | GetDebitAuthorizations_Output | GetHttpCreds_Output | GetLNURLChannelLink_Output | GetLnurlPayLink_Output | GetLnurlWithdrawLink_Output | GetManageAuthorizations_Output | GetPaymentState_Output | GetUserInfo_Output | GetUserOffer_Output | GetUserOfferInvoices_Output | GetUserOffers_Output | GetUserOperations_Output | NewAddress_Output | NewInvoice_Output | NewProductInvoice_Output | PayAddress_Output | PayInvoice_Output | ResetDebit_Output | ResetManage_Output | RespondToDebit_Output | UpdateCallbackUrl_Output | UpdateUserOffer_Output | UserHealth_Output +export type UserMethodInputs = AddProduct_Input | AddUserOffer_Input | AuthorizeManage_Input | BanDebit_Input | DecodeInvoice_Input | DeleteUserOffer_Input | EditDebit_Input | EnrollAdminToken_Input | EnrollMessagingToken_Input | GetDebitAuthorizations_Input | GetHttpCreds_Input | GetLNURLChannelLink_Input | GetLnurlPayLink_Input | GetLnurlWithdrawLink_Input | GetManageAuthorizations_Input | GetPaymentState_Input | GetTransactionSwapQuote_Input | GetUserInfo_Input | GetUserOffer_Input | GetUserOfferInvoices_Input | GetUserOffers_Input | GetUserOperations_Input | NewAddress_Input | NewInvoice_Input | NewProductInvoice_Input | PayAddress_Input | PayInvoice_Input | ResetDebit_Input | ResetManage_Input | RespondToDebit_Input | UpdateCallbackUrl_Input | UpdateUserOffer_Input | UserHealth_Input +export type UserMethodOutputs = AddProduct_Output | AddUserOffer_Output | AuthorizeManage_Output | BanDebit_Output | DecodeInvoice_Output | DeleteUserOffer_Output | EditDebit_Output | EnrollAdminToken_Output | EnrollMessagingToken_Output | GetDebitAuthorizations_Output | GetHttpCreds_Output | GetLNURLChannelLink_Output | GetLnurlPayLink_Output | GetLnurlWithdrawLink_Output | GetManageAuthorizations_Output | GetPaymentState_Output | GetTransactionSwapQuote_Output | GetUserInfo_Output | GetUserOffer_Output | GetUserOfferInvoices_Output | GetUserOffers_Output | GetUserOperations_Output | NewAddress_Output | NewInvoice_Output | NewProductInvoice_Output | PayAddress_Output | PayInvoice_Output | ResetDebit_Output | ResetManage_Output | RespondToDebit_Output | UpdateCallbackUrl_Output | UpdateUserOffer_Output | UserHealth_Output export type AuthContext = AdminContext | AppContext | GuestContext | GuestWithPubContext | MetricsContext | UserContext export type AddApp_Input = {rpcName:'AddApp', req: AddAppRequest} @@ -186,6 +186,9 @@ export type GetSingleBundleMetrics_Output = ResultError | ({ status: 'OK' } & Bu export type GetSingleUsageMetrics_Input = {rpcName:'GetSingleUsageMetrics', req: SingleMetricReq} export type GetSingleUsageMetrics_Output = ResultError | ({ status: 'OK' } & UsageMetricTlv) +export type GetTransactionSwapQuote_Input = {rpcName:'GetTransactionSwapQuote', req: TransactionSwapRequest} +export type GetTransactionSwapQuote_Output = ResultError | ({ status: 'OK' } & TransactionSwapQuote) + export type GetUsageMetrics_Input = {rpcName:'GetUsageMetrics', req: LatestUsageMetricReq} export type GetUsageMetrics_Output = ResultError | ({ status: 'OK' } & UsageMetrics) @@ -369,6 +372,7 @@ export type ServerMethods = { GetSeed?: (req: GetSeed_Input & {ctx: AdminContext }) => Promise GetSingleBundleMetrics?: (req: GetSingleBundleMetrics_Input & {ctx: MetricsContext }) => Promise GetSingleUsageMetrics?: (req: GetSingleUsageMetrics_Input & {ctx: MetricsContext }) => Promise + GetTransactionSwapQuote?: (req: GetTransactionSwapQuote_Input & {ctx: UserContext }) => Promise GetUsageMetrics?: (req: GetUsageMetrics_Input & {ctx: MetricsContext }) => Promise GetUserInfo?: (req: GetUserInfo_Input & {ctx: UserContext }) => Promise GetUserOffer?: (req: GetUserOffer_Input & {ctx: UserContext }) => Promise @@ -3132,13 +3136,16 @@ export type PayAddressRequest = { address: string amoutSats: number satsPerVByte: number + swap_operation_id?: string } -export const PayAddressRequestOptionalFields: [] = [] +export type PayAddressRequestOptionalField = 'swap_operation_id' +export const PayAddressRequestOptionalFields: PayAddressRequestOptionalField[] = ['swap_operation_id'] export type PayAddressRequestOptions = OptionsBaseMessage & { - checkOptionalsAreSet?: [] + checkOptionalsAreSet?: PayAddressRequestOptionalField[] address_CustomCheck?: (v: string) => boolean amoutSats_CustomCheck?: (v: number) => boolean satsPerVByte_CustomCheck?: (v: number) => boolean + swap_operation_id_CustomCheck?: (v?: string) => boolean } export const PayAddressRequestValidate = (o?: PayAddressRequest, opts: PayAddressRequestOptions = {}, path: string = 'PayAddressRequest::root.'): Error | null => { if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') @@ -3153,6 +3160,9 @@ export const PayAddressRequestValidate = (o?: PayAddressRequest, opts: PayAddres if (typeof o.satsPerVByte !== 'number') return new Error(`${path}.satsPerVByte: is not a number`) if (opts.satsPerVByte_CustomCheck && !opts.satsPerVByte_CustomCheck(o.satsPerVByte)) return new Error(`${path}.satsPerVByte: custom check failed`) + if ((o.swap_operation_id || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('swap_operation_id')) && typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) + if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) + return null } @@ -3744,6 +3754,62 @@ export const SingleMetricReqValidate = (o?: SingleMetricReq, opts: SingleMetricR return null } +export type TransactionSwapQuote = { + chain_fee_sats: number + invoice_amount_sats: number + swap_fee_sats: number + swap_operation_id: string + transaction_amount_sats: number +} +export const TransactionSwapQuoteOptionalFields: [] = [] +export type TransactionSwapQuoteOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + chain_fee_sats_CustomCheck?: (v: number) => boolean + invoice_amount_sats_CustomCheck?: (v: number) => boolean + swap_fee_sats_CustomCheck?: (v: number) => boolean + swap_operation_id_CustomCheck?: (v: string) => boolean + transaction_amount_sats_CustomCheck?: (v: number) => boolean +} +export const TransactionSwapQuoteValidate = (o?: TransactionSwapQuote, opts: TransactionSwapQuoteOptions = {}, path: string = 'TransactionSwapQuote::root.'): Error | null => { + if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') + if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') + + if (typeof o.chain_fee_sats !== 'number') return new Error(`${path}.chain_fee_sats: is not a number`) + if (opts.chain_fee_sats_CustomCheck && !opts.chain_fee_sats_CustomCheck(o.chain_fee_sats)) return new Error(`${path}.chain_fee_sats: custom check failed`) + + if (typeof o.invoice_amount_sats !== 'number') return new Error(`${path}.invoice_amount_sats: is not a number`) + if (opts.invoice_amount_sats_CustomCheck && !opts.invoice_amount_sats_CustomCheck(o.invoice_amount_sats)) return new Error(`${path}.invoice_amount_sats: custom check failed`) + + if (typeof o.swap_fee_sats !== 'number') return new Error(`${path}.swap_fee_sats: is not a number`) + if (opts.swap_fee_sats_CustomCheck && !opts.swap_fee_sats_CustomCheck(o.swap_fee_sats)) return new Error(`${path}.swap_fee_sats: custom check failed`) + + if (typeof o.swap_operation_id !== 'string') return new Error(`${path}.swap_operation_id: is not a string`) + if (opts.swap_operation_id_CustomCheck && !opts.swap_operation_id_CustomCheck(o.swap_operation_id)) return new Error(`${path}.swap_operation_id: custom check failed`) + + if (typeof o.transaction_amount_sats !== 'number') return new Error(`${path}.transaction_amount_sats: is not a number`) + if (opts.transaction_amount_sats_CustomCheck && !opts.transaction_amount_sats_CustomCheck(o.transaction_amount_sats)) return new Error(`${path}.transaction_amount_sats: custom check failed`) + + return null +} + +export type TransactionSwapRequest = { + transaction_amount_sats: number +} +export const TransactionSwapRequestOptionalFields: [] = [] +export type TransactionSwapRequestOptions = OptionsBaseMessage & { + checkOptionalsAreSet?: [] + transaction_amount_sats_CustomCheck?: (v: number) => boolean +} +export const TransactionSwapRequestValidate = (o?: TransactionSwapRequest, opts: TransactionSwapRequestOptions = {}, path: string = 'TransactionSwapRequest::root.'): Error | null => { + if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message') + if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null') + + if (typeof o.transaction_amount_sats !== 'number') return new Error(`${path}.transaction_amount_sats: is not a number`) + if (opts.transaction_amount_sats_CustomCheck && !opts.transaction_amount_sats_CustomCheck(o.transaction_amount_sats)) return new Error(`${path}.transaction_amount_sats: custom check failed`) + + return null +} + export type UpdateChannelPolicyRequest = { policy: ChannelPolicy update: UpdateChannelPolicyRequest_update diff --git a/proto/service/methods.proto b/proto/service/methods.proto index 4cff7379..0f4147f4 100644 --- a/proto/service/methods.proto +++ b/proto/service/methods.proto @@ -496,6 +496,13 @@ service LightningPub { option (nostr) = true; } + rpc GetTransactionSwapQuote(structs.TransactionSwapRequest) returns (structs.TransactionSwapQuote){ + option (auth_type) = "User"; + option (http_method) = "post"; + option (http_route) = "/api/user/swap/quote"; + option (nostr) = true; + } + rpc NewInvoice(structs.NewInvoiceRequest) returns (structs.NewInvoiceResponse){ option (auth_type) = "User"; option (http_method) = "post"; diff --git a/proto/service/structs.proto b/proto/service/structs.proto index 201fd30b..65771361 100644 --- a/proto/service/structs.proto +++ b/proto/service/structs.proto @@ -432,6 +432,7 @@ message PayAddressRequest{ string address = 1; int64 amoutSats = 2; int64 satsPerVByte = 3; + optional string swap_operation_id = 4; } message PayAddressResponse{ @@ -824,3 +825,15 @@ message MessagingToken { string device_id = 1; string firebase_messaging_token = 2; } + +message TransactionSwapRequest { + int64 transaction_amount_sats = 2; +} + +message TransactionSwapQuote { + string swap_operation_id = 1; + int64 swap_fee_sats = 2; + int64 invoice_amount_sats = 3; + int64 transaction_amount_sats = 4; + int64 chain_fee_sats = 5; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 09a9d5dd..42c02f46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,7 +47,6 @@ const start = async () => { adminManager.setAppNprofile(appNprofile) const Server = NewServer(serverMethods, serverOptions(mainHandler)) Server.Listen(mainHandler.settings.getSettings().serviceSettings.servicePort) - await TMP_swapTest_TMP(mainHandler) // TMP -- remove this } start() @@ -65,16 +64,4 @@ const exitHandler = async (kill: () => void) => { kill(); process.exit(99); }); -} - -const TMP_swapTest_TMP = async (mainHandler: Main) => { - await new Promise(resolve => setTimeout(resolve, 10000)) - /* const i = await mainHandler.lnd.NewInvoice(25000, 'test', 3600, { useProvider: true, from: 'user' }) - console.log('Invoice created with provider destination', i.providerDst) - const decoded = await mainHandler.lnd.DecodeInvoice(i.payRequest) - await mainHandler.paymentManager.swaps.SwapInvoice(i.payRequest, decoded.paymentHash) */ - const a = await mainHandler.lnd.NewAddress(Types.AddressType.WITNESS_PUBKEY_HASH, { useProvider: false, from: 'user' }) - console.log('Address created', a.address) - await mainHandler.paymentManager.swaps.SwapTransaction(a.address, 25000, networks.bitcoin) - } \ No newline at end of file diff --git a/src/services/lnd/swaps.ts b/src/services/lnd/swaps.ts index a5b884fe..ab87309c 100644 --- a/src/services/lnd/swaps.ts +++ b/src/services/lnd/swaps.ts @@ -4,7 +4,8 @@ import { crypto, initEccLib, Transaction, address, Network } from 'bitcoinjs-lib // import bolt11 from 'bolt11'; import { Musig, SwapTreeSerializer, TaprootUtils, detectSwap, - constructClaimTransaction, targetFee, OutputType + constructClaimTransaction, targetFee, OutputType, + Networks, } from 'boltz-core'; import { randomBytes, createHash } from 'crypto'; import { ECPairFactory, ECPairInterface } from 'ecpair'; @@ -12,22 +13,57 @@ import * as ecc from 'tiny-secp256k1'; import ws from 'ws'; import { getLogger, PubLogger, ERROR } from '../helpers/logger.js'; import SettingsManager from '../main/settingsManager.js'; +import * as Types from '../../../proto/autogenerated/ts/types.js'; type InvoiceSwapResponse = { id: string, claimPublicKey: string, swapTree: string } type InvoiceSwapInfo = { paymentHash: string, keys: ECPairInterface } type InvoiceSwapData = { createdResponse: InvoiceSwapResponse, info: InvoiceSwapInfo } -type TransactionSwapResponse = { id: string, refundPublicKey: string, swapTree: string } -type TransactionSwapInfo = { destinationAddress: string, network: Network, preimage: Buffer, keys: ECPairInterface, txHex: string } -type TransactionSwapData = { createdResponse: TransactionSwapResponse, info: TransactionSwapInfo } +type TransactionSwapFees = { + percentage: number, + minerFees: { + claim: number, + lockup: number, + } +} +type TransactionSwapFeesRes = { + BTC?: { + BTC?: { + fees: TransactionSwapFees + } + } +} + + +type TransactionSwapResponse = { + id: string, refundPublicKey: string, swapTree: string, + timeoutBlockHeight: number, lockupAddress: string, invoice: string, + onchainAmount?: number +} +type TransactionSwapInfo = { destinationAddress: string, preimage: Buffer, keys: ECPairInterface, chainFee: number } +export type TransactionSwapData = { createdResponse: TransactionSwapResponse, info: TransactionSwapInfo } +const network = Networks.bitcoinMainnet export class Swaps { + reverseSwaps: ReverseSwaps + submarineSwaps: SubmarineSwaps + constructor(settings: SettingsManager) { + this.reverseSwaps = new ReverseSwaps(settings) + this.submarineSwaps = new SubmarineSwaps(settings) + } + + GetKeys = (privateKey: string) => { + const keys = ECPairFactory(ecc).fromPrivateKey(Buffer.from(privateKey, 'hex')) + return keys + } +} + +export class SubmarineSwaps { settings: SettingsManager log: PubLogger constructor(settings: SettingsManager) { this.settings = settings this.log = getLogger({ component: 'SwapsService' }) - initEccLib(ecc) } SwapInvoice = async (invoice: string, paymentHash: string) => { @@ -40,11 +76,11 @@ export class Swaps { const req = { invoice, to: 'BTC', from: 'BTC', refundPublicKey } const url = `${this.settings.getSettings().swapsSettings.boltzHttpUrl}/v2/swap/submarine` this.log('Sending invoice swap request to', url); - const createdResponse = await loggedPost(this.log, url, req) - if (!createdResponse) { - return; + const createdResponseRes = await loggedPost(this.log, url, req) + if (!createdResponseRes.ok) { + return createdResponseRes } - + const createdResponse = createdResponseRes.data this.log('Created invoice swap'); this.log(createdResponse); @@ -98,11 +134,11 @@ export class Swaps { const { boltzHttpUrl } = this.settings.getSettings().swapsSettings // Get the information request to create a partial signature const url = `${boltzHttpUrl}/v2/swap/submarine/${createdResponse.id}/claim` - const claimTxDetails = await loggedGet<{ preimage: string, transactionHash: string, pubNonce: string }>(this.log, url) - if (!claimTxDetails) { - return; + const claimTxDetailsRes = await loggedGet<{ preimage: string, transactionHash: string, pubNonce: string }>(this.log, url) + if (!claimTxDetailsRes.ok) { + return claimTxDetailsRes } - + const claimTxDetails = claimTxDetailsRes.data // Verify that Boltz actually paid the invoice by comparing the preimage hash // of the invoice to the SHA256 hash of the preimage from the response const claimTxPreimageHash = createHash('sha256').update(Buffer.from(claimTxDetails.preimage, 'hex')).digest() @@ -141,53 +177,111 @@ export class Swaps { pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'), partialSignature: Buffer.from(musig.signPartial()).toString('hex'), } - const claimResponse = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq) - if (!claimResponse) { - return; + const claimResponseRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq) + if (!claimResponseRes.ok) { + return claimResponseRes } + const claimResponse = claimResponseRes.data this.log('Claim response', claimResponse) } +} +export class ReverseSwaps { + settings: SettingsManager + log: PubLogger + constructor(settings: SettingsManager) { + this.settings = settings + this.log = getLogger({ component: 'SwapsService' }) + initEccLib(ecc) + } - SwapTransaction = async (destinationAddress: string, invoiceAmount: number, network: Network) => { + calculateFees = (fees: TransactionSwapFees, receiveAmount: number) => { + const pct = fees.percentage / 100 + const minerFee = fees.minerFees.claim + fees.minerFees.lockup + + const preFee = receiveAmount + minerFee + const fee = Math.ceil(preFee * pct) + const total = preFee + fee + return { total, fee, minerFee } + } + + GetFees = async (): Promise<{ ok: true, fees: TransactionSwapFees, } | { ok: false, error: string }> => { + const url = `${this.settings.getSettings().swapsSettings.boltzHttpUrl}/v2/swap/reverse` + const feesRes = await loggedGet(this.log, url) + if (!feesRes.ok) { + return { ok: false, error: feesRes.error } + } + if (!feesRes.data.BTC?.BTC?.fees) { + return { ok: false, error: 'No fees found for BTC to BTC swap' } + } + + return { ok: true, fees: feesRes.data.BTC.BTC.fees } + } + + SwapTransaction = async (txAmount: number): Promise<{ ok: true, createdResponse: TransactionSwapResponse, preimage: string, pubkey: string, privKey: string } | { ok: false, error: string }> => { if (!this.settings.getSettings().swapsSettings.enableSwaps) { this.log(ERROR, 'Swaps are not enabled'); - return; + return { ok: false, error: 'Swaps are not enabled' } } const preimage = randomBytes(32); const keys = ECPairFactory(ecc).makeRandom() + if (!keys.privateKey) { + return { ok: false, error: 'Failed to generate keys' } + } const url = `${this.settings.getSettings().swapsSettings.boltzHttpUrl}/v2/swap/reverse` - const req = { - onchainAmount: invoiceAmount, + const req: any = { + onchainAmount: txAmount, + // invoiceAmount, to: 'BTC', from: 'BTC', claimPublicKey: Buffer.from(keys.publicKey).toString('hex'), preimageHash: createHash('sha256').update(preimage).digest('hex'), } - const createdResponse = await loggedPost(this.log, url, req) - if (!createdResponse) { - return; + /* if (amount.type === Types.TransactionSwapRequest_amount_type.INVOICE_AMOUNT_SATS) { + req.invoiceAmount = amount.invoice_amount_sats + } else if (amount.type === Types.TransactionSwapRequest_amount_type.TRANSACTION_AMOUNT_SATS) { + req.onchainAmount = amount.transaction_amount_sats + } */ + const createdResponseRes = await loggedPost(this.log, url, req) + if (!createdResponseRes.ok) { + return createdResponseRes } + const createdResponse = createdResponseRes.data this.log('Created transaction swap'); this.log(createdResponse); + return { + ok: true, createdResponse, + preimage: Buffer.from(preimage).toString('hex'), + pubkey: Buffer.from(keys.publicKey).toString('hex'), + privKey: Buffer.from(keys.privateKey).toString('hex') + } + } + SubscribeToTransactionSwap = async (data: TransactionSwapData) => { const webSocket = new ws(`${this.settings.getSettings().swapsSettings.boltzWebSocketUrl}/v2/ws`) - const subReq = { op: 'subscribe', channel: 'swap.update', args: [createdResponse.id] } + const subReq = { op: 'subscribe', channel: 'swap.update', args: [data.createdResponse.id] } webSocket.on('open', () => { webSocket.send(JSON.stringify(subReq)) }) - - webSocket.on('message', async (rawMsg) => { - try { - await this.handleSwapTransactionMessage(rawMsg, { createdResponse, info: { destinationAddress, network, preimage, keys, txHex: '' } }, () => webSocket.close()) - } catch (err: any) { - this.log(ERROR, 'Error handling transaction WebSocket message', err.message) + return new Promise((resolve, reject) => { + const done = (txId: string) => { webSocket.close() - return + resolve(txId) } + webSocket.on('message', async (rawMsg) => { + try { + await this.handleSwapTransactionMessage(rawMsg, data, done) + } catch (err: any) { + this.log(ERROR, 'Error handling transaction WebSocket message', err.message) + webSocket.close() + reject(err) + return + } + }) }) + } - handleSwapTransactionMessage = async (rawMsg: ws.RawData, data: TransactionSwapData, closeWebSocket: () => void) => { + handleSwapTransactionMessage = async (rawMsg: ws.RawData, data: TransactionSwapData, done: (txId: string) => void) => { const msg = JSON.parse(rawMsg.toString('utf-8')); if (msg.event !== 'update') { return; @@ -195,7 +289,7 @@ export class Swaps { this.log('Got WebSocket update'); this.log(msg); - + let txId = "" switch (msg.args[0].status) { // "swap.created" means Boltz is waiting for the invoice to be paid case 'swap.created': @@ -204,20 +298,23 @@ export class Swaps { // "transaction.mempool" means that Boltz sent an onchain transaction case 'transaction.mempool': - data.info.txHex = msg.args[0].transaction.hex - await this.handleTransactionMempool(data) + const txIdRes = await this.handleTransactionMempool(data, msg.args[0].transaction.hex) + if (!txIdRes.ok) { + throw new Error(txIdRes.error) + } + txId = txIdRes.txId return case 'invoice.settled': this.log('Transaction swap successful'); - closeWebSocket() + done(txId) return; } } - handleTransactionMempool = async (data: TransactionSwapData) => { + handleTransactionMempool = async (data: TransactionSwapData, txHex: string): Promise<{ ok: true, txId: string } | { ok: false, error: string }> => { this.log('Creating claim transaction'); const { createdResponse, info } = data - const { destinationAddress, network, keys, preimage, txHex } = info + const { destinationAddress, keys, preimage, chainFee } = info const boltzPublicKey = Buffer.from( createdResponse.refundPublicKey, 'hex', @@ -238,26 +335,24 @@ export class Swaps { const swapOutput = detectSwap(tweakedKey, lockupTx); if (swapOutput === undefined) { this.log(ERROR, 'No swap output found in lockup transaction'); - return; + return { ok: false, error: 'No swap output found in lockup transaction' } } // Create a claim transaction to be signed cooperatively via a key path spend - const claimTx = targetFee(2, (fee) => - constructClaimTransaction( - [ - { - ...swapOutput, - keys, - preimage, - cooperative: true, - type: OutputType.Taproot, - txHash: lockupTx.getHash(), - }, - ], - address.toOutputScript(destinationAddress, network), - fee, - ), - ); + const claimTx = constructClaimTransaction( + [ + { + ...swapOutput, + keys, + preimage, + cooperative: true, + type: OutputType.Taproot, + txHash: lockupTx.getHash(), + }, + ], + address.toOutputScript(destinationAddress, network), + chainFee, + ) const { boltzHttpUrl } = this.settings.getSettings().swapsSettings // Get the partial signature from Boltz const claimUrl = `${boltzHttpUrl}/v2/swap/reverse/${createdResponse.id}/claim` @@ -267,10 +362,11 @@ export class Swaps { preimage: preimage.toString('hex'), pubNonce: Buffer.from(musig.getPublicNonce()).toString('hex'), } - const boltzSig = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq) - if (!boltzSig) { - return; + const boltzSigRes = await loggedPost<{ pubNonce: string, partialSignature: string }>(this.log, claimUrl, claimReq) + if (!boltzSigRes.ok) { + return boltzSigRes } + const boltzSig = boltzSigRes.data // Aggregate the nonces musig.aggregateNonces([ @@ -304,38 +400,41 @@ export class Swaps { const broadcastReq = { hex: claimTx.toHex(), } - const broadcastResponse = await loggedPost(this.log, broadcastUrl, broadcastReq) - if (!broadcastResponse) { - return; + + const broadcastResponse = await loggedPost(this.log, broadcastUrl, broadcastReq) + if (!broadcastResponse.ok) { + return broadcastResponse } - this.log('Transaction broadcasted', broadcastResponse) + this.log('Transaction broadcasted', broadcastResponse.data) + const txId = claimTx.getId() + return { ok: true, txId } } } -const loggedPost = async (log: PubLogger, url: string, req: any): Promise => { +const loggedPost = async (log: PubLogger, url: string, req: any): Promise<{ ok: true, data: T } | { ok: false, error: string }> => { try { const { data } = await axios.post(url, req) - return data as T + return { ok: true, data: data as T } } catch (err: any) { if (err.response?.data) { log(ERROR, 'Error sending request', err.response.data) - return null + return { ok: false, error: err.response.data } } log(ERROR, 'Error sending request', err.message) - return null + return { ok: false, error: err.message } } } -const loggedGet = async (log: PubLogger, url: string): Promise => { +const loggedGet = async (log: PubLogger, url: string): Promise<{ ok: true, data: T } | { ok: false, error: string }> => { try { const { data } = await axios.get(url) - return data as T + return { ok: true, data: data as T } } catch (err: any) { if (err.response?.data) { log(ERROR, 'Error getting request', err.response.data) - return null + return { ok: false, error: err.response.data } } log(ERROR, 'Error getting request', err.message) - return null + return { ok: false, error: err.message } } } \ No newline at end of file diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 756e9aa4..143d25b5 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -457,6 +457,4 @@ export default class { } this.nostrReset(s) } -} - - +} \ No newline at end of file diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 6a40facc..918dc72e 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -17,7 +17,7 @@ import { LiquidityManager } from './liquidityManager.js' import { Utils } from '../helpers/utilsWrapper.js' import { UserInvoicePayment } from '../storage/entity/UserInvoicePayment.js' import SettingsManager from './settingsManager.js' -import { Swaps } from '../lnd/swaps.js' +import { Swaps, TransactionSwapData } from '../lnd/swaps.js' interface UserOperationInfo { serial_id: number paid_amount: number @@ -251,7 +251,7 @@ export default class { } } - async PayInvoice(userId: string, req: Types.PayInvoiceRequest, linkedApplication: Application): Promise { + async PayInvoice(userId: string, req: Types.PayInvoiceRequest, linkedApplication: Application, swapOperationId?: string): Promise { await this.watchDog.PaymentRequested() const maybeBanned = await this.storage.userStorage.GetUser(userId) if (maybeBanned.locked) { @@ -279,7 +279,7 @@ export default class { if (internalInvoice) { paymentInfo = await this.PayInternalInvoice(userId, internalInvoice, { payAmount, serviceFee }, linkedApplication, req.debit_npub) } else { - paymentInfo = await this.PayExternalInvoice(userId, req.invoice, { payAmount, serviceFee, amountForLnd: req.amount }, linkedApplication, req.debit_npub) + paymentInfo = await this.PayExternalInvoice(userId, req.invoice, { payAmount, serviceFee, amountForLnd: req.amount }, linkedApplication, { debitNpub: req.debit_npub, swapOperationId }) } if (isAppUserPayment && serviceFee > 0) { await this.storage.userStorage.IncrementUserBalance(linkedApplication.owner.user_id, serviceFee, "fees") @@ -295,7 +295,8 @@ export default class { } } - async PayExternalInvoice(userId: string, invoice: string, amounts: { payAmount: number, serviceFee: number, amountForLnd: number }, linkedApplication: Application, debitNpub?: string) { + async PayExternalInvoice(userId: string, invoice: string, amounts: { payAmount: number, serviceFee: number, amountForLnd: number }, linkedApplication: Application, optionals: { debitNpub?: string, swapOperationId?: string } = {}) { + if (this.settings.getSettings().serviceSettings.disableExternalPayments) { throw new Error("something went wrong sending payment, please try again later") } @@ -315,7 +316,7 @@ export default class { const provider = use === 'provider' ? this.lnd.liquidProvider.GetProviderDestination() : undefined const pendingPayment = await this.storage.StartTransaction(async tx => { await this.storage.userStorage.DecrementUserBalance(userId, totalAmountToDecrement + routingFeeLimit, invoice, tx) - return await this.storage.paymentStorage.AddPendingExternalPayment(userId, invoice, { payAmount, serviceFee, networkFee: routingFeeLimit }, linkedApplication, provider, tx, debitNpub) + return await this.storage.paymentStorage.AddPendingExternalPayment(userId, invoice, { payAmount, serviceFee, networkFee: routingFeeLimit }, linkedApplication, provider, tx, optionals) }, "payment started") this.log("ready to pay") try { @@ -358,51 +359,132 @@ export default class { } + async GetTransactionSwapQuote(ctx: Types.UserContext, req: Types.TransactionSwapRequest): Promise { + const feesRes = await this.swaps.reverseSwaps.GetFees() + if (!feesRes.ok) { + throw new Error(feesRes.error) + } + const { claim, lockup } = feesRes.fees.minerFees + const minerFee = claim + lockup + const chainTotal = req.transaction_amount_sats + minerFee + const res = await this.swaps.reverseSwaps.SwapTransaction(chainTotal) + if (!res.ok) { + throw new Error(res.error) + } + const decoded = await this.lnd.DecodeInvoice(res.createdResponse.invoice) + const swapFee = decoded.numSatoshis - chainTotal + + const newSwap = await this.storage.paymentStorage.AddTransactionSwap({ + app_user_id: ctx.app_user_id, + swap_quote_id: res.createdResponse.id, + swap_tree: JSON.stringify(res.createdResponse.swapTree), + lockup_address: res.createdResponse.lockupAddress, + refund_public_key: res.createdResponse.refundPublicKey, + timeout_block_height: res.createdResponse.timeoutBlockHeight, + invoice: res.createdResponse.invoice, + invoice_amount: decoded.numSatoshis, + transaction_amount: chainTotal, + swap_fee_sats: swapFee, + chain_fee_sats: minerFee, + preimage: res.preimage, + ephemeral_private_key: res.privKey, + ephemeral_public_key: res.pubkey, + }) + return { + swap_operation_id: newSwap.swap_operation_id, + swap_fee_sats: swapFee, + invoice_amount_sats: decoded.numSatoshis, + transaction_amount_sats: req.transaction_amount_sats, + chain_fee_sats: minerFee, + } + } + + + + + async PayAddress(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise { - throw new Error("address payment currently disabled, use Lightning instead") await this.watchDog.PaymentRequested() this.log("paying address", req.address, "for user", ctx.user_id, "with amount", req.amoutSats) const maybeBanned = await this.storage.userStorage.GetUser(ctx.user_id) if (maybeBanned.locked) { throw new Error("user is banned, cannot send chain tx") } + const internalAddress = await this.storage.paymentStorage.GetAddressOwner(req.address) + if (internalAddress) { + return this.PayInternalAddress(ctx, req) + } + return this.PayAddressWithSwap(ctx, req) + } + + async PayAddressWithSwap(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise { + this.log("paying external address") + if (!req.swap_operation_id) { + throw new Error("request a swap quote before payng an external address") + } + const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) + const txSwap = await this.storage.paymentStorage.GetTransactionSwap(req.swap_operation_id) + if (!txSwap) { + throw new Error("swap quote not found") + } + const keys = this.swaps.GetKeys(txSwap.ephemeral_private_key) + const data: TransactionSwapData = { + createdResponse: { + id: txSwap.swap_quote_id, + invoice: txSwap.invoice, + lockupAddress: txSwap.lockup_address, + refundPublicKey: txSwap.refund_public_key, + swapTree: txSwap.swap_tree, + timeoutBlockHeight: txSwap.timeout_block_height, + onchainAmount: txSwap.transaction_amount, + }, + info: { + destinationAddress: req.address, + keys, + chainFee: txSwap.chain_fee_sats, + preimage: Buffer.from(txSwap.preimage, 'hex'), + } + } + const swapPromise = this.swaps.reverseSwaps.SubscribeToTransactionSwap(data) + const payment = await this.PayInvoice(ctx.user_id, { amount: 0, invoice: txSwap.invoice }, app, req.swap_operation_id) + let txId = "" + try { + txId = await swapPromise + await this.storage.paymentStorage.FinalizeTransactionSwap(req.swap_operation_id, txId) + } catch (err: any) { + await this.storage.paymentStorage.FailTransactionSwap(req.swap_operation_id, err.message) + throw err + } + const networkFeesTotal = txSwap.chain_fee_sats + txSwap.swap_fee_sats + payment.network_fee + return { + txId: txId, + network_fee: networkFeesTotal, + service_fee: payment.service_fee, + operation_id: payment.operation_id, + } + } + + async PayInternalAddress(ctx: Types.UserContext, req: Types.PayAddressRequest): Promise { + this.log("paying internal address") + if (req.swap_operation_id) { + await this.storage.paymentStorage.DeleteTransactionSwap(req.swap_operation_id) + } const { blockHeight } = await this.lnd.GetInfo() const app = await this.storage.applicationStorage.GetApplication(ctx.app_id) const serviceFee = this.getServiceFee(Types.UserOperationType.OUTGOING_TX, req.amoutSats, false) const isAppUserPayment = ctx.user_id !== app.owner.user_id - const internalAddress = await this.storage.paymentStorage.GetAddressOwner(req.address) - let txId = "" - let chainFees = 0 - if (!internalAddress) { - this.log("paying external address") - const estimate = await this.lnd.EstimateChainFees(req.address, req.amoutSats, 1) - const vBytes = Math.ceil(Number(estimate.feeSat / estimate.satPerVbyte)) - chainFees = vBytes * req.satsPerVByte - const total = req.amoutSats + chainFees - // WARNING, before re-enabling this, make sure to add the tx_hash to the DecrementUserBalance "reason"!! - this.storage.userStorage.DecrementUserBalance(ctx.user_id, total + serviceFee, req.address) - try { - const payment = await this.lnd.PayAddress(req.address, req.amoutSats, req.satsPerVByte, "", { useProvider: false, from: 'user' }) - txId = payment.txid - } catch (err) { - // WARNING, before re-enabling this, make sure to add the tx_hash to the IncrementUserBalance "reason"!! - await this.storage.userStorage.IncrementUserBalance(ctx.user_id, total + serviceFee, req.address) - throw err - } - } else { - this.log("paying internal address") - txId = crypto.randomBytes(32).toString("hex") - const addressData = `${req.address}:${txId}` - await this.storage.userStorage.DecrementUserBalance(ctx.user_id, req.amoutSats + serviceFee, addressData) - this.addressPaidCb({ hash: txId, index: 0 }, req.address, req.amoutSats, 'internal') - } + const txId = crypto.randomBytes(32).toString("hex") + const addressData = `${req.address}:${txId}` + await this.storage.userStorage.DecrementUserBalance(ctx.user_id, req.amoutSats + serviceFee, addressData) + this.addressPaidCb({ hash: txId, index: 0 }, req.address, req.amoutSats, 'internal') if (isAppUserPayment && serviceFee > 0) { await this.storage.userStorage.IncrementUserBalance(app.owner.user_id, serviceFee, 'fees') } - - const newTx = await this.storage.paymentStorage.AddUserTransactionPayment(ctx.user_id, req.address, txId, 0, req.amoutSats, chainFees, serviceFee, !!internalAddress, blockHeight, app) + const chainFees = 0 + const internalAddress = true + const newTx = await this.storage.paymentStorage.AddUserTransactionPayment(ctx.user_id, req.address, txId, 0, req.amoutSats, chainFees, serviceFee, internalAddress, blockHeight, app) const user = await this.storage.userStorage.GetUser(ctx.user_id) const txData = `${newTx.address}:${newTx.tx_hash}` this.storage.eventsLog.LogEvent({ type: 'address_payment', userId: ctx.user_id, appId: app.app_id, appUserId: "", balance: user.balance_sats, data: txData, amount: req.amoutSats }) diff --git a/src/services/serverMethods/index.ts b/src/services/serverMethods/index.ts index c9f92932..651383f0 100644 --- a/src/services/serverMethods/index.ts +++ b/src/services/serverMethods/index.ts @@ -142,11 +142,14 @@ export default (mainHandler: Main): Types.ServerMethods => { const err = Types.PayAddressRequestValidate(req, { address_CustomCheck: addr => addr !== '', amoutSats_CustomCheck: amt => amt > 0, - satsPerVByte_CustomCheck: spb => spb > 0 + // satsPerVByte_CustomCheck: spb => spb > 0 }) if (err != null) throw new Error(err.message) return mainHandler.paymentManager.PayAddress(ctx, req) }, + GetTransactionSwapQuote: async ({ ctx, req }) => { + return mainHandler.paymentManager.GetTransactionSwapQuote(ctx, req) + }, NewInvoice: ({ ctx, req }) => mainHandler.appUserManager.NewInvoice(ctx, req), DecodeInvoice: async ({ ctx, req }) => { return mainHandler.paymentManager.DecodeInvoice(req) diff --git a/src/services/storage/db/db.ts b/src/services/storage/db/db.ts index 98ab32bd..335f6848 100644 --- a/src/services/storage/db/db.ts +++ b/src/services/storage/db/db.ts @@ -29,6 +29,7 @@ import { AppUserDevice } from "../entity/AppUserDevice.js" import * as fs from 'fs' import { UserAccess } from "../entity/UserAccess.js" import { AdminSettings } from "../entity/AdminSettings.js" +import { TransactionSwap } from "../entity/TransactionSwap.js" export type DbSettings = { @@ -74,7 +75,8 @@ export const MainDbEntities = { 'ManagementGrant': ManagementGrant, 'AppUserDevice': AppUserDevice, 'UserAccess': UserAccess, - 'AdminSettings': AdminSettings + 'AdminSettings': AdminSettings, + 'TransactionSwap': TransactionSwap } export type MainDbNames = keyof typeof MainDbEntities export const MainDbEntitiesNames = Object.keys(MainDbEntities) diff --git a/src/services/storage/entity/TransactionSwap.ts b/src/services/storage/entity/TransactionSwap.ts new file mode 100644 index 00000000..6fb01c99 --- /dev/null +++ b/src/services/storage/entity/TransactionSwap.ts @@ -0,0 +1,65 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, UpdateDateColumn } from "typeorm"; +import { User } from "./User"; + +@Entity() +export class TransactionSwap { + @PrimaryGeneratedColumn('uuid') + swap_operation_id: string + + @Column() + app_user_id: string + + @Column() + swap_quote_id: string + + @Column() + swap_tree: string + + @Column() + lockup_address: string + + @Column() + refund_public_key: string + + @Column() + timeout_block_height: number + + @Column() + invoice: string + + @Column() + invoice_amount: number + + @Column() + transaction_amount: number + + @Column() + swap_fee_sats: number + + @Column() + chain_fee_sats: number + + @Column() + preimage: string + + @Column() + ephemeral_public_key: string + + @Column() + ephemeral_private_key: string + + @Column({ default: false }) + used: boolean + + @Column({ default: "" }) + failure_reason: string + + @Column({ default: "" }) + tx_id: string + + @CreateDateColumn() + created_at: Date + + @UpdateDateColumn() + updated_at: Date +} \ No newline at end of file diff --git a/src/services/storage/entity/UserInvoicePayment.ts b/src/services/storage/entity/UserInvoicePayment.ts index b9b83b4c..ce999e72 100644 --- a/src/services/storage/entity/UserInvoicePayment.ts +++ b/src/services/storage/entity/UserInvoicePayment.ts @@ -47,6 +47,9 @@ export class UserInvoicePayment { @Column({ nullable: true }) debit_to_pub: string + @Column({ nullable: true }) + swap_operation_id: string + @CreateDateColumn() created_at: Date diff --git a/src/services/storage/migrations/1762890527098-tx_swap.ts b/src/services/storage/migrations/1762890527098-tx_swap.ts new file mode 100644 index 00000000..af366d67 --- /dev/null +++ b/src/services/storage/migrations/1762890527098-tx_swap.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class TxSwap1762890527098 implements MigrationInterface { + name = 'TxSwap1762890527098' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "transaction_swap" ("swap_operation_id" varchar PRIMARY KEY NOT NULL, "app_user_id" varchar NOT NULL, "swap_quote_id" varchar NOT NULL, "swap_tree" varchar NOT NULL, "lockup_address" varchar NOT NULL, "refund_public_key" varchar NOT NULL, "timeout_block_height" integer NOT NULL, "invoice" varchar NOT NULL, "invoice_amount" integer NOT NULL, "transaction_amount" integer NOT NULL, "swap_fee_sats" integer NOT NULL, "chain_fee_sats" integer NOT NULL, "preimage" varchar NOT NULL, "ephemeral_public_key" varchar NOT NULL, "ephemeral_private_key" varchar NOT NULL, "used" boolean NOT NULL DEFAULT (0), "failure_reason" varchar NOT NULL DEFAULT (''), "tx_id" varchar NOT NULL DEFAULT (''), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`); + await queryRunner.query(`DROP INDEX "IDX_a609a4d3d8d9b07b90692a3c45"`); + await queryRunner.query(`CREATE TABLE "temporary_user_invoice_payment" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "invoice" varchar NOT NULL, "paid_amount" integer NOT NULL, "routing_fees" integer NOT NULL, "service_fees" integer NOT NULL, "paid_at_unix" integer NOT NULL, "internal" boolean NOT NULL DEFAULT (0), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "linkedApplicationSerialId" integer, "liquidityProvider" varchar, "paymentIndex" integer NOT NULL DEFAULT (-1), "debit_to_pub" varchar, "swap_operation_id" varchar, CONSTRAINT "FK_ef2aa6761ab681bbbd5f94e0fcb" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_6bcac90887eea1dc61d37db2994" FOREIGN KEY ("linkedApplicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_user_invoice_payment"("serial_id", "invoice", "paid_amount", "routing_fees", "service_fees", "paid_at_unix", "internal", "created_at", "updated_at", "userSerialId", "linkedApplicationSerialId", "liquidityProvider", "paymentIndex", "debit_to_pub") SELECT "serial_id", "invoice", "paid_amount", "routing_fees", "service_fees", "paid_at_unix", "internal", "created_at", "updated_at", "userSerialId", "linkedApplicationSerialId", "liquidityProvider", "paymentIndex", "debit_to_pub" FROM "user_invoice_payment"`); + await queryRunner.query(`DROP TABLE "user_invoice_payment"`); + await queryRunner.query(`ALTER TABLE "temporary_user_invoice_payment" RENAME TO "user_invoice_payment"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a609a4d3d8d9b07b90692a3c45" ON "user_invoice_payment" ("invoice") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_a609a4d3d8d9b07b90692a3c45"`); + await queryRunner.query(`ALTER TABLE "user_invoice_payment" RENAME TO "temporary_user_invoice_payment"`); + await queryRunner.query(`CREATE TABLE "user_invoice_payment" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "invoice" varchar NOT NULL, "paid_amount" integer NOT NULL, "routing_fees" integer NOT NULL, "service_fees" integer NOT NULL, "paid_at_unix" integer NOT NULL, "internal" boolean NOT NULL DEFAULT (0), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')), "userSerialId" integer, "linkedApplicationSerialId" integer, "liquidityProvider" varchar, "paymentIndex" integer NOT NULL DEFAULT (-1), "debit_to_pub" varchar, CONSTRAINT "FK_ef2aa6761ab681bbbd5f94e0fcb" FOREIGN KEY ("userSerialId") REFERENCES "user" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_6bcac90887eea1dc61d37db2994" FOREIGN KEY ("linkedApplicationSerialId") REFERENCES "application" ("serial_id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "user_invoice_payment"("serial_id", "invoice", "paid_amount", "routing_fees", "service_fees", "paid_at_unix", "internal", "created_at", "updated_at", "userSerialId", "linkedApplicationSerialId", "liquidityProvider", "paymentIndex", "debit_to_pub") SELECT "serial_id", "invoice", "paid_amount", "routing_fees", "service_fees", "paid_at_unix", "internal", "created_at", "updated_at", "userSerialId", "linkedApplicationSerialId", "liquidityProvider", "paymentIndex", "debit_to_pub" FROM "temporary_user_invoice_payment"`); + await queryRunner.query(`DROP TABLE "temporary_user_invoice_payment"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a609a4d3d8d9b07b90692a3c45" ON "user_invoice_payment" ("invoice") `); + await queryRunner.query(`DROP TABLE "transaction_swap"`); + } + +} diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index 5f3d15a9..5e75f367 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -14,6 +14,7 @@ import { Application } from './entity/Application.js'; import TransactionsQueue from "./db/transactionsQueue.js"; import { LoggedEvent } from './eventsLog.js'; import { StorageInterface } from './db/storageInterface.js'; +import { TransactionSwap } from './entity/TransactionSwap.js'; export type InboundOptionals = { product?: Product, callbackUrl?: string, expiry: number, expectedPayer?: User, linkedApplication?: Application, zapInfo?: ZapInfo, offerId?: string, payerData?: Record, rejectUnauthorized?: boolean, token?: string, blind?: boolean } export const defaultInvoiceExpiry = 60 * 60 export default class { @@ -90,7 +91,7 @@ export default class { take }, txId); items.push(...firstBatch); - } + } const needMore = take - items.length // If need more, fetch higher paid_at_unix @@ -134,7 +135,7 @@ export default class { } async RemoveUserInvoices(userId: string, txId?: string) { - return this.dbs.Delete('UserReceivingInvoice', { user: { user_id: userId } }, txId) + return this.dbs.Delete('UserReceivingInvoice', { user: { user_id: userId } }, txId) } async GetAddressOwner(address: string, txId?: string): Promise { @@ -158,7 +159,8 @@ export default class { return this.dbs.FindOne('UserToUserPayment', { where: { serial_id: serialId } }, txId) } - async AddPendingExternalPayment(userId: string, invoice: string, amounts: { payAmount: number, serviceFee: number, networkFee: number }, linkedApplication: Application, liquidityProvider: string | undefined, txId: string, debitNpub?: string): Promise { + async AddPendingExternalPayment(userId: string, invoice: string, amounts: { payAmount: number, serviceFee: number, networkFee: number }, linkedApplication: Application, liquidityProvider: string | undefined, txId: string, optionals: { debitNpub?: string, swapOperationId?: string } = {}): Promise { + const { debitNpub, swapOperationId } = optionals const user = await this.userStorage.GetUser(userId, txId) return this.dbs.CreateAndSave('UserInvoicePayment', { user, @@ -170,7 +172,8 @@ export default class { internal: false, linkedApplication, liquidityProvider, - debit_to_pub: debitNpub + debit_to_pub: debitNpub, + swap_operation_id: swapOperationId }, txId) } @@ -401,7 +404,7 @@ export default class { ]) const receivingTransactions = await Promise.all(receivingAddresses.map(addr => this.dbs.Find('AddressReceivingTransaction', { where: { user_address: { serial_id: addr.serial_id }, ...time } }))) - return { + return { receivingInvoices, receivingAddresses, receivingTransactions, outgoingInvoices, outgoingTransactions, userToUser @@ -458,6 +461,36 @@ export default class { } return this.dbs.Find('UserReceivingInvoice', { where }) } + + async AddTransactionSwap(swap: Partial) { + return this.dbs.CreateAndSave('TransactionSwap', swap) + } + + async GetTransactionSwap(swapOperationId: string, txId?: string) { + return this.dbs.FindOne('TransactionSwap', { where: { swap_operation_id: swapOperationId } }, txId) + } + + async FinalizeTransactionSwap(swapOperationId: string, txId: string) { + return this.dbs.Update('TransactionSwap', { swap_operation_id: swapOperationId }, { + used: true, + tx_id: txId, + }) + } + + async FailTransactionSwap(swapOperationId: string, failureReason: string) { + return this.dbs.Update('TransactionSwap', { swap_operation_id: swapOperationId }, { + used: true, + failure_reason: failureReason, + }) + } + + async DeleteTransactionSwap(swapOperationId: string, txId?: string) { + return this.dbs.Delete('TransactionSwap', { swap_operation_id: swapOperationId }, txId) + } + + async DeleteExpiredTransactionSwaps(currentHeight: number, txId?: string) { + return this.dbs.Delete('TransactionSwap', { timeout_block_height: LessThan(currentHeight) }, txId) + } } const orFail = async (resultPromise: Promise) => {