diff --git a/.cspell.json b/.cspell.json index b0185233..13316039 100644 --- a/.cspell.json +++ b/.cspell.json @@ -9,6 +9,7 @@ "Funder", "binkey", "binsec", + "blockscan", "chainlist", "cirip", "Claimability", @@ -16,6 +17,8 @@ "devpool", "ethersproject", "fract", + "giftcards", + "gnosischain", "gnosisscan", "godb", "greyscale", @@ -25,6 +28,8 @@ "libsodium", "Numberish", "outdir", + "pageable", + "Reloadly", "Rpcs", "scalarmult", "servedir", diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 00d8dfb4..adf32dca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,5 +34,10 @@ jobs: - name: Upload build artifact uses: actions/upload-artifact@v4 with: - name: static - path: static + 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 795e037e..56f77824 100644 --- a/.github/workflows/cypress-testing.yml +++ b/.github/workflows/cypress-testing.yml @@ -46,14 +46,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" @@ -65,6 +68,7 @@ jobs: PAYMENT_TOKEN_ADDRESS: "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d" RPC_PROVIDER_URL: "http://localhost:8545" UBIQUIBOT_PRIVATE_KEY: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ff53c534..1e62db8d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,13 +17,14 @@ jobs: with: repository: ${{ github.repository }} production_branch: ${{ github.event.repository.default_branch }} - build_artifact_name: "static" - output_directory: "static" + build_artifact_name: "full-stack-app" + output_directory: "full-stack-app" current_branch: ${{ github.event.workflow_run.head_branch }} cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }} commit_sha: ${{ github.event.workflow_run.head_sha }} workflow_run_id: ${{ github.event.workflow_run.id }} + statics_directory: "static" - name: Check out repository uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 704a6abe..7f1b1d6f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ commit.txt static/dist cypress/screenshots -cypress/videos \ No newline at end of file +cypress/videos +.wrangler \ No newline at end of file diff --git a/README.md b/README.md index ee021d22..261afa9a 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,88 @@ -# Generate Permit +# [pay.ubq.fi](https://pay.ubq.fi) -Tool for generating offline permits for bounty hunters to withdraw their payments. +A vanilla Typescript dApp for claiming Ubiquity Rewards. It also includes tools for generating and invalidating permits and can be used to claim both ERC20 and ERC721 tokens. -## How to set up +## Setup Local Testing Environment -Create a `.env` file in the project root: +1. Install [Foundry](https://book.getfoundry.sh/getting-started/installation). +2. Create a `.env` file in the project root with the following settings: +- These are the suggested default test environment variables that allow for local setup using the supplied yarn commands. If you want to produce or invalidate real on-chain permits you must change the below values to reflect the real permit information such as address, chain ID, private key and so on. -``` -# common variables -CHAIN_ID="" # mainnet: 1, goerli: 5 -FRONTEND_URL="" -UBIQUIBOT_PRIVATE_KEY="" -RPC_PROVIDER_URL="" -PAYMENT_TOKEN_ADDRESS="" # // DAI address, mainnet: 0x6b175474e89094c44da98b954eedeac495271d0f, goerli: 0x11fE4B6AE13d2a6055C8D9cF65c55bac32B5d844 -# variables depending on spender (bounty hunter) -AMOUNT_IN_ETH="1" # amount in ether, 1 AMOUNT_IN_ETH = 1000000000000000000 WEI -BENEFICIARY_ADDRESS="" -``` + ```env + # Common variables + CHAIN_ID="31337" + FRONTEND_URL="http://localhost:8080" + UBIQUIBOT_PRIVATE_KEY="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + RPC_PROVIDER_URL="http://127.0.0.1:8545" + PAYMENT_TOKEN_ADDRESS="0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d" + + # Variables depending on spender (bounty hunter) + AMOUNT_IN_ETH="1" + BENEFICIARY_ADDRESS="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + ``` -## How it works +## Local Testing -1. Admin sets `env.AMOUNT_IN_ETH` and `env.BENEFICIARY_ADDRESS` depending on a bounty hunter's reward and address -2. Admin generates an offline permit URL via `npx tsx generate-permit2-url.ts`. Permit URL example: +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`. +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. +8. To test ERC721 permits, deploy the `nft-rewards` contract from the [repository](https://github.com/ubiquity/nft-rewards). -``` -http://localhost:8080?claim=eyJwZXJtaXQiOnsicGVybWl0dGVkIjp7InRva2VuIjoiMHgxMWZFNEI2QUUxM2QyYTYwNTVDOEQ5Y0Y2NWM1NWJhYzMyQjVkODQ0IiwiYW1vdW50IjoiMTAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJub25jZSI6IjQ0NTUxMjc4NTQwNTU0MzM1MDQ2NzU2NDQ3MzM2MjI1ODg5OTE4OTY5MTczODQwNTU0Nzk2NzQ3MzQzMzAwOTg0NzU4MDIyMzY1ODczIiwiZGVhZGxpbmUiOiIxMTU3OTIwODkyMzczMTYxOTU0MjM1NzA5ODUwMDg2ODc5MDc4NTMyNjk5ODQ2NjU2NDA1NjQwMzk0NTc1ODQwMDc5MTMxMjk2Mzk5MzUifSwidHJhbnNmZXJEZXRhaWxzIjp7InRvIjoiMHhjODZhMDU5NzgwMThlMDRkNmVGMmFhNzNFNjlhNzMzQzA2ZDFmODllIiwicmVxdWVzdGVkQW1vdW50IjoiMTAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJvd25lciI6IjB4NTRmNGEzNjQyMkRjOTZkMDg0OTY3NWMxZjBkZDJCOTZEMjc1NThFMiIsInNpZ25hdHVyZSI6IjB4NWI0OTE5MjhmYzI4MzBlMjZiNTViMWUxOWQ3YzVhMmVjNGE2ZmRhYWI1OGFiYjgyOWMwNmYzYzlkNGE4YTc5YjAzYmE2NjlkMDM4YjFmYzg5NjgzYzMyYjBiYTA5MzU2MDRjMGU1MDNjYWE3ZmY2ZWM2MDg2ZWZlYjY2MTY5MjQxYyJ9 -``` +### Importing Anvil Accounts -3. Admin posts offline permit URL in issue comments -4. Bounty hunter opens permit URL, connects wallet and clicks a "withdraw" button to get a payment +1. Open your wallet provider and select `import wallet` or `import account`. +2. Obtain the private keys by running `anvil` or using the yarn command. +3. Copy and paste the private keys into your wallet provider. -## How to test locally +### Expected Behavior -1. Set `.env` variables. -2. Run `anvil --chain-id 31337 --fork-url https://rpc.gnosis.gateway.fm` in a separate terminal. -3. Run the Anvil commands (uses the Anvil default wallets). -4. In the project root run `yarn start`. -5. A permit URL for both ERC20 and ERC721 is generated in the terminal. -6. Open the generated permit URL defaulting to the variable values in the `.env` file. -7. Connect the bounty hunter's address. -8. Click the "withdraw" button to get a reward. -9. Testing the ERC721 permit is easiest deploying the `nft-rewards` contract from the [repository](https://github.com/ubiquity/nft-rewards) +#### Setup -#### Anvil commands +- A local blockchain instance will be created for testing. +- The permit URL will be generated in the console. Ensure your console has enough space for the full URL. +- Imported anvil accounts [0] & [1] can claim and invalidate permits. -###### Using any other `--chain-id` will hit real RPC endpoints. +#### Claiming -```shell -cast rpc anvil_impersonateAccount 0xba12222222228d8ba445958a75a0704d566bf2c8 & -cast send 0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d --unlocked --from 0xba12222222228d8ba445958a75a0704d566bf2c8 "transfer(address,uint256)(bool)" 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 337888400000000000000000 & -cast send 0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d --unlocked --from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 "approve(address,uint256)(bool)" 0x000000000022D473030F116dDEE9F6B43aC78BA3 9999999999999991111111119999999999999999 & -cast send 0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d --unlocked --from 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 "approve(address,uint256)(bool)" 0x000000000022D473030F116dDEE9F6B43aC78BA3 999999999999999111119999999999999999 +- Uses chain id `31337` and RPC provider `http://localhost:8545`. +- Claiming involves transferring tokens from the signer's account to the beneficiary's account. +- Signer must have signed a permit and have enough balance approved for the permit2 contract. -``` +#### Invalidating -## CloudFlare Setup (GitHub Secrets) +- Only the permit signer can invalidate it. +- Invalidating calls `invalidateUnorderedNonces` on the `Permit2` smart contract. -##### CLOUDFLARE_ACCOUNT_ID = +### Considerations - https://dash.cloudflare.com/***/pages - https://dash.cloudflare.com/abcd1234/pages - (Here `abcd1234` is your account ID) +- MetaMask is considered the default wallet provider. +- Ensure correct network selection in your wallet (`http://localhost:8545` with chain id `31337`). +- Use MetaMask Mobile Wallet Browser for mobile testing. -##### CLOUDFLARE_API_TOKEN = +### Errors - https://dash.cloudflare.com/profile/api-tokens > Create Token > API token templates > Edit Cloudflare Workers > Use Template - Account Resources = All Accounts or Target Account - Zone Resources = All Zones - (Detailed Instructions: https://developers.cloudflare.com/workers/wrangler/ci-cd/) +- Clear transaction history in MetaMask if transactions hang after restarting the Anvil instance. +- The test suite may show error toasts due to MetaMask spoofing. +- 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()`. -##### CLOUDFLARE_ASSET_DIRECTORY = +## How to generate a permit2 URL using the script - static +1. Admin sets `env.AMOUNT_IN_ETH` and `env.BENEFICIARY_ADDRESS` depending on a bounty hunter's reward and address +2. Admin generates an offline permit URL via `npx tsx generate-permit2-url.ts`. Permit URL example: -##### CLOUDFLARE_PROJECT_NAME = +``` +http://localhost:8080?claim=eyJwZXJtaXQiOnsicGVybWl0dGVkIjp7InRva2VuIjoiMHgxMWZFNEI2QUUxM2QyYTYwNTVDOEQ5Y0Y2NWM1NWJhYzMyQjVkODQ0IiwiYW1vdW50IjoiMTAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJub25jZSI6IjQ0NTUxMjc4NTQwNTU0MzM1MDQ2NzU2NDQ3MzM2MjI1ODg5OTE4OTY5MTczODQwNTU0Nzk2NzQ3MzQzMzAwOTg0NzU4MDIyMzY1ODczIiwiZGVhZGxpbmUiOiIxMTU3OTIwODkyMzczMTYxOTU0MjM1NzA5ODUwMDg2ODc5MDc4NTMyNjk5ODQ2NjU2NDA1NjQwMzk0NTc1ODQwMDc5MTMxMjk2Mzk5MzUifSwidHJhbnNmZXJEZXRhaWxzIjp7InRvIjoiMHhjODZhMDU5NzgwMThlMDRkNmVGMmFhNzNFNjlhNzMzQzA2ZDFmODllIiwicmVxdWVzdGVkQW1vdW50IjoiMTAwMDAwMDAwMDAwMDAwMDAwMCJ9LCJvd25lciI6IjB4NTRmNGEzNjQyMkRjOTZkMDg0OTY3NWMxZjBkZDJCOTZEMjc1NThFMiIsInNpZ25hdHVyZSI6IjB4NWI0OTE5MjhmYzI4MzBlMjZiNTViMWUxOWQ3YzVhMmVjNGE2ZmRhYWI1OGFiYjgyOWMwNmYzYzlkNGE4YTc5YjAzYmE2NjlkMDM4YjFmYzg5NjgzYzMyYjBiYTA5MzU2MDRjMGU1MDNjYWE3ZmY2ZWM2MDg2ZWZlYjY2MTY5MjQxYyJ9 +``` - npm install -g wrangler - wrangler login - wrangler pages project create +3. Admin posts offline permit URL in issue comments (with the payment portal domain name) +4. Bounty hunter opens permit URL, connects wallet and clicks a "withdraw" button to get a payment -## How to invalidate a permit2 nonce by example +## How to invalidate a permit2 nonce using the script This section describes how to invalidate the following [permit](https://github.com/ubiquity/ubiquity-dollar/issues/643#issuecomment-1607152588) (i.e. invalidate a permit2 nonce) diff --git a/cypress.config.ts b/cypress.config.ts index 8eee7a78..772753d2 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ setupNodeEvents() {}, 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..dd17866c --- /dev/null +++ b/cypress/e2e/claim-gift-card.cy.ts @@ -0,0 +1,196 @@ +/* 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("should show/hide activation info", () => { + const permitConfig = Cypress.env("permitConfig"); + void cy.getPermitUrl(permitConfig).then((permitUrl) => { + cy.visit(`${permitUrl as string}`); + cy.wait(2000); + + cy.wait("@listGiftCards"); + + cy.get("#gift-cards").should("exist").and("include.text", "Or claim in virtual visa/mastercard"); + cy.get(".gift-card").should("have.length.above", 0); + + cy.get(".gift-card.purchased").should("not.exist"); + + cy.get('#activate-info .redeem-info-wrapper[data-show="true"]').should("not.exist"); + cy.get(".gift-card").eq(0).find(".activate-btn").invoke("click"); + + cy.get('#activate-info .redeem-info-wrapper[data-show="true"]').should("exist"); + cy.get("#activate-info .close-btn").invoke("click"); + cy.get('#activate-info .redeem-info-wrapper[data-show="true"]').should("not.exist"); + }); + }); + + 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("@listGiftCards"); + cy.get(".gift-card").should("have.length.above", 0); + cy.get(".gift-card .available").should("have.length.above", 0); + cy.get(".gift-card .available") + .eq(0) + .parent() + .parent() + .find("h3") + .eq(0) + .then(($name) => { + const giftCardName = $name; + cy.wrap(giftCardName).as("giftCardName"); + }); + + cy.intercept({ method: "POST", url: "/post-order" }).as("postOrder"); + + cy.get(".gift-card .available").eq(0).parent().parent().find(".claim-gift-card-btn").should("have.length", 1); + + cy.intercept({ method: "GET", url: "/get-order**" }).as("getOrder"); + cy.get(".gift-card .available").eq(0).parent().parent().find(".claim-gift-card-btn").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. Loading your card now."); + cy.wait("@getOrder", { timeout: 10000 }); + + cy.get("#gift-cards").should("exist").and("include.text", "Your gift card"); + + cy.get("@giftCardName").then((name) => { + cy.get(".gift-card 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("@listGiftCards"); + + cy.get("#gift-cards").should("exist").and("include.text", "Your gift card"); + cy.get(".gift-card.redeem-code > h3").eq(0).should("have.text", "Your redeem code"); + cy.get(".gift-card.redeem-code > p").eq(0).should("have.text", "xxxxxxxxxxxx"); + cy.get(".gift-card.redeem-code > p").eq(1).should("have.text", "xxxxxxxxxxxx"); + cy.get(".gift-card.redeem-code > p").eq(2).should("have.text", "xxxxxxxxxxxx"); + cy.get(".gift-card.redeem-code > .buttons > #reveal-btn").invoke("click"); + + cy.get(".gift-card.redeem-code > h3").eq(0).should("have.text", "Your redeem code"); + cy.get(".gift-card.redeem-code > p").should("exist"); + cy.get(".gift-card.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: "/list-gift-cards" }).as("listGiftCards"); +} + +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 9f0b1bb5..67745da1 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"); @@ -36,6 +36,16 @@ describe("Claims Portal Success", () => { // anvil confirms it instantly so there is two notifications cy.get("body").should("contain.text", "Transaction sent"); cy.get("body").should("contain.text", "Claim Complete"); + + cy.window().then((win) => { + win.open = cy.stub().as("open"); + }); + + cy.get("#view-claim") + .invoke("click") + .then(() => { + cy.get("@open").should("be.calledWithMatch", /https:\/\/blockscan.com\/tx/); + }); }); }); 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/scripts/anvil.ts b/cypress/scripts/anvil.ts index efbb52b7..a0272ca1 100644 --- a/cypress/scripts/anvil.ts +++ b/cypress/scripts/anvil.ts @@ -1,63 +1,21 @@ -import { spawnSync } from "child_process"; -import { useHandler } from "../../static/scripts/rewards/web3/use-rpc-handler"; -// @ts-expect-error - Missing types -import { RPCHandler } from "@ubiquity-dao/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 = await 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-order.ts b/functions/get-order.ts new file mode 100644 index 00000000..e62f3a05 --- /dev/null +++ b/functions/get-order.ts @@ -0,0 +1,65 @@ +import { OrderTransaction } from "../shared/types"; +import { commonHeaders, getAccessToken, getBaseUrl } from "./helpers"; +import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyGetTransactionResponse } from "./types"; +import { validateEnvVars, validateRequestMethod } from "./validators"; + +export async function onRequest(ctx: Context): Promise { + try { + validateRequestMethod(ctx.request.method, "GET"); + validateEnvVars(ctx); + + const { searchParams } = new URL(ctx.request.url); + const orderId = searchParams.get("orderId"); + + if (!orderId) { + throw new Error(`Invalid query parameters: ${{ orderId }}`); + } + + const 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") { + return Response.json(reloadlyTransaction, { 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..f92a1824 --- /dev/null +++ b/functions/get-redeem-code.ts @@ -0,0 +1,93 @@ +import { verifyMessage } from "ethers/lib/utils"; +import { getGiftCardOrderId, getMessageToSign } from "../shared/helpers"; +import { RedeemCode } from "../shared/types"; +import { getTransactionFromOrderId } from "./get-order"; +import { commonHeaders, getAccessToken, getBaseUrl } from "./helpers"; +import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyRedeemCodeResponse } from "./types"; +import { validateEnvVars, validateRequestMethod } from "./validators"; + +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 transactionId = Number(searchParams.get("transactionId")); + const signedMessage = searchParams.get("signedMessage"); + const wallet = searchParams.get("wallet"); + const permitSig = searchParams.get("permitSig"); + + if (isNaN(transactionId) || !(transactionId && signedMessage && wallet && permitSig)) { + throw new Error( + `Invalid query parameters: ${{ + transactionId, + signedMessage, + wallet, + permitSig, + }}` + ); + } + + const 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..02d0be48 --- /dev/null +++ b/functions/helpers.ts @@ -0,0 +1,54 @@ +import { AccessToken } from "./types"; + +export const allowedChainIds = [1, 5, 100, 31337]; + +export const commonHeaders = { + "Content-Type": "application/json", + Accept: "application/com.reloadly.giftcards-v1+json", +}; + +export interface Env { + USE_RELOADLY_SANDBOX: boolean; + 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"; +} diff --git a/functions/list-gift-cards.ts b/functions/list-gift-cards.ts new file mode 100644 index 00000000..1446d82f --- /dev/null +++ b/functions/list-gift-cards.ts @@ -0,0 +1,57 @@ +import { GiftCard } from "../shared/types"; +import { commonHeaders, getAccessToken, getBaseUrl } from "./helpers"; +import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyListGiftCardResponse } from "./types"; +import { validateEnvVars, validateRequestMethod } from "./validators"; + +export async function onRequest(ctx: Context): Promise { + try { + validateRequestMethod(ctx.request.method, "GET"); + validateEnvVars(ctx); + + const accessToken = await getAccessToken(ctx.env); + const [masterCards, visaCards] = await Promise.all([getGiftCards("mastercard", accessToken), getGiftCards("visa", accessToken)]); + + const giftCards = [...masterCards, ...visaCards]; + + if (giftCards.length) { + return Response.json(giftCards, { 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 }); + } +} + +async function getGiftCards(productQuery: string, accessToken: AccessToken): Promise { + const url = `${getBaseUrl(accessToken.isSandbox)}/products?productName=${productQuery}`; + 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 ReloadlyListGiftCardResponse).content; +} diff --git a/functions/post-order.ts b/functions/post-order.ts new file mode 100644 index 00000000..dd3f432c --- /dev/null +++ b/functions/post-order.ts @@ -0,0 +1,239 @@ +import { TransactionReceipt, TransactionResponse } from "@ethersproject/providers"; +import { JsonRpcProvider } from "@ethersproject/providers/lib/json-rpc-provider"; +import { Interface, TransactionDescription } from "ethers/lib/utils"; +import { Tokens, chainIdToRewardTokenMap, giftCardTreasuryAddress, permit2Address } from "../shared/constants"; +import { getFastestRpcUrl, getGiftCardOrderId } from "../shared/helpers"; +import { getGiftCardValue, isClaimableForAmount } from "../shared/pricing"; +import { ExchangeRate, GiftCard, OrderRequestParams } from "../shared/types"; +import { permit2Abi } from "../static/scripts/rewards/abis/permit2Abi"; +import { getTransactionFromOrderId } from "./get-order"; +import { allowedChainIds, commonHeaders, getAccessToken, getBaseUrl } from "./helpers"; +import { AccessToken, Context, ReloadlyFailureResponse, ReloadlyOrderResponse } from "./types"; +import { validateEnvVars, validateRequestMethod } from "./validators"; + +export async function onRequest(ctx: Context): Promise { + try { + validateRequestMethod(ctx.request.method, "POST"); + validateEnvVars(ctx); + + const accessToken = await getAccessToken(ctx.env); + + const { productId, txHash, chainId } = (await ctx.request.json()) as OrderRequestParams; + + if (isNaN(productId) || isNaN(chainId) || !(productId && txHash && chainId)) { + throw new Error(`Invalid post parameters: ${JSON.stringify({ productId, txHash, chainId })}`); + } + + if (!allowedChainIds.includes(chainId)) { + throw new Error(`Unsupported chain: ${JSON.stringify({ chainId })}`); + } + + 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.`); + } + + const iface = new Interface(permit2Abi); + + const txParsed = iface.parseTransaction({ data: tx.data }); + + console.log("Parsed transaction data: ", JSON.stringify(txParsed)); + + const errorResponse = validateTransaction(txParsed, txReceipt, chainId, giftCard); + if (errorResponse) { + return errorResponse; + } + + const amountDaiWei = txParsed.args.transferDetails.requestedAmount; + + let exchangeRate = 1; + if (giftCard.recipientCurrencyCode != "USD") { + const exchangeRateResponse = await getExchangeRate(1, giftCard.recipientCurrencyCode, accessToken); + exchangeRate = exchangeRateResponse.senderAmount; + } + const giftCardValue = getGiftCardValue(giftCard, amountDaiWei, exchangeRate); + + const orderId = getGiftCardOrderId(txReceipt.from, txParsed.args.signature); + + const isDuplicate = await isDuplicateOrder(orderId, accessToken); + if (isDuplicate) { + return Response.json({ message: "The permit has already claimed a gift card." }, { status: 400 }); + } + + 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 }); + } +} + +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 validateTransaction(txParsed: TransactionDescription, txReceipt: TransactionReceipt, chainId: number, giftCard: GiftCard): Response | void { + 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/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 55eb48e6..05d16a0f 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 .", @@ -47,6 +48,7 @@ "npm-run-all": "^4.1.5" }, "devDependencies": { + "@cloudflare/workers-types": "^4.20240423.0", "@commitlint/cli": "^18.6.1", "@commitlint/config-conventional": "^18.6.2", "@cspell/dict-node": "^4.0.3", @@ -68,7 +70,8 @@ "npm-run-all": "^4.1.5", "prettier": "^3.2.5", "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 d7824a88..f4a62912 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 e1e0f7c5..d6336a01 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/constants.ts b/shared/constants.ts new file mode 100644 index 00000000..3eb6c46f --- /dev/null +++ b/shared/constants.ts @@ -0,0 +1,13 @@ +export enum Tokens { + DAI = "0x6b175474e89094c44da98b954eedeac495271d0f", + WXDAI = "0xe91d153e0b41518a2ce8dd3d7944fa863463a97d", +} + +export const permit2Address = "0x000000000022D473030F116dDEE9F6B43aC78BA3"; +export const giftCardTreasuryAddress = "0x3B47E3e4758E133acf72684727Dc10550C40e4B9"; + +export const chainIdToRewardTokenMap = { + 1: Tokens.DAI, + 100: Tokens.WXDAI, + 31337: Tokens.WXDAI, +}; diff --git a/shared/helpers.ts b/shared/helpers.ts new file mode 100644 index 00000000..76ab3609 --- /dev/null +++ b/shared/helpers.ts @@ -0,0 +1,32 @@ +import { ethers } from "ethers"; +import { RPCHandler } from "@ubiquity-dao/rpc-handler"; + +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; +} 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..a328d14c --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,105 @@ +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 OrderRequestParams { + productId: number; + txHash: string; + chainId: number; +} + +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 c17698d7..fc3baa2d 100644 --- a/static/index.html +++ b/static/index.html @@ -6,6 +6,7 @@ + @@ -55,7 +56,7 @@
- +
@@ -123,7 +124,7 @@ -
+
-
-