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

PR: retry mint for a failed payment card order #357

Merged
merged 11 commits into from
Nov 24, 2024
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
42 changes: 35 additions & 7 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 @@ -70,7 +70,7 @@ 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);
const errorResponse = validatePermitTransaction(txParsed, txReceipt, result.data, giftCard);
if (errorResponse) {
return errorResponse;
}
Expand Down Expand Up @@ -242,15 +242,43 @@ function validateTransferTransaction(txParsed: TransactionDescription, txReceipt
}
}

function validatePermitTransaction(txParsed: TransactionDescription, txReceipt: TransactionReceipt, chainId: number, giftCard: GiftCard): Response | void {
if (!permitAllowedChainIds.includes(chainId)) {
function validatePermitTransaction(
txParsed: TransactionDescription,
txReceipt: TransactionReceipt,
postOrderParams: PostOrderParams,
giftCard: GiftCard
): Response | void {
EresDev marked this conversation as resolved.
Show resolved Hide resolved
if (!permitAllowedChainIds.includes(postOrderParams.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 });
}

const { type, productId, txHash, chainId, country, signedMessage } = postOrderParams;
if (!signedMessage) {
console.error(`Signed message is empty. ${JSON.stringify({ signedMessage })}`);
return Response.json({ message: "Signed message is missing in the request." }, { status: 403 });
}
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 Response.json({ message: "You have provided invalid signed message." }, { status: 403 });
}

const rewardAmount = txParsed.args.transferDetails.requestedAmount;

if (!isClaimableForAmount(giftCard, rewardAmount)) {
Expand Down Expand Up @@ -281,7 +309,7 @@ function validatePermitTransaction(txParsed: TransactionDescription, txReceipt:
return errorResponse;
}

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({
Expand Down
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.");
EresDev marked this conversation as resolved.
Show resolved Hide resolved
return;
}

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

if (txHash) {
let signedMessage = "";
EresDev marked this conversation as resolved.
Show resolved Hide resolved
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));
}
}
6 changes: 3 additions & 3 deletions static/scripts/rewards/gift-cards/reveal/reveal-action.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getMessageToSign } from "../../../../../shared/helpers";
import { getRevealMessageToSign } from "../../../../../shared/helpers";
import { RedeemCode, OrderTransaction } from "../../../../../shared/types";
import { AppState } from "../../app-state";
import { toaster } from "../../toaster";
Expand All @@ -12,10 +12,10 @@ export function attachRevealAction(transaction: OrderTransaction, app: AppState)
const transactionId = document.getElementById("redeem-code")?.getAttribute("data-transaction-id");
if (app?.signer && transactionId) {
try {
const signedMessage = await app.signer.signMessage(getMessageToSign(Number(transactionId)));
const signedMessage = await app.signer.signMessage(getRevealMessageToSign(Number(transactionId)));
await revealRedeemCode(transaction.transactionId, signedMessage, app);
} catch (error) {
toaster.create("error", "User did not sign the message to reveal redeem code.");
toaster.create("error", "You did not sign the message to reveal redeem code.");
revealBtn.setAttribute(loaderAttribute, "false");
}
} else {
Expand Down
4 changes: 2 additions & 2 deletions static/scripts/ubiquity-dollar/gift-card.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isAllowed } from "../../../shared/allowed-country-list";
import { getGiftCardOrderId, getMessageToSign, isGiftCardAvailable } from "../../../shared/helpers";
import { getGiftCardOrderId, getRevealMessageToSign, isGiftCardAvailable } from "../../../shared/helpers";
import { GiftCard, OrderTransaction, RedeemCode } from "../../../shared/types";
import { getUserCountryCode } from "../rewards/gift-cards/helpers";
import { getRedeemCodeHtml } from "../rewards/gift-cards/reveal/redeem-code-html";
Expand Down Expand Up @@ -233,7 +233,7 @@ export function attachRevealAction(transaction: OrderTransaction) {

if (signer && transactionId) {
try {
const signedMessage = await signer.signMessage(getMessageToSign(Number(transactionId)));
const signedMessage = await signer.signMessage(getRevealMessageToSign(Number(transactionId)));
await revealRedeemCode(transaction.transactionId, address, txHash, signedMessage);
} catch (error) {
toaster.create("error", "User did not sign the message to reveal redeem code.");
Expand Down
Loading
Loading