Files
smom-dbis-138/frontend-dapp/src/components/bridge/BridgeButtons.tsx
zaragoza444 5dd67e2b1d
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m22s
CI/CD Pipeline / Security Scanning (push) Successful in 2m30s
CI/CD Pipeline / Lint and Format (push) Failing after 44s
CI/CD Pipeline / Terraform Validation (push) Failing after 27s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 31s
Deploy ChainID 138 / Deploy ChainID 138 (push) Failing after 56s
HYBX OMNL TypeScript & anchor / token-aggregation build + reconcile artifact (push) Failing after 29s
Validation / validate-genesis (push) Successful in 34s
Validation / validate-terraform (push) Failing after 39s
Validation / validate-kubernetes (push) Failing after 14s
Validation / validate-smart-contracts (push) Failing after 20s
Validation / validate-security (push) Failing after 1m19s
Validation / validate-documentation (push) Failing after 27s
Verify Deployment / Verify Deployment (push) Failing after 1m13s
feat: OMNL Bank online production — 128 chains, Central Bank UI, settlement stack
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 08:49:36 -07:00

682 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file BridgeButtons.tsx
* @notice Custom bridge UI with Wrap, Approve, and Bridge buttons using thirdweb
*/
import {
useContract,
useContractWrite,
useContractRead,
useAddress,
useBalance,
} from '@thirdweb-dev/react';
import { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import toast from 'react-hot-toast';
import {
CONTRACTS,
CHAIN_SELECTORS,
BRIDGE_ABI,
WETH9_ABI,
ERC20_ABI,
CCIP_DESTINATIONS,
} from '../../config/bridge';
import CopyButton from '../ui/CopyButton';
import ConfirmationModal from '../ui/ConfirmationModal';
import Tooltip from '../ui/Tooltip';
import LoadingSkeleton from '../ui/LoadingSkeleton';
import TokenIcon from '../ui/TokenIcon';
interface BridgeButtonsProps {
destinationChainSelector?: string; // Defaults to Ethereum Mainnet
recipientAddress?: string; // Defaults to connected wallet
}
function getErrorMessage(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const maybeMessage = 'message' in error ? error.message : undefined
const maybeReason = 'reason' in error ? error.reason : undefined
if (typeof maybeMessage === 'string' && maybeMessage.length > 0) return maybeMessage
if (typeof maybeReason === 'string' && maybeReason.length > 0) return maybeReason
}
return 'Unknown error'
}
/** Fetches CCIP fee only when amount > 0 (bridge reverts on zero). Reports result to parent via onFee. */
function CalculateFeeFetcher({
bridgeContract,
destinationChainSelector,
amountWei,
onFee,
}: {
bridgeContract: NonNullable<ReturnType<typeof useContract>['contract']>;
destinationChainSelector: string;
amountWei: ethers.BigNumber;
onFee: (value: ethers.BigNumber | null) => void;
}) {
const { data } = useContractRead(bridgeContract, 'calculateFee', [
destinationChainSelector,
amountWei,
]);
useEffect(() => {
if (data != null) onFee(ethers.BigNumber.from(data.toString()));
return () => {};
}, [data, onFee]);
return null;
}
/** Inner bridge form: only mounted when address is set so balance/allowance are never called with zero address. */
function BridgeButtonsConnected({
address,
destinationChainSelector = CHAIN_SELECTORS.ETHEREUM_MAINNET,
recipientAddress,
}: BridgeButtonsProps & { address: string }) {
const [amount, setAmount] = useState<string>('');
const [recipient, setRecipient] = useState<string>(recipientAddress || address || '');
const [isWrapping, setIsWrapping] = useState(false);
const [isApproving, setIsApproving] = useState(false);
const [isBridging, setIsBridging] = useState(false);
const [showWrapModal, setShowWrapModal] = useState(false);
const [showApproveModal, setShowApproveModal] = useState(false);
const [showBridgeModal, setShowBridgeModal] = useState(false);
const [amountError, setAmountError] = useState<string>('');
const [recipientError, setRecipientError] = useState<string>('');
const [ccipFee, setCcipFee] = useState<ethers.BigNumber | null>(null);
const { contract: weth9Contract } = useContract(CONTRACTS.WETH9, WETH9_ABI);
const { contract: bridgeContract } = useContract(CONTRACTS.WETH9_BRIDGE, BRIDGE_ABI);
const { contract: linkContract } = useContract(CONTRACTS.LINK_TOKEN, ERC20_ABI);
const { data: ethBalance, refetch: refetchEthBalance } = useBalance();
const { data: weth9Balance, refetch: refetchWeth9Balance } = useContractRead(
weth9Contract,
'balanceOf',
weth9Contract ? [address] : undefined
);
const { data: linkBalance, refetch: refetchLinkBalance } = useContractRead(
linkContract,
'balanceOf',
linkContract ? [address] : undefined
);
const { data: weth9Allowance, refetch: refetchWeth9Allowance } = useContractRead(
weth9Contract,
'allowance',
weth9Contract && bridgeContract ? [address, CONTRACTS.WETH9_BRIDGE] : undefined
);
const { data: linkAllowance, refetch: refetchLinkAllowance } = useContractRead(
linkContract,
'allowance',
linkContract && bridgeContract ? [address, CONTRACTS.WETH9_BRIDGE] : undefined
);
const amountWei = amount && !isNaN(parseFloat(amount)) && parseFloat(amount) > 0
? ethers.utils.parseEther(amount)
: ethers.BigNumber.from(0);
useEffect(() => {
if (!recipientAddress) setRecipient((r) => r || address);
}, [address, recipientAddress]);
useEffect(() => {
if (!amount) {
setAmountError('');
return;
}
const numAmount = parseFloat(amount);
if (isNaN(numAmount) || numAmount <= 0) {
setAmountError('Amount must be greater than 0');
} else if (ethBalance && numAmount > parseFloat(ethBalance.displayValue)) {
setAmountError('Insufficient ETH balance');
} else {
setAmountError('');
}
}, [amount, ethBalance]);
useEffect(() => {
if (!recipient) {
setRecipientError('');
return;
}
setRecipientError(ethers.utils.isAddress(recipient) ? '' : 'Invalid Ethereum address');
}, [recipient]);
useEffect(() => {
if (!amountWei.gt(0)) setCcipFee(null);
}, [amountWei]);
// Write operations
const { mutateAsync: wrapETH } = useContractWrite(weth9Contract, 'deposit');
const { mutateAsync: approveWETH9 } = useContractWrite(weth9Contract, 'approve');
const { mutateAsync: approveLINK } = useContractWrite(linkContract, 'approve');
const { mutateAsync: sendCrossChain } = useContractWrite(bridgeContract, 'sendCrossChain');
const handleRefreshBalances = async () => {
try {
await Promise.all([
refetchEthBalance().catch(() => {}),
refetchWeth9Balance().catch(() => {}),
refetchLinkBalance().catch(() => {}),
refetchWeth9Allowance().catch(() => {}),
refetchLinkAllowance().catch(() => {}),
]);
toast.success('Balances refreshed');
} catch (error) {
// Silently handle errors - some refetches may fail if contracts aren't ready
toast.success('Balances refreshed');
}
};
const handleWrap = async () => {
if (!address) {
toast.error('Please connect your wallet');
return;
}
if (!amount || parseFloat(amount) <= 0) {
toast.error('Please enter an amount');
return;
}
if (amountError) {
toast.error(amountError);
return;
}
if (!ethBalance || parseFloat(ethBalance.displayValue) < parseFloat(amount)) {
toast.error('Insufficient ETH balance');
return;
}
setIsWrapping(true);
const toastId = toast.loading('Wrapping ETH...');
try {
const tx = await wrapETH({
overrides: {
value: ethers.utils.parseEther(amount),
},
});
console.log('Wrap transaction:', tx);
toast.success('ETH wrapped successfully!', { id: toastId });
setAmount('');
await handleRefreshBalances();
} catch (error: unknown) {
console.error('Wrap error:', error);
toast.error(`Wrap failed: ${getErrorMessage(error)}`, { id: toastId });
} finally {
setIsWrapping(false);
}
};
const handleApprove = async () => {
if (!address) {
toast.error('Please connect your wallet');
return;
}
if (!amount || parseFloat(amount) <= 0) {
toast.error('Please enter an amount');
return;
}
setIsApproving(true);
const toastId = toast.loading('Approving tokens...');
try {
const amountWei = ethers.utils.parseEther(amount);
const maxApproval = ethers.constants.MaxUint256;
// Approve WETH9
const weth9AllowanceBN = weth9Allowance
? ethers.BigNumber.from(weth9Allowance.toString())
: ethers.BigNumber.from(0);
if (weth9AllowanceBN.lt(amountWei)) {
const weth9Tx = await approveWETH9({
args: [CONTRACTS.WETH9_BRIDGE, maxApproval],
});
console.log('WETH9 approval transaction:', weth9Tx);
toast.success('WETH9 approved', { id: toastId });
}
// Approve LINK for fees (if needed)
if (ccipFee?.gt(0)) {
const linkAllowanceBN = linkAllowance
? ethers.BigNumber.from(linkAllowance.toString())
: ethers.BigNumber.from(0);
if (linkAllowanceBN.lt(ccipFee)) {
const linkTx = await approveLINK({
args: [CONTRACTS.WETH9_BRIDGE, maxApproval],
});
console.log('LINK approval transaction:', linkTx);
toast.success('LINK approved', { id: toastId });
}
}
toast.success('All approvals successful!', { id: toastId });
await handleRefreshBalances();
} catch (error: unknown) {
console.error('Approve error:', error);
toast.error(`Approval failed: ${getErrorMessage(error)}`, { id: toastId });
} finally {
setIsApproving(false);
}
};
const handleBridge = async () => {
if (!address) {
toast.error('Please connect your wallet');
return;
}
if (!amount || parseFloat(amount) <= 0) {
toast.error('Please enter an amount');
return;
}
if (amountError || recipientError) {
toast.error('Please fix the errors before bridging');
return;
}
if (!recipient || !ethers.utils.isAddress(recipient)) {
toast.error('Please enter a valid recipient address');
return;
}
const amountWei = ethers.utils.parseEther(amount);
const weth9BalanceBN = weth9Balance
? ethers.BigNumber.from(weth9Balance.toString())
: ethers.BigNumber.from(0);
if (weth9BalanceBN.lt(amountWei)) {
toast.error('Insufficient WETH9 balance. Please wrap ETH first.');
return;
}
const weth9AllowanceBN = weth9Allowance
? ethers.BigNumber.from(weth9Allowance.toString())
: ethers.BigNumber.from(0);
if (weth9AllowanceBN.lt(amountWei)) {
toast.error('Insufficient WETH9 allowance. Please approve first.');
return;
}
if (ccipFee?.gt(0)) {
const linkBalanceBN = linkBalance
? ethers.BigNumber.from(linkBalance.toString())
: ethers.BigNumber.from(0);
if (linkBalanceBN.lt(ccipFee)) {
const feeFormatted = ethers.utils.formatEther(ccipFee);
toast.error(`Insufficient LINK for fees. Required: ${feeFormatted} LINK`);
return;
}
}
setIsBridging(true);
const toastId = toast.loading('Bridging tokens...');
try {
const result = await sendCrossChain({
args: [
destinationChainSelector, // destinationChainSelector
recipient, // recipient
amountWei, // amount
],
});
console.log('Bridge transaction:', result);
const txHash = result.receipt?.transactionHash || 'N/A';
toast.success(
<div>
<p className="font-semibold">Bridge transaction sent!</p>
<p className="text-xs mt-1">TX: {txHash.slice(0, 10)}...{txHash.slice(-8)}</p>
</div>,
{ id: toastId, duration: 6000 }
);
setAmount('');
await handleRefreshBalances();
} catch (error: unknown) {
console.error('Bridge error:', error);
toast.error(`Bridge failed: ${getErrorMessage(error)}`, { id: toastId });
} finally {
setIsBridging(false);
}
};
// Button state calculations
const needsWrapping =
ethBalance &&
amount &&
parseFloat(amount) > 0 &&
parseFloat(ethBalance.displayValue) >= parseFloat(amount) &&
!amountError;
const needsApproval =
amount &&
parseFloat(amount) > 0 &&
weth9Allowance &&
ethers.BigNumber.from(weth9Allowance.toString()).lt(ethers.utils.parseEther(amount)) &&
!amountError;
const canBridge =
amount &&
parseFloat(amount) > 0 &&
!amountError &&
!recipientError &&
weth9Balance &&
ethers.BigNumber.from(weth9Balance.toString()).gte(ethers.utils.parseEther(amount)) &&
weth9Allowance &&
ethers.BigNumber.from(weth9Allowance.toString()).gte(ethers.utils.parseEther(amount)) &&
recipient &&
ethers.utils.isAddress(recipient);
const destination = CCIP_DESTINATIONS.find((d) => d.selector === destinationChainSelector) ?? null;
return (
<div className="w-full">
{bridgeContract && amountWei.gt(0) && (
<CalculateFeeFetcher
bridgeContract={bridgeContract}
destinationChainSelector={destinationChainSelector}
amountWei={amountWei}
onFee={setCcipFee}
/>
)}
<div className="relative">
<div className="mb-6 flex items-center justify-between gap-3">
<p className="ui-muted text-sm">
Wrap ETH, approve tokens, and bridge WETH9 via CCIP to{' '}
{destination?.name ?? 'Ethereum Mainnet'}
</p>
<Tooltip content="Refresh all balances and allowances">
<button
type="button"
onClick={handleRefreshBalances}
disabled={!address}
className="ui-btn-icon disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
aria-label="Refresh balances"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</Tooltip>
</div>
<div className="mb-8">
<label className="ui-label">
<TokenIcon symbol="ETH" size={20} />
<span>Amount (ETH)</span>
<Tooltip content="Enter the amount of ETH you want to bridge. This will be wrapped to WETH9 first.">
<span className="text-white/60 cursor-help text-xs"></span>
</Tooltip>
</label>
<div className="relative">
<input
type="number"
step="0.001"
min="0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className={`ui-input pl-4 pr-24 text-lg ${
amountError ? '!border-red-500 focus:!border-red-500' : ''
}`}
placeholder="0.0"
aria-invalid={!!amountError}
aria-describedby={amountError ? 'amount-error' : undefined}
/>
{ethBalance && (
<button
onClick={() => {
setAmount(ethBalance.displayValue);
setAmountError('');
}}
className="ui-btn-primary absolute right-2 top-1/2 -translate-y-1/2 !py-2 !min-h-0 text-sm"
>
MAX
</button>
)}
</div>
{amountError && (
<p id="amount-error" className="mt-2 text-sm text-red-600 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{amountError}
</p>
)}
</div>
<div className="mb-8">
<label htmlFor="bridge-recipient-address" className="ui-label">
Recipient Address
<Tooltip content="The Ethereum address that will receive the bridged tokens on the destination chain.">
<span className="ml-2 text-white/60 cursor-help text-xs"></span>
</Tooltip>
</label>
<div className="relative">
<input
id="bridge-recipient-address"
name="recipient"
type="text"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
className={`ui-input font-mono pr-36 ${
recipientError ? '!border-red-500 focus:!border-red-500' : ''
}`}
placeholder="0x..."
aria-invalid={!!recipientError}
aria-describedby={recipientError ? 'recipient-error' : undefined}
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{address && (
<button
onClick={() => {
setRecipient(address);
setRecipientError('');
}}
className="ui-btn-secondary !py-2 text-sm"
>
Use my address
</button>
)}
{recipient && ethers.utils.isAddress(recipient) && (
<CopyButton text={recipient} />
)}
</div>
</div>
{recipientError && (
<p id="recipient-error" className="mt-2 text-sm text-red-600 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{recipientError}
</p>
)}
</div>
<div className="mb-8 p-6 ui-inset rounded-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold ui-muted">Balances & Fees</h3>
{address && (
<CopyButton text={address} className="text-xs">
<span className="text-xs">Copy Address</span>
</CopyButton>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="ui-stat-row">
<span className="ui-muted font-medium flex items-center gap-2">
<TokenIcon symbol="ETH" size={20} />
ETH Balance:
</span>
<span className="font-medium">
{ethBalance ? ethBalance.displayValue : <LoadingSkeleton />} <span className="ui-muted">ETH</span>
</span>
</div>
<div className="ui-stat-row">
<span className="ui-muted font-medium flex items-center gap-2">
<TokenIcon symbol="WETH9" size={20} />
WETH9 Balance:
</span>
<span className="font-medium">
{address && weth9Balance !== undefined
? `${ethers.utils.formatEther(weth9Balance.toString())}`
: address
? <LoadingSkeleton />
: '0'} <span className="ui-muted">WETH9</span>
</span>
</div>
<div className="ui-stat-row">
<span className="ui-muted font-medium flex items-center gap-2">
<TokenIcon symbol="LINK" size={20} />
LINK Balance:
</span>
<span className="font-medium">
{address && linkBalance !== undefined
? `${ethers.utils.formatEther(linkBalance.toString())}`
: address
? <LoadingSkeleton />
: '0'} <span className="ui-muted">LINK</span>
</span>
</div>
{ccipFee != null && ccipFee.gt(0) && (
<div className="ui-stat-row ui-stat-row--highlight">
<span className="ui-muted font-medium flex items-center gap-2">
<TokenIcon symbol="LINK" size={20} />
CCIP Fee:
</span>
<span className="font-medium">
{ethers.utils.formatEther(ccipFee)} <span className="ui-muted">LINK</span>
</span>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<button
onClick={() => setShowWrapModal(true)}
disabled={!needsWrapping || !address || isWrapping}
className="ui-btn-secondary w-full"
aria-label="Wrap ETH to WETH9"
>
{isWrapping ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Wrapping...
</span>
) : (
'Wrap (Deposit)'
)}
</button>
<button
onClick={() => setShowApproveModal(true)}
disabled={!needsApproval || !address || isApproving}
className="ui-btn-secondary w-full"
aria-label="Approve tokens for bridge"
>
{isApproving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Approving...
</span>
) : (
'Approve'
)}
</button>
</div>
<div className="mb-8">
<button
onClick={() => setShowBridgeModal(true)}
disabled={!canBridge || !address || isBridging}
className="ui-btn-primary w-full min-h-[56px] text-lg"
aria-label="Start bridge transfer"
>
{isBridging ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Bridging...
</span>
) : (
'Start Bridge Transfer'
)}
</button>
</div>
{/* Confirmation Modals */}
<ConfirmationModal
isOpen={showWrapModal}
onClose={() => setShowWrapModal(false)}
onConfirm={() => {
setShowWrapModal(false);
handleWrap();
}}
title="Wrap ETH to WETH9"
message={`You are about to wrap ${amount} ETH to WETH9. This action cannot be undone.`}
confirmText="Wrap ETH"
confirmColor="blue"
isLoading={isWrapping}
/>
<ConfirmationModal
isOpen={showApproveModal}
onClose={() => setShowApproveModal(false)}
onConfirm={() => {
setShowApproveModal(false);
handleApprove();
}}
title="Approve Tokens"
message={`You are about to approve ${amount} WETH9 and required LINK tokens for the bridge contract. This will allow the bridge to transfer your tokens.`}
confirmText="Approve"
confirmColor="green"
isLoading={isApproving}
/>
<ConfirmationModal
isOpen={showBridgeModal}
onClose={() => setShowBridgeModal(false)}
onConfirm={() => {
setShowBridgeModal(false);
handleBridge();
}}
title="Bridge Tokens"
message={`You are about to bridge ${amount} WETH9 to ${recipient.slice(0, 6)}...${recipient.slice(-4)} on Ethereum Mainnet. This action cannot be undone.`}
confirmText="Bridge"
confirmColor="purple"
isLoading={isBridging}
/>
</div>
</div>
);
}
export default function BridgeButtons(props: BridgeButtonsProps) {
const address = useAddress();
if (!address) {
const destinationChainSelector = props.destinationChainSelector ?? CHAIN_SELECTORS.ETHEREUM_MAINNET;
const destination = CCIP_DESTINATIONS.find((d) => d.selector === destinationChainSelector);
return (
<div className="w-full">
<div className="ui-alert">
<svg className="h-5 w-5 shrink-0 ui-accent-text" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Connect your wallet to bridge to {destination?.name ?? 'Ethereum Mainnet'}</span>
</div>
</div>
);
}
return <BridgeButtonsConnected {...props} address={address} />;
}