Skip to content

Commit ac66ef4

Browse files
dfapelnVlast
andauthored
Fix: xrpl metamask (ITS-128) (#706)
Co-authored-by: nVlast <[email protected]>
1 parent b49ff6c commit ac66ef4

File tree

7 files changed

+380
-42
lines changed

7 files changed

+380
-42
lines changed

apps/maestro/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@axelarjs/utils": "workspace:*",
4646
"@creit.tech/stellar-wallets-kit": "^1.6.1",
4747
"@hookform/resolvers": "^3.3.4",
48+
"@metamask/providers": "^22.1.1",
4849
"@mysten/bcs": "^1.1.0",
4950
"@mysten/dapp-kit": "^0.14.50",
5051
"@mysten/sui": "^1.21.2",
@@ -65,7 +66,6 @@
6566
"@xrpl-wallet-adapter/base": "^0.1.4",
6667
"@xrpl-wallet-adapter/crossmark": "^0.1.4",
6768
"@xrpl-wallet-adapter/ledger": "^0.1.0",
68-
"@xrpl-wallet-adapter/metamask": "^0.1.2",
6969
"@xrpl-wallet-adapter/walletconnect": "^0.2.0",
7070
"@xrpl-wallet-adapter/xaman": "^0.1.4",
7171
"@xrpl-wallet-standard/app": "0.1.4",

apps/maestro/src/config/next-auth.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,6 @@ export const NEXT_AUTH_OPTIONS: NextAuthOptions = {
149149
const memoHex = memo.MemoData as string;
150150
const memoData = Buffer.from(memoHex, "hex").toString("utf8");
151151

152-
console.warn("Reconstructed memo from transaction:", memoData, message);
153152
isMessageSigned =
154153
(memoData === message) // require that the memo matches the challenge (we don't care about the other data)
155154
&& (xrpl.verifySignature(encodedTx, signerPublicKey)) // AND that the signature is valid

apps/maestro/src/lib/providers/XRPLWalletProvider.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,27 @@ import { type XRPLBaseWallet } from "@xrpl-wallet-adapter/base";
44
import { CrossmarkWallet } from "@xrpl-wallet-adapter/crossmark";
55
import { WalletConnectWallet } from "@xrpl-wallet-adapter/walletconnect";
66
import { XamanWallet } from "@xrpl-wallet-adapter/xaman";
7-
import { MetaMaskWallet} from "@xrpl-wallet-adapter/metamask";
7+
import { MetaMaskWallet } from './eip6963/MetaMaskEIP6963Wallet';
8+
import { useMetaMaskProvider } from './eip6963/eip6963';
89
import { WalletProvider as StandardWalletProvider } from "@xrpl-wallet-standard/react";
910

1011
import { xrplChainConfig } from "~/config/chains";
1112

1213
export const XrplWalletProvider: FC<PropsWithChildren> = ({ children }) => {
14+
// xrpl - with EIP-6963 support for MetaMask
15+
const metamaskProvider = useMetaMaskProvider();
16+
1317
const xrplWallets = useMemo(() => {
1418
// Avoid constructing wallets during SSR
1519
if (typeof window === "undefined") {
1620
return [] as Array<XRPLBaseWallet>;
1721
}
1822

19-
const availableWallets: Array<XRPLBaseWallet> = [new CrossmarkWallet(), new MetaMaskWallet()];
23+
const availableWallets: Array<XRPLBaseWallet> = [new CrossmarkWallet()];
24+
25+
if (metamaskProvider) {
26+
availableWallets.push(new MetaMaskWallet(metamaskProvider));
27+
}
2028

2129
const walletConnectProjectId =
2230
process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID;
@@ -39,7 +47,7 @@ export const XrplWalletProvider: FC<PropsWithChildren> = ({ children }) => {
3947
}
4048

4149
return availableWallets;
42-
}, []);
50+
}, [metamaskProvider]);
4351

4452
// If wallets are not available (SSR/disabled), just render children
4553
if (xrplWallets.length === 0) return <>{children}</>;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* EIP-6963: Multi Injected Provider Discovery
3+
*
4+
* Type definitions based on:
5+
* - https://eips.ethereum.org/EIPS/eip-6963
6+
* - https://metamask.io/news/how-to-implement-eip-6963-support-in-your-web3-dapp
7+
*/
8+
9+
import type { BaseProvider } from '@metamask/providers'
10+
11+
/**
12+
* Represents the assets needed to display a wallet
13+
*/
14+
export interface EIP6963ProviderInfo {
15+
uuid: string;
16+
name: string;
17+
icon: string;
18+
rdns: string;
19+
}
20+
21+
/**
22+
* Interface detailing the structure of provider information and its Ethereum provider.
23+
*/
24+
export interface EIP6963ProviderDetail {
25+
info: EIP6963ProviderInfo;
26+
provider: BaseProvider;
27+
}
28+
29+
/**
30+
* Type representing the event structure for announcing a provider based on EIP-6963.
31+
*/
32+
export type EIP6963AnnounceProviderEvent = CustomEvent<EIP6963ProviderDetail>;
33+
34+
/**
35+
* Extend the global WindowEventMap to include EIP-6963 events
36+
*/
37+
declare global {
38+
interface WindowEventMap {
39+
'eip6963:announceProvider': EIP6963AnnounceProviderEvent;
40+
}
41+
}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/** @noprettier -- trying to keep it as close to the original code as possible */
2+
/* eslint-disable -- trying to keep it as close to the original code as possible */
3+
4+
/**
5+
* Portions Copyright (c) 2023 tequdev
6+
* Based on: https://github.com/tequdev/xrpl-wallet-standard/blob/1f404d9507c30810d8e663a7bc7160d51b0421f1/packages/adapter/metamask/src/index.ts
7+
*
8+
* Original code (ISC license) — permission & disclaimer (required to be preserved):
9+
*
10+
* Permission to use, copy, modify, and/or distribute this software for any purpose
11+
* with or without fee is hereby granted, provided that the above copyright notice
12+
* and this permission notice appear in all copies.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
15+
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
16+
* FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
17+
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
18+
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
19+
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
20+
* PERFORMANCE OF THIS SOFTWARE.
21+
*
22+
* ---------------------------------------------------------------------------
23+
* Modifications Copyright (c) 2025 Axelarnetwork
24+
* Modified locally to accept an injected provider and work with EIP-6963 discovery.
25+
* Modifications are licensed under this repository's MIT license (see LICENSE).
26+
*
27+
* Notes:
28+
* - The original portions above remain under ISC and its permission/disclaimer must
29+
* be preserved in any copies of those portions.
30+
* - If you build/distribute a bundled/minified artifact, ensure the ISC notice
31+
* remains included in the distributed artifact (or otherwise shipped with it).
32+
*/
33+
34+
import type { BaseProvider } from '@metamask/providers'
35+
import { type XRPLBaseWallet, XRPLWalletAccount } from '@xrpl-wallet-adapter/base'
36+
import {
37+
type StandardConnectFeature,
38+
type StandardConnectMethod,
39+
type StandardEventsFeature,
40+
type StandardEventsListeners,
41+
type StandardEventsNames,
42+
type StandardEventsOnMethod,
43+
type XRPLIdentifierString,
44+
type XRPLSignAndSubmitTransactionFeature,
45+
type XRPLSignAndSubmitTransactionMethod,
46+
type XRPLSignTransactionFeature,
47+
type XRPLSignTransactionMethod,
48+
type XRPLStandardIdentifier,
49+
XRPL_DEVNET,
50+
XRPL_MAINNET,
51+
XRPL_TESTNET,
52+
} from '@xrpl-wallet-standard/core'
53+
import type { BaseTransaction, SubmitResponse } from 'xrpl'
54+
55+
export const SNAP_ORIGIN = 'npm:xrpl-snap'
56+
57+
interface MetamaskAccount {
58+
account: string
59+
publicKey: string
60+
}
61+
62+
interface NetworkInfo {
63+
chainId: number
64+
name: string
65+
nodeUrl: string
66+
explorerUrl: string
67+
}
68+
69+
type SnapInfo = {
70+
[SNAP_ORIGIN]: {
71+
blocked: boolean
72+
enabled: boolean
73+
version: string
74+
initialPermissions: Record<string, any>
75+
}
76+
}
77+
78+
export class MetaMaskWallet implements XRPLBaseWallet {
79+
#name = 'MetaMask'
80+
#icon =
81+
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIGlkPSJMYXllcl8xIiB4PSIwIiB5PSIwIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAzMTguNiAzMTguNiI+CiAgPHN0eWxlPgogICAgLnN0MSwuc3Q2e2ZpbGw6I2U0NzYxYjtzdHJva2U6I2U0NzYxYjtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmR9LnN0NntmaWxsOiNmNjg1MWI7c3Ryb2tlOiNmNjg1MWJ9CiAgPC9zdHlsZT4KICA8cGF0aCBmaWxsPSIjZTI3NjFiIiBzdHJva2U9IiNlMjc2MWIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgZD0ibTI3NC4xIDM1LjUtOTkuNSA3My45TDE5MyA2NS44eiIvPgogIDxwYXRoIGQ9Im00NC40IDM1LjUgOTguNyA3NC42LTE3LjUtNDQuM3ptMTkzLjkgMTcxLjMtMjYuNSA0MC42IDU2LjcgMTUuNiAxNi4zLTU1LjN6bS0yMDQuNC45TDUwLjEgMjYzbDU2LjctMTUuNi0yNi41LTQwLjZ6IiBjbGFzcz0ic3QxIi8+CiAgPHBhdGggZD0ibTEwMy42IDEzOC4yLTE1LjggMjMuOSA1Ni4zIDIuNS0yLTYwLjV6bTExMS4zIDAtMzktMzQuOC0xLjMgNjEuMiA1Ni4yLTIuNXpNMTA2LjggMjQ3LjRsMzMuOC0xNi41LTI5LjItMjIuOHptNzEuMS0xNi41IDMzLjkgMTYuNS00LjctMzkuM3oiIGNsYXNzPSJzdDEiLz4KICA8cGF0aCBmaWxsPSIjZDdjMWIzIiBzdHJva2U9IiNkN2MxYjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgZD0ibTIxMS44IDI0Ny40LTMzLjktMTYuNSAyLjcgMjIuMS0uMyA5LjN6bS0xMDUgMCAzMS41IDE0LjktLjItOS4zIDIuNS0yMi4xeiIvPgogIDxwYXRoIGZpbGw9IiMyMzM0NDciIHN0cm9rZT0iIzIzMzQ0NyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBkPSJtMTM4LjggMTkzLjUtMjguMi04LjMgMTkuOS05LjF6bTQwLjkgMCA4LjMtMTcuNCAyMCA5LjF6Ii8+CiAgPHBhdGggZmlsbD0iI2NkNjExNiIgc3Ryb2tlPSIjY2Q2MTE2IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Im0xMDYuOCAyNDcuNCA0LjgtNDAuNi0zMS4zLjl6TTIwNyAyMDYuOGw0LjggNDAuNiAyNi41LTM5Ljd6bTIzLjgtNDQuNy01Ni4yIDIuNSA1LjIgMjguOSA4LjMtMTcuNCAyMCA5LjF6bS0xMjAuMiAyMy4xIDIwLTkuMSA4LjIgMTcuNCA1LjMtMjguOS01Ni4zLTIuNXoiLz4KICA8cGF0aCBmaWxsPSIjZTQ3NTFmIiBzdHJva2U9IiNlNDc1MWYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgZD0ibTg3LjggMTYyLjEgMjMuNiA0Ni0uOC0yMi45em0xMjAuMyAyMy4xLTEgMjIuOSAyMy43LTQ2em0tNjQtMjAuNi01LjMgMjguOSA2LjYgMzQuMSAxLjUtNDQuOXptMzAuNSAwLTIuNyAxOCAxLjIgNDUgNi43LTM0LjF6Ii8+CiAgPHBhdGggZD0ibTE3OS44IDE5My41LTYuNyAzNC4xIDQuOCAzLjMgMjkuMi0yMi44IDEtMjIuOXptLTY5LjItOC4zLjggMjIuOSAyOS4yIDIyLjggNC44LTMuMy02LjYtMzQuMXoiIGNsYXNzPSJzdDYiLz4KICA8cGF0aCBmaWxsPSIjYzBhZDllIiBzdHJva2U9IiNjMGFkOWUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgZD0ibTE4MC4zIDI2Mi4zLjMtOS4zLTIuNS0yLjJoLTM3LjdsLTIuMyAyLjIuMiA5LjMtMzEuNS0xNC45IDExIDkgMjIuMyAxNS41aDM4LjNsMjIuNC0xNS41IDExLTl6Ii8+CiAgPHBhdGggZmlsbD0iIzE2MTYxNiIgc3Ryb2tlPSIjMTYxNjE2IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Im0xNzcuOSAyMzAuOS00LjgtMy4zaC0yNy43bC00LjggMy4zLTIuNSAyMi4xIDIuMy0yLjJoMzcuN2wyLjUgMi4yeiIvPgogIDxwYXRoIGZpbGw9IiM3NjNkMTYiIHN0cm9rZT0iIzc2M2QxNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBkPSJtMjc4LjMgMTE0LjIgOC41LTQwLjgtMTIuNy0zNy45LTk2LjIgNzEuNCAzNyAzMS4zIDUyLjMgMTUuMyAxMS42LTEzLjUtNS0zLjYgOC03LjMtNi4yLTQuOCA4LTYuMXpNMzEuOCA3My40bDguNSA0MC44LTUuNCA0IDggNi4xLTYuMSA0LjggOCA3LjMtNSAzLjYgMTEuNSAxMy41IDUyLjMtMTUuMyAzNy0zMS4zLTk2LjItNzEuNHoiLz4KICA8cGF0aCBkPSJtMjY3LjIgMTUzLjUtNTIuMy0xNS4zIDE1LjkgMjMuOS0yMy43IDQ2IDMxLjItLjRoNDYuNXptLTE2My42LTE1LjMtNTIuMyAxNS4zLTE3LjQgNTQuMmg0Ni40bDMxLjEuNC0yMy42LTQ2em03MSAyNi40IDMuMy01Ny43IDE1LjItNDEuMWgtNjcuNWwxNSA0MS4xIDMuNSA1Ny43IDEuMiAxOC4yLjEgNDQuOGgyNy43bC4yLTQ0Ljh6IiBjbGFzcz0ic3Q2Ii8+Cjwvc3ZnPg==' as const
82+
83+
#accounts: XRPLWalletAccount[] = []
84+
85+
readonly #listeners: { [E in StandardEventsNames]?: StandardEventsListeners[E][] } = {}
86+
87+
#discoveredProvider?: BaseProvider
88+
89+
constructor(provider?: BaseProvider) {
90+
this.#discoveredProvider = provider
91+
}
92+
93+
get version() {
94+
return '1.0.0' as const
95+
}
96+
97+
get name() {
98+
return this.#name
99+
}
100+
101+
get icon() {
102+
return this.#icon
103+
}
104+
105+
get chains() {
106+
return [XRPL_MAINNET, XRPL_TESTNET, XRPL_DEVNET]
107+
}
108+
109+
get features(): StandardConnectFeature &
110+
StandardEventsFeature &
111+
XRPLSignTransactionFeature &
112+
XRPLSignAndSubmitTransactionFeature {
113+
return {
114+
'standard:connect': {
115+
version: '1.0.0',
116+
connect: this.#connect,
117+
},
118+
'standard:events': {
119+
version: '1.0.0',
120+
on: this.#on,
121+
},
122+
'xrpl:signTransaction': {
123+
version: '1.0.0',
124+
signTransaction: this.#signTransaction,
125+
},
126+
'xrpl:signAndSubmitTransaction': {
127+
version: '1.0.0',
128+
signAndSubmitTransaction: this.#signAndSubmitTransaction,
129+
},
130+
}
131+
}
132+
133+
get accounts() {
134+
return this.#accounts
135+
}
136+
137+
get #provider(): BaseProvider {
138+
return this.#discoveredProvider ?? (window as any).ethereum
139+
}
140+
141+
#invokeSnapRequest = async (request: { method: string; params?: Record<string, any> }) => {
142+
return await this.#provider.request({
143+
method: 'wallet_invokeSnap',
144+
params: {
145+
snapId: SNAP_ORIGIN,
146+
request,
147+
},
148+
})
149+
}
150+
151+
#changeNetworkHandler = async (network: XRPLStandardIdentifier) => {
152+
const currentNetworkInfo = (await this.#invokeSnapRequest({
153+
method: 'xrpl_getActiveNetwork',
154+
})) as NetworkInfo
155+
const currentNetworkId = currentNetworkInfo.chainId
156+
const txNetworkId = Number(network.split(':')[1])
157+
158+
if (txNetworkId === currentNetworkId) return
159+
160+
const networkList = (await this.#invokeSnapRequest({
161+
method: 'xrpl_getStoredNetworks',
162+
})) as NetworkInfo[]
163+
const networkInfo = networkList.find((n) => n.chainId === txNetworkId)
164+
if (!networkInfo) throw new Error('Network not found')
165+
166+
await this.#invokeSnapRequest({
167+
method: 'xrpl_changeNetwork',
168+
params: {
169+
chainId: networkInfo.chainId,
170+
},
171+
})
172+
}
173+
174+
#signTransactionHandler = async (tx_json: BaseTransaction) => {
175+
return (await this.#invokeSnapRequest({
176+
method: 'xrpl_sign',
177+
params: tx_json,
178+
})) as { tx_blob: string; hash: string }
179+
}
180+
181+
#signAndSubmitTransactionHandler = async (tx_json: BaseTransaction) => {
182+
return (await this.#invokeSnapRequest({
183+
method: 'xrpl_signAndSubmit',
184+
params: tx_json,
185+
})) as SubmitResponse
186+
}
187+
188+
#connect: StandardConnectMethod = async ({ silent = false } = {}) => {
189+
if (silent) {
190+
const snaps = (await this.#provider.request({
191+
method: 'wallet_getSnaps',
192+
})) as SnapInfo
193+
if (!snaps[SNAP_ORIGIN]) {
194+
this.#accounts = []
195+
this.#emit('change', { accounts: this.accounts })
196+
return {
197+
accounts: this.#accounts,
198+
}
199+
}
200+
} else {
201+
await this.#provider.request({
202+
method: 'wallet_requestSnaps',
203+
params: {
204+
[SNAP_ORIGIN]: {},
205+
},
206+
})
207+
}
208+
const snapAccount = (await this.#invokeSnapRequest({
209+
method: 'xrpl_getAccount',
210+
})) as MetamaskAccount
211+
212+
this.#accounts = [new XRPLWalletAccount(snapAccount.account)]
213+
this.#emit('change', { accounts: this.accounts })
214+
return {
215+
accounts: this.#accounts,
216+
}
217+
}
218+
219+
#signTransaction: XRPLSignTransactionMethod = async ({ tx_json, network }) => {
220+
const networkId = this.#convertNetworkToId(network)
221+
await this.#changeNetworkHandler(networkId)
222+
const { tx_blob } = await this.#signTransactionHandler(tx_json)
223+
return {
224+
signed_tx_blob: tx_blob,
225+
}
226+
}
227+
228+
#signAndSubmitTransaction: XRPLSignAndSubmitTransactionMethod = async ({ tx_json, network }) => {
229+
const networkId = this.#convertNetworkToId(network)
230+
await this.#changeNetworkHandler(networkId)
231+
const txResponse = await this.#signAndSubmitTransactionHandler(tx_json)
232+
return {
233+
tx_json: txResponse.result.tx_json as any,
234+
tx_hash: txResponse.result.tx_json.hash!,
235+
}
236+
}
237+
238+
#on: StandardEventsOnMethod = (event, listener) => {
239+
if (this.#listeners[event]) this.#listeners[event]?.push(listener)
240+
else this.#listeners[event] = [listener]
241+
return (): void => this.#off(event, listener)
242+
}
243+
244+
#emit<E extends StandardEventsNames>(event: E, ...args: Parameters<StandardEventsListeners[E]>): void {
245+
this.#listeners[event]?.forEach((listener) => listener.apply(null, args))
246+
}
247+
248+
#off<E extends StandardEventsNames>(event: E, listener: StandardEventsListeners[E]): void {
249+
this.#listeners[event] = this.#listeners[event]?.filter((existingListener) => listener !== existingListener)
250+
}
251+
252+
#convertNetworkToId = (network: XRPLIdentifierString): XRPLStandardIdentifier => {
253+
const networkId = network.split(':')[1]
254+
switch (networkId) {
255+
case 'mainnet':
256+
return 'xrpl:0'
257+
case 'testnet':
258+
return 'xrpl:1'
259+
case 'devnet':
260+
return 'xrpl:2'
261+
case 'xahau-mainnet':
262+
return 'xrpl:21337'
263+
case 'xahau-testnet':
264+
return 'xrpl:21338'
265+
default:
266+
return network as XRPLStandardIdentifier
267+
}
268+
}
269+
}

0 commit comments

Comments
 (0)