Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: mint gift card with ubiquity dollars #313

Merged
merged 6 commits into from
Oct 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/knip.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { KnipConfig } from "knip";

const config: KnipConfig = {
entry: ["build/esbuild-build.ts", "static/scripts/rewards/init.ts"],
entry: ["build/esbuild-build.ts", "static/scripts/rewards/init.ts", "static/scripts/ubiquity-dollar/init.ts"],
project: ["src/**/*.ts", "static/scripts/**/*.ts"],
ignore: ["src/types/config.ts", "**/__mocks__/**", "**/__fixtures__/**", "lib/**/*"],
ignoreExportsUsedInFile: true,
Expand Down
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
6 changes: 2 additions & 4 deletions functions/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ 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 commonHeaders = {
"Content-Type": "application/json",
Accept: "application/com.reloadly.giftcards-v1+json",
Expand Down Expand Up @@ -110,7 +108,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 +117,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: 64 additions & 21 deletions functions/post-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import { Interface, TransactionDescription } from "ethers/lib/utils";
import { Tokens, chainIdToRewardTokenMap, giftCardTreasuryAddress, permit2Address } 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 { commonHeaders, findBestCard, getAccessToken, getBaseUrl } from "./helpers";
import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyOrderResponse } from "./types";
import { validateEnvVars, validateRequestMethod } from "./validators";
import { postOrderParamsSchema } from "../shared/api-types";
import { permitAllowedChainIds, ubiquityDollarAllowedChainIds, ubiquityDollarChainAddresses } from "../shared/constants";

export async function onRequest(ctx: Context): Promise<Response> {
try {
Expand All @@ -19,15 +22,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 +48,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") {
0x4007 marked this conversation as resolved.
Show resolved Hide resolved
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);
whilefoo marked this conversation as resolved.
Show resolved Hide resolved
}

let exchangeRate = 1;
if (giftCard.recipientCurrencyCode != "USD") {
Expand All @@ -75,8 +91,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 +216,36 @@ 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 });
}

const ubiquityDollarErc20Address = ubiquityDollarChainAddresses[chainId];
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 });
0x4007 marked this conversation as resolved.
Show resolved Hide resolved
}

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>;
20 changes: 19 additions & 1 deletion shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,29 @@ export enum Tokens {
WXDAI = "0xe91d153e0b41518a2ce8dd3d7944fa863463a97d",
}

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

export const ubiquityDollarAllowedChainIds = [1, 100, 31337];

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

export const chainIdToRewardTokenMap = {
export const ubiquityDollarChainAddresses: Record<number, string> = {
1: "0x0F644658510c95CB46955e55D7BA9DDa9E9fBEc6",
100: "0xC6ed4f520f6A4e4DC27273509239b7F8A68d2068",
31337: "0x0F644658510c95CB46955e55D7BA9DDa9E9fBEc6",
};

export const chainIdToRewardTokenMap: Record<number, string> = {
1: Tokens.DAI,
100: Tokens.WXDAI,
31337: Tokens.WXDAI,
};

export const chainIdToNameMap: Record<number, string> = {
1: "Ethereum",
5: "Goerli Testnet",
10: "Optimism",
100: "Gnosis",
31337: "Local Testnet",
};
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);
Loading
Loading