diff --git a/test/bats/gql/on-chain-tx-fee.gql b/test/bats/gql/on-chain-tx-fee.gql new file mode 100644 index 0000000000..df5780cef5 --- /dev/null +++ b/test/bats/gql/on-chain-tx-fee.gql @@ -0,0 +1,5 @@ +query onChainTxFee($walletId: WalletId!, $address: OnChainAddress!, $amount: SatAmount!) { + onChainTxFee(walletId: $walletId, address: $address, amount: $amount) { + amount + } +} diff --git a/test/bats/gql/on-chain-usd-tx-fee-as-btc-denominated.gql b/test/bats/gql/on-chain-usd-tx-fee-as-btc-denominated.gql new file mode 100644 index 0000000000..0997c148ea --- /dev/null +++ b/test/bats/gql/on-chain-usd-tx-fee-as-btc-denominated.gql @@ -0,0 +1,13 @@ +query onChainUsdTxFeeAsBtcDenominated( + $walletId: WalletId! + $address: OnChainAddress! + $amount: SatAmount! +) { + onChainUsdTxFeeAsBtcDenominated( + walletId: $walletId + address: $address + amount: $amount + ) { + amount + } +} diff --git a/test/bats/gql/on-chain-usd-tx-fee.gql b/test/bats/gql/on-chain-usd-tx-fee.gql new file mode 100644 index 0000000000..0a7444a388 --- /dev/null +++ b/test/bats/gql/on-chain-usd-tx-fee.gql @@ -0,0 +1,9 @@ +query onChainUsdTxFee( + $walletId: WalletId! + $address: OnChainAddress! + $amount: CentAmount! +) { + onChainUsdTxFee(walletId: $walletId, address: $address, amount: $amount) { + amount + } +} diff --git a/test/bats/onchain-send.bats b/test/bats/onchain-send.bats index 507f2b987e..ecd4e12d72 100644 --- a/test/bats/onchain-send.bats +++ b/test/bats/onchain-send.bats @@ -346,3 +346,114 @@ teardown() { retry 3 1 check_for_onchain_initiated_settled "$token_name" "$on_chain_usd_payment_send_address" 4 retry 3 1 check_for_onchain_initiated_settled "$token_name" "$on_chain_payment_send_address" 4 } + +@test "onchain-send: get fee for external address" { + token_name="$ALICE_TOKEN_NAME" + btc_wallet_name="$token_name.btc_wallet_id" + usd_wallet_name="$token_name.usd_wallet_id" + + # EXECUTE GQL FEE ESTIMATES + # ---------- + + address=$(bitcoin_cli getnewaddress) + [[ "${address}" != "null" ]] || exit 1 + + # mutation: onChainTxFee + variables=$( + jq -n \ + --arg wallet_id "$(read_value $btc_wallet_name)" \ + --arg address "$address" \ + --arg amount 12345 \ + '{walletId: $wallet_id, address: $address, amount: $amount}' + ) + exec_graphql "$token_name" 'on-chain-tx-fee' "$variables" + amount="$(graphql_output '.data.onChainTxFee.amount')" + [[ "${amount}" -gt 0 ]] || exit 1 + + # mutation: onChainUsdTxFee + variables=$( + jq -n \ + --arg wallet_id "$(read_value $usd_wallet_name)" \ + --arg address "$address" \ + --arg amount 200 \ + '{walletId: $wallet_id, address: $address, amount: $amount}' + ) + exec_graphql "$token_name" 'on-chain-usd-tx-fee' "$variables" + amount="$(graphql_output '.data.onChainUsdTxFee.amount')" + [[ "${amount}" -gt 0 ]] || exit 1 + + # mutation: onChainUsdTxFeeAsBtcDenominated + variables=$( + jq -n \ + --arg wallet_id "$(read_value $usd_wallet_name)" \ + --arg address "$address" \ + --arg amount 12345 \ + '{walletId: $wallet_id, address: $address, amount: $amount}' + ) + exec_graphql "$token_name" 'on-chain-usd-tx-fee-as-btc-denominated' "$variables" + amount="$(graphql_output '.data.onChainUsdTxFeeAsBtcDenominated.amount')" + [[ "${amount}" -gt 0 ]] || exit 1 +} + +@test "onchain-send: get fee for internal address" { + token_name="$ALICE_TOKEN_NAME" + btc_wallet_name="$token_name.btc_wallet_id" + usd_wallet_name="$token_name.usd_wallet_id" + + # EXECUTE GQL FEE ESTIMATES + # ---------- + + recipient_token_name="user_$RANDOM" + recipient_phone="$(random_phone)" + login_user \ + "$recipient_token_name" \ + "$recipient_phone" \ + "$CODE" + user_update_username "$recipient_token_name" + btc_recipient_wallet_name="$recipient_token_name.btc_wallet_id" + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $btc_recipient_wallet_name)" \ + '{input: {walletId: $wallet_id}}' + ) + exec_graphql "$recipient_token_name" 'on-chain-address-create' "$variables" + address="$(graphql_output '.data.onChainAddressCreate.address')" + [[ "${address}" != "null" ]] || exit 1 + + # mutation: onChainTxFee + variables=$( + jq -n \ + --arg wallet_id "$(read_value $btc_wallet_name)" \ + --arg address "$address" \ + --arg amount 12345 \ + '{walletId: $wallet_id, address: $address, amount: $amount}' + ) + exec_graphql "$token_name" 'on-chain-tx-fee' "$variables" + amount="$(graphql_output '.data.onChainTxFee.amount')" + [[ "${amount}" == 0 ]] || exit 1 + + # mutation: onChainUsdTxFee + variables=$( + jq -n \ + --arg wallet_id "$(read_value $usd_wallet_name)" \ + --arg address "$address" \ + --arg amount 200 \ + '{walletId: $wallet_id, address: $address, amount: $amount}' + ) + exec_graphql "$token_name" 'on-chain-usd-tx-fee' "$variables" + amount="$(graphql_output '.data.onChainUsdTxFee.amount')" + [[ "${amount}" == 0 ]] || exit 1 + + # mutation: onChainUsdTxFeeAsBtcDenominated + variables=$( + jq -n \ + --arg wallet_id "$(read_value $usd_wallet_name)" \ + --arg address "$address" \ + --arg amount 12345 \ + '{walletId: $wallet_id, address: $address, amount: $amount}' + ) + exec_graphql "$token_name" 'on-chain-usd-tx-fee-as-btc-denominated' "$variables" + amount="$(graphql_output '.data.onChainUsdTxFeeAsBtcDenominated.amount')" + [[ "${amount}" == 0 ]] || exit 1 +} diff --git a/test/integration/app/wallets/get-on-chain-fee.spec.ts b/test/integration/app/wallets/get-on-chain-fee.spec.ts new file mode 100644 index 0000000000..9385cbd3fe --- /dev/null +++ b/test/integration/app/wallets/get-on-chain-fee.spec.ts @@ -0,0 +1,57 @@ +import { Wallets } from "@app" + +import { getOnChainWalletConfig } from "@config" + +import { PayoutSpeed } from "@domain/bitcoin/onchain" +import { LessThanDustThresholdError } from "@domain/errors" + +import { AccountsRepository } from "@services/mongoose" + +import { createRandomUserAndWallets } from "test/helpers" + +let outsideAddress: OnChainAddress + +beforeAll(async () => { + outsideAddress = "bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw" as OnChainAddress +}) + +const amountBelowDustThreshold = getOnChainWalletConfig().dustThreshold - 1 + +describe("onChainPay", () => { + describe("settles onchain", () => { + it("fails to fetch fee for dust amount", async () => { + const { btcWalletDescriptor, usdWalletDescriptor } = + await createRandomUserAndWallets() + const account = await AccountsRepository().findById(btcWalletDescriptor.accountId) + if (account instanceof Error) throw account + + const resultBtcWallet = await Wallets.getOnChainFeeForBtcWallet({ + walletId: btcWalletDescriptor.id, + account, + address: outsideAddress, + amount: amountBelowDustThreshold, + speed: PayoutSpeed.Fast, + }) + expect(resultBtcWallet).toBeInstanceOf(LessThanDustThresholdError) + + const resultUsdWallet = await Wallets.getOnChainFeeForUsdWallet({ + walletId: usdWalletDescriptor.id, + account, + address: outsideAddress, + amount: 1, + speed: PayoutSpeed.Fast, + }) + expect(resultUsdWallet).toBeInstanceOf(LessThanDustThresholdError) + + const resultUsdWalletAndBtcAmount = + await Wallets.getOnChainFeeForUsdWalletAndBtcAmount({ + walletId: usdWalletDescriptor.id, + account, + address: outsideAddress, + amount: amountBelowDustThreshold, + speed: PayoutSpeed.Fast, + }) + expect(resultUsdWalletAndBtcAmount).toBeInstanceOf(LessThanDustThresholdError) + }) + }) +}) diff --git a/test/legacy-integration/02-user-wallet/02-tx-onchain-fees.spec.ts b/test/legacy-integration/02-user-wallet/02-tx-onchain-fees.spec.ts deleted file mode 100644 index 190e2516f9..0000000000 --- a/test/legacy-integration/02-user-wallet/02-tx-onchain-fees.spec.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { once } from "events" - -import { Wallets } from "@app" -import { getOnChainWalletConfig } from "@config" -import { sat2btc, toSats } from "@domain/bitcoin" -import { LessThanDustThresholdError } from "@domain/errors" -import { toCents } from "@domain/fiat" -import { WalletCurrency, paymentAmountFromNumber } from "@domain/shared" -import { PayoutSpeed } from "@domain/bitcoin/onchain" - -import { DealerPriceService } from "@services/dealer-price" -import { AccountsRepository, WalletsRepository } from "@services/mongoose" -import { baseLogger } from "@services/logger" -import { getFunderWalletId } from "@services/ledger/caching" - -import { - lndCreateOnChainAddress, - bitcoindClient, - bitcoindOutside, - createUserAndWalletFromPhone, - getAccountByPhone, - getDefaultWalletIdByPhone, - getUsdWalletIdByPhone, - lndonchain, - randomPhone, - sendToAddressAndConfirm, - subscribeToChainAddress, - waitUntilBlockHeight, -} from "test/helpers" - -const defaultAmount = toSats(5244) -const defaultUsdAmount = toCents(105) -const defaultSpeed = PayoutSpeed.Fast -const { dustThreshold } = getOnChainWalletConfig() - -let walletIdA: WalletId -let walletIdB: WalletId -let walletIdUsdA: WalletId -let accountA: Account - -const dealerFns = DealerPriceService() - -const phoneA = randomPhone() -const phoneB = randomPhone() - -beforeAll(async () => { - await bitcoindClient.loadWallet({ filename: "outside" }) - - await createUserAndWalletFromPhone(phoneA) - await createUserAndWalletFromPhone(phoneB) - - walletIdA = await getDefaultWalletIdByPhone(phoneA) - walletIdUsdA = await getUsdWalletIdByPhone(phoneA) - accountA = await getAccountByPhone(phoneA) - walletIdB = await getDefaultWalletIdByPhone(phoneB) - - // Fund walletIdA - await sendToLndWalletTestWrapper({ - amountSats: toSats(defaultAmount * 5), - walletId: walletIdA, - }) - - // Fund walletIdUsdA - await sendToLndWalletTestWrapper({ - amountSats: toSats(defaultAmount * 5), - walletId: walletIdUsdA, - }) - - // Fund lnd - const funderWalletId = await getFunderWalletId() - await sendToLndWalletTestWrapper({ - amountSats: toSats(2_000_000_000), - walletId: funderWalletId, - }) -}) - -afterAll(async () => { - await bitcoindClient.unloadWallet({ walletName: "outside" }) -}) - -const sendToLndWalletTestWrapper = async ({ - amountSats, - walletId, -}: { - amountSats: Satoshis - walletId: WalletId -}) => { - const address = await lndCreateOnChainAddress(walletId) - if (address instanceof Error) throw address - expect(address.substring(0, 4)).toBe("bcrt") - - const initialBlockNumber = await bitcoindClient.getBlockCount() - const txId = await sendToAddressAndConfirm({ - walletClient: bitcoindOutside, - address, - amount: sat2btc(amountSats), - }) - if (txId instanceof Error) throw txId - - // Register confirmed txn in database - const sub = subscribeToChainAddress({ - lnd: lndonchain, - bech32_address: address, - min_height: initialBlockNumber, - }) - await once(sub, "confirmation") - sub.removeAllListeners() - - await waitUntilBlockHeight({ lnd: lndonchain }) - - const updated = await Wallets.updateLegacyOnChainReceipt({ logger: baseLogger }) - if (updated instanceof Error) throw updated - - return txId as OnChainTxHash -} - -describe("UserWallet - getOnchainFee", () => { - describe("from btc wallet", () => { - it("returns a fee greater than zero for an external address", async () => { - const address = (await bitcoindOutside.getNewAddress()) as OnChainAddress - const feeAmount = await Wallets.getOnChainFeeForBtcWallet({ - walletId: walletIdA, - account: accountA, - amount: defaultAmount, - address, - speed: defaultSpeed, - }) - if (feeAmount instanceof Error) throw feeAmount - expect(feeAmount.currency).toBe(WalletCurrency.Btc) - const fee = Number(feeAmount.amount) - expect(fee).toBeGreaterThan(0) - - const wallet = await WalletsRepository().findById(walletIdA) - if (wallet instanceof Error) throw wallet - - const account = await AccountsRepository().findById(wallet.accountId) - if (account instanceof Error) throw account - - expect(fee).toBeGreaterThan(account.withdrawFee) - }) - - it("returns zero for an on us address", async () => { - const address = await Wallets.createOnChainAddress({ - walletId: walletIdB, - }) - if (address instanceof Error) throw address - const feeAmount = await Wallets.getOnChainFeeForBtcWallet({ - walletId: walletIdA, - account: accountA, - amount: defaultAmount, - address, - speed: defaultSpeed, - }) - if (feeAmount instanceof Error) throw feeAmount - const fee = Number(feeAmount.amount) - expect(fee).toBe(0) - }) - - it("returns error for dust amount", async () => { - const address = (await bitcoindOutside.getNewAddress()) as OnChainAddress - const amount = toSats(dustThreshold - 1) - const fee = await Wallets.getOnChainFeeForBtcWallet({ - walletId: walletIdA, - account: accountA, - amount, - address, - speed: defaultSpeed, - }) - expect(fee).toBeInstanceOf(LessThanDustThresholdError) - expect(fee).toHaveProperty( - "message", - `Use lightning to send amounts less than ${dustThreshold} sats`, - ) - }) - - it("returns error for minimum amount", async () => { - const address = (await bitcoindOutside.getNewAddress()) as OnChainAddress - const amount = toSats(1) - const fee = await Wallets.getOnChainFeeForBtcWallet({ - walletId: walletIdA, - account: accountA, - amount, - address, - speed: defaultSpeed, - }) - expect(fee).toBeInstanceOf(LessThanDustThresholdError) - expect(fee).toHaveProperty( - "message", - `Use lightning to send amounts less than ${dustThreshold} sats`, - ) - }) - }) - - describe("from usd wallet", () => { - const amountCases = [ - { amountCurrency: WalletCurrency.Usd, senderAmount: defaultUsdAmount }, - { amountCurrency: WalletCurrency.Btc, senderAmount: defaultAmount }, - ] - const testAmountCaseAmounts = async ( - convert: ( - amount: UsdPaymentAmount, - ) => Promise, - ) => { - const usdAmount = amountCases.filter( - (testCase) => testCase.amountCurrency === WalletCurrency.Usd, - )[0].senderAmount - - const convertedBtcFromUsdAmount = await convert({ - amount: BigInt(usdAmount), - currency: WalletCurrency.Usd, - }) - if (convertedBtcFromUsdAmount instanceof Error) throw convertedBtcFromUsdAmount - - expect(defaultAmount).toEqual(Number(convertedBtcFromUsdAmount.amount)) - } - - amountCases.forEach(({ amountCurrency, senderAmount }) => { - describe(`${amountCurrency.toLowerCase()} send amount`, () => { - it("returns a fee greater than zero for an external address", async () => { - await testAmountCaseAmounts(dealerFns.getSatsFromCentsForImmediateSell) - - const address = (await bitcoindOutside.getNewAddress()) as OnChainAddress - - const paymentAmount = paymentAmountFromNumber({ - amount: defaultAmount, - currency: WalletCurrency.Btc, - }) - if (paymentAmount instanceof Error) throw paymentAmount - - const getFeeArgs = { - walletId: walletIdUsdA, - account: accountA, - address, - speed: defaultSpeed, - amount: senderAmount, - } - const feeAmount = - amountCurrency === WalletCurrency.Usd - ? await Wallets.getOnChainFeeForUsdWallet(getFeeArgs) - : await Wallets.getOnChainFeeForUsdWalletAndBtcAmount(getFeeArgs) - if (feeAmount instanceof Error) throw feeAmount - expect(feeAmount.currency).toBe(WalletCurrency.Usd) - const fee = Number(feeAmount.amount) - expect(fee).toBeGreaterThan(0) - - const wallet = await WalletsRepository().findById(walletIdUsdA) - if (wallet instanceof Error) throw wallet - - const account = await AccountsRepository().findById(wallet.accountId) - if (account instanceof Error) throw account - - const usdAmount = await dealerFns.getCentsFromSatsForImmediateSell({ - amount: BigInt(account.withdrawFee), - currency: WalletCurrency.Btc, - }) - if (usdAmount instanceof Error) throw usdAmount - const withdrawFeeAsUsd = Number(usdAmount.amount) - expect(fee).toBeGreaterThan(withdrawFeeAsUsd) - }) - - it("returns zero for an on us address", async () => { - const address = await Wallets.createOnChainAddress({ - walletId: walletIdB, - }) - if (address instanceof Error) throw address - - const getFeeArgs = { - walletId: walletIdUsdA, - account: accountA, - address, - speed: defaultSpeed, - amount: senderAmount, - } - const feeAmount = - amountCurrency === WalletCurrency.Usd - ? await Wallets.getOnChainFeeForUsdWallet(getFeeArgs) - : await Wallets.getOnChainFeeForUsdWalletAndBtcAmount(getFeeArgs) - if (feeAmount instanceof Error) throw feeAmount - const fee = Number(feeAmount.amount) - expect(fee).toBe(0) - }) - - it("returns error for dust amount", async () => { - const address = (await bitcoindOutside.getNewAddress()) as OnChainAddress - const amount = toSats(dustThreshold - 1) - - const usdAmount = await dealerFns.getCentsFromSatsForImmediateBuy({ - amount: BigInt(amount), - currency: WalletCurrency.Btc, - }) - if (usdAmount instanceof Error) throw usdAmount - const amountInUsd = Number(usdAmount.amount) - - const getFeeArgsPartial = { - walletId: walletIdUsdA, - account: accountA, - address, - speed: defaultSpeed, - } - const fee = - amountCurrency === WalletCurrency.Usd - ? await Wallets.getOnChainFeeForUsdWallet({ - ...getFeeArgsPartial, - amount: amountInUsd, - }) - : await Wallets.getOnChainFeeForUsdWalletAndBtcAmount({ - ...getFeeArgsPartial, - amount, - }) - expect(fee).toBeInstanceOf(LessThanDustThresholdError) - expect(fee).toHaveProperty( - "message", - `Use lightning to send amounts less than ${dustThreshold} sats`, - ) - }) - - it("returns error for minimum amount", async () => { - const address = (await bitcoindOutside.getNewAddress()) as OnChainAddress - - const getFeeArgsPartial = { - walletId: walletIdUsdA, - account: accountA, - address, - speed: defaultSpeed, - } - const fee = - amountCurrency === WalletCurrency.Usd - ? await Wallets.getOnChainFeeForUsdWallet({ - ...getFeeArgsPartial, - amount: toCents(1), - }) - : await Wallets.getOnChainFeeForUsdWalletAndBtcAmount({ - ...getFeeArgsPartial, - amount: toSats(1), - }) - expect(fee).toBeInstanceOf(LessThanDustThresholdError) - expect(fee).toHaveProperty( - "message", - `Use lightning to send amounts less than ${dustThreshold} sats`, - ) - }) - }) - }) - }) -})