Skip to content

Commit 80c2399

Browse files
authored
Arbitrary destination address support (#3113)
* Arbitrary destination address support Allows the user to set an arbitrary destination address. Manual routes are disabled when the destination wallet is not connected. * rename to ReadOnlyWallet * use sdk hex isValid method * rename file * defer dispatch fix * fix linter error * address feedback * added back comment * update comment * sanctioned address check * case insensitive sdn check * prevent ATA from being destination address * dont set readonly wallet in local storage
1 parent 47a7c1d commit 80c2399

File tree

10 files changed

+340
-10
lines changed

10 files changed

+340
-10
lines changed

wormhole-connect/src/hooks/useFetchSupportedRoutes.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { RootState } from 'store';
55
import config from 'config';
66
import { getTokenDetails } from 'telemetry';
77
import { maybeLogSdkError } from 'utils/errors';
8+
import { ReadOnlyWallet } from 'utils/wallet/ReadOnlyWallet';
89

910
type HookReturn = {
1011
supportedRoutes: string[];
@@ -21,6 +22,10 @@ const useFetchSupportedRoutes = (): HookReturn => {
2122

2223
const { toNativeToken } = useSelector((state: RootState) => state.relay);
2324

25+
const receivingWallet = useSelector(
26+
(state: RootState) => state.wallet.receiving,
27+
);
28+
2429
useEffect(() => {
2530
if (!fromChain || !toChain || !token || !destToken) {
2631
setRoutes([]);
@@ -34,6 +39,15 @@ const useFetchSupportedRoutes = (): HookReturn => {
3439
setIsFetching(true);
3540
const _routes: string[] = [];
3641
await config.routes.forEach(async (name, route) => {
42+
// Disable manual routes when the receiving wallet is a ReadOnlyWallet
43+
// because the receiving wallet can't sign/complete the transaction
44+
if (
45+
!route.AUTOMATIC_DEPOSIT &&
46+
receivingWallet.name === ReadOnlyWallet.NAME
47+
) {
48+
return;
49+
}
50+
3751
let supported = false;
3852

3953
try {
@@ -75,7 +89,15 @@ const useFetchSupportedRoutes = (): HookReturn => {
7589
return () => {
7690
isActive = false;
7791
};
78-
}, [token, destToken, amount, fromChain, toChain, toNativeToken]);
92+
}, [
93+
token,
94+
destToken,
95+
amount,
96+
fromChain,
97+
toChain,
98+
toNativeToken,
99+
receivingWallet,
100+
]);
79101

80102
return {
81103
supportedRoutes: routes,

wormhole-connect/src/store/wallet.ts

+9
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
swapWalletConnections,
66
TransferWallet,
77
} from 'utils/wallet';
8+
import { ReadOnlyWallet } from 'utils/wallet/ReadOnlyWallet';
89

910
export type WalletData = {
1011
type: Context | undefined;
@@ -105,7 +106,15 @@ export const walletSlice = createSlice({
105106
const tmp = state.sending;
106107
state.sending = state.receiving;
107108
state.receiving = tmp;
109+
108110
swapWalletConnections();
111+
112+
// If the new sending wallet is a ReadOnlyWallet,
113+
// disconnect it since it can't be used for signing
114+
if (state.sending.name === ReadOnlyWallet.NAME) {
115+
disconnect(TransferWallet.SENDING);
116+
state[TransferWallet.SENDING] = NO_WALLET;
117+
}
109118
},
110119
},
111120
});

wormhole-connect/src/utils/address.ts

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
Chain,
3+
chainToPlatform,
4+
encoding,
5+
NativeAddress,
6+
toNative,
7+
} from '@wormhole-foundation/sdk';
8+
import { isValidSuiAddress } from '@mysten/sui.js';
9+
import { Connection, PublicKey } from '@solana/web3.js';
10+
import {
11+
getAccount,
12+
TOKEN_2022_PROGRAM_ID,
13+
TOKEN_PROGRAM_ID,
14+
} from '@solana/spl-token';
15+
import { getAddress } from 'ethers';
16+
import config from 'config';
17+
18+
function isValidEvmAddress(address: string): boolean {
19+
if (
20+
!address.startsWith('0x') ||
21+
address.length !== 42 ||
22+
!encoding.hex.valid(address)
23+
) {
24+
return false;
25+
}
26+
27+
try {
28+
getAddress(address);
29+
return true;
30+
} catch {
31+
return false;
32+
}
33+
}
34+
35+
async function isValidSolanaAddress(address: string): Promise<boolean> {
36+
try {
37+
const key = new PublicKey(address);
38+
if (config.rpcs.Solana) {
39+
const connection = new Connection(config.rpcs.Solana);
40+
const results = await Promise.allSettled([
41+
getAccount(connection, key, 'finalized', TOKEN_PROGRAM_ID),
42+
getAccount(connection, key, 'finalized', TOKEN_2022_PROGRAM_ID),
43+
]);
44+
// A token account is not a valid wallet address
45+
if (results.some((r) => r.status === 'fulfilled')) return false;
46+
}
47+
return true;
48+
} catch {
49+
return false;
50+
}
51+
}
52+
53+
function isValidAptosAddress(address: string): boolean {
54+
return (
55+
address.startsWith('0x') &&
56+
address.length === 66 &&
57+
encoding.hex.valid(address)
58+
);
59+
}
60+
61+
export async function validateWalletAddress(
62+
chain: Chain,
63+
address: string,
64+
): Promise<NativeAddress<Chain> | null> {
65+
const platform = chainToPlatform(chain);
66+
67+
// toNative() is permissive and accepts various address formats,
68+
// including ICAP Ethereum addresses, hex-encoded Solana addresses, and attempts to parse as a UniversalAddress if parsing fails.
69+
// We are being more restrictive here to prevent the user from accidentally using an incorrect address.
70+
switch (platform) {
71+
case 'Evm':
72+
if (!isValidEvmAddress(address)) return null;
73+
break;
74+
case 'Solana':
75+
if (!(await isValidSolanaAddress(address))) return null;
76+
break;
77+
case 'Sui':
78+
if (!isValidSuiAddress(address)) return null;
79+
break;
80+
case 'Aptos':
81+
if (!isValidAptosAddress(address)) return null;
82+
break;
83+
default:
84+
console.warn(`Unsupported platform: ${platform}`);
85+
return null;
86+
}
87+
88+
try {
89+
// This will throw an error if the address is invalid
90+
return toNative(chain, address);
91+
} catch (e) {
92+
console.error(
93+
`Invalid address for chain ${chain}: ${address}, error: ${e}`,
94+
);
95+
}
96+
97+
return null;
98+
}

wormhole-connect/src/utils/transferValidation.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export const validateAmount = (
111111
return '';
112112
};
113113

114-
const checkAddressIsSanctioned = (address: string): boolean =>
114+
export const checkAddressIsSanctioned = (address: string): boolean =>
115115
SANCTIONED_WALLETS.has(address) || SANCTIONED_WALLETS.has('0x' + address);
116116

117117
export const validateWallet = async (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
Address,
3+
ChainId,
4+
IconSource,
5+
SendTransactionResult,
6+
Wallet,
7+
} from '@xlabs-libs/wallet-aggregator-core';
8+
import { Chain, chainToChainId, NativeAddress } from '@wormhole-foundation/sdk';
9+
10+
export class ReadOnlyWallet extends Wallet {
11+
private _isConnected = true;
12+
13+
static readonly NAME = 'ReadyOnlyWallet';
14+
15+
constructor(readonly _address: NativeAddress<Chain>, readonly _chain: Chain) {
16+
super();
17+
}
18+
19+
getName(): string {
20+
return ReadOnlyWallet.NAME;
21+
}
22+
23+
getUrl(): string {
24+
return '';
25+
}
26+
27+
async connect(): Promise<Address[]> {
28+
this._isConnected = true;
29+
this.emit('connect');
30+
return [this._address.toString()];
31+
}
32+
33+
async disconnect(): Promise<void> {
34+
this._isConnected = false;
35+
this.emit('disconnect');
36+
}
37+
38+
getChainId(): ChainId {
39+
// TODO: wallet aggregator should use SDK ChainId type
40+
return chainToChainId(this._chain) as ChainId;
41+
}
42+
43+
getNetworkInfo() {
44+
throw new Error('Method not implemented.');
45+
}
46+
47+
getAddress(): Address {
48+
return this._address.toString();
49+
}
50+
51+
getAddresses(): Address[] {
52+
return [this.getAddress()];
53+
}
54+
55+
setMainAddress(address: Address): void {
56+
// No-op: can't change address for read-only wallet
57+
}
58+
59+
async getBalance(): Promise<string> {
60+
// Could implement this to fetch balance from RPC if needed
61+
throw new Error('Address only wallet cannot fetch balance');
62+
}
63+
64+
isConnected(): boolean {
65+
return this._isConnected;
66+
}
67+
68+
getIcon(): IconSource {
69+
return '';
70+
}
71+
72+
async signTransaction(tx: any): Promise<any> {
73+
throw new Error('Address only wallet cannot sign transactions');
74+
}
75+
76+
async sendTransaction(tx: any): Promise<SendTransactionResult<any>> {
77+
throw new Error('Address only wallet cannot send transactions');
78+
}
79+
80+
async signMessage(msg: any): Promise<any> {
81+
throw new Error('Address only wallet cannot sign messages');
82+
}
83+
84+
async signAndSendTransaction(tx: any): Promise<SendTransactionResult<any>> {
85+
throw new Error('Address only wallet cannot sign or send transactions');
86+
}
87+
88+
getFeatures(): string[] {
89+
return [];
90+
}
91+
92+
supportsChain(chainId: ChainId): boolean {
93+
return this.getChainId() === chainId;
94+
}
95+
}

wormhole-connect/src/utils/wallet/index.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import {
55
WalletState,
66
} from '@xlabs-libs/wallet-aggregator-core';
77
import {
8-
connectReceivingWallet,
98
connectWallet as connectSourceWallet,
109
clearWallet,
10+
connectReceivingWallet,
1111
} from 'store/wallet';
1212

1313
import config from 'config';
@@ -33,6 +33,7 @@ import {
3333
AptosChains,
3434
} from '@wormhole-foundation/sdk-aptos';
3535
import { SolanaUnsignedTransaction } from '@wormhole-foundation/sdk-solana';
36+
import { ReadOnlyWallet } from './ReadOnlyWallet';
3637

3738
export enum TransferWallet {
3839
SENDING = 'sending',
@@ -98,10 +99,15 @@ export const connectWallet = async (
9899
dispatch(connectReceivingWallet(payload));
99100
}
100101

101-
// clear wallet when the user manually disconnects from outside the app
102+
// Clear wallet when the user manually disconnects from outside the app
102103
wallet.on('disconnect', () => {
103104
wallet.removeAllListeners();
104-
dispatch(clearWallet(type));
105+
// Use setTimeout to defer the dispatch call to the next event loop tick.
106+
// This ensures that the dispatch does not occur while a reducer is executing,
107+
// preventing the "You may not call store.getState() while the reducer is executing" error.
108+
setTimeout(() => {
109+
dispatch(clearWallet(type));
110+
}, 0);
105111
localStorage.removeItem(`wormhole-connect:wallet:${context}`);
106112
});
107113

@@ -117,7 +123,9 @@ export const connectWallet = async (
117123
}
118124
});
119125

120-
localStorage.setItem(`wormhole-connect:wallet:${context}`, name);
126+
if (name !== ReadOnlyWallet.NAME) {
127+
localStorage.setItem(`wormhole-connect:wallet:${context}`, name);
128+
}
121129
};
122130

123131
// Checks localStorage for previously used wallet for this chain
@@ -131,6 +139,7 @@ export const connectLastUsedWallet = async (
131139
const lastUsedWallet = localStorage.getItem(
132140
`wormhole-connect:wallet:${chainConfig.context}`,
133141
);
142+
134143
// if the last used wallet is not WalletConnect, try to connect to it
135144
if (lastUsedWallet && lastUsedWallet !== 'WalletConnect') {
136145
const options = await getWalletOptions(chainConfig);

wormhole-connect/src/views/v2/Bridge/ReviewTransaction/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ const ReviewTransaction = (props: Props) => {
255255
),
256256
receivedTokenKey: config.tokens[destToken].key, // TODO: possibly wrong (e..g if portico swap fails)
257257
relayerFee,
258-
receiveAmount: (quote.destinationToken.amount),
258+
receiveAmount: quote.destinationToken.amount,
259259
receiveNativeAmount,
260260
eta: quote.eta || 0,
261261
};

wormhole-connect/src/views/v2/Bridge/WalletConnector/Controller.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ const ConnectedWallet = (props: Props) => {
168168
onClose={() => {
169169
setIsOpen(false);
170170
}}
171+
showAddressInput={props.type === TransferWallet.RECEIVING}
171172
/>
172173
</>
173174
);

0 commit comments

Comments
 (0)