Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: add an approval/funding UI #30

Open
wants to merge 53 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
bc37549
feat: rollback to template
zugdev Nov 20, 2024
6719d29
chore: install dependencies
zugdev Nov 21, 2024
dc3aaaa
feat: base UI
zugdev Nov 21, 2024
68e1715
feat: add network button
zugdev Nov 21, 2024
a220ca3
feat: add chains
zugdev Nov 21, 2024
f6a4e17
chore: format
zugdev Nov 21, 2024
4571b5e
feat: basic styiling and form
zugdev Nov 21, 2024
287505d
feat: add current allowance
zugdev Nov 21, 2024
a3caa79
feat: add neon border
zugdev Nov 26, 2024
9634f81
feat: add token-selector and change expected allowance
zugdev Nov 26, 2024
edd0772
feat: fix token selector
zugdev Nov 26, 2024
3befb07
feat: update dropdown tokens based on network
zugdev Nov 26, 2024
03b9dcf
feat: add erc20 abi
zugdev Nov 26, 2024
6fd952e
feat: rollback to current allowance
zugdev Nov 26, 2024
9d1ffb8
feat: add permit2 address table
zugdev Nov 26, 2024
19f3bbf
feat: add web3 integration
zugdev Nov 26, 2024
5b93446
feat: initially disabled
zugdev Nov 26, 2024
5f48675
feat: better error modal
zugdev Nov 26, 2024
18adb4e
feat: all providers
zugdev Dec 13, 2024
1337208
chore: pull alchemy key from secrets on build
zugdev Dec 13, 2024
3c77a03
chore: yarn
zugdev Dec 18, 2024
6478209
feat: make ALCHEMY_KEY optional
zugdev Dec 18, 2024
4605da3
feat: enable anvil in localhost
zugdev Dec 18, 2024
e2149ad
chore: prettier
zugdev Dec 18, 2024
89b0f04
feat: batch update
zugdev Dec 21, 2024
ba36a2c
feat: responsivity
zugdev Dec 21, 2024
6bec149
chore: fix eslint version
zugdev Dec 21, 2024
1f81afa
Merge branch 'development' of https://github.com/zugdev/onboard.ubq.f…
zugdev Dec 21, 2024
8ea7037
chore: unlog
zugdev Dec 21, 2024
7878d48
feat: fix signer
zugdev Dec 21, 2024
fa44c95
feat: loading while minting tx
zugdev Dec 21, 2024
f421786
feat: capitalize
zugdev Dec 21, 2024
c526d60
feat: get rid of chainId
zugdev Dec 21, 2024
b5c844a
feat: remove button setup
zugdev Dec 21, 2024
f801b95
feat: better naming
zugdev Dec 21, 2024
3ffcf15
feat: add reference for zksync permit2 address
zugdev Dec 21, 2024
bded1b3
feat: use actions/create-github-app-token
zugdev Dec 21, 2024
8f8fa38
feat: getAllowance on address only
zugdev Dec 21, 2024
c22af00
feat: add success modal
zugdev Dec 21, 2024
c0ca66d
feat: success pop up
zugdev Dec 21, 2024
228f7d1
chore: fix jest
zugdev Dec 26, 2024
834b8dd
feat: add explorers map
zugdev Dec 26, 2024
1bd7ca8
feat: render success in revoke
zugdev Dec 26, 2024
8105aab
feat: add tx linkback in success
zugdev Dec 26, 2024
9299ea3
feat: remove alchemy from deploy
zugdev Dec 26, 2024
23f3344
chore: fix knip
zugdev Dec 26, 2024
f04f3d1
chore: typo
zugdev Dec 26, 2024
656e862
chore: missing div
zugdev Dec 26, 2024
58edb53
Merge branch 'development' of github.com:zugdev/onboard.ubq.fi into d…
zugdev Dec 26, 2024
c906697
feat: const
zugdev Jan 9, 2025
78fb771
chore: delete outdated
zugdev Jan 9, 2025
b525724
feat: feedback on erc20 fail
zugdev Jan 9, 2025
326dbc2
chore: cspell
zugdev Jan 9, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
ALCHEMY_KEY=""
13 changes: 12 additions & 1 deletion .github/knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ const config: KnipConfig = {
ignore: ["src/types/config.ts", "**/__mocks__/**", "**/__fixtures__/**"],
ignoreExportsUsedInFile: true,
// eslint can also be safely ignored as per the docs: https://knip.dev/guides/handling-issues#eslint--jest
ignoreDependencies: ["eslint-config-prettier", "eslint-plugin-prettier", "@types/jest", "@mswjs/data"],
ignoreDependencies: [
"eslint-config-prettier",
"eslint-plugin-prettier",
"@types/jest",
"@mswjs/data",
"@coinbase/wallet-sdk",
"@reown/appkit",
"@reown/appkit-adapter-ethers5",
"ethers",
"@types/react",
"react",
],
eslint: true,
};

Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ jobs:
run: |
yarn
yarn build
env:
ALCHEMY_KEY: ${{ secrets.ALCHEMY_KEY }}

