diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c9fb88..cefc92d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [0.1.20-beta.5](https://github.com/bandohq/widget/compare/v0.1.20-beta.4...v0.1.20-beta.5) (2025-05-14) + +### [0.1.20-beta.4](https://github.com/bandohq/widget/compare/v0.1.20-beta.3...v0.1.20-beta.4) (2025-05-13) + +### [0.1.20-beta.3](https://github.com/bandohq/widget/compare/v0.1.20-beta.2...v0.1.20-beta.3) (2025-05-12) + +### [0.1.20-beta.2](https://github.com/bandohq/widget/compare/v0.1.20-beta.1...v0.1.20-beta.2) (2025-05-09) + +### [0.1.20-beta.1](https://github.com/bandohq/widget/compare/v0.1.20-beta.0...v0.1.20-beta.1) (2025-05-09) + +### [0.1.20-beta.0](https://github.com/bandohq/widget/compare/v0.1.19...v0.1.20-beta.0) (2025-05-09) + ### [0.1.19](https://github.com/bandohq/widget/compare/v0.1.19-beta.0...v0.1.19) (2025-05-05) ### [0.1.19-beta.0](https://github.com/bandohq/widget/compare/v0.1.18...v0.1.19-beta.0) (2025-05-05) diff --git a/package.json b/package.json index d902655b..ecd5de82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bandohq/widget", - "version": "0.1.19", + "version": "0.1.20-beta.5", "license": "Apache-2.0", "type": "commonjs", "main": "dist/index.js", @@ -51,6 +51,7 @@ "@mui/material": "^6.1.5", "@mui/system": "^6.1.6", "@phosphor-icons/react": "^2.1.7", + "@safe-global/safe-apps-sdk": "^9.1.0", "@solana/wallet-adapter-base": "^0.9.23", "@solana/web3.js": "^1.95.3", "@tanstack/react-virtual": "^3.10.9", diff --git a/src/hooks/useTransactionHelpers.ts b/src/hooks/useTransactionHelpers.ts index edaf99d0..0f935dbe 100644 --- a/src/hooks/useTransactionHelpers.ts +++ b/src/hooks/useTransactionHelpers.ts @@ -2,14 +2,20 @@ import { defineChain, parseUnits } from "viem"; import { transformToChainConfig } from "../utils/TransformToChainConfig"; import BandoRouter from "@bandohq/contract-abis/abis/BandoRouterV1.json"; import nativeTokenCatalog from "../utils/nativeTokenCatalog"; -import { writeContract } from '@wagmi/core' -import { ERC20ApproveABI } from "../utils/abis"; +import { writeContract, readContract } from "@wagmi/core"; +import { ERC20ApproveABI } from "../utils/abis"; import { validateReference } from "../utils/validateReference"; import { useConfig } from "wagmi"; import { useNotificationContext } from "../providers/AlertProvider/NotificationProvider"; import { checkAllowance } from "../utils/checkAllowance"; import { useSteps } from "../providers/StepsProvider/StepsProvider"; import { useCallback } from "react"; +import { + detectMultisig, + sdk, + sendViaSafe, + sendBatchViaSafe, +} from "../utils/safeFunctions"; export const useTransactionHelpers = () => { const config = useConfig(); @@ -28,7 +34,22 @@ export const useTransactionHelpers = () => { chain, config ) => { + const isMultisig = await detectMultisig(); + try { + if (isMultisig) { + const safeResponse = await sendViaSafe({ + to: tokenAddress, + abi: ERC20ApproveABI, + functionName: "approve", + args: [spenderAddress, amount], + }); + + console.log("Safe transaction submitted:", safeResponse); + + return { success: true, isMultisig: true, safeResponse }; + } + await writeContract(config, { address: tokenAddress, abi: ERC20ApproveABI, @@ -38,11 +59,11 @@ export const useTransactionHelpers = () => { account: account?.address, }); - return true; + return { success: true, isMultisig: false }; } catch (error) { showNotification("error", "Error on approving tokens, try later"); console.error("Error on approving tokens:", error); - return false; + return { success: false }; } }; @@ -54,6 +75,8 @@ export const useTransactionHelpers = () => { serviceID, formattedChain, }) => { + const isMultisig = await detectMultisig(); + const requestServiceABI = BandoRouter.abi.find( (item) => item.name === "requestService" ); @@ -68,7 +91,9 @@ export const useTransactionHelpers = () => { ); const payload = { - payer: account?.address, + payer: isMultisig + ? (await sdk.safe.getInfo()).safeAddress + : account?.address, fiatAmount: formatFiatAmount(quote?.totalAmount), serviceRef: txId, weiAmount, @@ -76,20 +101,67 @@ export const useTransactionHelpers = () => { addStep({ message: "form.status.signTransaction", type: "info" }); - await writeContract(config, { - value, - address: chain?.protocolContracts?.BandoRouterProxy, - abi: [requestServiceABI], - functionName: "requestService", - args: [serviceID, payload], - chain: formattedChain, - account: account?.address, - }); + if (isMultisig) { + const safeResponse = await sendViaSafe({ + to: chain?.protocolContracts?.BandoRouterProxy, + abi: [requestServiceABI], + functionName: "requestService", + args: [serviceID, payload], + value: value.toString(), + }); - updateStep({ - message: "form.status.signTransactionCompleted", - type: "completed", - }); + console.log("Safe transaction submitted:", safeResponse); + + updateStep({ + message: "form.status.signTransaction", + type: "info", + description: "form.status.wait", + }); + + return { success: true, isMultisig: true, safeResponse }; + } else { + await writeContract(config, { + value, + address: chain?.protocolContracts?.BandoRouterProxy, + abi: [requestServiceABI], + functionName: "requestService", + chain: formattedChain, + account: account?.address, + }); + + updateStep({ + message: "form.status.signTransactionCompleted", + type: "completed", + }); + + return { success: true, isMultisig: false }; + } + }; + + const checkCurrentAllowance = async ( + spenderAddress, + tokenAddress, + account, + chain, + config + ) => { + try { + const safeAddress = (await sdk.safe.getInfo()).safeAddress; + const ownerAddress = safeAddress || account?.address; + + const allowance = await readContract(config, { + address: tokenAddress, + abi: ERC20ApproveABI, + functionName: "allowance", + args: [ownerAddress, spenderAddress], + chainId: chain.chainId, + }); + + return allowance; + } catch (error) { + console.error("Error checking allowance:", error); + return BigInt(0); + } }; const handleERC20TokenRequest = async ({ @@ -100,6 +172,7 @@ export const useTransactionHelpers = () => { serviceID, token, }) => { + const isMultisig = await detectMultisig(); const totalAmount = parseFloat(quote?.totalAmount); const increaseAmount = totalAmount * 1.01; //Add 1% to the total amount for allowance issue const amountInUnits = parseUnits( @@ -112,7 +185,19 @@ export const useTransactionHelpers = () => { variables: { amount: increaseAmount, tokenSymbol: token?.symbol }, }); - await approveERC20( + if (isMultisig) { + return await handleERC20BatchMultisig({ + chain, + account, + quote, + txId, + serviceID, + token, + amountInUnits, + }); + } + + const approveResult = await approveERC20( chain?.protocolContracts?.BandoRouterProxy, amountInUnits, token.address, @@ -121,6 +206,20 @@ export const useTransactionHelpers = () => { config ); + if (!approveResult.success) { + throw new Error("Failed to approve tokens"); + } + + if (approveResult.isMultisig) { + updateStep({ + message: "form.status.approveTokens", + type: "info", + description: "form.status.wait", + variables: { amount: increaseAmount, tokenSymbol: token?.symbol }, + }); + return approveResult; + } + updateStep({ message: "form.status.validateAllowance", type: "loading" }); await checkAllowance( @@ -129,7 +228,8 @@ export const useTransactionHelpers = () => { account, chain, config, - parseUnits(quote?.totalAmount.toString(), token?.decimals) + parseUnits(quote?.totalAmount.toString(), token?.decimals), + isMultisig ); updateStep({ @@ -142,7 +242,9 @@ export const useTransactionHelpers = () => { ); const payload = { - payer: account?.address, + payer: isMultisig + ? (await sdk.safe.getInfo()).safeAddress + : account?.address, fiatAmount: formatFiatAmount(quote?.totalAmount), serviceRef: txId, token: token.address, @@ -154,19 +256,123 @@ export const useTransactionHelpers = () => { addStep({ message: "form.status.signTransaction", type: "info" }); - await writeContract(config, { - address: chain?.protocolContracts?.BandoRouterProxy, + if (isMultisig) { + const safeResponse = await sendViaSafe({ + to: chain?.protocolContracts?.BandoRouterProxy, + abi: [requestERC20ServiceABI], + functionName: "requestERC20Service", + args: [serviceID, payload], + }); + + console.log("Safe ERC20 transaction submitted:", safeResponse); + + updateStep({ + message: "form.status.signTransaction", + type: "info", + description: "form.status.wait", + }); + + return { success: true, isMultisig: true, safeResponse }; + } else { + await writeContract(config, { + address: chain?.protocolContracts?.BandoRouterProxy, + abi: [requestERC20ServiceABI], + functionName: "requestERC20Service", + args: [serviceID, payload], + chain: chain.chainId, + account: account?.address, + }); + + updateStep({ + message: "form.status.signTransactionCompleted", + type: "completed", + }); + + return { success: true, isMultisig: false }; + } + }; + + const handleERC20BatchMultisig = async ({ + chain, + account, + quote, + txId, + serviceID, + token, + amountInUnits, + }) => { + const safeAddress = (await sdk.safe.getInfo()).safeAddress; + const requestERC20ServiceABI = BandoRouter.abi.find( + (item) => item.name === "requestERC20Service" + ); + + const payload = { + payer: safeAddress, + fiatAmount: formatFiatAmount(quote?.totalAmount), + serviceRef: txId, + token: token.address, + tokenAmount: parseUnits( + quote?.digitalAssetAmount.toString(), + token?.decimals + ), + }; + + // Check if the user has enough allowance + updateStep({ message: "form.status.checkingAllowance", type: "loading" }); + const requiredAmount = parseUnits( + quote?.totalAmount.toString(), + token?.decimals + ); + const currentAllowance = await checkCurrentAllowance( + chain?.protocolContracts?.BandoRouterProxy, + token.address, + account, + chain, + config + ); + + let transactions = []; + + // Only add the approve transaction if necessary + if ( + typeof currentAllowance === "bigint" && + currentAllowance < BigInt(requiredAmount) + ) { + transactions.push({ + to: token.address, + abi: ERC20ApproveABI, + functionName: "approve", + args: [chain?.protocolContracts?.BandoRouterProxy, amountInUnits], + }); + } + + // Add the service request transaction + transactions.push({ + to: chain?.protocolContracts?.BandoRouterProxy, abi: [requestERC20ServiceABI], functionName: "requestERC20Service", args: [serviceID, payload], - chain: chain.chainId, - account: account?.address, }); updateStep({ - message: "form.status.signTransactionCompleted", - type: "completed", + message: + transactions.length > 1 + ? "form.status.batchTransaction" + : "form.status.signTransaction", + type: "info", + description: "form.status.wait", }); + + try { + const safeResponse = await sendBatchViaSafe(transactions); + console.log("Safe batch transaction submitted:", safeResponse); + + return { success: true, isMultisig: true, safeResponse }; + } catch (error) { + showNotification("error", "Error on transaction"); + console.error("Error on batch transaction:", error); + throw error; + } }; const handleServiceRequest = useCallback( @@ -205,8 +411,9 @@ export const useTransactionHelpers = () => { return; } + let result; if (token.key === nativeToken?.native_token.symbol) { - await handleNativeTokenRequest({ + result = await handleNativeTokenRequest({ chain, account, quote, @@ -215,7 +422,7 @@ export const useTransactionHelpers = () => { formattedChain, }); } else { - await handleERC20TokenRequest({ + result = await handleERC20TokenRequest({ chain, account, quote, @@ -225,7 +432,11 @@ export const useTransactionHelpers = () => { }); } - clearStep(); + if (!result.isMultisig) { + clearStep(); + } else { + showNotification("warning", "form.status.wait"); + } } catch (error) { clearStep(); showNotification("error", "Error in handleServiceRequest"); @@ -239,5 +450,6 @@ export const useTransactionHelpers = () => { return { approveERC20, handleServiceRequest, + handleERC20BatchMultisig, }; }; diff --git a/src/pages/TransactionHistory/TransactionDetail.tsx b/src/pages/TransactionHistory/TransactionDetail.tsx index 64441132..64ddbf0f 100644 --- a/src/pages/TransactionHistory/TransactionDetail.tsx +++ b/src/pages/TransactionHistory/TransactionDetail.tsx @@ -4,7 +4,7 @@ import { PageContainer } from "../../components/PageContainer"; import { useHeader } from "../../hooks/useHeader"; import { useAccount } from "@lifi/wallet-management"; import { BottomSheet } from "../../components/BottomSheet/BottomSheet"; -import { defineChain } from "viem"; +import { defineChain, encodeFunctionData } from "viem"; import { Button, List, @@ -30,7 +30,7 @@ import { useEffect, useState } from "react"; import { useNotificationContext } from "../../providers/AlertProvider/NotificationProvider"; import { executeRefund } from "../../utils/refunds"; import { useTheme } from "@mui/system"; -import { s } from "vite/dist/node/types.d-aGj9QkWt"; +import { detectMultisig, sendViaSafe } from "../../utils/safeFunctions"; export const TransactionsDetailPage = () => { const { t, i18n } = useTranslation(); @@ -83,23 +83,49 @@ export const TransactionsDetailPage = () => { if (serviceId && formattedChain) { try { const isNativeToken = nativeToken.key === token.key; + const isMultisig = await detectMultisig(); - await executeRefund({ - config, - chain: formattedChain, - contractAddress: chain?.protocolContracts?.BandoRouterProxy, - abiName: isNativeToken ? "withdrawRefund" : "withdrawERC20Refund", - abi: BandoRouter.abi, - functionName: isNativeToken + if (isMultisig) { + const abiName = isNativeToken ? "withdrawRefund" - : "withdrawERC20Refund", - args: [serviceId, transactionData?.recordId], - accountAddress: account?.address, - }); + : "withdrawERC20Refund"; + const contractABI = BandoRouter.abi.find( + (item) => item.name === abiName + ); - setLoading(false); - setOpen(false); - showNotification("success", t("history.refundSuccess")); + if (!contractABI) { + throw new Error(`ABI for function ${abiName} not found`); + } + + const safeResponse = await sendViaSafe({ + to: chain?.protocolContracts?.BandoRouterProxy, + abi: [contractABI], + functionName: abiName, + args: [serviceId, transactionData?.recordId], + }); + + console.log("Safe refund transaction submitted:", safeResponse); + setLoading(false); + setOpen(false); + showNotification("success", t("history.refundSuccess")); + } else { + await executeRefund({ + config, + chain: formattedChain, + contractAddress: chain?.protocolContracts?.BandoRouterProxy, + abiName: isNativeToken ? "withdrawRefund" : "withdrawERC20Refund", + abi: BandoRouter.abi, + functionName: isNativeToken + ? "withdrawRefund" + : "withdrawERC20Refund", + args: [serviceId, transactionData?.recordId], + accountAddress: account?.address, + }); + + setLoading(false); + setOpen(false); + showNotification("success", t("history.refundSuccess")); + } } catch (error) { setLoading(false); setOpen(false); diff --git a/src/utils/checkAllowance.ts b/src/utils/checkAllowance.ts index 249040c9..39d688a6 100644 --- a/src/utils/checkAllowance.ts +++ b/src/utils/checkAllowance.ts @@ -1,5 +1,6 @@ import { readContract } from "@wagmi/core"; import { ERC20AllowanceABI } from "../utils/abis"; +import { detectMultisig, sdk } from "./safeFunctions"; export const checkAllowance = async ( spenderAddress, @@ -7,23 +8,43 @@ export const checkAllowance = async ( account, chain, config, - amount + amount, + skipWaitForSafe = false ) => { const maxAttempts = 10; const delay = 5000; let attempt = 0; let allowance = BigInt(0); + const isMultisig = await detectMultisig(); + + // For safe transactions, if skipWaitForSafe is true, we don't wait for the allowance check + if (isMultisig && skipWaitForSafe) { + console.log("Safe transaction detected, skipping allowance check wait"); + return BigInt(amount.toString()); + } while (attempt < maxAttempts) { try { attempt++; - allowance = (await readContract(config, { - address: tokenAddress, - abi: ERC20AllowanceABI, - functionName: "allowance", - args: [account?.address, spenderAddress], - chainId: chain?.chainId, - })) as bigint; + + if (isMultisig) { + const safeInfo = await sdk.safe.getInfo(); + allowance = (await readContract(config, { + address: tokenAddress, + abi: ERC20AllowanceABI, + functionName: "allowance", + args: [safeInfo.safeAddress, spenderAddress], + chainId: chain?.chainId, + })) as bigint; + } else { + allowance = (await readContract(config, { + address: tokenAddress, + abi: ERC20AllowanceABI, + functionName: "allowance", + args: [account?.address, spenderAddress], + chainId: chain?.chainId, + })) as bigint; + } console.log( `Allowance check attempt ${attempt}:`, diff --git a/src/utils/safeFunctions.ts b/src/utils/safeFunctions.ts new file mode 100644 index 00000000..d5508230 --- /dev/null +++ b/src/utils/safeFunctions.ts @@ -0,0 +1,54 @@ +import SafeAppsSDK from "@safe-global/safe-apps-sdk"; +import { encodeFunctionData } from "viem"; + +export const sdk = new SafeAppsSDK(); + +export const detectMultisig = async (): Promise => { + try { + const info = await Promise.race([ + sdk.safe.getInfo(), + new Promise((res) => setTimeout(res, 300)), + ]); + return !!info?.safeAddress; + } catch { + return false; + } +}; + +export const sendViaSafe = async ({ + to, + abi, + functionName, + args, + value = "0", +}) => { + const data = encodeFunctionData({ + abi, + functionName, + args, + }); + + const response = await sdk.txs.send({ + txs: [{ to, value, data }], + }); + + return response; +}; + +export const sendBatchViaSafe = async (transactions) => { + const txs = transactions.map( + ({ to, abi, functionName, args, value = "0" }) => { + const data = encodeFunctionData({ + abi, + functionName, + args, + }); + + return { to, value, data }; + } + ); + + const response = await sdk.txs.send({ txs }); + + return response; +};