diff --git a/.cspell.json b/.cspell.json index 3e3efd13..733671ee 100644 --- a/.cspell.json +++ b/.cspell.json @@ -5,6 +5,7 @@ "useGitignore": true, "language": "en", "words": [ + "adblocker", "binkey", "binsec", "blockscan", @@ -15,6 +16,8 @@ "devpool", "ethersproject", "fract", + "giftcards", + "gnosischain", "funder", "Funder", "gnosisscan", @@ -29,9 +32,13 @@ "Numberish", "outdir", "outfile", + "pageable", + "ress", + "Reloadly", "Rpcs", "scalarmult", "servedir", + "skus", "solmate", "sonarjs", "SUPABASE", diff --git a/.github/knip.ts b/.github/knip.ts index 05734a2c..c70c9889 100644 --- a/.github/knip.ts +++ b/.github/knip.ts @@ -1,8 +1,8 @@ import type { KnipConfig } from "knip"; const config: KnipConfig = { - entry: ["build/esbuild-build.ts", "static/scripts/rewards/init.ts"], - project: ["src/**/*.ts", "static/scripts/**/*.ts"], + entry: ["build/esbuild-build.ts", "static/scripts/rewards/init.ts", "static/scripts/ubiquity-dollar/init.ts", "static/scripts/shared/api.ts"], + project: ["src/**/*.ts", "static/scripts/**/*.ts", "shared/**/api-types.ts"], ignore: ["src/types/config.ts", "**/__mocks__/**", "**/__fixtures__/**", "lib/**/*"], ignoreExportsUsedInFile: true, // eslint can also be safely ignored as per the docs: https://knip.dev/guides/handling-issues#eslint--jest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b1d6247..adf32dca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,5 +37,7 @@ jobs: name: full-stack-app path: | static + functions + shared package.json yarn.lock diff --git a/.github/workflows/cypress-testing.yml b/.github/workflows/cypress-testing.yml index d90ea484..2dfa3d2d 100644 --- a/.github/workflows/cypress-testing.yml +++ b/.github/workflows/cypress-testing.yml @@ -38,14 +38,17 @@ jobs: sleep 1 done || exit 1 - - name: Fund test accounts - run: yarn test:fund + - name: Start Cloudflare Wrangler + run: npx wrangler pages dev static --port 8080 --binding USE_RELOADLY_SANDBOX=true RELOADLY_API_CLIENT_ID="$RELOADLY_SANDBOX_API_CLIENT_ID" RELOADLY_API_CLIENT_SECRET="$RELOADLY_SANDBOX_API_CLIENT_SECRET" & + env: + RELOADLY_SANDBOX_API_CLIENT_ID: ${{ secrets.RELOADLY_SANDBOX_API_CLIENT_ID }} + RELOADLY_SANDBOX_API_CLIENT_SECRET: ${{ secrets.RELOADLY_SANDBOX_API_CLIENT_SECRET }} - name: Cypress run uses: cypress-io/github-action@v6 with: build: yarn run build - start: yarn start + start: yarn test:fund env: SUPABASE_URL: "https://wfzpewmlyiozupulbuur.supabase.co" diff --git a/.gitignore b/.gitignore index dcd9090e..ba45fa85 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,6 @@ static/bundles cypress/screenshots cypress/videos +.wrangler coverage junit.xml diff --git a/README.md b/README.md index 786c985f..fd006057 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ A vanilla Typescript dApp for claiming Ubiquity Rewards. It also includes tools SUPABASE_ANON_KEY="...." # used for storing permit tx data # Variables depending on spender (bounty hunter) - AMOUNT_IN_ETH="1" + AMOUNT_IN_ETH="50" BENEFICIARY_ADDRESS="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" # Legacy env vars (only used when invalidating **REAL** permits via /scripts/solidity/getInvalidateNonceParams.ts) @@ -31,15 +31,31 @@ A vanilla Typescript dApp for claiming Ubiquity Rewards. It also includes tools NONCE_SIGNER_ADDRESS="0x" ``` +3. Update values for wrangler variables to use Reloadly sandbox or production API in the `wrangler.toml` file. + +``` +[vars] +USE_RELOADLY_SANDBOX = "true" +RELOADLY_API_CLIENT_ID = "xxxxxxxxxxxxxxxxxx" +RELOADLY_API_CLIENT_SECRET = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +``` + ## Local Testing 1. Set `.env` variables. -2. Run `yarn test:anvil` in terminal A and `yarn test:fund` in terminal B. -3. In terminal B, run `yarn start`. +2. Run `yarn` +3. Run `yarn test:anvil` in terminal A and `yarn test:fund` in terminal B. +4. In terminal B, run + +``` +yarn build +yarn start +``` + 4. A permit URL for both ERC20 and ERC721 will be generated. 5. Open the generated permit URL from the console. 6. Connect your wallet (import anvil accounts [0] & [1] into your wallet). -7. Depending on your connected account, either the claim or invalidate button will be visible. +7. Depending on your connected account, either the claim or invalidate button will be visible. The virtual card section will also display an available virtual card. 8. To test ERC721 permits, deploy the `nft-rewards` contract from the [repository](https://github.com/ubiquity/nft-rewards). ### Importing Anvil Accounts @@ -80,6 +96,19 @@ A vanilla Typescript dApp for claiming Ubiquity Rewards. It also includes tools - Ensure `.env` is correctly configured and wallet provider network is correct if `Allowance` or `Balance` is `0.00`. - Always start the Anvil instance before using `yarn start` as permit generation requires an on-chain call to `token.decimals()`. +### Troubleshooting virtual cards + +Virtual cards are subject to regulations and are not available for all countries. Moreover, each virtual card is available for specific amounts. If you are unable to see an available virtual card it is either because of your location or the amount of your permit. + +If you are not getting an available card, you can perform a few extra steps to get a virtual card for testing purposes. You can set the permit amount `AMOUNT_IN_ETH` to be 50 WXDAI in the `.env` file and mock your location as United States. To set your location to United States, you can follow one of the steps given below: + +- Use a USA VPN +- Set your timezone to `Eastern Time (ET) New York` and block the ajax request to `https://ipinfo.io/json` so that your timezone is used to detect your location. + +One of these steps should get you a virtual card to try both on Reloadly sandbox and production. Please note that if you are minting a virtual card with a mock location on Reloadly production, you will get a redeem code but you may not able to use the card due to restrictions on the card, and there is no refund or replacement. Use your real location if you want to use the virtual card. + +If you are using mainnet with your local environments, you may want to change the `giftCardTreasuryAddress` to a wallet that you own in the file `shared/constants.ts`. It is the wallet where payments for the virtual cards are sent. + ## How to generate a permit2 URL using the script 1. Admin sets `env.AMOUNT_IN_ETH` and `env.BENEFICIARY_ADDRESS` depending on a bounty hunter's reward and address diff --git a/build/esbuild-build.ts b/build/esbuild-build.ts index c213d436..9e659c09 100644 --- a/build/esbuild-build.ts +++ b/build/esbuild-build.ts @@ -9,14 +9,16 @@ const cssFiles: string[] = [ "static/styles/rewards/background.css", "static/styles/toast.css", "static/styles/rewards/claim-table.css", + "static/styles/rewards/gift-cards.css", + "static/styles/rewards/ubiquity-dollar.css", "static/styles/rewards/media-queries.css", "static/styles/rewards/light-mode.css", ]; // Output bundles file -const outputFilePath = "static/bundles/bundles.css"; +const outputFilePath = "static/out/bundles.css"; -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 = { @@ -32,8 +34,8 @@ export const esBuildContext: esbuild.BuildOptions = { ".ttf": "dataurl", ".svg": "dataurl", }, - outfile: "static/bundles/bundles.js", - entryNames: "bundles", // Ensure the CSS is named bundles.css + outdir: "static/out", + entryNames: "[dir]", // Ensure the CSS is named bundles.css define: createEnvDefines(["SUPABASE_URL", "SUPABASE_ANON_KEY"], { commitHash: execSync(`git rev-parse --short HEAD`).toString().trim(), }), diff --git a/cypress.config.ts b/cypress.config.ts index ab1ff844..39ecf9c8 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -7,6 +7,9 @@ export default defineConfig({ }, baseUrl: "http://localhost:8080", experimentalStudio: true, + env: { + permitConfig: { ...process.env }, + }, }, viewportHeight: 900, viewportWidth: 1440, diff --git a/cypress/e2e/claim-gift-card.cy.ts b/cypress/e2e/claim-gift-card.cy.ts new file mode 100644 index 00000000..a37b00f7 --- /dev/null +++ b/cypress/e2e/claim-gift-card.cy.ts @@ -0,0 +1,200 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { JsonRpcProvider, JsonRpcSigner } from "@ethersproject/providers"; +import { Wallet } from "ethers"; +import { PermitConfig, generateErc20Permit } from "../../scripts/typescript/generate-erc20-permit-url"; + +const beneficiary = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; // anvil +const SENDER_PRIVATE_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; // anvil + +describe("Gift Cards", () => { + beforeEach(() => { + cy.clearAllCookies(); + cy.clearAllLocalStorage(); + cy.clearAllSessionStorage(); + setupStubs(); + + setupIntercepts(); + }); + + it.only("should show redeem info", () => { + const permitConfig = Cypress.env("permitConfig"); + void cy.getPermitUrl(permitConfig).then((permitUrl) => { + cy.visit(`${permitUrl as string}`); + + cy.wait("@getBestCard"); + cy.wait(2000); + + cy.get("#gift-cards").should("exist").and("include.text", "Or mint a virtual visa/mastercard"); + cy.get(".card-section").should("have.length.above", 0); + cy.get(".redeem-info").should("exist"); + cy.get(".redeem-info").eq(0).should("include.text", "How to use redeem code?"); + }); + }); + + it("should claim a gift card", () => { + const permitConfig = Cypress.env("permitConfig"); + + const customPermitConfig = { ...permitConfig, AMOUNT_IN_ETH: "30.0" }; + + void cy.getPermitUrl(customPermitConfig).then((permitUrl) => { + cy.visit(permitUrl); + cy.wait(2000); + + cy.wait("@getBestCard"); + cy.get(".card-section").should("have.length.above", 0); + cy.get("#offered-card").should("exist"); + cy.get("#offered-card .details h3").then(($name) => { + const giftCardName = $name; + cy.wrap(giftCardName).as("giftCardName"); + }); + + cy.intercept({ method: "POST", url: "/post-order?country=US" }).as("postOrder"); + + cy.get("#offered-card .details #mint").should("exist"); + cy.intercept({ method: "GET", url: "/get-order**" }).as("getOrder"); + + cy.get("#offered-card .details #mint").invoke("click"); + + cy.get(".notifications", { timeout: 10000 }).should("contain.text", "Processing... Please wait. Do not close this page."); + cy.get(".notifications", { timeout: 10000 }).should("contain.text", "Transaction confirmed. Minting your card now."); + cy.wait("@getOrder", { timeout: 10000 }); + + cy.get("#gift-cards").should("exist").and("include.text", "Your virtual visa/mastercard"); + + cy.get("#redeem-code").should("exist"); + cy.get("@giftCardName").then((name) => { + cy.get("#offered-card .details h3") + .eq(0) + .should("have.text", name.text() as string); + }); + }); + }); + + it("should reveal a redeem code after claim", () => { + cy.visit( + "http://localhost:8080/?claim=W3sidHlwZSI6ImVyYzIwLXBlcm1pdCIsInBlcm1pdCI6eyJwZXJtaXR0ZWQiOnsidG9rZW4iOiIweGU5MUQxNTNFMGI0MTUxOEEyQ2U4RGQzRDc5NDRGYTg2MzQ2M2E5N2QiLCJhbW91bnQiOiIzMDAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJub25jZSI6IjczMDU2NzU0MjU1ODU4ODMxMzQ0NTMzNDgxMDc0Njg5NTE1ODEyNzIzNDE5NTkwNjMwOTY2MTUwOTIxNzk3ODEzMzExMDE4NjgyMDMzIiwiZGVhZGxpbmUiOiIxMTU3OTIwODkyMzczMTYxOTU0MjM1NzA5ODUwMDg2ODc5MDc4NTMyNjk5ODQ2NjU2NDA1NjQwMzk0NTc1ODQwMDc5MTMxMjk2Mzk5MzUifSwidHJhbnNmZXJEZXRhaWxzIjp7InRvIjoiMHhmMzlGZDZlNTFhYWQ4OEY2RjRjZTZhQjg4MjcyNzljZmZGYjkyMjY2IiwicmVxdWVzdGVkQW1vdW50IjoiMzAwMDAwMDAwMDAwMDAwMDAwMDAifSwib3duZXIiOiIweDcwOTk3OTcwQzUxODEyZGMzQTAxMEM3ZDAxYjUwZTBkMTdkYzc5QzgiLCJzaWduYXR1cmUiOiIweDdkYWYxMTNhNTA0ZjYxYzk5MDg0ZGM2ZGFlZTZkZDFkZjhhM2I4YjM5ZTU0N2VkYWIxMjNhNzQxNjBhNWVhNDYwZDgyODdmYWM1MDlhYTc5M2ZhNjc5M2RlOTg5YmVhOTg4Y2M3NDAyNGE5ZmQyNjAyMjY2YTQzZjg1MDlhYTJkMWIiLCJuZXR3b3JrSWQiOjMxMzM3fSx7InR5cGUiOiJlcmMyMC1wZXJtaXQiLCJwZXJtaXQiOnsicGVybWl0dGVkIjp7InRva2VuIjoiMHhlOTFEMTUzRTBiNDE1MThBMkNlOERkM0Q3OTQ0RmE4NjM0NjNhOTdkIiwiYW1vdW50IjoiOTAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJub25jZSI6IjYyOTc2MjY4MDU3NjQ1MTA0ODc3MTI4NDU3MTU1NDgwNTU5NzU1OTQwMjA4MzExMDQ3Mjc1Njc2NjAyNDI3NzQwODY1NzE0MDkxMzAwIiwiZGVhZGxpbmUiOiIxMTU3OTIwODkyMzczMTYxOTU0MjM1NzA5ODUwMDg2ODc5MDc4NTMyNjk5ODQ2NjU2NDA1NjQwMzk0NTc1ODQwMDc5MTMxMjk2Mzk5MzUifSwidHJhbnNmZXJEZXRhaWxzIjp7InRvIjoiMHhmMzlGZDZlNTFhYWQ4OEY2RjRjZTZhQjg4MjcyNzljZmZGYjkyMjY2IiwicmVxdWVzdGVkQW1vdW50IjoiOTAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJvd25lciI6IjB4NzA5OTc5NzBDNTE4MTJkYzNBMDEwQzdkMDFiNTBlMGQxN2RjNzlDOCIsInNpZ25hdHVyZSI6IjB4N2RhZjExM2E1MDRmNjFjOTkwODRkYzZkYWVlNmRkMWRmOGEzYjhiMzllNTQ3ZWRhYjEyM2E3NDE2MGE1ZWE0NjBkODI4N2ZhYzUwOWFhNzkzZmE2NzkzZGU5ODliZWE5ODhjYzc0MDI0YTlmZDI2MDIyNjZhNDNmODUwOWFhMmQxYiIsIm5ldHdvcmtJZCI6MzEzMzd9XQ==" + ); + cy.wait(2000); + + cy.wait("@getBestCard"); + + cy.get("#gift-cards").should("exist").and("include.text", "Your virtual visa/mastercard"); + cy.get("#redeem-code > h3").eq(0).should("have.text", "Redeem code"); + cy.get("#redeem-code > p").eq(0).should("have.text", "xxxxxxxxxxxx"); + cy.get("#redeem-code > p").eq(1).should("have.text", "xxxxxxxxxxxx"); + cy.get("#redeem-code > p").eq(2).should("have.text", "xxxxxxxxxxxx"); + cy.get("#redeem-code > #reveal").invoke("click"); + + cy.get("#redeem-code > h3").eq(0).should("have.text", "Redeem code"); + cy.get("#redeem-code > p").should("exist"); + cy.get("#redeem-code > p").eq(0).should("not.have.text", "xxxxxxxxxxxx"); + }); +}); + +function setupStubs() { + const provider = new JsonRpcProvider("http://localhost:8545"); + const signer = provider.getSigner(beneficiary); + const wallet = new Wallet(SENDER_PRIVATE_KEY, provider); + + signer.signMessage = cy.stub().callsFake(async () => { + return "0x4d9f92f69898fd112748ff04c98e294cced4dbde80ac3cba42fb546538bf54ca0e3fbc3f94416813f8da58a4b26957b62bae66c48bf01ca1068af0f222bf18df1c"; + }); + stubEthereum(signer); + + return { provider, signer, wallet }; +} + +function setupIntercepts() { + cy.intercept("POST", "*", (req) => { + // capturing the RPC optimization calls + if (req.body.method === "eth_getBlockByNumber") { + req.reply({ + statusCode: 200, + body: cy.fixture("eth_getBlockByNumber.json"), + }); + } + }); + + cy.intercept("POST", "https://wfzpewmlyiozupulbuur.supabase.co/rest/v1/*", { + statusCode: 200, + body: {}, + }); + cy.intercept("PATCH", "https://wfzpewmlyiozupulbuur.supabase.co/rest/v1/*", { + statusCode: 200, + body: {}, + }); + cy.intercept("GET", "https://wfzpewmlyiozupulbuur.supabase.co/rest/v1/*", { + statusCode: 200, + body: {}, + }); + + cy.intercept({ method: "GET", url: "/get-best-card?country=US**" }).as("getBestCard"); + cy.intercept("GET", "https://ipinfo.io/json", { + statusCode: 200, + body: { + ip: "192.158.1.38", + hostname: "example.com", + city: "Los Angeles", + region: "California", + country: "US", + loc: "34.0522,-118.2437", + org: "Example org", + postal: "90009", + timezone: "America/Los_Angeles", + readme: "https://ipinfo.io/missingauth", + }, + }); +} + +function stubEthereum(signer: JsonRpcSigner) { + // Stubbing the ethereum object + cy.on("window:before:load", (win) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((win as any).ethereum = { + isMetaMask: true, + enable: cy.stub().resolves(["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]), + request: cy.stub().callsFake(async (method) => providerFunctions(method)), + on: cy.stub().callsFake((event, cb) => { + if (event === "accountsChanged") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (win as any).ethereum.onAccountsChanged = cb; + } + }), + autoRefreshOnNetworkChange: false, + chainId: "0x7a69", + selectedAddress: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + requestAccounts: cy.stub().resolves(["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]), + send: cy.stub().callsFake(async (method) => providerFunctions(method)), + getSigner: () => signer, + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((win as any).signer = signer); + }); +} + +function providerFunctions(method: string) { + switch (method) { + case "eth_requestAccounts": + return ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]; + case "wallet_sendDomainMetadata": + return true; + case "wallet_addEthereumChain": + return true; + case "wallet_switchEthereumChain": + return true; + case "wallet_watchAsset": + return true; + case "eth_chainId": + return "0x7a69"; + case "eth_accounts": + return ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]; + case "eth_signTypedData_v4": + return "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + case "eth_estimateGas": + return "0x7a69"; + } +} + +Cypress.Commands.add("getPermitUrl", (customPermitConfig: PermitConfig) => { + return generateErc20Permit(customPermitConfig); +}); diff --git a/cypress/e2e/claim-portal-success.cy.ts b/cypress/e2e/claim-portal-success.cy.ts index 1f715b3e..39937453 100644 --- a/cypress/e2e/claim-portal-success.cy.ts +++ b/cypress/e2e/claim-portal-success.cy.ts @@ -19,7 +19,7 @@ describe("Claims Portal Success", () => { it("should successfully claim a permit", () => { cy.get("#additionalDetails", { timeout: 15000 }).should("be.visible").invoke("click"); - cy.get('table[data-make-claim="ok"]').should("exist").and("include.text", "337888.4 WXDAI"); + cy.get('table[data-make-claim="ok"]').should("exist"); cy.get("button[id='make-claim']").invoke("click"); diff --git a/cypress/e2e/index.d.ts b/cypress/e2e/index.d.ts new file mode 100644 index 00000000..63248ca0 --- /dev/null +++ b/cypress/e2e/index.d.ts @@ -0,0 +1,7 @@ +/// + +declare namespace Cypress { + interface Chainable { + getPermitUrl(permitConfig: PermitConfig): Promise; + } +} diff --git a/cypress/e2e/main.cy.ts b/cypress/e2e/main.cy.ts new file mode 100644 index 00000000..7e96d3dd --- /dev/null +++ b/cypress/e2e/main.cy.ts @@ -0,0 +1,11 @@ +describe("Homepage tests", () => { + it("Console is cleared of errors and warnings", () => { + cy.visit("/", { + onBeforeLoad(win) { + cy.stub(win.console, "error").as("consoleError"); + }, + }); + cy.get("@consoleError").should("not.be.called"); + cy.get("body").should("exist"); + }); +}); diff --git a/cypress/scripts/anvil.ts b/cypress/scripts/anvil.ts index ffb21fe5..a0272ca1 100644 --- a/cypress/scripts/anvil.ts +++ b/cypress/scripts/anvil.ts @@ -1,62 +1,21 @@ -import { RPCHandler } from "@ubiquity-dao/rpc-handler"; -import { spawnSync } from "child_process"; -import { useHandler } from "../../static/scripts/rewards/web3/use-rpc-handler"; +/* eslint-disable sonarjs/no-duplicate-string */ +import { spawn } from "child_process"; +import { getFastestRpcUrl } from "../../shared/helpers"; -class Anvil { - rpcs: string[] = []; - rpcHandler: RPCHandler | null = null; +async function forkNetwork() { + const fastestRpcUrl = await getFastestRpcUrl(100); - async init() { - this.rpcHandler = useHandler(100); - console.log(`[RPCHandler] Fetching RPCs...`); - await this.rpcHandler.testRpcPerformance(); - const latencies: Record = this.rpcHandler.getLatencies(); - const sorted = Object.entries(latencies).sort(([, a], [, b]) => a - b); - console.log( - `Fetched ${sorted.length} RPCs.\nFastest: ${sorted[0][0]} (${sorted[0][1]}ms)\nSlowest: ${sorted[sorted.length - 1][0]} (${sorted[sorted.length - 1][1]}ms)` - ); + const anvil = spawn("anvil", ["--chain-id", "31337", "--fork-url", fastestRpcUrl, "--host", "127.0.0.1", "--port", "8545"], { + stdio: "inherit", + }); - this.rpcs = sorted.map(([rpc]) => rpc.split("__")[1]); - } + anvil.on("close", (code) => { + console.log(`Anvil exited with code ${code}`); + }); - async run() { - await this.init(); - console.log(`Starting Anvil...`); - const isSuccess = await this.spawner(this.rpcs.shift()); - - if (!isSuccess) { - throw new Error(`Anvil failed to start`); - } - } - - async spawner(rpc?: string): Promise { - if (!rpc) { - console.log(`No RPCs left to try`); - return false; - } - - console.log(`Forking with RPC: ${rpc}`); - - const anvil = spawnSync("anvil", ["--chain-id", "31337", "--fork-url", rpc, "--host", "127.0.0.1", "--port", "8545"], { - stdio: "inherit", - }); - - if (anvil.status !== 0) { - console.log(`Anvil failed to start with RPC: ${rpc}`); - console.log(`Retrying with next RPC...`); - return this.spawner(this.rpcs.shift()); - } - - return true; - } -} - -async function main() { - const anvil = new Anvil(); - await anvil.run(); + anvil.on("error", (err) => { + console.error("Failed to start Anvil", err); + }); } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +forkNetwork().catch(console.error); diff --git a/functions/get-best-card.ts b/functions/get-best-card.ts new file mode 100644 index 00000000..5bd9e4a1 --- /dev/null +++ b/functions/get-best-card.ts @@ -0,0 +1,33 @@ +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 { + try { + validateRequestMethod(ctx.request.method, "GET"); + validateEnvVars(ctx); + + const { searchParams } = new URL(ctx.request.url); + 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); + + if (bestCard) { + return Response.json(bestCard, { status: 200 }); + } + return Response.json({ message: "There are no gift cards available." }, { status: 404 }); + } catch (error) { + console.error("There was an error while processing your request.", error); + return Response.json({ message: "There was an error while processing your request." }, { status: 500 }); + } +} diff --git a/functions/get-order.ts b/functions/get-order.ts new file mode 100644 index 00000000..5f60eccb --- /dev/null +++ b/functions/get-order.ts @@ -0,0 +1,74 @@ +import { OrderTransaction } from "../shared/types"; +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 { + try { + validateRequestMethod(ctx.request.method, "GET"); + validateEnvVars(ctx); + + const { searchParams } = new URL(ctx.request.url); + 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); + + const reloadlyTransaction = await getTransactionFromOrderId(orderId, accessToken); + + if (!reloadlyTransaction) { + return Response.json("Order not found.", { status: 404 }); + } else if (reloadlyTransaction.status && reloadlyTransaction.status == "SUCCESSFUL") { + try { + const product = await getGiftCardById(reloadlyTransaction.product.productId, accessToken); + return Response.json({ transaction: reloadlyTransaction, product: product }, { status: 200 }); + } catch (error) { + return Response.json({ transaction: reloadlyTransaction, product: null }, { status: 200 }); + } + } else { + return Response.json({ message: "There is no successful transaction for given order ID." }, { status: 404 }); + } + } catch (error) { + console.error("There was an error while processing your request.", error); + return Response.json({ message: "There was an error while processing your request." }, { status: 500 }); + } +} + +export async function getTransactionFromOrderId(orderId: string, accessToken: AccessToken): Promise { + 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 url = `${getBaseUrl(accessToken.isSandbox)}/reports/transactions?size=1&page=1&customIdentifier=${orderId}&startDate=${oneYearAgoFormatted}&endDate=${nowFormatted}`; + console.log(`Retrieving transaction from ${url}`); + const options = { + method: "GET", + headers: { + ...commonHeaders, + Authorization: `Bearer ${accessToken.token}`, + }, + }; + + const response = await fetch(url, options); + const responseJson = await response.json(); + + console.log("Response status", response.status); + console.log(`Response from ${url}`, responseJson); + + if (response.status != 200) { + throw new Error( + `Error from Reloadly API: ${JSON.stringify({ + status: response.status, + message: (responseJson as ReloadlyFailureResponse).message, + })}` + ); + } + return (responseJson as ReloadlyGetTransactionResponse).content[0]; +} diff --git a/functions/get-redeem-code.ts b/functions/get-redeem-code.ts new file mode 100644 index 00000000..13ab251f --- /dev/null +++ b/functions/get-redeem-code.ts @@ -0,0 +1,90 @@ +import { verifyMessage } from "ethers/lib/utils"; +import { getGiftCardOrderId, getMessageToSign } from "../shared/helpers"; +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 { + try { + validateRequestMethod(ctx.request.method, "GET"); + validateEnvVars(ctx); + + const accessToken = await getAccessToken(ctx.env); + + const { searchParams } = new URL(ctx.request.url); + + 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 }); + + if (verifyMessage(getMessageToSign(transactionId), signedMessage) != wallet) { + console.error( + `Signed message verification failed: ${JSON.stringify({ + signedMessage, + transactionId, + })}` + ); + return errorResponse; + } + + const orderId = getGiftCardOrderId(wallet, permitSig); + const order = await getTransactionFromOrderId(orderId, accessToken); + + if (order.transactionId != transactionId) { + console.error( + `Given transaction does not match with retrieved transactionId using generated orderId: ${JSON.stringify({ + transactionId, + orderId, + transactionIdFromOrder: order.transactionId, + })}` + ); + return errorResponse; + } + + const redeemCode = await getRedeemCode(transactionId, accessToken); + return Response.json(redeemCode, { status: 200 }); + } catch (error) { + console.error("There was an error while processing your request.", error); + return Response.json({ message: "There was an error while processing your request." }, { status: 500 }); + } +} + +export async function getRedeemCode(transactionId: number, accessToken: AccessToken): Promise { + const url = `${getBaseUrl(accessToken.isSandbox)}/orders/transactions/${transactionId}/cards`; + console.log(`Retrieving redeem codes from ${url}`); + const options = { + method: "GET", + headers: { + ...commonHeaders, + Authorization: `Bearer ${accessToken.token}`, + }, + }; + + const response = await fetch(url, options); + const responseJson = await response.json(); + + if (response.status != 200) { + throw new Error( + `Error from Reloadly API: ${JSON.stringify({ + status: response.status, + message: (responseJson as ReloadlyFailureResponse).message, + })}` + ); + } + console.log("Response status", response.status); + console.log(`Response from ${url}`, responseJson); + + return responseJson as ReloadlyRedeemCodeResponse; +} diff --git a/functions/helpers.ts b/functions/helpers.ts new file mode 100644 index 00000000..016b6800 --- /dev/null +++ b/functions/helpers.ts @@ -0,0 +1,200 @@ +import { BigNumberish } from "ethers"; +import { isAllowed } from "../shared/allowed-country-list"; +import { isGiftCardAvailable } from "../shared/helpers"; +import { GiftCard } from "../shared/types"; +import { getGiftCardById } from "./post-order"; +import { fallbackIntlMastercard, fallbackIntlVisa, masterCardIntlSkus, visaIntlSkus } from "./reloadly-lists"; +import { AccessToken, ReloadlyFailureResponse } from "./types"; + +export const commonHeaders = { + "Content-Type": "application/json", + Accept: "application/com.reloadly.giftcards-v1+json", +}; + +export interface Env { + USE_RELOADLY_SANDBOX: string; + RELOADLY_API_CLIENT_ID: string; + RELOADLY_API_CLIENT_SECRET: string; +} + +export interface ReloadlyAuthResponse { + access_token: string; + scope: string; + expires_in: number; + token_type: string; +} + +export async function getAccessToken(env: Env): Promise { + console.log("Using Reloadly Sandbox:", env.USE_RELOADLY_SANDBOX !== "false"); + + const url = "https://auth.reloadly.com/oauth/token"; + const options = { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + client_id: env.RELOADLY_API_CLIENT_ID, + client_secret: env.RELOADLY_API_CLIENT_SECRET, + grant_type: "client_credentials", + audience: env.USE_RELOADLY_SANDBOX === "false" ? "https://giftcards.reloadly.com" : "https://giftcards-sandbox.reloadly.com", + }), + }; + + const res = await fetch(url, options); + if (res.status == 200) { + const successResponse = (await res.json()) as ReloadlyAuthResponse; + return { + token: successResponse.access_token, + isSandbox: env.USE_RELOADLY_SANDBOX !== "false", + }; + } + throw `Getting access token failed: ${JSON.stringify(await res.json())}`; +} + +export function getBaseUrl(isSandbox: boolean): string { + if (isSandbox === false) { + return "https://giftcards.reloadly.com"; + } + return "https://giftcards-sandbox.reloadly.com"; +} + +export async function findBestCard(countryCode: string, amount: BigNumberish, accessToken: AccessToken): Promise { + if (!isAllowed(countryCode)) { + throw new Error(`Country ${countryCode} is not in the allowed country list.`); + } + + const masterCards = await getGiftCards("mastercard", countryCode, accessToken); + + const masterCardIntlSku = masterCardIntlSkus.find((sku) => sku.countryCode == countryCode); + if (masterCardIntlSku) { + const tokenizedIntlMastercard = masterCards.find((masterCard) => masterCard.productId == masterCardIntlSku.sku); + if (tokenizedIntlMastercard && isGiftCardAvailable(tokenizedIntlMastercard, amount)) { + return tokenizedIntlMastercard; + } + } + + const fallbackMastercard = await getFallbackIntlMastercard(accessToken); + if (fallbackMastercard && isGiftCardAvailable(fallbackMastercard, amount)) { + return fallbackMastercard; + } + + const visaCards = await getGiftCards("visa", countryCode, accessToken); + const visaIntlSku = visaIntlSkus.find((sku) => sku.countryCode == countryCode); + if (visaIntlSku) { + const intlVisa = visaCards.find((visaCard) => visaCard.productId == visaIntlSku.sku); + if (intlVisa && isGiftCardAvailable(intlVisa, amount)) { + return intlVisa; + } + } + + const fallbackVisa = await getFallbackIntlVisa(accessToken); + if (fallbackVisa && isGiftCardAvailable(fallbackVisa, amount)) { + return fallbackVisa; + } + + const anyMastercard = masterCards.find((masterCard) => isGiftCardAvailable(masterCard, amount)); + if (anyMastercard) { + return anyMastercard; + } + + const anyVisa = visaCards.find((visaCard) => isGiftCardAvailable(visaCard, amount)); + if (anyVisa) { + return anyVisa; + } + + throw new Error(`No suitable card found for country code ${countryCode} and amount ${amount}.`); +} + +async function getFallbackIntlMastercard(accessToken: AccessToken): Promise { + try { + return await getGiftCardById(fallbackIntlMastercard.sku, accessToken); + } catch (e) { + console.error(`Failed to load international US mastercard: ${JSON.stringify(fallbackIntlMastercard)}`, e); + return null; + } +} + +async function getFallbackIntlVisa(accessToken: AccessToken): Promise { + try { + return await getGiftCardById(fallbackIntlVisa.sku, accessToken); + } catch (e) { + console.error(`Failed to load international US visa: ${JSON.stringify(fallbackIntlVisa)}\n${e}`); + return null; + } +} + +export async function getGiftCards(productQuery: string, country: string, accessToken: AccessToken): Promise { + if (accessToken.isSandbox) { + // Load product differently on Reloadly sandbox + // Sandbox doesn't have mastercard, it has only 1 visa card for US. + // This visa card doesn't load with location based url, let's use special url + // for this so that we have something to try on sandbox + return await getSandboxGiftCards(productQuery, country, accessToken); + } + // productCategoryId = 1 = Finance. + // This should prevent mixing of other gift cards with similar keywords + const url = `${getBaseUrl(accessToken.isSandbox)}/countries/${country}/products?productName=${productQuery}&productCategoryId=1`; + + console.log(`Retrieving gift cards from ${url}`); + const options = { + method: "GET", + headers: { + ...commonHeaders, + Authorization: `Bearer ${accessToken.token}`, + }, + }; + + const response = await fetch(url, options); + const responseJson = await response.json(); + + console.log("Response status", response.status); + console.log(`Response from ${url}`, responseJson); + + if (response.status == 404) { + return []; + } + + if (response.status != 200) { + throw new Error( + `Error from Reloadly API: ${JSON.stringify({ + status: response.status, + message: (responseJson as ReloadlyFailureResponse).message, + })}` + ); + } + + return responseJson as GiftCard[]; +} + +async function getSandboxGiftCards(productQuery: string, country: string, accessToken: AccessToken): Promise { + const url = `${getBaseUrl(accessToken.isSandbox)}/products?productName=${productQuery}&productCategoryId=1`; + + console.log(`Retrieving gift cards from ${url}`); + const options = { + method: "GET", + headers: { + ...commonHeaders, + Authorization: `Bearer ${accessToken.token}`, + }, + }; + + const response = await fetch(url, options); + const responseJson = await response.json(); + + console.log("Response status", response.status); + console.log(`Response from ${url}`, responseJson); + + if (response.status == 404) { + return []; + } + + if (response.status != 200) { + throw new Error( + `Error from Reloadly API: ${JSON.stringify({ + status: response.status, + message: (responseJson as ReloadlyFailureResponse).message, + })}` + ); + } + + return (responseJson as { content: GiftCard[] })?.content; +} diff --git a/functions/post-order.ts b/functions/post-order.ts new file mode 100644 index 00000000..01584da4 --- /dev/null +++ b/functions/post-order.ts @@ -0,0 +1,293 @@ +import { TransactionReceipt, TransactionResponse } from "@ethersproject/providers"; +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 { getFastestRpcUrl, getGiftCardOrderId } from "../shared/helpers"; +import { getGiftCardValue, isClaimableForAmount } from "../shared/pricing"; +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 { 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 { + try { + validateRequestMethod(ctx.request.method, "POST"); + validateEnvVars(ctx); + + const accessToken = await getAccessToken(ctx.env); + + 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); + + const provider = new JsonRpcProvider( + { + url: fastestRpcUrl, + skipFetchSetup: true, + }, + chainId + ); + + const [txReceipt, tx, giftCard]: [TransactionReceipt, TransactionResponse, GiftCard] = await Promise.all([ + provider.getTransactionReceipt(txHash), + provider.getTransaction(txHash), + getGiftCardById(productId, accessToken), + ]); + + if (!txReceipt) { + throw new Error(`Given transaction has not been mined yet. Please wait for it to be mined.`); + } + + let amountDaiWei; + let orderId; + + if (type === "ubiquity-dollar") { + const iface = new Interface(erc20Abi); + 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; + } + + 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; + } + + amountDaiWei = txParsed.args.transferDetails.requestedAmount; + orderId = getGiftCardOrderId(txReceipt.from, txParsed.args.signature); + } + + let exchangeRate = 1; + if (giftCard.recipientCurrencyCode != "USD") { + const exchangeRateResponse = await getExchangeRate(1, giftCard.recipientCurrencyCode, accessToken); + exchangeRate = exchangeRateResponse.senderAmount; + } + + const bestCard = await findBestCard(country, amountDaiWei, accessToken); + if (bestCard.productId != productId) { + throw new Error(`You are not ordering the suitable card: ${JSON.stringify({ ordered: productId, suitable: bestCard })}`); + } + + const giftCardValue = getGiftCardValue(giftCard, amountDaiWei, exchangeRate); + + const isDuplicate = await isDuplicateOrder(orderId, accessToken); + if (isDuplicate) { + return Response.json({ message: "The permit has already claimed a gift card." }, { status: 400 }); + } + + const order = await orderGiftCard(productId, giftCardValue, orderId, accessToken); + + if (order.status != "REFUNDED" && order.status != "FAILED") { + return Response.json(order, { status: 200 }); + } else { + throw new Error(`Order failed: ${JSON.stringify(order)}`); + } + } catch (error) { + console.error("There was an error while processing your request.", error); + return Response.json({ message: "There was an error while processing your request." }, { status: 500 }); + } +} + +export async function getGiftCardById(productId: number, accessToken: AccessToken): Promise { + const url = `${getBaseUrl(accessToken.isSandbox)}/products/${productId}`; + console.log(`Retrieving gift cards from ${url}`); + const options = { + method: "GET", + headers: { + ...commonHeaders, + Authorization: `Bearer ${accessToken.token}`, + }, + }; + + const response = await fetch(url, options); + const responseJson = await response.json(); + + if (response.status != 200) { + throw new Error( + `Error from Reloadly API: ${JSON.stringify({ + status: response.status, + message: (responseJson as ReloadlyFailureResponse).message, + })}` + ); + } + console.log("response.status", response.status); + console.log(`Response from ${url}`, responseJson); + + return responseJson as GiftCard; +} + +async function orderGiftCard(productId: number, cardValue: number, identifier: string, accessToken: AccessToken): Promise { + const url = `${getBaseUrl(accessToken.isSandbox)}/orders`; + console.log(`Placing order at url: ${url}`); + + const requestBody = JSON.stringify({ + productId: productId, + quantity: 1, + unitPrice: cardValue.toFixed(2), + customIdentifier: identifier, + preOrder: false, + }); + + console.log(`Placing order at url: ${url}`); + console.log(`Request body: ${requestBody}`); + + const options = { + method: "POST", + headers: { + ...commonHeaders, + Authorization: `Bearer ${accessToken.token}`, + }, + body: requestBody, + }; + + const response = await fetch(url, options); + const responseJson = await response.json(); + + if (response.status != 200) { + throw new Error( + `Error from Reloadly API: ${JSON.stringify({ + status: response.status, + message: (responseJson as ReloadlyFailureResponse).message, + })}` + ); + } + + console.log("Response status", response.status); + console.log(`Response from ${url}`, responseJson); + + return responseJson as ReloadlyOrderResponse; +} + +async function isDuplicateOrder(orderId: string, accessToken: AccessToken): Promise { + try { + const transaction = await getTransactionFromOrderId(orderId, accessToken); + return !!transaction.transactionId; + } catch (error) { + return false; + } +} + +async function getExchangeRate(usdAmount: number, fromCurrency: string, accessToken: AccessToken): Promise { + const url = `${getBaseUrl(accessToken.isSandbox)}/fx-rate?currencyCode=${fromCurrency}&amount=${usdAmount}`; + console.log(`Retrieving url ${url}`); + const options = { + method: "GET", + headers: { + ...commonHeaders, + Authorization: `Bearer ${accessToken.token}`, + }, + }; + + const response = await fetch(url, options); + const responseJson = await response.json(); + + if (response.status != 200) { + throw new Error( + `Error from Reloadly API: ${JSON.stringify({ + status: response.status, + message: (responseJson as ReloadlyFailureResponse).message, + })}` + ); + } + console.log("Response status", response.status); + console.log(`Response from ${url}`, responseJson); + + return responseJson as ExchangeRate; +} + +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 }); + } + + if (BigNumber.from(txParsed.args.permit.deadline).lt(Math.floor(Date.now() / 1000))) { + return Response.json({ message: "The reward has expired." }, { status: 403 }); + } + + 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 }); + } + + const errorResponse = Response.json({ message: "Transaction is not authorized to purchase gift card." }, { status: 403 }); + + 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; + } + + if (txParsed.args.transferDetails.to.toLowerCase() != giftCardTreasuryAddress.toLowerCase()) { + console.error( + "Given transaction hash is not a token transfer to giftCardTreasuryAddress", + `txParsed.args.transferDetails.to=${txParsed.args.transferDetails.to}`, + `giftCardTreasuryAddress=${giftCardTreasuryAddress}` + ); + return errorResponse; + } + + 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; + } + + if (txParsed.args.permit[0].token.toLowerCase() != chainIdToRewardTokenMap[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; + } +} diff --git a/functions/reloadly-lists.ts b/functions/reloadly-lists.ts new file mode 100644 index 00000000..392255c7 --- /dev/null +++ b/functions/reloadly-lists.ts @@ -0,0 +1,418 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +// Keep duplicate country names in different lists + +export const fallbackIntlMastercard = { + country: "United States", + countryCode: "US", + name: "Virtual MasterCard International USD US", + sku: 18597, +}; + +export const fallbackIntlVisa = { + country: "United States", + countryCode: "US", + name: "Visa International USD (Virtual) US", + sku: 18598, +}; + +export const masterCardIntlSkus = [ + { + country: "United States", + countryCode: "US", + name: "Virtual MasterCard International USD US", + sku: 18597, + }, + { + country: "Algeria", + countryCode: "DZ", + name: "Mastercard Prepaid USD Debit (Virtual only) DZ", + sku: 18630, + }, + { + country: "Australia", + countryCode: "AU", + name: "Mastercard Prepaid USD Debit (Virtual only) AU", + sku: 18631, + }, + { + country: "Austria", + countryCode: "AT", + name: "Mastercard Prepaid USD Debit (Virtual only) AT", + sku: 18632, + }, + { + country: "Bangladesh", + countryCode: "BD", + name: "Mastercard Prepaid USD Debit (Virtual only) BD", + sku: 18633, + }, + { + country: "Belgium", + countryCode: "BE", + name: "Mastercard Prepaid USD Debit (Virtual only) BE", + sku: 18634, + }, + { + country: "Brazil", + countryCode: "BR", + name: "Mastercard Prepaid USD Debit (Virtual only) BR", + sku: 18635, + }, + { + country: "Canada", + countryCode: "CA", + name: "Mastercard Prepaid USD Debit (Virtual only) CA", + sku: 18636, + }, + { + country: "Colombia", + countryCode: "CO", + name: "Mastercard Prepaid USD Debit (Virtual only) CO", + sku: 18637, + }, + { + country: "Dominica", + countryCode: "DM", + name: "Mastercard Prepaid USD Debit (Virtual only) DM", + sku: 18638, + }, + { + country: "Ecuador", + countryCode: "EC", + name: "Mastercard Prepaid USD Debit (Virtual only) EC", + sku: 18639, + }, + { + country: "Egypt", + countryCode: "EG", + name: "Mastercard Prepaid USD Debit (Virtual only) EG", + sku: 18640, + }, + { + country: "Finland", + countryCode: "FI", + name: "Mastercard Prepaid USD Debit (Virtual only) FI", + sku: 18641, + }, + { + country: "France", + countryCode: "FR", + name: "Mastercard Prepaid USD Debit (Virtual only) FR", + sku: 18642, + }, + { + country: "Germany", + countryCode: "DE", + name: "Mastercard Prepaid USD Debit (Virtual only) DE", + sku: 18643, + }, + { + country: "Greece", + countryCode: "GR", + name: "Mastercard Prepaid USD Debit (Virtual only) GR", + sku: 18644, + }, + { + country: "India", + countryCode: "IN", + name: "Mastercard Prepaid USD Debit (Virtual only) IN", + sku: 18645, + }, + { + country: "Indonesia", + countryCode: "ID", + name: "Mastercard Prepaid USD Debit (Virtual only) ID", + sku: 18646, + }, + { + country: "Ireland", + countryCode: "IE", + name: "Mastercard Prepaid USD Debit (Virtual only) IE", + sku: 18647, + }, + { + country: "Italy", + countryCode: "IT", + name: "Mastercard Prepaid USD Debit (Virtual only) IT", + sku: 18648, + }, + { + country: "Jamaica", + countryCode: "JM", + name: "Mastercard Prepaid USD Debit (Virtual only) JM", + sku: 18649, + }, + { + country: "Jordan", + countryCode: "JO", + name: "Mastercard Prepaid USD Debit (Virtual only) JO", + sku: 18650, + }, + { + country: "Malaysia", + countryCode: "MY", + name: "Mastercard Prepaid USD Debit (Virtual only) MY", + sku: 18651, + }, + { + country: "Mexico", + countryCode: "MX", + name: "Mastercard Prepaid USD Debit (Virtual only) MX", + sku: 18652, + }, + { + country: "Netherlands", + countryCode: "NL", + name: "Mastercard Prepaid USD Debit (Virtual only) NL", + sku: 18653, + }, + { + country: "Philippines", + countryCode: "PH", + name: "Mastercard Prepaid USD Debit (Virtual only) PH", + sku: 18654, + }, + { + country: "Poland", + countryCode: "PL", + name: "Mastercard Prepaid USD Debit (Virtual only) PL", + sku: 18655, + }, + { + country: "Portugal", + countryCode: "PT", + name: "Mastercard Prepaid USD Debit (Virtual only) PT", + sku: 18656, + }, + { + country: "Qatar", + countryCode: "QA", + name: "Mastercard Prepaid USD Debit (Virtual only) QA", + sku: 18657, + }, + { + country: "Saudi Arabia", + countryCode: "SA", + name: "Mastercard Prepaid USD Debit (Virtual only) SA", + sku: 18658, + }, + { + country: "Singapore", + countryCode: "SG", + name: "Mastercard Prepaid USD Debit (Virtual only) SG", + sku: 18659, + }, + { + country: "Spain", + countryCode: "ES", + name: "Mastercard Prepaid USD Debit (Virtual only) ES", + sku: 18660, + }, + { + country: "Thailand", + countryCode: "TH", + name: "Mastercard Prepaid USD Debit (Virtual only) TH", + sku: 18661, + }, + { + country: "United Arab Emirates", + countryCode: "AE", + name: "Mastercard Prepaid USD Debit (Virtual only) AE", + sku: 18662, + }, + { + country: "United Kingdom", + countryCode: "GB", + name: "Mastercard Prepaid USD Debit (Virtual only) GB", + sku: 18663, + }, + { + country: "United Arab Emirates", + countryCode: "AE", + name: "Mastercard Prepaid USD Debit (Virtual only) AE", + sku: 18674, + }, +]; + +export const visaIntlSkus = [ + { + country: "United States", + countryCode: "US", + name: "Visa International USD (Virtual) US", + sku: 18598, + }, + { + country: "Algeria", + countryCode: "DZ", + name: "Virtual Promotional Prepaid Visa USD DZ", + sku: 18601, + }, + { + country: "Australia", + countryCode: "AU", + name: "Virtual Promotional Prepaid Visa USD AU", + sku: 18602, + }, + { + country: "Bangladesh", + countryCode: "BD", + name: "Virtual Promotional Prepaid Visa USD BD", + sku: 18603, + }, + { + country: "Belgium", + countryCode: "BE", + name: "Virtual Promotional Prepaid Visa USD BE", + sku: 18604, + }, + { + country: "Brazil", + countryCode: "BR", + name: "Virtual Promotional Prepaid Visa USD BR", + sku: 18605, + }, + { + country: "Canada", + countryCode: "CA", + name: "Virtual Promotional Prepaid Visa USD CA", + sku: 18606, + }, + { + country: "Dominica", + countryCode: "DM", + name: "Virtual Promotional Prepaid Visa USD DM", + sku: 18607, + }, + { + country: "Egypt", + countryCode: "EG", + name: "Virtual Promotional Prepaid Visa USD EG", + sku: 18608, + }, + { + country: "Finland", + countryCode: "FI", + name: "Virtual Promotional Prepaid Visa USD FI", + sku: 18609, + }, + { + country: "France", + countryCode: "FR", + name: "Virtual Promotional Prepaid Visa USD FR", + sku: 18610, + }, + { + country: "Georgia", + countryCode: "GE", + name: "Virtual Promotional Prepaid Visa USD GE", + sku: 18611, + }, + { + country: "Greece", + countryCode: "GR", + name: "Virtual Promotional Prepaid Visa USD GR", + sku: 18612, + }, + { + country: "India", + countryCode: "IN", + name: "Virtual Promotional Prepaid Visa USD IN", + sku: 18613, + }, + { + country: "Indonesia", + countryCode: "ID", + name: "Virtual Promotional Prepaid Visa USD ID", + sku: 18614, + }, + { + country: "Italy", + countryCode: "IT", + name: "Virtual Promotional Prepaid Visa USD IT", + sku: 18615, + }, + { + country: "Jamaica", + countryCode: "JM", + name: "Virtual Promotional Prepaid Visa USD JM", + sku: 18616, + }, + { + country: "Jordan", + countryCode: "JO", + name: "Virtual Promotional Prepaid Visa USD JO", + sku: 18617, + }, + { + country: "Mexico", + countryCode: "MX", + name: "Virtual Promotional Prepaid Visa USD MX", + sku: 18618, + }, + { + country: "Morocco", + countryCode: "MA", + name: "Virtual Promotional Prepaid Visa USD MA", + sku: 18619, + }, + { + country: "Netherlands", + countryCode: "NL", + name: "Virtual Promotional Prepaid Visa USD NL", + sku: 18620, + }, + { + country: "Philippines", + countryCode: "PH", + name: "Virtual Promotional Prepaid Visa USD PH", + sku: 18621, + }, + { + country: "Poland", + countryCode: "PL", + name: "Virtual Promotional Prepaid Visa USD PL", + sku: 18622, + }, + { + country: "Qatar", + countryCode: "QA", + name: "Virtual Promotional Prepaid Visa USD QA", + sku: 18623, + }, + { + country: "Saudi Arabia", + countryCode: "SA", + name: "Virtual Promotional Prepaid Visa USD SA", + sku: 18624, + }, + { + country: "Spain", + countryCode: "ES", + name: "Virtual Promotional Prepaid Visa USD ES", + sku: 18625, + }, + { + country: "Sri Lanka", + countryCode: "LK", + name: "Virtual Promotional Prepaid Visa USD LK", + sku: 18626, + }, + { + country: "Taiwan", + countryCode: "TW", + name: "Virtual Promotional Prepaid Visa USD TW", + sku: 18627, + }, + { + country: "United Arab Emirates", + countryCode: "AE", + name: "Virtual Promotional Prepaid Visa USD AE", + sku: 18628, + }, + { + country: "United Kingdom", + countryCode: "GB", + name: "Virtual Promotional Prepaid Visa USD GB", + sku: 18629, + }, +]; diff --git a/functions/tsconfig.json b/functions/tsconfig.json new file mode 100644 index 00000000..feaa2e53 --- /dev/null +++ b/functions/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "lib": ["esnext"], + "types": ["@cloudflare/workers-types"], + "moduleResolution": "Node" + } +} diff --git a/functions/types.ts b/functions/types.ts new file mode 100644 index 00000000..97ebd3bb --- /dev/null +++ b/functions/types.ts @@ -0,0 +1,71 @@ +import { GiftCard, Order, OrderTransaction, RedeemCode } from "../shared/types"; +import { Env } from "./helpers"; + +export interface AccessToken { + token: string; + isSandbox: boolean; +} + +export interface ReloadlyListGiftCardResponse { + content: GiftCard[]; + pageable: { + sort: { + sorted: boolean; + unsorted: boolean; + empty: boolean; + }; + pageNumber: number; + pageSize: number; + offset: number; + unpaged: boolean; + paged: boolean; + }; + totalElements: number; + totalPages: number; + last: boolean; + first: boolean; + sort: { + sorted: boolean; + unsorted: boolean; + empty: boolean; + }; + numberOfElements: number; + size: number; + number: number; + empty: boolean; +} + +export interface ReloadlyOrderResponse extends Order {} +export interface ReloadlyGetTransactionResponse { + content: OrderTransaction[]; + pageable: { + sort: { sorted: boolean; unsorted: boolean; empty: boolean }; + pageNumber: number; + pageSize: number; + offset: number; + unpaged: boolean; + paged: boolean; + }; + totalElements: number; + totalPages: number; + last: boolean; + first: boolean; + sort: { sorted: boolean; unsorted: boolean; empty: boolean }; + numberOfElements: number; + size: number; + number: number; + empty: boolean; +} + +export type ReloadlyRedeemCodeResponse = RedeemCode[]; + +export interface ReloadlyFailureResponse { + timeStamp: string; + message: string; + path: string; + errorCode: string; + infoLink?: string; + details: []; +} + +export type Context = EventContext>; diff --git a/functions/validators.ts b/functions/validators.ts new file mode 100644 index 00000000..91e01afe --- /dev/null +++ b/functions/validators.ts @@ -0,0 +1,19 @@ +export function validateRequestMethod(expectedMethod: string, receivedMethod: string) { + if (receivedMethod !== expectedMethod) { + console.error( + "Invalid request method.", + JSON.stringify({ + expectedMethod, + receivedMethod, + }) + ); + throw new Error("Invalid request method."); + } +} + +export function validateEnvVars(ctx) { + if (!(ctx.env.RELOADLY_API_CLIENT_ID && ctx.env.RELOADLY_API_CLIENT_SECRET)) { + console.error("One or more environment variable is missing."); + throw new Error("Missing server configurations."); + } +} diff --git a/package.json b/package.json index d4a73a67..3eb9fe7e 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,14 @@ "node": "20.10.0" }, "scripts": { - "start": "run-s start:sign start:ui", + "start": "run-s start:sign serve", "watch": "nodemon -e ts,tsx --exec yarn start", "watch:ui": "nodemon -e ts,tsx --exec yarn start:ui", "format": "run-s format:lint format:prettier format:cspell", "build": "run-s utils:build", "start:ui": "tsx build/esbuild-server.ts", "start:sign": "tsx scripts/typescript/generate-permit2-url.ts", + "serve": "npx wrangler pages dev static --port 8080", "utils:build": "tsx build/esbuild-build.ts", "utils:get-invalidate-params": "forge script --via-ir scripts/solidity/GetInvalidateNonceParams.s.sol", "format:lint": "eslint --fix .", @@ -44,12 +45,16 @@ "dependencies": { "@ethersproject/providers": "^5.7.2", "@supabase/supabase-js": "^2.44.4", - "@ubiquibot/permit-generation": "1.4.1", + "@ubiquibot/permit-generation": "^1.4.1", "@ubiquity-dao/rpc-handler": "^1.1.0", + "countries-and-timezones": "^3.6.0", "dotenv": "^16.4.4", - "ethers": "^5.7.2" + "ethers": "^5.7.2", + "npm-run-all": "^4.1.5", + "zod": "^3.23.8" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20240423.0", "@commitlint/cli": "^18.6.1", "@commitlint/config-conventional": "^18.6.2", "@cspell/dict-node": "^4.0.3", @@ -59,7 +64,7 @@ "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "cspell": "^8.4.0", - "cypress": "13.6.6", + "cypress": "^13.7.0", "esbuild": "^0.20.1", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", @@ -78,7 +83,8 @@ "prettier": "^3.2.5", "ts-jest": "29.1.2", "tsx": "^4.7.1", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "wrangler": "^3.51.2" }, "lint-staged": { "*.ts": [ diff --git a/scripts/typescript/generate-erc20-permit-url.ts b/scripts/typescript/generate-erc20-permit-url.ts index c83d74c3..c1847747 100644 --- a/scripts/typescript/generate-erc20-permit-url.ts +++ b/scripts/typescript/generate-erc20-permit-url.ts @@ -5,42 +5,44 @@ import { BigNumber, ethers } from "ethers"; import { log } from "./utils"; dotenv.config(); +export type PermitConfig = NodeJS.ProcessEnv; + const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; // same on all chains -function createProviderAndWallet() { - const provider = new ethers.providers.JsonRpcProvider(process.env.RPC_PROVIDER_URL); - const myWallet = new ethers.Wallet(process.env.UBIQUIBOT_PRIVATE_KEY, provider); +function createProviderAndWallet(permitConfig: PermitConfig) { + const provider = new ethers.providers.JsonRpcProvider(permitConfig.RPC_PROVIDER_URL); + const myWallet = new ethers.Wallet(permitConfig.UBIQUIBOT_PRIVATE_KEY, provider); return { provider, myWallet }; } -async function createPermitTransferFromData(amount: string) { +async function createPermitTransferFromData(permitConfig: PermitConfig) { // get payment token decimals - const { provider } = createProviderAndWallet(); + const { provider } = createProviderAndWallet(permitConfig); const erc20Abi = ["function decimals() public view returns (uint8)"]; - const tokenContract = new ethers.Contract(process.env.PAYMENT_TOKEN_ADDRESS, erc20Abi, provider); + const tokenContract = new ethers.Contract(permitConfig.PAYMENT_TOKEN_ADDRESS, erc20Abi, provider); const tokenDecimals = await tokenContract.decimals(); return { permitted: { - token: process.env.PAYMENT_TOKEN_ADDRESS || "", - amount: ethers.utils.parseUnits(amount || "", tokenDecimals), + token: permitConfig.PAYMENT_TOKEN_ADDRESS || "", + amount: ethers.utils.parseUnits(permitConfig.AMOUNT_IN_ETH || "", tokenDecimals), }, - spender: process.env.BENEFICIARY_ADDRESS, + spender: permitConfig.BENEFICIARY_ADDRESS, nonce: BigNumber.from(`0x${randomBytes(32).toString("hex")}`), deadline: MaxUint256, }; } -async function signTypedData(myWallet: ethers.Wallet, permitTransferFromData: PermitTransferFrom) { +async function signTypedData(myWallet: ethers.Wallet, permitTransferFromData: PermitTransferFrom, permitConfig: PermitConfig) { const { domain, types, values } = SignatureTransfer.getPermitData( permitTransferFromData, PERMIT2_ADDRESS, - process.env.CHAIN_ID ? Number(process.env.CHAIN_ID) : 1 + permitConfig.CHAIN_ID ? Number(permitConfig.CHAIN_ID) : 1 ); return await myWallet._signTypedData(domain, types, values); } -function createTxData(myWallet: ethers.Wallet, permitTransferFromData: PermitTransferFrom, signature: string) { +function createTxData(myWallet: ethers.Wallet, permitTransferFromData: PermitTransferFrom, signature: string, permitConfig: PermitConfig) { return { type: "erc20-permit", permit: { @@ -57,25 +59,30 @@ function createTxData(myWallet: ethers.Wallet, permitTransferFromData: PermitTra }, owner: myWallet.address, signature: signature, - networkId: Number(process.env.CHAIN_ID), + networkId: Number(permitConfig.CHAIN_ID), }; } -export async function generateErc20Permit() { - const { myWallet } = createProviderAndWallet(); +export async function generateErc20Permit(permitConfig: PermitConfig) { + const { myWallet } = createProviderAndWallet(permitConfig); - const permitTransferFromData = await createPermitTransferFromData(process.env.AMOUNT_IN_ETH); - const signature = await signTypedData(myWallet, permitTransferFromData); + const permitTransferFromData = await createPermitTransferFromData(permitConfig); + const signature = await signTypedData(myWallet, permitTransferFromData, permitConfig); - const permitTransferFromData2 = await createPermitTransferFromData("9"); - const sig = await signTypedData(myWallet, permitTransferFromData); + const permitTransferFromData2 = await createPermitTransferFromData({ ...permitConfig, AMOUNT_IN_ETH: "9" }); + const sig = await signTypedData(myWallet, permitTransferFromData, permitConfig); - const txData = [createTxData(myWallet, permitTransferFromData, signature), createTxData(myWallet, permitTransferFromData2, sig)]; + const txData = [createTxData(myWallet, permitTransferFromData, signature, permitConfig), createTxData(myWallet, permitTransferFromData2, sig, permitConfig)]; const base64encodedTxData = Buffer.from(JSON.stringify(txData)).toString("base64"); + return `${permitConfig.FRONTEND_URL}?claim=${base64encodedTxData}`; +} + +export async function logErc20Permit(permitConfig: PermitConfig) { + const erc20Permit = await generateErc20Permit(permitConfig); log.ok("ERC20 Local URL:"); - log.info(`${process.env.FRONTEND_URL}?claim=${base64encodedTxData}`); + log.info(erc20Permit); } /* eslint-disable @typescript-eslint/no-namespace */ diff --git a/scripts/typescript/generate-permit2-url.ts b/scripts/typescript/generate-permit2-url.ts index 8d3bcad4..6ac814b0 100644 --- a/scripts/typescript/generate-permit2-url.ts +++ b/scripts/typescript/generate-permit2-url.ts @@ -1,9 +1,9 @@ -import { generateErc20Permit } from "./generate-erc20-permit-url"; +import { logErc20Permit } from "./generate-erc20-permit-url"; import { generateErc721Permit } from "./generate-erc721-permit-url"; import { verifyEnvironmentVariables } from "./utils"; (async () => { verifyEnvironmentVariables(); generateErc721Permit().catch(console.error); - generateErc20Permit().catch(console.error); + logErc20Permit(process.env).catch(console.error); })().catch(console.error); diff --git a/shared/allowed-country-list.ts b/shared/allowed-country-list.ts new file mode 100644 index 00000000..8d1fd0d1 --- /dev/null +++ b/shared/allowed-country-list.ts @@ -0,0 +1,153 @@ +export function isAllowed(countryCode: string): boolean { + /** + * There are separate allow list for visa & mastercard + * But right now, both lists contain same countries + */ + const allowedCountries: Record = { + GP: "Guadeloupe", + AO: "Angola", + KI: "Kiribati", + CA: "Canada", + CM: "Cameroon", + QA: "Qatar", + VU: "Vanuatu", + EC: "Ecuador", + AE: "United Arab Emirates", + KZ: "Kazakhstan", + GI: "Gibraltar", + ID: "Indonesia", + BG: "Bulgaria", + BE: "Belgium", + CO: "Colombia", + BR: "Brazil", + IT: "Italy", + EG: "Egypt", + KR: "South Korea", + ZA: "South Africa", + OM: "Oman", + NF: "Norfolk Island", + SK: "Slovak Republic", + SB: "Solomon Islands", + LU: "Luxembourg", + //cspell:disable-next-line + BV: "Bouvet Island", + GG: "Guernsey", + GD: "Grenada", + AW: "Aruba", + JM: "Jamaica", + GF: "French Guiana", + NO: "Norway", + LI: "Liechtenstein", + HK: "Hong Kong", + HU: "Hungary", + PE: "Peru", + AM: "Armenia", + NL: "Netherlands", + BN: "Brunei Darussalam", + BM: "Bermuda", + VI: "Virgin Islands (U.S.)", + MS: "Montserrat", + GY: "Guyana", + DJ: "Djibouti", + PL: "Poland", + DK: "Denmark", + FM: "Federated States of Micronesia", + GH: "Ghana", + AD: "Andorra", + PF: "French Polynesia", + LV: "Latvia", + AZ: "Azerbaijan", + TO: "Tonga", + CC: "Cocos (Keeling) Islands", + MR: "Mauritania", + IL: "Israel", + GB: "UK", + LT: "Lithuania", + CL: "Chile", + DE: "Germany", + IN: "India", + BW: "Botswana", + IS: "Iceland", + FI: "Finland", + BH: "Bahrain", + US: "USA", + AI: "Anguilla", + CZ: "Czech Republic", + ES: "Spain", + MT: "Malta", + SG: "Singapore", + DZ: "Algeria", + CY: "Cyprus", + YT: "Mayotte", + MP: "Northern Mariana Islands", + HR: "Croatia", + CK: "Cook Islands", + GL: "Greenland", + SA: "Saudi Arabia", + GT: "Guatemala", + MY: "Malaysia", + JO: "Jordan", + IE: "Ireland", + CX: "Christmas Island", + RO: "Romania", + NZ: "New Zealand", + SV: "El Salvador", + FR: "France", + KE: "Kenya", + DM: "Dominica", + MA: "Morocco", + TW: "Taiwan", + NU: "Niue", + JP: "Japan", + PH: "Philippines", + GE: "Georgia", + GU: "Guam", + PW: "Palau", + AU: "Australia", + AS: "American Samoa", + //cspell:disable-next-line + CR: "Costa Rica", + GM: "Gambia", + TH: "Thailand", + //cspell:disable-next-line + CW: "Curaçao", + MQ: "Martinique", + PR: "Puerto Rico", + BQ: "Caribbean Netherlands", + BZ: "Belize", + MW: "Malawi", + NE: "Niger", + SI: "Slovenia", + AT: "Austria", + MX: "Mexico", + LK: "Sri lanka", + PT: "Portugal", + FK: "Falkland Islands", + UY: "Uruguay", + IO: "British Indian Ocean Territory", + GR: "Greece", + VA: "Vatican City", + AQ: "Antarctica", + FO: "Faroe Islands", + EE: "Estonia", + TV: "Tuvalu", + TF: "French Southern Territories", + SE: "Sweden", + AG: "Antigua and Barbuda", + BO: "Bolivia", + GA: "Gabon", + TM: "Turkmenistan", + AR: "Argentina", + JE: "Jersey", + //cspell:disable-next-line + CV: "Cabo Verde", + HM: "Heard Island and McDonald Islands", + BD: "Bangladesh", + BT: "Bhutan", + FJ: "Fiji", + CH: "Switzerland", + MC: "Monaco", + }; + + return Object.hasOwn(allowedCountries, countryCode); +} diff --git a/shared/api-types.ts b/shared/api-types.ts new file mode 100644 index 00000000..46dc0a24 --- /dev/null +++ b/shared/api-types.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +export const getBestCardParamsSchema = z.object({ + country: z.string(), + amount: z.string(), +}); + +export type GetBestCardParams = z.infer; + +export const getOrderParamsSchema = z.object({ + orderId: z.string(), +}); + +export type GetOrderParams = z.infer; + +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; + +export const getRedeemCodeParamsSchema = z.object({ + transactionId: z.coerce.number(), + signedMessage: z.string(), + wallet: z.string(), + permitSig: z.string(), +}); + +export type GetRedeemCodeParams = z.infer; diff --git a/shared/constants.ts b/shared/constants.ts new file mode 100644 index 00000000..d783281a --- /dev/null +++ b/shared/constants.ts @@ -0,0 +1,31 @@ +export enum Tokens { + DAI = "0x6b175474e89094c44da98b954eedeac495271d0f", + 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 ubiquityDollarChainAddresses: Record = { + 1: "0x0F644658510c95CB46955e55D7BA9DDa9E9fBEc6", + 100: "0xC6ed4f520f6A4e4DC27273509239b7F8A68d2068", + 31337: "0x0F644658510c95CB46955e55D7BA9DDa9E9fBEc6", +}; + +export const chainIdToRewardTokenMap: Record = { + 1: Tokens.DAI, + 100: Tokens.WXDAI, + 31337: Tokens.WXDAI, +}; + +export const chainIdToNameMap: Record = { + 1: "Ethereum", + 5: "Goerli Testnet", + 10: "Optimism", + 100: "Gnosis", + 31337: "Local Testnet", +}; diff --git a/shared/helpers.ts b/shared/helpers.ts new file mode 100644 index 00000000..34fb0679 --- /dev/null +++ b/shared/helpers.ts @@ -0,0 +1,38 @@ +import { BigNumberish, ethers } from "ethers"; +import { RPCHandler } from "@ubiquity-dao/rpc-handler"; +import { GiftCard } from "./types"; +import { isRangePriceGiftCardClaimable } from "./pricing"; + +export function getGiftCardOrderId(rewardToAddress: string, signature: string) { + const checksumAddress = ethers.utils.getAddress(rewardToAddress); + const integrityString = checksumAddress + ":" + signature; + const integrityBytes = ethers.utils.toUtf8Bytes(integrityString); + return ethers.utils.keccak256(integrityBytes); +} + +export function getMessageToSign(transactionId: number) { + return JSON.stringify({ + from: "pay.ubq.fi", + transactionId: transactionId, + }); +} + +export async function getFastestRpcUrl(networkId: string | number) { + const config = { + networkId: networkId, + autoStorage: true, + cacheRefreshCycles: 5, + rpcTimeout: 1500, + networkName: null, + runtimeRpcs: null, + networkRpcs: null, + }; + + const handler = new RPCHandler(config); + const provider = await handler.getFastestRpcProvider(); + return provider.connection.url; +} + +export function isGiftCardAvailable(giftCard: GiftCard, reward: BigNumberish): boolean { + return giftCard.denominationType == "RANGE" && isRangePriceGiftCardClaimable(giftCard, reward); +} diff --git a/shared/pricing.ts b/shared/pricing.ts new file mode 100644 index 00000000..73c70bbf --- /dev/null +++ b/shared/pricing.ts @@ -0,0 +1,142 @@ +import { BigNumber, BigNumberish } from "ethers"; +import { formatEther, parseEther } from "ethers/lib/utils"; +import { PriceToValueMap, GiftCard } from "./types"; + +/** + * PRICE OF A GIFT CARD + * ==================== + * Price of a gift card is the amount that a user must pay to get the gift card. + * It includes fees and discounts. It is always in USD. No field in the Reloadly API + * provides exact price of gift card. It must be calculated manually from value of card, fees, and discount. + * price = value + percent discount of value - senderFee - percentFee of value + * + * VALUE OF A GIFT CARD + * ==================== + * Value of a gift is the amount that is available within the gift card. + * It can be in any currency. + * + * For fixed price gift cards, the value is provided by following fields. + * Elements of GiftCard.fixedRecipientDenominations[] + * Keys of GiftCard.fixedRecipientToSenderDenominationsMap {}[] + * value = price - percent discount of value + senderFee + percentFee of value + * + * For ranged price gift cards, the value is any amount between the following fields. + * GiftCard.minRecipientDenomination + * GiftCard.maxRecipientDenomination + * + * Following fields are the equivalent of available values range in our account currency (USD). + * GiftCard.minSenderDenomination + * GiftCard.maxSenderDenomination + * Values of GiftCard.fixedRecipientToSenderDenominationsMap{}[] + */ + +export function isClaimableForAmount(giftCard: GiftCard, rewardAmount: BigNumberish) { + if (giftCard.senderCurrencyCode != "USD") { + throw new Error(`Failed to validate price because gift card's senderCurrencyCode is not USD: ${JSON.stringify({ rewardAmount, giftCard: giftCard })}`); + } + + if (giftCard.denominationType == "RANGE") { + return isRangePriceGiftCardClaimable(giftCard, rewardAmount); + } else if (giftCard.denominationType == "FIXED") { + return isFixedPriceGiftCardClaimable(giftCard, rewardAmount); + } +} + +export function getEstimatedExchangeRate(giftCard: GiftCard) { + let exchangeRate = 1; + if (giftCard.recipientCurrencyCode != "USD") { + if (giftCard.denominationType == "FIXED") { + const key = Object.keys(giftCard.fixedRecipientToSenderDenominationsMap)[0]; + exchangeRate = giftCard.fixedRecipientToSenderDenominationsMap[key] / Number(key); + } else { + exchangeRate = giftCard.minSenderDenomination / giftCard.minRecipientDenomination; + } + } + return exchangeRate; +} + +export function getTotalPriceOfValue(value: number, giftCard: GiftCard) { + const exchangeRate = getEstimatedExchangeRate(giftCard); + const usdValue = parseEther((exchangeRate * value).toString()); + + // multiply by extra 100 to support minimum upto 0.01% + // because we are using BigNumbers + const feePercentage = BigNumber.from((giftCard.senderFeePercentage * 100).toString()); + const fee = usdValue.mul(feePercentage).div(100 * 100); + const totalFee = fee.add(parseEther(giftCard.senderFee.toString())); + const discountPercent = BigNumber.from(Math.trunc(giftCard.discountPercentage * 100).toString()); + const discount = usdValue.mul(discountPercent).div(100 * 100); + + return Number(formatEther(usdValue.add(totalFee).sub(discount))); +} + +export function getRangePriceToValueMap(giftCard: GiftCard) { + const priceToValueMap: PriceToValueMap = {}; + + [giftCard.minRecipientDenomination, giftCard.maxRecipientDenomination].forEach((value) => { + const totalPrice = getTotalPriceOfValue(Number(value), giftCard); + priceToValueMap[totalPrice.toFixed(2).toString()] = Number(value); + }); + + return priceToValueMap; +} + +export function getUsdValueForRangePrice(giftCard: GiftCard, price: BigNumberish) { + // price = value + senderFee + feePercent - discountPercent + const priceWei = BigNumber.from(price.toString()); + const priceAfterFee = priceWei.sub(parseEther(giftCard.senderFee.toString())); + + const feeDiscountPercentDiff = giftCard.senderFeePercentage - giftCard.discountPercentage; + // multiply by extra 100 to support minimum upto 0.01% + // because we are using BigNumbers + const feeDiscountPercentDiffWei = parseEther(Math.trunc(feeDiscountPercentDiff * 100).toString()); + const hundredPercent = parseEther((100 * 100).toString()); + const priceWithAddedPercentFromFees = hundredPercent.add(feeDiscountPercentDiffWei); + const usdValue = hundredPercent.mul(priceAfterFee).div(priceWithAddedPercentFromFees); + return Number(formatEther(usdValue)); +} + +export function isRangePriceGiftCardClaimable(giftCard: GiftCard, rewardAmount: BigNumberish) { + const value = Number(getGiftCardValue(giftCard, rewardAmount).toFixed(2)); + return value >= giftCard.minRecipientDenomination && value <= giftCard.maxRecipientDenomination; +} + +export function getFixedPriceToValueMap(giftCard: GiftCard) { + const valueToPriceMap = giftCard.fixedRecipientToSenderDenominationsMap; + + const priceToValueMap: PriceToValueMap = {}; + Object.keys(valueToPriceMap).forEach((value) => { + const totalPrice = getTotalPriceOfValue(Number(value), giftCard); + priceToValueMap[totalPrice.toFixed(2).toString()] = Number(value); + }); + + return priceToValueMap; +} + +export function isFixedPriceGiftCardClaimable(giftCard: GiftCard, rewardAmount: BigNumberish) { + const priceToValueMap = getFixedPriceToValueMap(giftCard); + const priceAsKey = Number(formatEther(rewardAmount)).toFixed(2).toString(); + return !!priceToValueMap[priceAsKey]; +} + +export function getGiftCardValue(giftCard: GiftCard, reward: BigNumberish, exchangeRate?: number) { + let giftCardValue; + const amountDaiEth = Number(formatEther(reward)).toFixed(2); + if (giftCard.denominationType == "FIXED") { + const priceToValueMap = getFixedPriceToValueMap(giftCard); + giftCardValue = priceToValueMap[amountDaiEth]; + } else if (giftCard.denominationType == "RANGE") { + const usdValue = getUsdValueForRangePrice(giftCard, reward); + if (!exchangeRate) { + exchangeRate = getEstimatedExchangeRate(giftCard); + } + giftCardValue = usdValue / exchangeRate; + } else { + throw new Error( + `Unknown denomination type of gift card: ${JSON.stringify({ + denominationType: giftCard.denominationType, + })}` + ); + } + return giftCardValue; +} diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 00000000..df2797c9 --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,99 @@ +export interface GiftCard { + productId: number; + productName: string; + global: boolean; + supportsPreOrder: boolean; + senderFee: number; + senderFeePercentage: number; + discountPercentage: number; + denominationType: "FIXED" | "RANGE"; + recipientCurrencyCode: string; + minRecipientDenomination: number; + maxRecipientDenomination: number; + senderCurrencyCode: string; + minSenderDenomination: number; + maxSenderDenomination: number; + fixedRecipientDenominations: number[]; + fixedSenderDenominations: number[]; + fixedRecipientToSenderDenominationsMap: ValueToPriceMap; + metadata?: object; + logoUrls: string[]; + brand: { + brandId: number; + brandName: string; + }; + country: { + isoName: string; + name: string; + flagUrl: string; + }; + redeemInstruction: { + concise: string; + verbose: string; + }; +} + +export interface OrderedProduct { + productId: number; + productName: string; + countryCode: string; + quantity: number; + unitPrice: number; + totalPrice: number; + currencyCode: string; + brand: { + brandId: number; + brandName: string; + }; +} + +export interface Order { + transactionId: number; + amount: number; + discount: number; + currencyCode: string; + fee: number; + recipientEmail: string; + customIdentifier: string; + status: string; + product: OrderedProduct; + smsFee: number; + recipientPhone: number; + transactionCreatedTime: string; //"2022-02-28 13:46:00", + preOrdered: boolean; +} + +export interface OrderTransaction { + transactionId: number; + amount: number; + discount: number; + currencyCode: string; + fee: number; + recipientEmail: string; + customIdentifier: string; + status: string; + product: OrderedProduct; + smsFee: number; + recipientPhone: number; + transactionCreatedTime: string; //"2022-02-28 13:46:00", + preOrdered: boolean; +} + +export interface RedeemCode { + cardNumber: string; + pinCode: string; +} + +export interface ExchangeRate { + senderCurrency: string; + senderAmount: number; + recipientCurrency: string; + recipientAmount: number; +} + +export interface PriceToValueMap { + [key: string]: number; +} +export interface ValueToPriceMap { + [key: string]: number; +} diff --git a/static/index.html b/static/index.html index 03f1ac57..d1317588 100644 --- a/static/index.html +++ b/static/index.html @@ -2,7 +2,7 @@ - + @@ -49,8 +49,8 @@ - - + + @@ -118,7 +118,7 @@ - + - Claiming + Loading Collect @@ -152,7 +152,6 @@ /> - Void @@ -168,12 +167,20 @@ -