diff --git a/core/api/src/app/wallets/register-broadcasted-payout-txn.ts b/core/api/src/app/wallets/register-broadcasted-payout-txn.ts index a15bba60b5..fd0b711f5b 100644 --- a/core/api/src/app/wallets/register-broadcasted-payout-txn.ts +++ b/core/api/src/app/wallets/register-broadcasted-payout-txn.ts @@ -21,6 +21,9 @@ export const registerBroadcastedPayout = async ({ const { estimatedProtocolFee } = setTxIdResult if (estimatedProtocolFee.amount === proportionalFee.amount) return true + const isRecorded = await LedgerFacade.isOnChainFeeReconciliationRecorded(payoutId) + if (isRecorded !== false) return isRecorded + const { metadata } = LedgerFacade.OnChainFeeReconciliationLedgerMetadata({ payoutId, txHash: txId, diff --git a/core/api/src/services/ledger/domain/errors.types.d.ts b/core/api/src/services/ledger/domain/errors.types.d.ts new file mode 100644 index 0000000000..30b83ac9ba --- /dev/null +++ b/core/api/src/services/ledger/domain/errors.types.d.ts @@ -0,0 +1 @@ +type LedgerFacadeError = import("./errors").LedgerFacadeError diff --git a/core/api/src/services/ledger/facade/index.ts b/core/api/src/services/ledger/facade/index.ts index cd39804d4b..78e89fe3e7 100644 --- a/core/api/src/services/ledger/facade/index.ts +++ b/core/api/src/services/ledger/facade/index.ts @@ -2,6 +2,7 @@ export * from "./intraledger" export * from "./offchain-receive" export * from "./offchain-send" export * from "./onchain-receive" +export * from "./onchain-reconcile" export * from "./onchain-send" export * from "./reconciliation" export * from "./static-account-ids" diff --git a/core/api/src/services/ledger/facade/onchain-receive.ts b/core/api/src/services/ledger/facade/onchain-receive.ts index 8107cdc1ca..2327a7270e 100644 --- a/core/api/src/services/ledger/facade/onchain-receive.ts +++ b/core/api/src/services/ledger/facade/onchain-receive.ts @@ -1,7 +1,6 @@ import { MainBook } from "../books" import { EntryBuilder, toLedgerAccountDescriptor } from "../domain" -import { FeeOnlyEntryBuilder } from "../domain/fee-only-entry-builder" import { persistAndReturnEntry } from "../helpers" import { staticAccountIds } from "./static-account-ids" @@ -48,42 +47,3 @@ export const recordReceiveOnChain = async ({ return persistAndReturnEntry({ entry, ...txMetadata }) } - -export const recordReceiveOnChainFeeReconciliation = async ({ - estimatedFee, - actualFee, - metadata, -}: { - estimatedFee: BtcPaymentAmount - actualFee: BtcPaymentAmount - metadata: AddOnChainFeeReconciliationLedgerMetadata -}) => { - const accountIds = await staticAccountIds() - if (accountIds instanceof Error) return accountIds - - let entry = MainBook.entry("") - if (actualFee.amount > estimatedFee.amount) { - const btcFeeDifference = calc.sub(actualFee, estimatedFee) - const builder = FeeOnlyEntryBuilder({ - staticAccountIds: accountIds, - entry, - metadata, - btcFee: btcFeeDifference, - }) - entry = builder.debitBankOwner().creditOnChain() - } else { - const btcFeeDifference = calc.sub(estimatedFee, actualFee) - const builder = FeeOnlyEntryBuilder({ - staticAccountIds: accountIds, - entry, - metadata, - btcFee: btcFeeDifference, - }) - entry = builder.debitOnChain().creditBankOwner() - } - - return persistAndReturnEntry({ - entry, - hash: metadata.hash, - }) -} diff --git a/core/api/src/services/ledger/facade/onchain-reconcile.ts b/core/api/src/services/ledger/facade/onchain-reconcile.ts new file mode 100644 index 0000000000..c6e1f64fa0 --- /dev/null +++ b/core/api/src/services/ledger/facade/onchain-reconcile.ts @@ -0,0 +1,76 @@ +import { MainBook } from "../books" + +import { translateToLedgerTx } from ".." +import { getBankOwnerWalletId } from "../caching" +import { UnknownLedgerError } from "../domain/errors" +import { persistAndReturnEntry } from "../helpers" +import { FeeOnlyEntryBuilder } from "../domain/fee-only-entry-builder" + +import { staticAccountIds } from "./static-account-ids" + +import { LedgerTransactionType, toLiabilitiesWalletId } from "@/domain/ledger" +import { AmountCalculator } from "@/domain/shared" + +const calc = AmountCalculator() + +export const recordReceiveOnChainFeeReconciliation = async ({ + estimatedFee, + actualFee, + metadata, +}: { + estimatedFee: BtcPaymentAmount + actualFee: BtcPaymentAmount + metadata: AddOnChainFeeReconciliationLedgerMetadata +}) => { + const accountIds = await staticAccountIds() + if (accountIds instanceof Error) return accountIds + + let entry = MainBook.entry("") + if (actualFee.amount > estimatedFee.amount) { + const btcFeeDifference = calc.sub(actualFee, estimatedFee) + const builder = FeeOnlyEntryBuilder({ + staticAccountIds: accountIds, + entry, + metadata, + btcFee: btcFeeDifference, + }) + entry = builder.debitBankOwner().creditOnChain() + } else { + const btcFeeDifference = calc.sub(estimatedFee, actualFee) + const builder = FeeOnlyEntryBuilder({ + staticAccountIds: accountIds, + entry, + metadata, + btcFee: btcFeeDifference, + }) + entry = builder.debitOnChain().creditBankOwner() + } + + return persistAndReturnEntry({ + entry, + hash: metadata.hash, + }) +} + +export const isOnChainFeeReconciliationTxn = ( + txn: LedgerTransaction, +): boolean => + txn.type === LedgerTransactionType.OnchainPayment && txn.address === undefined + +export const isOnChainFeeReconciliationRecorded = async ( + payoutId: PayoutId, +): Promise => { + try { + const bankOwnerWalletId = await getBankOwnerWalletId() + const { results } = await MainBook.ledger({ + payout_id: payoutId, + account: toLiabilitiesWalletId(bankOwnerWalletId), + }) + const txns = results.map((tx) => translateToLedgerTx(tx)) + + const reconciliationTxn = txns.find((txn) => isOnChainFeeReconciliationTxn(txn)) + return reconciliationTxn !== undefined + } catch (err) { + return new UnknownLedgerError(err) + } +} diff --git a/core/api/src/services/ledger/facade/onchain-send.ts b/core/api/src/services/ledger/facade/onchain-send.ts index ec7d92653f..99191f15f5 100644 --- a/core/api/src/services/ledger/facade/onchain-send.ts +++ b/core/api/src/services/ledger/facade/onchain-send.ts @@ -14,6 +14,7 @@ import { TransactionsMetadataRepository } from "../services" import { translateToLedgerTx } from ".." +import { isOnChainFeeReconciliationTxn } from "./onchain-reconcile" import { staticAccountIds } from "./static-account-ids" import { @@ -168,7 +169,7 @@ export const setOnChainTxIdByPayoutId = async ({ const bankOwnerWalletId = await getBankOwnerWalletId() const bankOwnerTxns = txns.filter( - (txn) => txn.satsFee && txn.walletId === bankOwnerWalletId, + (txn) => txn.walletId === bankOwnerWalletId && !isOnChainFeeReconciliationTxn(txn), ) if (bankOwnerTxns.length !== 1) { return new InvalidLedgerTransactionStateError(`payoutId: ${payoutId}`) diff --git a/core/api/test/legacy-integration/02-user-wallet/02-bria-handlers.spec.ts b/core/api/test/legacy-integration/02-user-wallet/02-bria-handlers.spec.ts index 51df5fca5e..4f693cefcf 100644 --- a/core/api/test/legacy-integration/02-user-wallet/02-bria-handlers.spec.ts +++ b/core/api/test/legacy-integration/02-user-wallet/02-bria-handlers.spec.ts @@ -514,6 +514,15 @@ describe("Bria Event Handlers", () => { }) expect(res).toBe(true) + // Idempotency check + const resRerun = await registerBroadcastedPayout({ + payoutId, + proportionalFee, + txId, + vout, + }) + expect(resRerun).toBe(true) + // Run after-broadcast checks const txnsAfter = await LedgerFacade.getTransactionsByPayoutId(payoutId) if (txnsAfter instanceof Error) throw txnsAfter @@ -562,6 +571,15 @@ describe("Bria Event Handlers", () => { }) expect(res).toBe(true) + // Idempotency check + const resRerun = await registerBroadcastedPayout({ + payoutId, + proportionalFee, + txId, + vout, + }) + expect(resRerun).toBe(true) + // Run after-broadcast checks const txnsAfter = await LedgerFacade.getTransactionsByPayoutId(payoutId) if (txnsAfter instanceof Error) throw txnsAfter @@ -617,6 +635,15 @@ describe("Bria Event Handlers", () => { }) expect(res).toBe(true) + // Idempotency check + const resRerun = await registerBroadcastedPayout({ + payoutId, + proportionalFee, + txId, + vout, + }) + expect(resRerun).toBe(true) + // Run after-broadcast checks const txnsAfter = await LedgerFacade.getTransactionsByPayoutId(payoutId) if (txnsAfter instanceof Error) throw txnsAfter @@ -677,6 +704,15 @@ describe("Bria Event Handlers", () => { }) expect(res).toBeInstanceOf(UnknownLedgerError) + // Idempotency check + const resRerun = await registerBroadcastedPayout({ + payoutId, + proportionalFee, + txId, + vout, + }) + expect(resRerun).toBeInstanceOf(UnknownLedgerError) + spy.mockRestore() // Run after-broadcast checks