- name: Upload build artifact
uses: actions/upload-artifact@v4
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/no-empty-strings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ jobs:
with:
node-version: "20.10.0"
- name: Get GitHub App token
uses: tibdex/github-app-token@v1.7.0
id: get_installation_token
uses: actions/create-github-app-token
id: get_app_token
with:
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }}
Expand All @@ -26,7 +26,7 @@ jobs:
run: |
yarn tsx .github/empty-string-checker.ts
env:
GITHUB_TOKEN: ${{ steps.get_installation_token.outputs.token }}
GITHUB_TOKEN: ${{ steps.get_app_token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_BASE_REF: ${{ github.base_ref }}
37 changes: 17 additions & 20 deletions build/esbuild-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ export const esbuildOptions: BuildOptions = {
minify: false,
loader: Object.fromEntries(DATA_URL_LOADERS.map((ext) => [ext, "dataurl"])),
outdir: "static/dist",
define: createEnvDefines(["ALCHEMY_KEY"], {
ALCHEMY_KEY: process.env.ALCHEMY_KEY || "",
}),
};

async function runBuild() {
Expand All @@ -33,20 +30,20 @@ async function runBuild() {

void runBuild();

function createEnvDefines(environmentVariables: string[], generatedAtBuild: Record<string, unknown>): Record<string, string> {
const defines: Record<string, string> = {};
for (const name of environmentVariables) {
const envVar = process.env[name];
if (envVar !== undefined) {
defines[name] = JSON.stringify(envVar);
} else {
throw new Error(`Missing environment variable: ${name}`);
}
}
for (const key in generatedAtBuild) {
if (Object.prototype.hasOwnProperty.call(generatedAtBuild, key)) {
defines[key] = JSON.stringify(generatedAtBuild[key]);
}
}
return defines;
}
// function createEnvDefines(environmentVariables: string[], generatedAtBuild: Record<string, unknown>): Record<string, string> {
// const defines: Record<string, string> = {};
// for (const name of environmentVariables) {
// const envVar = process.env[name];
// if (envVar !== undefined) {
// defines[name] = JSON.stringify(envVar);
// } else {
// throw new Error(`Missing environment variable: ${name}`);
// }
// }
// for (const key in generatedAtBuild) {
// if (Object.prototype.hasOwnProperty.call(generatedAtBuild, key)) {
// defines[key] = JSON.stringify(generatedAtBuild[key]);
// }
// }
// return defines;
// }
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-filename-rules": "^1.3.1",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-sonarjs": "^2.0.3",
"eslint-plugin-sonarjs": "^0.24.0",
"husky": "^9.0.11",
"jest": "29.7.0",
"jest-junit": "16.0.0",
Expand Down
46 changes: 43 additions & 3 deletions static/display-popup-modal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { appState, explorersUrl } from "./main";

export function renderErrorInModal(error: Error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for common errors like ACTION_REJECTED you can display a more user-friendly error message

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already display action rejected in a pretty way, also we try to avoid all errors instead of better displaying them. insufficient funds, missing new, numeric fault .. are handled. we could handle nonce id if user waits tooooo long to sign tho

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be pretty enough for a developer but it's not really pretty a normal user though

const modal = document.getElementById("modal");
const closeButton = document.getElementsByClassName("close-modal");
const modal = document.getElementById("error-modal");
const closeButton = document.getElementsByClassName("error-close-modal");
if (closeButton) {
closeButton[0].addEventListener("click", closeErrorModal);
}
Expand All @@ -16,7 +18,45 @@ export function renderErrorInModal(error: Error) {
}

export function closeErrorModal() {
const modal = document.getElementById("modal");
const modal = document.getElementById("error-modal");
if (modal) {
modal.style.display = "none";
}
}

export function renderSuccessModal(transactionHash: string) {
const modal = document.getElementById("success-modal");
const closeButton = document.getElementsByClassName("success-close-modal");
if (closeButton) {
closeButton[0].addEventListener("click", closeSuccessModal);
}
const successMessageElement = document.getElementById("success-message");

if (successMessageElement) {
successMessageElement.innerHTML = `You've successfully signed the transaction. Your allowance balance should be updated in a few blocks.<br><br>transaction hash: <span class="tx-hash">${transactionHash}</span>`;
const chainId = appState.getChainId();
const explorerUrl = chainId !== undefined ? explorersUrl[chainId] : "";
const txLink = document.createElement("a");
txLink.href = `${explorerUrl}/tx/${transactionHash}`;
txLink.target = "_blank";
txLink.rel = "noopener noreferrer";
txLink.style.color = "white";
txLink.textContent = transactionHash;

const txHashElement = successMessageElement.querySelector(".tx-hash");
if (txHashElement) {
txHashElement.innerHTML = "";
txHashElement.appendChild(txLink);
}
}

if (modal) {
modal.style.display = "flex";
}
}

export function closeSuccessModal() {
const modal = document.getElementById("success-modal");
if (modal) {
modal.style.display = "none";
}
Expand Down
131 changes: 85 additions & 46 deletions static/handle-approval.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { erc20Abi } from "./abis";
import { renderErrorInModal } from "./display-popup-modal";
import { renderErrorInModal, renderSuccessModal } from "./display-popup-modal";
import { appState, provider, userSigner } from "./main";
import { getPermit2Address } from "./permit2-addresses";
import { ethers } from "ethers";
Expand All @@ -11,53 +11,54 @@ const approveButton = document.querySelector(".approve-button") as HTMLButtonEle
const revokeButton = document.querySelector(".revoke-button") as HTMLButtonElement;

function isValidAddress(): boolean {
// Check if the address is 20 bytes long (40 characters) excluding the '0x' prefix
const result = /^0x[a-fA-F0-9]{40}$/.test(addressInput.value);
const isValid = /^0x[a-fA-F0-9]{40}$/.test(addressInput.value);

console.log("a");

if (result) {
if (isValid) {
addressInput.style.border = "1px solid #5af55a";
} else if (addressInput.value === "") {
addressInput.style.border = "1px solid rgba(255, 255, 255, 0.1)";
} else {
addressInput.style.border = "1px solid red";
}

return result;
return isValid;
}

function isValidAmount(): boolean {
// Check if the amount is a positive number
const result = !isNaN(Number(amountInput.value)) && Number(amountInput.value) > 0;
const isValid = !isNaN(Number(amountInput.value)) && Number(amountInput.value) > 0;

if (result) {
if (isValid) {
amountInput.style.border = "1px solid #5af55a";
} else if (amountInput.value === "") {
amountInput.style.border = "1px solid rgba(255, 255, 255, 0.1)";
} else {
amountInput.style.border = "1px solid red";
}

return result;
return isValid;
}

function isApprovalValid() {
const addressValidity = isValidAddress();
const amountValidity = isValidAmount();
const isValid = addressValidity && amountValidity;
export function isApprovalButtonsValid() {
const isConnected = appState.getIsConnectedState();
const isAddressValid = isValidAddress();
const isAmountValid = isValidAmount();

approveButton.disabled = !isValid;
revokeButton.disabled = !addressValidity;
approveButton.disabled = !(isConnected && isAddressValid && isAmountValid);
revokeButton.disabled = !(isConnected && isAddressValid);

if (addressValidity && appState.getIsConnectedState()) {
if (isAddressValid && isConnected) {
void getCurrentAllowance();
}
}

async function getCurrentAllowance() {
if (!provider) {
console.error("Provider is not initialized");
return;
}

const tokenAddress = addressInput.value;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might wanna check if the address is ERC20 because if put a random address then it throws a revert error and it can be confusing to the users

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

managed that, thanks

const permit2Address = getPermit2Address(appState.getCaipNetworkId() as number);
const permit2Address = getPermit2Address(appState.getChainId() as number);
const userAddress = appState.getAddress();
const tokenContract = new ethers.Contract(tokenAddress, erc20Abi, provider);

Expand All @@ -75,42 +76,80 @@ async function getCurrentAllowance() {
}

export function setupApproveButton() {
approveButton.addEventListener("click", async () => {
approveButton.removeEventListener("click", onApproveClick); // ensure no duplicate listeners
approveButton.addEventListener("click", onApproveClick);
}

async function onApproveClick() {
if (!userSigner) {
console.error("No signer available. Cannot send transaction.");
return;
}

const originalText = approveButton.textContent;
try {
approveButton.textContent = "Loading...";
approveButton.disabled = true;
revokeButton.disabled = true; // disable revoke as well to prevent conflicting actions

const tokenAddress = addressInput.value;
const permit2Address = getPermit2Address(appState.getCaipNetworkId() as number);
const permit2Address = getPermit2Address(appState.getChainId() as number);
const tokenContract = new ethers.Contract(tokenAddress, erc20Abi, userSigner);
const decimals = await tokenContract.decimals();
const amount = ethers.utils.parseUnits(amountInput.value, decimals);

try {
const decimals = await tokenContract.decimals();
const amount = ethers.utils.parseUnits(amountInput.value, decimals);
const tx = await tokenContract.connect(userSigner).approve(permit2Address, amount);
await tx.wait();
await getCurrentAllowance();
} catch (error) {
console.error("Error approving allowance:", error);
renderErrorInModal(error as Error);
}
});
const tx = await tokenContract.approve(permit2Address, amount);
await tx.wait();

renderSuccessModal(tx.hash);

await getCurrentAllowance();
} catch (error) {
console.error("Error approving allowance:", error);
renderErrorInModal(error as Error);
} finally {
approveButton.textContent = originalText;
isApprovalButtonsValid(); // re-check the state to restore buttons correctly
}
}

export function setupRevokeButton() {
revokeButton.addEventListener("click", async () => {
revokeButton.removeEventListener("click", onRevokeClick); // ensure no duplicate listeners
revokeButton.addEventListener("click", onRevokeClick);
}

async function onRevokeClick() {
if (!userSigner) {
console.error("No signer available. Cannot send transaction.");
return;
}

const originalText = revokeButton.textContent;
try {
revokeButton.textContent = "Loading...";
revokeButton.disabled = true;
approveButton.disabled = true; // disable approve as well

const tokenAddress = addressInput.value;
const permit2Address = getPermit2Address(appState.getCaipNetworkId() as number);
const permit2Address = getPermit2Address(appState.getChainId() as number);
const tokenContract = new ethers.Contract(tokenAddress, erc20Abi, userSigner);

try {
const tx = await tokenContract.connect(userSigner).approve(permit2Address, 0);
await tx.wait();
await getCurrentAllowance();
} catch (error) {
console.error("Error revoking allowance:", error);
renderErrorInModal(error as Error);
}
});
const tx = await tokenContract.approve(permit2Address, 0);
await tx.wait();

renderSuccessModal(tx.hash);

await getCurrentAllowance();
} catch (error) {
console.error("Error revoking allowance:", error);
renderErrorInModal(error as Error);
} finally {
revokeButton.textContent = originalText;
isApprovalButtonsValid(); // re-check state to restore buttons correctly
}
}

export function setupValidityListener() {
amountInput.addEventListener("change", isApprovalValid);
addressInput.addEventListener("change", isApprovalValid);
export function setupButtonValidityListener() {
amountInput.addEventListener("change", isApprovalButtonsValid);
addressInput.addEventListener("change", isApprovalButtonsValid);
}
Loading
Loading