Skip to content

Commit

Permalink
feat: mint gift card with ubiquity dollars
Browse files Browse the repository at this point in the history
  • Loading branch information
whilefoo committed Sep 22, 2024
1 parent bb5ec42 commit 27f332a
Show file tree
Hide file tree
Showing 30 changed files with 874 additions and 116 deletions.
2 changes: 1 addition & 1 deletion build/esbuild-build.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execSync } from "child_process";
import { config } from "dotenv";
import esbuild from "esbuild";
const typescriptEntries = ["static/scripts/rewards/init.ts"];
const typescriptEntries = ["static/scripts/rewards/init.ts", "static/scripts/ubiquity-dollar/init.ts"];
export const entries = [...typescriptEntries];

export const esBuildContext: esbuild.BuildOptions = {
Expand Down
13 changes: 8 additions & 5 deletions functions/get-best-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@ import { BigNumber } from "ethers";
import { getAccessToken, findBestCard } from "./helpers";
import { Context } from "./types";
import { validateEnvVars, validateRequestMethod } from "./validators";
import { getBestCardParamsSchema } from "../shared/api-types";

export async function onRequest(ctx: Context): Promise<Response> {
try {
validateRequestMethod(ctx.request.method, "GET");
validateEnvVars(ctx);

const { searchParams } = new URL(ctx.request.url);
const country = searchParams.get("country");
const amount = searchParams.get("amount");

if (isNaN(Number(amount)) || !(country && amount)) {
throw new Error(`Invalid query parameters: ${{ country, amount }}`);
const result = getBestCardParamsSchema.safeParse({
country: searchParams.get("country"),
amount: searchParams.get("amount"),
});
if (!result.success) {
throw new Error(`Invalid parameters: ${JSON.stringify(result.error.errors)}`);
}
const { country, amount } = result.data;

const accessToken = await getAccessToken(ctx.env);
const bestCard = await findBestCard(country, BigNumber.from(amount), accessToken);
Expand Down
11 changes: 7 additions & 4 deletions functions/get-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@ import { commonHeaders, getAccessToken, getBaseUrl } from "./helpers";
import { getGiftCardById } from "./post-order";
import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyGetTransactionResponse } from "./types";
import { validateEnvVars, validateRequestMethod } from "./validators";
import { getOrderParamsSchema } from "../shared/api-types";

export async function onRequest(ctx: Context): Promise<Response> {
try {
validateRequestMethod(ctx.request.method, "GET");
validateEnvVars(ctx);

const { searchParams } = new URL(ctx.request.url);
const orderId = searchParams.get("orderId");

if (!orderId) {
throw new Error(`Invalid query parameters: ${{ orderId }}`);
const result = getOrderParamsSchema.safeParse({
orderId: searchParams.get("orderId"),
});
if (!result.success) {
throw new Error(`Invalid parameters: ${JSON.stringify(result.error.errors)}`);
}
const { orderId } = result.data;

const accessToken = await getAccessToken(ctx.env);

Expand Down
25 changes: 11 additions & 14 deletions functions/get-redeem-code.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { verifyMessage } from "ethers/lib/utils";
import { getGiftCardOrderId, getMessageToSign } from "../shared/helpers";
import { RedeemCode } from "../shared/types";
import { getRedeemCodeParamsSchema } from "../shared/api-types";
import { getTransactionFromOrderId } from "./get-order";
import { commonHeaders, getAccessToken, getBaseUrl } from "./helpers";
import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyRedeemCodeResponse } from "./types";
import { validateEnvVars, validateRequestMethod } from "./validators";
import { RedeemCode } from "../shared/types";

export async function onRequest(ctx: Context): Promise<Response> {
try {
Expand All @@ -14,21 +15,17 @@ export async function onRequest(ctx: Context): Promise<Response> {
const accessToken = await getAccessToken(ctx.env);

const { searchParams } = new URL(ctx.request.url);
const transactionId = Number(searchParams.get("transactionId"));
const signedMessage = searchParams.get("signedMessage");
const wallet = searchParams.get("wallet");
const permitSig = searchParams.get("permitSig");

if (isNaN(transactionId) || !(transactionId && signedMessage && wallet && permitSig)) {
throw new Error(
`Invalid query parameters: ${{
transactionId,
signedMessage,
wallet,
permitSig,
}}`
);
const result = getRedeemCodeParamsSchema.safeParse({
transactionId: searchParams.get("transactionId"),
signedMessage: searchParams.get("signedMessage"),
wallet: searchParams.get("wallet"),
permitSig: searchParams.get("permitSig"),
});
if (!result.success) {
throw new Error(`Invalid parameters: ${JSON.stringify(result.error.errors)}`);
}
const { transactionId, signedMessage, wallet, permitSig } = result.data;

const errorResponse = Response.json({ message: "Given details are not valid to redeem code." }, { status: 403 });

Expand Down
8 changes: 5 additions & 3 deletions functions/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { getGiftCardById } from "./post-order";
import { fallbackIntlMastercard, fallbackIntlVisa, masterCardIntlSkus, visaIntlSkus } from "./reloadly-lists";
import { AccessToken, ReloadlyFailureResponse } from "./types";

export const allowedChainIds = [1, 5, 100, 31337];
export const permitAllowedChainIds = [1, 5, 100, 31337];

export const ubiquityDollarAllowedChainIds = [1, 31337];

export const commonHeaders = {
"Content-Type": "application/json",
Expand Down Expand Up @@ -110,7 +112,7 @@ async function getFallbackIntlMastercard(accessToken: AccessToken): Promise<Gift
try {
return await getGiftCardById(fallbackIntlMastercard.sku, accessToken);
} catch (e) {
console.log(`Failed to load international US mastercard: ${JSON.stringify(fallbackIntlMastercard)}\n${JSON.stringify(JSON.stringify)}`);
console.error(`Failed to load international US mastercard: ${JSON.stringify(fallbackIntlMastercard)}`, e);
return null;
}
}
Expand All @@ -119,7 +121,7 @@ async function getFallbackIntlVisa(accessToken: AccessToken): Promise<GiftCard |
try {
return await getGiftCardById(fallbackIntlVisa.sku, accessToken);
} catch (e) {
console.log(`Failed to load international US visa: ${JSON.stringify(fallbackIntlVisa)}\n${JSON.stringify(JSON.stringify)}`);
console.error(`Failed to load international US visa: ${JSON.stringify(fallbackIntlVisa)}\n${e}`);
return null;
}
}
Expand Down
85 changes: 63 additions & 22 deletions functions/post-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ import { TransactionReceipt, TransactionResponse } from "@ethersproject/provider
import { JsonRpcProvider } from "@ethersproject/providers/lib/json-rpc-provider";
import { BigNumber } from "ethers";
import { Interface, TransactionDescription } from "ethers/lib/utils";
import { Tokens, chainIdToRewardTokenMap, giftCardTreasuryAddress, permit2Address } from "../shared/constants";
import { Tokens, chainIdToRewardTokenMap, giftCardTreasuryAddress, permit2Address, ubiquityDollarErc20Address } from "../shared/constants";
import { getFastestRpcUrl, getGiftCardOrderId } from "../shared/helpers";
import { getGiftCardValue, isClaimableForAmount } from "../shared/pricing";
import { ExchangeRate, GiftCard, OrderRequestParams } from "../shared/types";
import { ExchangeRate, GiftCard } from "../shared/types";
import { permit2Abi } from "../static/scripts/rewards/abis/permit2-abi";
import { erc20Abi } from "../static/scripts/rewards/abis/erc20-abi";
import { getTransactionFromOrderId } from "./get-order";
import { allowedChainIds, commonHeaders, findBestCard, getAccessToken, getBaseUrl } from "./helpers";
import { permitAllowedChainIds, commonHeaders, findBestCard, getAccessToken, getBaseUrl, ubiquityDollarAllowedChainIds } from "./helpers";
import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyOrderResponse } from "./types";
import { validateEnvVars, validateRequestMethod } from "./validators";
import { postOrderParamsSchema } from "../shared/api-types";

export async function onRequest(ctx: Context): Promise<Response> {
try {
Expand All @@ -19,15 +21,11 @@ export async function onRequest(ctx: Context): Promise<Response> {

const accessToken = await getAccessToken(ctx.env);

const { productId, txHash, chainId, country } = (await ctx.request.json()) as OrderRequestParams;

if (isNaN(productId) || isNaN(chainId) || !(productId && txHash && chainId && country)) {
throw new Error(`Invalid post parameters: ${JSON.stringify({ productId, txHash, chainId })}`);
}

if (!allowedChainIds.includes(chainId)) {
throw new Error(`Unsupported chain: ${JSON.stringify({ chainId })}`);
const result = postOrderParamsSchema.safeParse(await ctx.request.json());
if (!result.success) {
throw new Error(`Invalid post parameters: ${JSON.stringify(result.error.errors)}`);
}
const { type, productId, txHash, chainId, country } = result.data;

const fastestRpcUrl = await getFastestRpcUrl(chainId);

Expand All @@ -49,18 +47,35 @@ export async function onRequest(ctx: Context): Promise<Response> {
throw new Error(`Given transaction has not been mined yet. Please wait for it to be mined.`);
}

const iface = new Interface(permit2Abi);
let amountDaiWei;
let orderId;

const txParsed = iface.parseTransaction({ data: tx.data });
if (type === "ubiquity-dollar") {
const iface = new Interface(erc20Abi);
const txParsed = iface.parseTransaction({ data: tx.data });
console.log("Parsed transaction data: ", JSON.stringify(txParsed));

console.log("Parsed transaction data: ", JSON.stringify(txParsed));
const errorResponse = validateTransferTransaction(txParsed, txReceipt, chainId, giftCard);
if (errorResponse) {
return errorResponse;
}

const errorResponse = validateTransaction(txParsed, txReceipt, chainId, giftCard);
if (errorResponse) {
return errorResponse;
}
orderId = getGiftCardOrderId(txReceipt.from, txHash);
amountDaiWei = txParsed.args[1];
} else if (type === "permit") {
const iface = new Interface(permit2Abi);

const txParsed = iface.parseTransaction({ data: tx.data });
console.log("Parsed transaction data: ", JSON.stringify(txParsed));

const errorResponse = validatePermitTransaction(txParsed, txReceipt, chainId, giftCard);
if (errorResponse) {
return errorResponse;
}

const amountDaiWei = txParsed.args.transferDetails.requestedAmount;
amountDaiWei = txParsed.args.transferDetails.requestedAmount;
orderId = getGiftCardOrderId(txReceipt.from, txParsed.args.signature);
}

let exchangeRate = 1;
if (giftCard.recipientCurrencyCode != "USD") {
Expand All @@ -75,8 +90,6 @@ export async function onRequest(ctx: Context): Promise<Response> {

const giftCardValue = getGiftCardValue(giftCard, amountDaiWei, exchangeRate);

const orderId = getGiftCardOrderId(txReceipt.from, txParsed.args.signature);

const isDuplicate = await isDuplicateOrder(orderId, accessToken);
if (isDuplicate) {
return Response.json({ message: "The permit has already claimed a gift card." }, { status: 400 });
Expand Down Expand Up @@ -202,7 +215,35 @@ async function getExchangeRate(usdAmount: number, fromCurrency: string, accessTo
return responseJson as ExchangeRate;
}

function validateTransaction(txParsed: TransactionDescription, txReceipt: TransactionReceipt, chainId: number, giftCard: GiftCard): Response | void {
function validateTransferTransaction(txParsed: TransactionDescription, txReceipt: TransactionReceipt, chainId: number, giftCard: GiftCard): Response | void {
const transferAmount = txParsed.args[1];

if (!ubiquityDollarAllowedChainIds.includes(chainId)) {
return Response.json({ message: "Unsupported chain" }, { status: 403 });
}

if (!isClaimableForAmount(giftCard, transferAmount)) {
return Response.json({ message: "Your reward amount is either too high or too low to buy this card." }, { status: 403 });
}

if (txParsed.functionFragment.name != "transfer") {
return Response.json({ message: "Given transaction is not a token transfer" }, { status: 403 });
}

if (txReceipt.to.toLowerCase() != ubiquityDollarErc20Address.toLowerCase()) {
return Response.json({ message: "Given transaction is not a Ubiquity Dollar transfer" }, { status: 403 });
}

if (txParsed.args[0].toLowerCase() != giftCardTreasuryAddress.toLowerCase()) {
return Response.json({ message: "Given transaction is not a token transfer to treasury address" }, { status: 403 });
}
}

function validatePermitTransaction(txParsed: TransactionDescription, txReceipt: TransactionReceipt, chainId: number, giftCard: GiftCard): Response | void {
if (!permitAllowedChainIds.includes(chainId)) {
return Response.json({ message: "Unsupported chain" }, { status: 403 });
}

if (BigNumber.from(txParsed.args.permit.deadline).lt(Math.floor(Date.now() / 1000))) {
return Response.json({ message: "The reward has expired." }, { status: 403 });
}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"countries-and-timezones": "^3.6.0",
"dotenv": "^16.4.4",
"ethers": "^5.7.2",
"npm-run-all": "^4.1.5"
"npm-run-all": "^4.1.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240423.0",
Expand Down
33 changes: 33 additions & 0 deletions shared/api-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { z } from "zod";

export const getBestCardParamsSchema = z.object({
country: z.string(),
amount: z.string(),
});

export type GetBestCardParams = z.infer<typeof getBestCardParamsSchema>;

export const getOrderParamsSchema = z.object({
orderId: z.string(),
});

export type GetOrderParams = z.infer<typeof getOrderParamsSchema>;

export const postOrderParamsSchema = z.object({
type: z.union([z.literal("permit"), z.literal("ubiquity-dollar")]),
productId: z.coerce.number(),
txHash: z.string(),
chainId: z.coerce.number(),
country: z.string(),
});

export type PostOrderParams = z.infer<typeof postOrderParamsSchema>;

export const getRedeemCodeParamsSchema = z.object({
transactionId: z.coerce.number(),
signedMessage: z.string(),
wallet: z.string(),
permitSig: z.string(),
});

export type GetRedeemCodeParams = z.infer<typeof getRedeemCodeParamsSchema>;
1 change: 1 addition & 0 deletions shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export enum Tokens {
}

export const permit2Address = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
export const ubiquityDollarErc20Address = "0x0F644658510c95CB46955e55D7BA9DDa9E9fBEc6";
export const giftCardTreasuryAddress = "0xD51B09ad92e08B962c994374F4e417d4AD435189";

export const chainIdToRewardTokenMap = {
Expand Down
7 changes: 0 additions & 7 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,6 @@ export interface RedeemCode {
pinCode: string;
}

export interface OrderRequestParams {
productId: number;
txHash: string;
chainId: number;
country: string;
}

export interface ExchangeRate {
senderCurrency: string;
senderAmount: number;
Expand Down
7 changes: 7 additions & 0 deletions static/scripts/rewards/button-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,10 @@ export class ButtonController {
this.hideInvalidator();
}
}

const controls = document.getElementById("controls") as HTMLDivElement;
export function getMakeClaimButton() {
return document.getElementById("make-claim") as HTMLButtonElement;
}
export const viewClaimButton = document.getElementById("view-claim") as HTMLButtonElement;
export const buttonController = new ButtonController(controls);
18 changes: 5 additions & 13 deletions static/scripts/rewards/gift-cards/mint/mint-action.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ethers } from "ethers";
import { giftCardTreasuryAddress, permit2Address } from "../../../../../shared/constants";
import { isClaimableForAmount } from "../../../../../shared/pricing";
import { GiftCard, OrderRequestParams } from "../../../../../shared/types";
import { GiftCard } from "../../../../../shared/types";
import { permit2Abi } from "../../abis";
import { AppState } from "../../app-state";
import { isErc20Permit } from "../../render-transaction/render-transaction";
Expand All @@ -10,6 +10,7 @@ import { checkPermitClaimable, transferFromPermit, waitForTransaction } from "..
import { getApiBaseUrl, getUserCountryCode } from "../helpers";
import { initClaimGiftCard } from "../index";
import { getGiftCardOrderId } from "../../../../../shared/helpers";
import { postOrder } from "../../../shared/api";

export function attachMintAction(giftCard: GiftCard, app: AppState) {
const mintBtn: HTMLElement | null = document.getElementById("mint");
Expand Down Expand Up @@ -52,23 +53,14 @@ async function mintGiftCard(productId: number, app: AppState) {
if (!tx) return;
await waitForTransaction(tx, `Transaction confirmed. Minting your card now.`);

const url = `${getApiBaseUrl()}/post-order`;

const orderParams: OrderRequestParams = {
const order = await postOrder({
type: "permit",
chainId: app.signer.provider.network.chainId,
txHash: tx.hash,
productId,
country: country,
};
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
},
body: JSON.stringify(orderParams),
});

if (response.status != 200) {
if (!order) {
toaster.create("error", "Order failed. Try again later.");
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app } from "../app-state";
import { initClaimGiftCard } from "../gift-cards/index";
import { getMakeClaimButton } from "../toaster";
import { getMakeClaimButton } from "../button-controller";
import { table } from "./read-claim-data-from-url";
import { renderTransaction } from "./render-transaction";
import { removeAllEventListeners } from "./utils";
Expand Down
Loading

0 comments on commit 27f332a

Please sign in to comment.