diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f413bbe..7a4490a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -86,9 +86,14 @@ components: description: Token type for a given transaction example: arweave + PriceFiatOrTokenType: + type: string + description: Type for a given price request. Either "fiat" type or "token" type + example: kyve + PaymentAmount: type: integer - description: Payment amount in a given currency's smallest unit value. For example, $10 USD is 1000 + description: Payment amount in a given currency's smallest unit value. For example, $10 USD is 1000. 1 AR is 1000000000000 example: 1000 SignatureHeader: @@ -242,6 +247,27 @@ components: description: Available on a checkout-session top up flow, this is the URL in which to fulfill the quote example: https://checkout.stripe.com/c/pay/cs_test_a1lFM2vIpifSqH8VtIjnbSGnr0RAQtEx6R2OMbhvbeK7fradNG7357Roxy#fidkdWxOYHwnPyd1blpxYHZxWjA0T1BEcXJGPWR1VUpSbkFJbTdDVV9uVG5sTl9AblFqM3J0YklGcVRqRmlJM1YxaTdvaWdnZjBIYkphckpQYVA8UWs8NktLc3REQmdwNDQwaW5PRm1IbG5CNTVdUGNRaGo3fycpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl + ReturnUrl: + type: string + description: The URL to return to after a successful payment + default: https://app.ardrive.io + + SuccessUrl: + type: string + description: The URL to return to after a successful payment + default: https://app.ardrive.io + + CancelUrl: + type: string + description: The URL to return to after a canceled payment + default: https://app.ardrive.io + + UiMode: + type: string + description: Which UI Mode to create the checkout session in + default: hosted + example: embedded + paths: # winc Price for ByteCount of Data Items /price/bytes/{byteCount}: @@ -272,8 +298,8 @@ paths: type: string default: "Invalid byte count" - "502": - description: Bad Gateway + "503": + description: Service Unavailable content: text/plain: schema: @@ -309,7 +335,7 @@ paths: in: path required: true schema: - "$ref": "#/components/schemas/CurrencyType" + "$ref": "#/components/schemas/PriceFiatOrTokenType" - name: amount in: path @@ -347,8 +373,8 @@ paths: description: "Error message string dependent on cause" example: "Payment Amount is Invalid" - "502": - description: Bad Gateway + "503": + description: Service Unavailable content: text/plain: schema: @@ -378,7 +404,7 @@ paths: get: summary: Get Current Balance of winc - description: Use a signed request or a previously obtained JWT to get the the signing wallet's current service balance in winc + description: Use a signed request or a previously obtained JWT to get the signing wallet's current service balance in winc responses: "200": @@ -472,10 +498,25 @@ paths: in: query required: false schema: - type: string - description: Which UI Mode to create the checkout session in - default: hosted - example: embedded + "$ref": "#/components/schemas/UiMode" + + - name: returnUrl + in: query + required: false + schema: + "$ref": "#/components/schemas/ReturnUrl" + + - name: successUrl + in: query + required: false + schema: + "$ref": "#/components/schemas/SuccessUrl" + + - name: cancelUrl + in: query + required: false + schema: + "$ref": "#/components/schemas/CancelUrl" responses: "200": @@ -513,8 +554,8 @@ paths: description: "Error message string dependent on cause" example: "Payment Amount is Invalid!" - "502": - description: Bad Gateway + "503": + description: Service Unavailable content: text/plain: schema: @@ -764,8 +805,8 @@ paths: type: string default: "Transaction ID not found!" - "500": - description: Internal Server Error + "503": + description: Service Unavailable content: text/plain: schema: diff --git a/src/constants.ts b/src/constants.ts index 9fa88a5..bc7433e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -18,6 +18,8 @@ import { ByteCount } from "./types/byteCount"; import { SupportedFiatPaymentCurrencyType } from "./types/supportedCurrencies"; export const isTestEnv = process.env.NODE_ENV === "test"; +export const isDevEnv = process.env.NODE_ENV === "dev"; + export const migrateOnStartup = process.env.MIGRATE_ON_STARTUP === "true"; export const defaultPort = +(process.env.PORT ?? 3000); export const msPerMinute = 1000 * 60; @@ -320,6 +322,9 @@ export const maxGiftMessageLength = process.env.MAX_GIFT_MESSAGE_LENGTH ?? 250; export const giftingEmailAddress = process.env.GIFTING_EMAIL_ADDRESS ?? "gift@ardrive.io"; +export const defaultCheckoutSuccessUrl = "https://app.ardrive.io"; +export const defaultCheckoutCancelUrl = "https://app.ardrive.io"; + /** gifting on top up via email depends on GIFTING_ENABLED="true" env var */ export const isGiftingEnabled = process.env.GIFTING_ENABLED === "true"; diff --git a/src/database/errors.ts b/src/database/errors.ts index 022c279..71fc33b 100644 --- a/src/database/errors.ts +++ b/src/database/errors.ts @@ -16,28 +16,32 @@ */ import { CurrencyType, PaymentAmount, Timestamp, UserAddress } from "./dbTypes"; -export class UserNotFoundWarning extends Error { +abstract class BaseError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +export class UserNotFoundWarning extends BaseError { constructor(userAddress: UserAddress) { super(`No user found in database with address '${userAddress}'`); - this.name = "UserNotFoundWarning"; } } -export class InsufficientBalance extends Error { +export class InsufficientBalance extends BaseError { constructor(userAddress: UserAddress) { super(`Insufficient balance for '${userAddress}'`); - this.name = "InsufficientBalance"; } } -export abstract class PaymentValidationError extends Error {} +export abstract class PaymentValidationError extends BaseError {} export class UnsupportedCurrencyType extends PaymentValidationError { constructor(currencyType: CurrencyType) { super( `The currency type '${currencyType}' is currently not supported by this API!` ); - this.name = "UnsupportedCurrencyType"; } } @@ -46,7 +50,6 @@ export class InvalidPaymentAmount extends PaymentValidationError { super( `The provided payment amount (${paymentAmount}) is invalid; it must be a positive non-decimal integer!` ); - this.name = "InvalidPaymentAmount"; } } @@ -59,7 +62,6 @@ export class PaymentAmountTooSmall extends PaymentValidationError { super( `The provided payment amount (${paymentAmount}) is too small for the currency type "${currencyType}"; it must be above ${minimumAllowedAmount}!` ); - this.name = "PaymentAmountTooSmall"; } } @@ -72,32 +74,28 @@ export class PaymentAmountTooLarge extends PaymentValidationError { super( `The provided payment amount (${paymentAmount}) is too large for the currency type "${currencyType}"; it must be below or equal to ${maximumAllowedAmount}!` ); - this.name = "PaymentAmountTooLarge"; } } -export abstract class PromoCodeError extends Error {} +export abstract class PromoCodeError extends BaseError {} export class UserIneligibleForPromoCode extends PromoCodeError { constructor(userAddress: UserAddress, promoCode: string) { super( `The user '${userAddress}' is ineligible for the promo code '${promoCode}'` ); - this.name = "UserIneligibleForPromoCode"; } } export class PromoCodeNotFound extends PromoCodeError { constructor(promoCode: string) { super(`No promo code found with code '${promoCode}'`); - this.name = "PromoCodeNotFound"; } } export class PromoCodeExpired extends PromoCodeError { constructor(promoCode: string, endDate: Timestamp) { super(`The promo code '${promoCode}' expired on '${endDate}'`); - this.name = "PromoCodeExpired"; } } @@ -106,7 +104,6 @@ export class PaymentAmountTooSmallForPromoCode extends PromoCodeError { super( `The promo code '${promoCode}' can only used on payments above '${minimumPaymentAmount}'` ); - this.name = "PaymentAmountTooSmallForPromoCode"; } } @@ -115,77 +112,73 @@ export class PromoCodeExceedsMaxUses extends PromoCodeError { super( `The promo code '${promoCode}' has already been used the maximum number of times (${maxUses})` ); - this.name = "PromoCodeExceedsMaxUses"; } } -export class GiftRedemptionError extends Error { +export class GiftRedemptionError extends BaseError { constructor(errorMessage = "Failure to redeem payment receipt!") { super(errorMessage); - this.name = "GiftRedemptionError"; } } export class GiftAlreadyRedeemed extends GiftRedemptionError { constructor() { super("Gift has already been redeemed!"); - this.name = "GiftAlreadyRedeemed"; } } -export class PaymentTransactionNotMined extends Error { +export class BadQueryParam extends BaseError { + constructor(message?: string) { + super(message ?? `Bad query parameter`); + } +} + +export class PaymentTransactionNotMined extends BaseError { constructor(transactionId: string) { super(`Transaction with id '${transactionId}' has not been mined yet!`); - this.name = "PaymentTransactionNotMined"; } } -export class PaymentTransactionNotFound extends Error { +export class PaymentTransactionNotFound extends BaseError { constructor(transactionId: string) { super(`No payment transaction found with id '${transactionId}'`); - this.name = "PaymentTransactionNotFound"; } } -export class PaymentTransactionHasWrongTarget extends Error { +export class PaymentTransactionHasWrongTarget extends BaseError { constructor(transactionId: string, targetAddress?: string) { super( `Payment transaction '${transactionId}' has wrong target address '${targetAddress}'` ); - this.name = "PaymentTransactionHasWrongTarget"; } } -export class TransactionNotAPaymentTransaction extends Error { +export class TransactionNotAPaymentTransaction extends BaseError { constructor(transactionId: string) { super( `Transaction with id '${transactionId}' is not a payment transaction!` ); - this.name = "TransactionNotAPaymentTransaction"; } } -export class PaymentTransactionRecipientOnExcludedList extends Error { +export class PaymentTransactionRecipientOnExcludedList extends BaseError { constructor(transactionId: string, senderAddress: string) { super( `Payment transaction '${transactionId}' has sender that is on the excluded address list: '${senderAddress}'` ); - this.name = "PaymentTransactionRecipientOnExcludedList"; } } -export class BadRequest extends Error { +export class BadRequest extends BaseError { constructor(message: string) { super(message); - this.name = "BadRequest"; } } -export class CryptoPaymentTooSmallError extends Error { +export class CryptoPaymentTooSmallError extends BadRequest { constructor() { super( `Crypto payment amount is too small! Token value must convert to at least one winc` ); - this.name = "CryptoPaymentTooSmallError"; } } diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 345b31d..fc6d119 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -29,7 +29,7 @@ export const supportedPaymentTokens = [ "ethereum", "solana", "kyve", - "matic" + "matic", ] as const; export type TokenType = (typeof supportedPaymentTokens)[number]; export function isSupportedPaymentToken(token: string): token is TokenType { diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 367353c..c23a4ce 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -20,4 +20,4 @@ export * from "./kyve"; export * from "./solana"; export * from "./ethereum"; export * from "./arweave"; -export * from './matic'; +export * from "./matic"; diff --git a/src/gateway/matic.ts b/src/gateway/matic.ts index 25aeb74..6add468 100644 --- a/src/gateway/matic.ts +++ b/src/gateway/matic.ts @@ -38,7 +38,7 @@ export class MaticGateway extends Gateway { endpoint = maticGatewayUrl, paymentTxPollingWaitTimeMs, pendingTxMaxAttempts, - minConfirmations = +(process.env.MATIC_MIN_CONFIRMATIONS || 5), + minConfirmations = +(process.env.MATIC_MIN_CONFIRMATIONS || 12), }: MaticGatewayParams = {}) { super({ paymentTxPollingWaitTimeMs, diff --git a/src/jobs/creditPendingTx.ts b/src/jobs/creditPendingTx.ts index 1140466..a68692f 100644 --- a/src/jobs/creditPendingTx.ts +++ b/src/jobs/creditPendingTx.ts @@ -20,13 +20,15 @@ import { ArweaveGateway, EthereumGateway, KyveGateway, - SolanaGateway, MaticGateway, + SolanaGateway, } from "../gateway"; import globalLogger from "../logger"; +import { TurboPricingService } from "../pricing/pricing"; +import { sendCryptoFundSlackMessage } from "../utils/slack"; export type CreditPendingTxParams = Partial< - Pick & { + Pick & { logger: typeof globalLogger; } >; @@ -40,9 +42,10 @@ export async function creditPendingTransactionsHandler({ ethereum: new EthereumGateway(), solana: new SolanaGateway(), kyve: new KyveGateway(), - matic: new MaticGateway() + matic: new MaticGateway(), }, paymentDatabase = new PostgresDatabase(), + pricingService = new TurboPricingService(), logger = globalLogger.child({ job: "credit-pending-transactions-job" }), }: CreditPendingTxParams = {}) { logger.debug("Starting credit pending transactions job"); @@ -56,7 +59,8 @@ export async function creditPendingTransactionsHandler({ } // for each tx check if tx has been confirmed - for (const { transactionId, tokenType, createdDate } of pendingTx) { + for (const tx of pendingTx) { + const { transactionId, tokenType, createdDate } = tx; try { const gateway = gatewayMap[tokenType]; @@ -66,6 +70,15 @@ export async function creditPendingTransactionsHandler({ `Transaction ${transactionId} has been confirmed, moving to credited tx`, { txStatus } ); + + await sendCryptoFundSlackMessage({ + ...tx, + usdEquivalent: await pricingService.getUsdPriceForCryptoAmount({ + amount: tx.transactionQuantity, + token: tx.tokenType, + }), + }); + await paymentDatabase.creditPendingTransaction( transactionId, txStatus.blockHeight diff --git a/src/pricing/oracles/tokenToFiatOracle.ts b/src/pricing/oracles/tokenToFiatOracle.ts index 76694f9..80fdb57 100644 --- a/src/pricing/oracles/tokenToFiatOracle.ts +++ b/src/pricing/oracles/tokenToFiatOracle.ts @@ -32,17 +32,20 @@ const coinGeckoTokenNames = [ "ethereum", "solana", "kyve-network", - "matic-network" + "matic-network", ] as const; type CoinGeckoTokenName = (typeof coinGeckoTokenNames)[number]; -const tokenNameToCoinGeckoTokenName: Record = { +export const tokenNameToCoinGeckoTokenName: Record< + TokenType, + CoinGeckoTokenName +> = { arweave: "arweave", ethereum: "ethereum", solana: "solana", kyve: "kyve-network", - matic: "matic-network" + matic: "matic-network", }; type CoinGeckoResponse = Record< @@ -148,4 +151,10 @@ export class ReadThroughTokenToFiatOracle { return tokenUsdPrice / arweaveUsdPrice; } + + async getUsdPriceForOneToken(token: TokenType): Promise { + const cachedValue = await this.readThroughPromiseCache.get("arweave"); + const coinGeckoToken = tokenNameToCoinGeckoTokenName[token]; + return cachedValue[coinGeckoToken].usd; + } } diff --git a/src/pricing/pricing.test.ts b/src/pricing/pricing.test.ts index e8f0e5f..13b3009 100644 --- a/src/pricing/pricing.test.ts +++ b/src/pricing/pricing.test.ts @@ -30,6 +30,7 @@ import { UploadAdjustmentCatalogDBInsert, } from "../database/dbTypes"; import { PostgresDatabase } from "../database/postgres"; +import { TokenType } from "../gateway"; import { ByteCount, W, Winston } from "../types"; import { Payment } from "../types/payment"; import { filterKeysFromObject } from "../utils/common"; @@ -755,4 +756,28 @@ describe("TurboPricingService class", () => { clock.restore(); }); }); + + describe("getUsdPriceForCryptoAmount", () => { + beforeEach(() => { + stub(oracle, "getFiatPricesForOneToken").resolves(expectedTokenPrices); + }); + + const testMap: Record = { + arweave: 0.7, + solana: 17341, + ethereum: 0, + kyve: 2336.11, + }; + + for (const [token, expectedPrice] of Object.entries(testMap)) { + it(`returns the expected price for a given ${token} payment`, async () => { + const price = await pricing.getUsdPriceForCryptoAmount({ + amount: 100_000_000_000, + token: token as TokenType, + }); + + expect(price).to.equal(expectedPrice); + }); + } + }); }); diff --git a/src/pricing/pricing.ts b/src/pricing/pricing.ts index 0bdf395..7e2b6e2 100644 --- a/src/pricing/pricing.ts +++ b/src/pricing/pricing.ts @@ -111,6 +111,10 @@ export interface PricingService { amount: number; type: CurrencyType; }) => Promise; + getUsdPriceForCryptoAmount: (params: { + amount: BigNumber.Value; + token: TokenType; + }) => Promise; } /** Stripe accepts 8 digits on all currency types except IDR */ @@ -782,44 +786,33 @@ export class TurboPricingService implements PricingService { inclusiveAdjustments: inclusiveAdjustments, }; } -} - -export function wincToCredits(winc: BigNumber.Value): BigNumber { - return BigNumber(winc).shiftedBy(-12); -} - -export function weiToEth(wei: BigNumber.Value): BigNumber { - return BigNumber(wei).shiftedBy(-18); -} - -export function baseToMatic(base: BigNumber.Value): BigNumber { - return BigNumber(base).shiftedBy(-18); -} -export function lamportsToSol(lamports: BigNumber.Value): BigNumber { - return BigNumber(lamports).shiftedBy(-9); + public async getUsdPriceForCryptoAmount({ + amount: baseTokenAmount, + token, + }: { + amount: BigNumber.Value; + token: TokenType; + }): Promise { + const usdPriceForOneToken = + await this.tokenToFiatOracle.getUsdPriceForOneToken(token); + const tokenAmount = baseAmountToTokenAmount(baseTokenAmount, token); + const usdPriceForTokens = tokenAmount.times(usdPriceForOneToken); + return +usdPriceForTokens.toFixed(2); + } } -export function ukyveToKyve(ukyve: BigNumber.Value): BigNumber { - return BigNumber(ukyve).shiftedBy(-6); -} +export const tokenExponentMap = { + arweave: 12, + ethereum: 18, + solana: 9, + kyve: 6, + matic: 18, +}; export function baseAmountToTokenAmount( amount: BigNumber.Value, token: TokenType ): BigNumber { - switch (token) { - case "arweave": - return wincToCredits(amount); - case "ethereum": - return weiToEth(amount); - case "solana": - return lamportsToSol(amount); - case "kyve": - return ukyveToKyve(amount); - case "matic": - return baseToMatic(amount); - default: - return BigNumber(amount); - } + return BigNumber(amount).shiftedBy(-tokenExponentMap[token]); } diff --git a/src/routes/addPendingPaymentTx.ts b/src/routes/addPendingPaymentTx.ts index 018d09e..4b6c805 100644 --- a/src/routes/addPendingPaymentTx.ts +++ b/src/routes/addPendingPaymentTx.ts @@ -34,6 +34,7 @@ import { import { isSupportedPaymentToken } from "../gateway"; import { KoaContext } from "../server"; import { W } from "../types"; +import { sendCryptoFundSlackMessage } from "../utils/slack"; import { walletAddresses } from "./info"; export async function addPendingPaymentTx(ctx: KoaContext, _next: Next) { @@ -150,6 +151,14 @@ export async function addPendingPaymentTx(ctx: KoaContext, _next: Next) { }; // User submitted an already confirmed transaction, credit it immediately await paymentDatabase.createNewCreditedTransaction(newCreditedTx); + + await sendCryptoFundSlackMessage({ + ...newCreditedTx, + usdEquivalent: await pricingService.getUsdPriceForCryptoAmount({ + amount: newCreditedTx.transactionQuantity, + token: newCreditedTx.tokenType, + }), + }); ctx.status = 200; // OK ctx.body = { creditedTransaction: { @@ -183,7 +192,7 @@ export async function addPendingPaymentTx(ctx: KoaContext, _next: Next) { ctx.status = 403; ctx.body = error.message; } else { - ctx.status = 500; + ctx.status = 503; logger.error("Error adding pending payment transaction", error); ctx.body = error instanceof Error ? error.message : "Internal server error"; diff --git a/src/routes/checkBalance.ts b/src/routes/checkBalance.ts index 8435019..ae737af 100644 --- a/src/routes/checkBalance.ts +++ b/src/routes/checkBalance.ts @@ -58,7 +58,7 @@ export async function checkBalance(ctx: KoaContext, next: Next) { walletAddress ); } catch (error) { - ctx.response.status = 502; + ctx.response.status = 503; ctx.body = "Error getting base credit amount"; logger.error("Error getting base credit amount", { walletAddress, @@ -114,7 +114,7 @@ export async function checkBalance(ctx: KoaContext, next: Next) { error, }); - ctx.response.status = 502; + ctx.response.status = 503; ctx.body = "Error checking balance"; } } diff --git a/src/routes/currencies.ts b/src/routes/currencies.ts index 6322ec4..4bb4968 100644 --- a/src/routes/currencies.ts +++ b/src/routes/currencies.ts @@ -52,7 +52,7 @@ export async function currenciesRoute(ctx: KoaContext, next: Next) { ctx.set("Cache-Control", `max-age=${oneHourInSeconds}`); } catch (error) { ctx.body = "Fiat Oracle Unavailable"; - ctx.status = 502; + ctx.status = 503; } return next(); diff --git a/src/routes/priceBytes.ts b/src/routes/priceBytes.ts index d7155f2..4e28662 100644 --- a/src/routes/priceBytes.ts +++ b/src/routes/priceBytes.ts @@ -61,7 +61,7 @@ export async function priceBytesHandler(ctx: KoaContext, next: Next) { ...priceWithAdjustments, }); } catch (error) { - ctx.response.status = 502; + ctx.response.status = 503; ctx.body = "Pricing Oracle Unavailable"; logger.error("Pricing Oracle Unavailable", { bytesValue }); } diff --git a/src/routes/priceCrypto.ts b/src/routes/priceCrypto.ts new file mode 100644 index 0000000..a261145 --- /dev/null +++ b/src/routes/priceCrypto.ts @@ -0,0 +1,71 @@ +/** + * Copyright (C) 2022-2024 Permanent Data Solutions, Inc. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Next } from "koa"; + +import { oneMinuteInSeconds } from "../constants"; +import { BadRequest } from "../database/errors"; +import { KoaContext } from "../server"; +import { W } from "../types"; + +export async function priceCryptoHandler(ctx: KoaContext, next: Next) { + const { pricingService, logger } = ctx.state; + const { amount, currency: token } = ctx.params; + + // TODO: Allow promo codes on crypto payments + // const { destinationAddress: rawDestinationAddress /*promoCode*/ } = ctx.query; + // const promoCodes = parseQueryParams(promoCode); + + try { + if (!amount || !token) { + throw new BadRequest("Missing required parameters"); + } + + const wincForPaymentResponse = await pricingService.getWCForCryptoPayment({ + amount: W(amount), + token, + }); + + const { actualPaymentAmount, finalPrice, inclusiveAdjustments } = + wincForPaymentResponse; + + ctx.body = { + winc: finalPrice.toString(), + fees: inclusiveAdjustments.map((adjustment) => ({ + ...adjustment, + catalogId: undefined, + })), + actualPaymentAmount, + }; + ctx.set("Cache-Control", `max-age=${oneMinuteInSeconds}`); + ctx.response.status = 200; + } catch (error) { + if (error instanceof BadRequest) { + ctx.response.status = 400; + ctx.body = error.message; + } else { + logger.error( + "Failed to get price for crypto payment!", + { amount, token, error }, + error + ); + ctx.response.status = 503; + ctx.body = "Fiat Oracle Unavailable"; + } + } + + return next(); +} diff --git a/src/routes/priceFiat.ts b/src/routes/priceFiat.ts index dae0c76..9d3ed3d 100644 --- a/src/routes/priceFiat.ts +++ b/src/routes/priceFiat.ts @@ -106,7 +106,7 @@ export async function priceFiatHandler(ctx: KoaContext, next: Next) { { payment, error }, error ); - ctx.response.status = 502; + ctx.response.status = 503; ctx.body = "Fiat Oracle Unavailable"; } } diff --git a/src/routes/priceRoutes.ts b/src/routes/priceRoutes.ts index 5168cb9..1877fd4 100644 --- a/src/routes/priceRoutes.ts +++ b/src/routes/priceRoutes.ts @@ -16,21 +16,20 @@ */ import { Next } from "koa"; +import { isSupportedPaymentToken } from "../gateway"; import { KoaContext } from "../server"; import { priceBytesHandler } from "./priceBytes"; +import { priceCryptoHandler } from "./priceCrypto"; import { priceFiatHandler } from "./priceFiat"; export async function priceRoutes(ctx: KoaContext, next: Next) { const currency = ctx.params.currency ?? "bytes"; - const walletAddress = ctx.state.walletAddress; - if (walletAddress) { - // TODO: Put any promotional info from the DB that may change pricing calculations into state - } - if (currency === "bytes") { return priceBytesHandler(ctx, next); - } else { - return priceFiatHandler(ctx, next); + } else if (isSupportedPaymentToken(currency)) { + return priceCryptoHandler(ctx, next); } + + return priceFiatHandler(ctx, next); } diff --git a/src/routes/rates.ts b/src/routes/rates.ts index 3418050..dcd5ce7 100644 --- a/src/routes/rates.ts +++ b/src/routes/rates.ts @@ -31,7 +31,7 @@ export async function ratesHandler(ctx: KoaContext, next: Next) { ctx.body = { ...rates, winc: rates.winc.toString() }; logger.info("Successfully calculated rates.", { rates }); } catch (error) { - ctx.status = 502; + ctx.status = 503; ctx.body = "Failed to calculate rates."; logger.error("Failed to calculate rates.", error); } @@ -66,7 +66,7 @@ export async function fiatToArRateHandler(ctx: KoaContext, next: Next) { }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; - ctx.status = 502; + ctx.status = 503; ctx.body = "Failed to fetch fiat conversion for 1 AR."; logger.error("Failed to fetch fiat conversion for 1 AR.", { error: message, diff --git a/src/routes/refundBalance.ts b/src/routes/refundBalance.ts index 1aabfbb..d2ba768 100644 --- a/src/routes/refundBalance.ts +++ b/src/routes/refundBalance.ts @@ -76,7 +76,7 @@ export async function refundBalance(ctx: KoaContext, next: Next) { winstonCreditsToRefund, }); } else { - ctx.response.status = 502; + ctx.response.status = 503; ctx.response.message = "Error refunding balance"; logger.error("Error refunding balance", { walletAddress, diff --git a/src/routes/reserveBalance.ts b/src/routes/reserveBalance.ts index 96e1c9c..d797cad 100644 --- a/src/routes/reserveBalance.ts +++ b/src/routes/reserveBalance.ts @@ -115,7 +115,7 @@ export async function reserveBalance(ctx: KoaContext, next: Next) { error, }); - ctx.response.status = 502; + ctx.response.status = 503; ctx.response.message = "Error reserving balance"; } } diff --git a/src/routes/topUp.ts b/src/routes/topUp.ts index af25f3c..6f8d6d8 100644 --- a/src/routes/topUp.ts +++ b/src/routes/topUp.ts @@ -21,6 +21,7 @@ import validator from "validator"; import { CurrencyLimitations, + defaultCheckoutCancelUrl, electronicallySuppliedServicesTaxCode, isGiftingEnabled, paymentIntentTopUpMethod, @@ -28,7 +29,11 @@ import { topUpQuoteExpirationMs, } from "../constants"; import { CreateTopUpQuoteParams } from "../database/dbTypes"; -import { PaymentValidationError, PromoCodeError } from "../database/errors"; +import { + BadQueryParam, + PaymentValidationError, + PromoCodeError, +} from "../database/errors"; import { MetricRegistry } from "../metricRegistry"; import { WincForPaymentResponse } from "../pricing/pricing"; import { KoaContext } from "../server"; @@ -37,11 +42,16 @@ import { winstonToArc } from "../types/winston"; import { isValidUserAddress } from "../utils/base64"; import { parseQueryParams } from "../utils/parseQueryParams"; import { + assertUiModeAndUrls, validateDestinationAddressType, validateGiftMessage, - validateUiMode, } from "../utils/validators"; +type StripeUiModeMetadata = + | { ui_mode: "hosted"; success_url: string; cancel_url: string | undefined } + | { ui_mode: "embedded"; redirect_on_completion: "never" } + | { ui_mode: "embedded"; return_url: string }; + export async function topUp(ctx: KoaContext, next: Next) { const logger = ctx.state.logger; @@ -69,6 +79,9 @@ export async function topUp(ctx: KoaContext, next: Next) { destinationAddressType: rawDestinationAddressType, giftMessage: rawGiftMessage, uiMode: rawUiMode, + returnUrl: rawReturnUrl, + successUrl: rawSuccessUrl, + cancelUrl: rawCancelUrl, } = ctx.query; // First use destinationAddressType from backwards compatible routes ("email" address type), else use token @@ -92,8 +105,41 @@ export async function topUp(ctx: KoaContext, next: Next) { return next(); } - const uiMode = rawUiMode ? validateUiMode(ctx, rawUiMode) : "hosted"; - if (uiMode === false) { + let stripeUiModeMetadata: StripeUiModeMetadata; + + try { + const validatedQueryParams = assertUiModeAndUrls({ + cancelUrl: rawCancelUrl, + returnUrl: rawReturnUrl, + successUrl: rawSuccessUrl, + uiMode: rawUiMode, + }); + + if (validatedQueryParams.uiMode === "hosted") { + stripeUiModeMetadata = { + ui_mode: validatedQueryParams.uiMode, + success_url: validatedQueryParams.successUrl, + cancel_url: validatedQueryParams.cancelUrl, + }; + } else { + stripeUiModeMetadata = { + ui_mode: validatedQueryParams.uiMode, + ...(validatedQueryParams.returnUrl + ? { return_url: validatedQueryParams.returnUrl } + : { redirect_on_completion: "never" }), + }; + } + } catch (error) { + // TODO: Expand this try catch to handle all errors thrown in route with Error instanceof catch pattern + if (error instanceof BadQueryParam) { + ctx.response.status = 400; + ctx.body = error.message; + logger.error(error.message, loggerObject); + } else { + ctx.response.status = 503; + ctx.body = "Internal Server Error"; + logger.error(error); + } return next(); } @@ -133,7 +179,7 @@ export async function topUp(ctx: KoaContext, next: Next) { currencyLimitations = await pricingService.getCurrencyLimitations(); } catch (error) { logger.error(error); - ctx.response.status = 502; + ctx.response.status = 503; ctx.body = "Fiat Oracle Unavailable"; return next(); } @@ -152,7 +198,7 @@ export async function topUp(ctx: KoaContext, next: Next) { logger.info(error.message, loggerObject); } else { logger.error(error); - ctx.response.status = 502; + ctx.response.status = 503; ctx.body = "Fiat Oracle Unavailable"; } @@ -178,7 +224,7 @@ export async function topUp(ctx: KoaContext, next: Next) { ctx.body = error.message; } else { logger.error(error); - ctx.response.status = 502; + ctx.response.status = 503; ctx.body = "Fiat Oracle Unavailable"; } @@ -254,30 +300,26 @@ export async function topUp(ctx: KoaContext, next: Next) { ? encodeURIComponent(giftMessage) : undefined; - const urls: - | { success_url: string; cancel_url: string } - | { redirect_on_completion: "never" } = - uiMode === "embedded" - ? { - redirect_on_completion: "never", - } - : { - // // TODO: Success and Cancel URLS (Do we need app origin? e.g: ArDrive Widget, Top Up Page, ario-turbo-cli) - success_url: "https://app.ardrive.io", - cancel_url: - destinationAddressType === "email" - ? `${giftUrl}?email=${destinationAddress}&amount=${ - payment.amount - }${ - urlEncodedGiftMessage - ? `&giftMessage=${urlEncodedGiftMessage}` - : "" - }` - : "https://app.ardrive.io", - }; - + if ( + stripeUiModeMetadata.ui_mode === "hosted" && + stripeUiModeMetadata.cancel_url === undefined + ) { + if (destinationAddressType === "email") { + const queryParams = new URLSearchParams({ + email: destinationAddress, + amount: payment.amount.toString(), + }); + if (urlEncodedGiftMessage) { + queryParams.append("giftMessage", urlEncodedGiftMessage); + } + + stripeUiModeMetadata.cancel_url = `${giftUrl}?${queryParams.toString()}`; + } else { + stripeUiModeMetadata.cancel_url = defaultCheckoutCancelUrl; + } + } intentOrCheckout = await stripe.checkout.sessions.create({ - ...urls, + ...stripeUiModeMetadata, currency: payment.type, automatic_tax: { enabled: !!process.env.ENABLE_AUTO_STRIPE_TAX || false, @@ -306,12 +348,13 @@ export async function topUp(ctx: KoaContext, next: Next) { metadata: stripeMetadata, }, mode: "payment", - ui_mode: uiMode, }); } } catch (error) { - ctx.response.status = 502; - ctx.body = `Error creating stripe payment session with method: ${method}!`; + ctx.response.status = 503; + ctx.body = `Error creating stripe payment session! ${ + error instanceof Error ? error.message : error + }`; MetricRegistry.stripeSessionCreationErrorCounter.inc(); logger.error(error); return next(); diff --git a/src/server.ts b/src/server.ts index fe9a7ec..df1ba8a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,8 +34,8 @@ import { EthereumGateway, GatewayMap, KyveGateway, + MaticGateway, SolanaGateway, - MaticGateway } from "./gateway"; import logger from "./logger"; import { MetricRegistry } from "./metricRegistry"; @@ -100,7 +100,7 @@ export async function createServer( ethereum: new EthereumGateway(), solana: new SolanaGateway(), kyve: new KyveGateway(), - matic: new MaticGateway() + matic: new MaticGateway(), }; const emailProvider = (() => { diff --git a/src/utils/slack.ts b/src/utils/slack.ts index bcdc78a..b79e4ab 100644 --- a/src/utils/slack.ts +++ b/src/utils/slack.ts @@ -14,11 +14,22 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import { isDevEnv } from "../constants"; +import { + CreateNewCreditedTransactionParams, + PendingPaymentTransaction, +} from "../database/dbTypes"; import globalLogger from "../logger"; +import { baseAmountToTokenAmount, tokenExponentMap } from "../pricing/pricing"; + +export const slackChannels = { + admin: process.env.SLACK_TURBO_ADMIN_CHANNEL_ID, + topUp: process.env.SLACK_TURBO_TOP_UP_CHANNEL_ID, +}; export const sendSlackMessage = async ({ message, - channel = process.env.SLACK_TURBO_ADMIN_CHANNEL_ID, + channel = slackChannels.admin, username = "Payment Service", icon_emoji = ":moneybag:", }: { @@ -61,3 +72,38 @@ export const sendSlackMessage = async ({ globalLogger.error(`slack message delivery failed`, error); } }; + +export const sendCryptoFundSlackMessage = async ({ + destinationAddress, + transactionId, + transactionQuantity, + tokenType, + winstonCreditAmount, + usdEquivalent, +}: (PendingPaymentTransaction | CreateNewCreditedTransactionParams) & { + usdEquivalent: number; +}) => { + const tokens = baseAmountToTokenAmount( + transactionQuantity, + tokenType + ).toFixed(tokenExponentMap[tokenType]); + const credits = baseAmountToTokenAmount( + winstonCreditAmount.toString(), + "arweave" + ).toFixed(12); + + if (isDevEnv) { + // Don't send slack messages in dev env + return; + } + + return sendSlackMessage({ + channel: slackChannels.topUp, + message: `New crypto payment credited:\`\`\` +Tokens: ${tokens} ${tokenType} +Credits: ${credits} +USD Equivalent: ${usdEquivalent === 0 ? "less than $0.01" : `$${usdEquivalent}`} +Address: ${destinationAddress} +TxID: ${transactionId}\`\`\``, + }); +}; diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 795c5ca..c051299 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -16,13 +16,14 @@ */ import validator from "validator"; -import { maxGiftMessageLength } from "../constants"; +import { defaultCheckoutSuccessUrl, maxGiftMessageLength } from "../constants"; import { DestinationAddressType, UserAddressType, destinationAddressTypes, userAddressTypes, } from "../database/dbTypes"; +import { BadQueryParam } from "../database/errors"; import { MetricRegistry } from "../metricRegistry"; import { KoaContext } from "../server"; import { ByteCount, Winston } from "../types"; @@ -130,8 +131,12 @@ function isDestinationAddressType( export function validateDestinationAddressType( ctx: KoaContext, - destinationAddressType: string | string[] + destinationAddressType: string | string[] | undefined ): DestinationAddressType | false { + if (destinationAddressType === undefined) { + return "arweave"; + } + const destType = validateSingularQueryParameter(ctx, destinationAddressType); if (!destType || !isDestinationAddressType(destType)) { @@ -196,21 +201,70 @@ export type UiMode = (typeof uiModes)[number]; function isUiMode(uiMode: string): uiMode is UiMode { return uiModes.includes(uiMode as UiMode); } -export function validateUiMode( - ctx: KoaContext, - uiMode: string | string[] -): UiMode | false { - const mode = validateSingularQueryParameter(ctx, uiMode); - if (!mode || !isUiMode(mode)) { - ctx.response.status = 400; - ctx.body = `Invalid ui mode! Allowed modes: "${uiModes.toString()}"`; - ctx.state.logger.error("Invalid ui mode!", { - query: ctx.query, - params: ctx.params, - }); - return false; +function assertSingleParam(queryParam: QueryParam): string | undefined { + if (Array.isArray(queryParam)) { + if (queryParam.length > 1) { + throw new BadQueryParam( + `Expected a singular query parameter but got an array ${queryParam}` + ); + } + return queryParam[0]; } + return queryParam; +} + +function assertUiMode(uiMode: QueryParam): UiMode { + const mode = assertSingleParam(uiMode); - return mode; + if (mode) { + if (!isUiMode(mode)) { + throw new BadQueryParam( + `Invalid ui mode! Allowed modes: "${uiModes.toString()}"` + ); + } + return mode; + } + + return "hosted"; +} + +function assertUrl(url: QueryParam): string | undefined { + const u = assertSingleParam(url); + + if (u && !validator.isURL(u)) { + throw new BadQueryParam(`Invalid url provided: ${u}!`); + } + return u; +} + +type QueryParam = undefined | string | string[]; +export function assertUiModeAndUrls({ + cancelUrl, + returnUrl, + successUrl, + uiMode, +}: { + returnUrl: QueryParam; + cancelUrl: QueryParam; + successUrl: QueryParam; + uiMode: QueryParam; +}): + | { uiMode: "hosted"; successUrl: string; cancelUrl: string | undefined } + | { uiMode: "embedded"; returnUrl: string | undefined } { + const mode = assertUiMode(uiMode); + if (mode === "hosted") { + const success = assertUrl(successUrl) ?? defaultCheckoutSuccessUrl; + const cancel = assertUrl(cancelUrl) ?? undefined; + return { + uiMode: "hosted", + successUrl: success, + cancelUrl: cancel, + }; + } + const retUrl = assertUrl(returnUrl); + return { + uiMode: "embedded", + returnUrl: retUrl, + }; } diff --git a/tests/helpers/stubs.ts b/tests/helpers/stubs.ts index b3bcff0..015506a 100644 --- a/tests/helpers/stubs.ts +++ b/tests/helpers/stubs.ts @@ -357,8 +357,7 @@ export const expectedTokenPrices = { cad: 0.497505, hkd: 2.87, brl: 2.05, - } -} + }, }; // TODO: we could make this a function and apply it against the arweave rates above using the turboPercentageFee constant diff --git a/tests/router.int.test.ts b/tests/router.int.test.ts index b932d41..2f3b9dd 100644 --- a/tests/router.int.test.ts +++ b/tests/router.int.test.ts @@ -50,6 +50,7 @@ import logger from "../src/logger"; import { CoingeckoTokenToFiatOracle, ReadThroughTokenToFiatOracle, + tokenNameToCoinGeckoTokenName, } from "../src/pricing/oracles/tokenToFiatOracle"; import { FinalPrice, NetworkPrice } from "../src/pricing/price"; import { @@ -208,14 +209,14 @@ describe("Router tests", () => { expect(data).to.equal("Invalid byte count"); }); - it("GET /price/arweave/:bytes returns 502 if bytes pricing oracle fails to get a price", async () => { + it("GET /price/arweave/:bytes returns 503 if bytes pricing oracle fails to get a price", async () => { stub(pricingService, "getWCForBytes").throws(Error("Serious failure")); const { status, statusText, data } = await axios.get( `/price/arweave/1321321` ); - expect(status).to.equal(502); + expect(status).to.equal(503); expect(data).to.equal("Pricing Oracle Unavailable"); - expect(statusText).to.equal("Bad Gateway"); + expect(statusText).to.equal("Service Unavailable"); }); it("GET /price/bytes returns 400 for bytes > max safe integer", async () => { @@ -236,32 +237,32 @@ describe("Router tests", () => { expect(data).to.equal("Invalid byte count"); }); - it("GET /price/bytes returns 502 if bytes pricing oracle fails to get a price", async () => { + it("GET /price/bytes returns 503 if bytes pricing oracle fails to get a price", async () => { stub(pricingService, "getWCForBytes").throws(Error("Serious failure")); const { status, statusText, data } = await axios.get( `/v1/price/bytes/1321321` ); - expect(status).to.equal(502); + expect(status).to.equal(503); expect(data).to.equal("Pricing Oracle Unavailable"); - expect(statusText).to.equal("Bad Gateway"); + expect(statusText).to.equal("Service Unavailable"); }); - it("GET /price/:currency/:value returns 502 if fiat pricing oracle response is unexpected", async () => { + it("GET /price/:currency/:value returns 503 if fiat pricing oracle response is unexpected", async () => { stub(pricingService, "getWCForPayment").throws(); const { data, status, statusText } = await axios.get(`/v1/price/usd/5000`); - expect(status).to.equal(502); - expect(statusText).to.equal("Bad Gateway"); + expect(status).to.equal(503); + expect(statusText).to.equal("Service Unavailable"); expect(data).to.equal("Fiat Oracle Unavailable"); }); - it("GET /rates returns 502 if unable to fetch prices", async () => { + it("GET /rates returns 503 if unable to fetch prices", async () => { stub(pricingService, "getWCForBytes").throws(Error("Serious failure")); const { status, statusText } = await axios.get(`/v1/rates`); - expect(status).to.equal(502); - expect(statusText).to.equal("Bad Gateway"); + expect(status).to.equal(503); + expect(statusText).to.equal("Service Unavailable"); }); it("GET /rates returns the correct response", async () => { @@ -366,11 +367,11 @@ describe("Router tests", () => { expect(data).to.equal("Invalid currency."); }); - it("GET /rates/:currency returns 502 if unable to fetch prices", async () => { + it("GET /rates/:currency returns 503 if unable to fetch prices", async () => { stub(pricingService, "getFiatPriceForOneAR").throws(); const { status, statusText } = await axios.get(`/v1/rates/usd`); - expect(status).to.equal(502); - expect(statusText).to.equal("Bad Gateway"); + expect(status).to.equal(503); + expect(statusText).to.equal("Service Unavailable"); }); it("GET /rates/:currency returns the correct response for supported currency", async () => { @@ -646,12 +647,12 @@ describe("Router tests", () => { expect(statusText).to.equal("Bad Request"); }); - it("GET /price/:currency/:value returns 502 if fiat pricing oracle fails to get a price", async () => { + it("GET /price/:currency/:value returns 503 if fiat pricing oracle fails to get a price", async () => { stub(pricingService, "getWCForPayment").throws(Error("Really bad failure")); const { data, status, statusText } = await axios.get(`/v1/price/usd/5000`); - expect(status).to.equal(502); - expect(statusText).to.equal("Bad Gateway"); + expect(status).to.equal(503); + expect(statusText).to.equal("Service Unavailable"); expect(data).to.equal("Fiat Oracle Unavailable"); }); @@ -1101,15 +1102,15 @@ describe("Router tests", () => { expect(statusText).to.equal("Bad Request"); }); - it("GET /top-up returns 502 when fiat pricing oracle is unreachable", async () => { + it("GET /top-up returns 503 when fiat pricing oracle is unreachable", async () => { stub(pricingService, "getWCForPayment").throws(Error("Oh no!")); const { status, data, statusText } = await axios.get( `/v1/top-up/checkout-session/${testAddress}/usd/1337` ); expect(data).to.equal("Fiat Oracle Unavailable"); - expect(status).to.equal(502); - expect(statusText).to.equal("Bad Gateway"); + expect(status).to.equal(503); + expect(statusText).to.equal("Service Unavailable"); }); // Ensure that we can handle all of our own exposed currency limitations @@ -1194,7 +1195,7 @@ describe("Router tests", () => { }); }); - it("GET /top-up returns 502 when stripe fails to create payment session", async () => { + it("GET /top-up returns 503 when stripe fails to create payment session", async () => { const checkoutStub = stub(stripe.checkout.sessions, "create").throws( Error("Oh no!") ); @@ -1202,11 +1203,9 @@ describe("Router tests", () => { `/v1/top-up/checkout-session/${testAddress}/usd/1337` ); - expect(data).to.equal( - "Error creating stripe payment session with method: checkout-session!" - ); - expect(status).to.equal(502); - expect(statusText).to.equal("Bad Gateway"); + expect(data).to.equal("Error creating stripe payment session! Oh no!"); + expect(status).to.equal(503); + expect(statusText).to.equal("Service Unavailable"); checkoutStub.restore(); }); @@ -1977,13 +1976,13 @@ describe("Router tests", () => { expect(data).to.equal("Webhook Error!"); }); - it("GET /rates returns 502 if unable to fetch prices", async () => { + it("GET /rates returns 503 if unable to fetch prices", async () => { stub(pricingService, "getWCForBytes").throws(Error("Serious failure")); const { status, statusText } = await axios.get(`/v1/rates`); - expect(status).to.equal(502); - expect(statusText).to.equal("Bad Gateway"); + expect(status).to.equal(503); + expect(statusText).to.equal("Service Unavailable"); }); it("GET /redeem returns 200 for valid params", async () => { @@ -2257,7 +2256,7 @@ describe("Router tests", () => { const transactionSenderAddress = "TotallyUniqueUserForThisPostBalTest1" + token; - const tokenAmount = "100000"; + const tokenAmount = "100000000"; stub(gatewayMap[token], "getTransaction").resolves({ transactionQuantity: BigNumber(tokenAmount), @@ -2278,7 +2277,7 @@ describe("Router tests", () => { const turboInfraFeeMagnitude = 0.766; const ratio = - expectedTokenPrices[token === "kyve" ? "kyve-network" : token].usd / + expectedTokenPrices[tokenNameToCoinGeckoTokenName[token]].usd / expectedTokenPrices.arweave.usd; const wc = W( baseAmountToTokenAmount(tokenAmount, token) @@ -2321,7 +2320,7 @@ describe("Router tests", () => { const transactionSenderAddress = "TotallyUniqueUserForThisPostBalTest2" + token; - const tokenAmount = "100000"; + const tokenAmount = "100000000"; stub(gatewayMap[token], "getTransaction").resolves({ transactionSenderAddress, @@ -2341,7 +2340,7 @@ describe("Router tests", () => { const turboInfraFeeMagnitude = 0.766; const ratio = - expectedTokenPrices[token === "kyve" ? "kyve-network" : token].usd / + expectedTokenPrices[tokenNameToCoinGeckoTokenName[token]].usd / expectedTokenPrices.arweave.usd; const wc = W( baseAmountToTokenAmount(tokenAmount, token) @@ -2593,8 +2592,8 @@ describe("Router tests", () => { } ); - expect(status).to.equal(500); - expect(statusText).to.equal("Internal Server Error"); + expect(status).to.equal(503); + expect(statusText).to.equal("Service Unavailable"); expect(data).to.equal("Gateway not found for currency!"); }); });