Skip to content

Commit

Permalink
Merge pull request #357 from EresDevOrg/development
Browse files Browse the repository at this point in the history
  • Loading branch information
0x4007 authored Nov 24, 2024
2 parents 3547192 + 68414d0 commit 53b57dd
Show file tree
Hide file tree
Showing 10 changed files with 215 additions and 70 deletions.
5 changes: 2 additions & 3 deletions functions/get-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ export async function onRequest(ctx: Context): Promise<Response> {

export async function getTransactionFromOrderId(orderId: string, accessToken: AccessToken): Promise<OrderTransaction> {
const nowFormatted = new Date().toISOString().replace("T", " ").substring(0, 19); //// yyyy-mm-dd HH:mm:ss
const oneYearAgo = new Date(new Date().setFullYear(new Date().getFullYear() - 1));
const oneYearAgoFormatted = oneYearAgo.toISOString().replace("T", " ").substring(0, 19);
const epochStartFormatted = "1970-01-01 00:00:00";

const url = `${getReloadlyApiBaseUrl(accessToken.isSandbox)}/reports/transactions?size=1&page=1&customIdentifier=${orderId}&startDate=${oneYearAgoFormatted}&endDate=${nowFormatted}`;
const url = `${getReloadlyApiBaseUrl(accessToken.isSandbox)}/reports/transactions?size=1&page=1&customIdentifier=${orderId}&startDate=${epochStartFormatted}&endDate=${nowFormatted}`;
console.log(`Retrieving transaction from ${url}`);
const options = {
method: "GET",
Expand Down
4 changes: 2 additions & 2 deletions functions/get-redeem-code.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { verifyMessage } from "@ethersproject/wallet";
import { getGiftCardOrderId, getMessageToSign } from "../shared/helpers";
import { getGiftCardOrderId, getRevealMessageToSign } from "../shared/helpers";
import { getRedeemCodeParamsSchema } from "../shared/api-types";
import { getTransactionFromOrderId } from "./get-order";
import { commonHeaders, getAccessToken, getReloadlyApiBaseUrl } from "./utils/shared";
Expand Down Expand Up @@ -29,7 +29,7 @@ export async function onRequest(ctx: Context): Promise<Response> {

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

if (verifyMessage(getMessageToSign(transactionId), signedMessage) != wallet) {
if (verifyMessage(getRevealMessageToSign(transactionId), signedMessage) != wallet) {
console.error(
`Signed message verification failed: ${JSON.stringify({
signedMessage,
Expand Down
83 changes: 57 additions & 26 deletions functions/post-order.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { JsonRpcProvider, TransactionReceipt, TransactionResponse } from "@ethersproject/providers";

import { verifyMessage } from "@ethersproject/wallet";
import { BigNumber } from "ethers";
import { Interface, TransactionDescription } from "@ethersproject/abi";
import { Tokens, chainIdToRewardTokenMap, giftCardTreasuryAddress, permit2Address } from "../shared/constants";
import { getFastestRpcUrl, getGiftCardOrderId } from "../shared/helpers";
import { getFastestRpcUrl, getGiftCardOrderId, getMintMessageToSign } from "../shared/helpers";
import { getGiftCardValue, isClaimableForAmount } from "../shared/pricing";
import { ExchangeRate, GiftCard } from "../shared/types";
import { permit2Abi } from "../static/scripts/rewards/abis/permit2-abi";
Expand All @@ -12,7 +12,7 @@ import { getTransactionFromOrderId } from "./get-order";
import { commonHeaders, getAccessToken, getReloadlyApiBaseUrl } from "./utils/shared";
import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyOrderResponse } from "./utils/types";
import { validateEnvVars, validateRequestMethod } from "./utils/validators";
import { postOrderParamsSchema } from "../shared/api-types";
import { PostOrderParams, postOrderParamsSchema } from "../shared/api-types";
import { permitAllowedChainIds, ubiquityDollarAllowedChainIds, ubiquityDollarChainAddresses } from "../shared/constants";
import { findBestCard } from "./utils/best-card-finder";

Expand Down Expand Up @@ -57,9 +57,9 @@ export async function onRequest(ctx: Context): Promise<Response> {
const txParsed = iface.parseTransaction({ data: tx.data });
console.log("Parsed transaction data: ", JSON.stringify(txParsed));

const errorResponse = validateTransferTransaction(txParsed, txReceipt, chainId, giftCard);
if (errorResponse) {
return errorResponse;
const validationErr = validateTransferTransaction(txParsed, txReceipt, chainId, giftCard);
if (validationErr) {
return Response.json({ message: validationErr }, { status: 403 });
}

orderId = getGiftCardOrderId(txReceipt.from, txHash);
Expand All @@ -70,9 +70,9 @@ export async function onRequest(ctx: Context): Promise<Response> {
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 validationErr = validatePermitTransaction(txParsed, txReceipt, result.data, giftCard);
if (validationErr) {
return Response.json({ message: validationErr }, { status: 403 });
}

amountDaiWei = txParsed.args.transferDetails.requestedAmount;
Expand Down Expand Up @@ -217,51 +217,80 @@ async function getExchangeRate(usdAmount: number, fromCurrency: string, accessTo
return responseJson as ExchangeRate;
}

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

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

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

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

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

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

return null;
}

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

if (BigNumber.from(txParsed.args.permit.deadline).lt(Math.floor(Date.now() / 1000))) {
return Response.json({ message: "The reward has expired." }, { status: 403 });
return "The reward has expired.";
}

const { type, productId, txHash, chainId, country, signedMessage } = postOrderParams;
if (!signedMessage) {
console.error(`Signed message is empty. ${JSON.stringify({ signedMessage })}`);
return "Signed message is missing in the request.";
}
const mintMessageToSign = getMintMessageToSign(type, chainId, txHash, productId, country);
const signingWallet = verifyMessage(mintMessageToSign, signedMessage).toLocaleLowerCase();
if (signingWallet != txReceipt.from.toLowerCase()) {
console.error(
`Signed message verification failed: ${JSON.stringify({
wallet: txReceipt.from.toLowerCase(),
signedMessage,
type,
chainId,
txHash,
productId,
country,
})}`
);
return "You have provided invalid signed message.";
}

const rewardAmount = txParsed.args.transferDetails.requestedAmount;

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

const errorResponse = Response.json({ message: "Transaction is not authorized to purchase gift card." }, { status: 403 });
const wrongContractErr = "Transaction is not authorized to purchase gift card.";

if (txReceipt.to.toLowerCase() != permit2Address.toLowerCase()) {
console.error("Given transaction hash is not an interaction with permit2Address", `txReceipt.to=${txReceipt.to}`, `permit2Address=${permit2Address}`);
return errorResponse;
return wrongContractErr;
}

if (txParsed.args.transferDetails.to.toLowerCase() != giftCardTreasuryAddress.toLowerCase()) {
Expand All @@ -270,25 +299,27 @@ function validatePermitTransaction(txParsed: TransactionDescription, txReceipt:
`txParsed.args.transferDetails.to=${txParsed.args.transferDetails.to}`,
`giftCardTreasuryAddress=${giftCardTreasuryAddress}`
);
return errorResponse;
return wrongContractErr;
}

if (txParsed.functionFragment.name != "permitTransferFrom") {
console.error(
"Given transaction hash is not call to contract function permitTransferFrom",
`txParsed.functionFragment.name=${txParsed.functionFragment.name}`
);
return errorResponse;
return wrongContractErr;
}

if (txParsed.args.permit[0].token.toLowerCase() != chainIdToRewardTokenMap[chainId].toLowerCase()) {
if (txParsed.args.permit[0].token.toLowerCase() != chainIdToRewardTokenMap[postOrderParams.chainId].toLowerCase()) {
console.error(
"Given transaction hash is not transferring the required ERC20 token.",
JSON.stringify({
transferredToken: txParsed.args.permit[0].token,
requiredToken: Tokens.WXDAI.toLowerCase(),
})
);
return errorResponse;
return wrongContractErr;
}

return null;
}
1 change: 1 addition & 0 deletions shared/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const postOrderParamsSchema = z.object({
txHash: z.string(),
chainId: z.coerce.number(),
country: z.string(),
signedMessage: z.optional(z.string()),
});

export type PostOrderParams = z.infer<typeof postOrderParamsSchema>;
Expand Down
13 changes: 12 additions & 1 deletion shared/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,24 @@ export function getGiftCardOrderId(rewardToAddress: string, signature: string) {
return ethers.utils.keccak256(integrityBytes);
}

export function getMessageToSign(transactionId: number) {
export function getRevealMessageToSign(transactionId: number) {
return JSON.stringify({
from: "pay.ubq.fi",
transactionId: transactionId,
});
}

export function getMintMessageToSign(type: "permit" | "ubiquity-dollar", chainId: number, txHash: string, productId: number, country: string) {
return JSON.stringify({
from: "pay.ubq.fi",
type,
chainId,
txHash,
productId,
country,
});
}

export async function getFastestRpcUrl(networkId: number) {
return (await useRpcHandler(networkId)).connection.url;
}
Expand Down
92 changes: 59 additions & 33 deletions static/scripts/rewards/gift-cards/mint/mint-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import { toaster } from "../../toaster";
import { checkPermitClaimable, transferFromPermit, waitForTransaction } from "../../web3/erc20-permit";
import { getApiBaseUrl, getUserCountryCode } from "../helpers";
import { initClaimGiftCard } from "../index";
import { getGiftCardOrderId } from "../../../../../shared/helpers";
import { getGiftCardOrderId, getMintMessageToSign } from "../../../../../shared/helpers";
import { postOrder } from "../../../shared/api";
import { getIncompleteMintTx, removeIncompleteMintTx, storeIncompleteMintTx } from "./mint-tx-tracker";
import { PostOrderParams } from "../../../../../shared/api-types";

export function attachMintAction(giftCard: GiftCard, app: AppState) {
const mintBtn: HTMLElement | null = document.getElementById("mint");
Expand All @@ -32,48 +34,48 @@ export function attachMintAction(giftCard: GiftCard, app: AppState) {
}

async function mintGiftCard(productId: number, app: AppState) {
if (app.signer) {
const country = await getUserCountryCode();
if (!country) {
toaster.create("error", "Failed to detect your location to pick a suitable card for you.");
if (!app.signer) {
toaster.create("error", "Connect your wallet.");
return;
}

const country = await getUserCountryCode();
if (!country) {
toaster.create("error", "Failed to detect your location to pick a suitable card for you.");
return;
}

const txHash: string = getIncompleteMintTx(app.reward.nonce) || (await claimPermitToCardTreasury(app));

if (txHash) {
let signedMessage;
try {
signedMessage = await app.signer.signMessage(getMintMessageToSign("permit", app.signer.provider.network.chainId, txHash, productId, country));
} catch (error) {
toaster.create("error", "You did not sign the message to mint a payment card.");
return;
}

const isClaimable = await checkPermitClaimable(app);
if (isClaimable) {
const permit2Contract = new ethers.Contract(permit2Address, permit2Abi, app.signer);
if (!permit2Contract) return;

const reward = {
...app.reward,
};
reward.beneficiary = giftCardTreasuryAddress;

const tx = await transferFromPermit(permit2Contract, reward, "Processing... Please wait. Do not close this page.");
if (!tx) return;
await waitForTransaction(tx, `Transaction confirmed. Minting your card now.`);

const order = await postOrder({
type: "permit",
chainId: app.signer.provider.network.chainId,
txHash: tx.hash,
productId,
country: country,
});
if (!order) {
toaster.create("error", "Order failed. Try again later.");
return;
}
const order = await postOrder({
type: "permit",
chainId: app.signer.provider.network.chainId,
txHash: txHash,
productId,
country: country,
signedMessage: signedMessage,
} as PostOrderParams);

await checkForMintingDelay(app);
} else {
toaster.create("error", "Connect your wallet to proceed.");
if (!order) {
toaster.create("error", "Order failed. Try again in a few minutes.");
return;
}
await checkForMintingDelay(app);
}
}

async function checkForMintingDelay(app: AppState) {
if (await hasMintingFinished(app)) {
removeIncompleteMintTx(app.reward.nonce);
await initClaimGiftCard(app);
} else {
const interval = setInterval(async () => {
Expand All @@ -88,6 +90,30 @@ async function checkForMintingDelay(app: AppState) {
}
}

async function claimPermitToCardTreasury(app: AppState) {
if (!app.signer) {
toaster.create("error", "Connect your wallet.");
return;
}
const isClaimable = await checkPermitClaimable(app);
if (isClaimable) {
const permit2Contract = new ethers.Contract(permit2Address, permit2Abi, app.signer);
if (!permit2Contract) return;

const reward = {
...app.reward,
};
reward.beneficiary = giftCardTreasuryAddress;

const tx = await transferFromPermit(permit2Contract, reward, "Processing... Please wait. Do not close this page.");
if (!tx) return;

storeIncompleteMintTx(app.reward.nonce, tx.hash);
await waitForTransaction(tx, `Transaction confirmed. Minting your card now.`, app.signer.provider.network.chainId);
return tx.hash;
}
}

async function hasMintingFinished(app: AppState): Promise<boolean> {
const retrieveOrderUrl = `${getApiBaseUrl()}/get-order?orderId=${getGiftCardOrderId(app.reward.beneficiary, app.reward.signature)}`;
const orderResponse = await fetch(retrieveOrderUrl, {
Expand Down
24 changes: 24 additions & 0 deletions static/scripts/rewards/gift-cards/mint/mint-tx-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const storageKey = "incompleteMints";

export function getIncompleteMintTx(permitNonce: string): string | null {
const incompleteClaims = localStorage.getItem(storageKey);
return incompleteClaims ? JSON.parse(incompleteClaims)[permitNonce] : null;
}

export function storeIncompleteMintTx(permitNonce: string, txHash: string) {
let incompleteClaims: { [key: string]: string } = { [permitNonce]: txHash };
const oldIncompleteClaims = localStorage.getItem(storageKey);
if (oldIncompleteClaims) {
incompleteClaims = { ...incompleteClaims, ...JSON.parse(oldIncompleteClaims) };
}
localStorage.setItem(storageKey, JSON.stringify(incompleteClaims));
}

export function removeIncompleteMintTx(permitNonce: string) {
const incompleteClaims = localStorage.getItem(storageKey);
if (incompleteClaims) {
const incompleteClaimsObj = JSON.parse(incompleteClaims);
delete incompleteClaimsObj[permitNonce];
localStorage.setItem(storageKey, JSON.stringify(incompleteClaimsObj));
}
}
Loading

0 comments on commit 53b57dd

Please sign in to comment.