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

Wallet Standard/Adapter Error Dialog #1125

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
539 changes: 281 additions & 258 deletions js/packages/mobile-wallet-adapter-protocol-web3js/src/transact.ts

Large diffs are not rendered by default.

355 changes: 181 additions & 174 deletions js/packages/mobile-wallet-adapter-protocol/src/createMobileWalletProxy.ts

Large diffs are not rendered by default.

200 changes: 101 additions & 99 deletions js/packages/mobile-wallet-adapter-protocol/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,99 +1,101 @@
// Typescript `enums` thwart tree-shaking. See https://bargsten.org/jsts/enums/
export const SolanaMobileWalletAdapterErrorCode = {
ERROR_ASSOCIATION_PORT_OUT_OF_RANGE: 'ERROR_ASSOCIATION_PORT_OUT_OF_RANGE',
ERROR_REFLECTOR_ID_OUT_OF_RANGE: 'ERROR_REFLECTOR_ID_OUT_OF_RANGE',
ERROR_FORBIDDEN_WALLET_BASE_URL: 'ERROR_FORBIDDEN_WALLET_BASE_URL',
ERROR_SECURE_CONTEXT_REQUIRED: 'ERROR_SECURE_CONTEXT_REQUIRED',
ERROR_SESSION_CLOSED: 'ERROR_SESSION_CLOSED',
ERROR_SESSION_TIMEOUT: 'ERROR_SESSION_TIMEOUT',
ERROR_WALLET_NOT_FOUND: 'ERROR_WALLET_NOT_FOUND',
ERROR_INVALID_PROTOCOL_VERSION: 'ERROR_INVALID_PROTOCOL_VERSION',
} as const;
type SolanaMobileWalletAdapterErrorCodeEnum =
typeof SolanaMobileWalletAdapterErrorCode[keyof typeof SolanaMobileWalletAdapterErrorCode];

type ErrorDataTypeMap = {
[SolanaMobileWalletAdapterErrorCode.ERROR_ASSOCIATION_PORT_OUT_OF_RANGE]: {
port: number;
};
[SolanaMobileWalletAdapterErrorCode.ERROR_REFLECTOR_ID_OUT_OF_RANGE]: {
id: number;
};
[SolanaMobileWalletAdapterErrorCode.ERROR_FORBIDDEN_WALLET_BASE_URL]: undefined;
[SolanaMobileWalletAdapterErrorCode.ERROR_SECURE_CONTEXT_REQUIRED]: undefined;
[SolanaMobileWalletAdapterErrorCode.ERROR_SESSION_CLOSED]: {
closeEvent: CloseEvent;
};
[SolanaMobileWalletAdapterErrorCode.ERROR_SESSION_TIMEOUT]: undefined;
[SolanaMobileWalletAdapterErrorCode.ERROR_WALLET_NOT_FOUND]: undefined;
[SolanaMobileWalletAdapterErrorCode.ERROR_INVALID_PROTOCOL_VERSION]: undefined;
};

export class SolanaMobileWalletAdapterError<TErrorCode extends SolanaMobileWalletAdapterErrorCodeEnum> extends Error {
data: ErrorDataTypeMap[TErrorCode] | undefined;
code: TErrorCode;
constructor(
...args: ErrorDataTypeMap[TErrorCode] extends Record<string, unknown>
? [code: TErrorCode, message: string, data: ErrorDataTypeMap[TErrorCode]]
: [code: TErrorCode, message: string]
) {
const [code, message, data] = args;
super(message);
this.code = code;
this.data = data;
this.name = 'SolanaMobileWalletAdapterError';
}
}

type JSONRPCErrorCode = number;

// Typescript `enums` thwart tree-shaking. See https://bargsten.org/jsts/enums/
export const SolanaMobileWalletAdapterProtocolErrorCode = {
// Keep these in sync with `mobilewalletadapter/common/ProtocolContract.java`.
ERROR_AUTHORIZATION_FAILED: -1,
ERROR_INVALID_PAYLOADS: -2,
ERROR_NOT_SIGNED: -3,
ERROR_NOT_SUBMITTED: -4,
ERROR_TOO_MANY_PAYLOADS: -5,
ERROR_ATTEST_ORIGIN_ANDROID: -100,
} as const;
type SolanaMobileWalletAdapterProtocolErrorCodeEnum =
typeof SolanaMobileWalletAdapterProtocolErrorCode[keyof typeof SolanaMobileWalletAdapterProtocolErrorCode];

type ProtocolErrorDataTypeMap = {
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_AUTHORIZATION_FAILED]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_INVALID_PAYLOADS]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_NOT_SIGNED]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_NOT_SUBMITTED]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_TOO_MANY_PAYLOADS]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_ATTEST_ORIGIN_ANDROID]: {
attest_origin_uri: string;
challenge: string;
context: string;
};
};

export class SolanaMobileWalletAdapterProtocolError<
TErrorCode extends SolanaMobileWalletAdapterProtocolErrorCodeEnum,
> extends Error {
data: ProtocolErrorDataTypeMap[TErrorCode] | undefined;
code: TErrorCode | JSONRPCErrorCode;
jsonRpcMessageId: number;
constructor(
...args: ProtocolErrorDataTypeMap[TErrorCode] extends Record<string, unknown>
? [
jsonRpcMessageId: number,
code: TErrorCode | JSONRPCErrorCode,
message: string,
data: ProtocolErrorDataTypeMap[TErrorCode],
]
: [jsonRpcMessageId: number, code: TErrorCode | JSONRPCErrorCode, message: string]
) {
const [jsonRpcMessageId, code, message, data] = args;
super(message);
this.code = code;
this.data = data;
this.jsonRpcMessageId = jsonRpcMessageId;
this.name = 'SolanaMobileWalletAdapterProtocolError';
}
}
// Typescript `enums` thwart tree-shaking. See https://bargsten.org/jsts/enums/
export const SolanaMobileWalletAdapterErrorCode = {
ERROR_ASSOCIATION_PORT_OUT_OF_RANGE: 'ERROR_ASSOCIATION_PORT_OUT_OF_RANGE',
ERROR_REFLECTOR_ID_OUT_OF_RANGE: 'ERROR_REFLECTOR_ID_OUT_OF_RANGE',
ERROR_FORBIDDEN_WALLET_BASE_URL: 'ERROR_FORBIDDEN_WALLET_BASE_URL',
ERROR_SECURE_CONTEXT_REQUIRED: 'ERROR_SECURE_CONTEXT_REQUIRED',
ERROR_SESSION_CLOSED: 'ERROR_SESSION_CLOSED',
ERROR_SESSION_TIMEOUT: 'ERROR_SESSION_TIMEOUT',
ERROR_WALLET_NOT_FOUND: 'ERROR_WALLET_NOT_FOUND',
ERROR_INVALID_PROTOCOL_VERSION: 'ERROR_INVALID_PROTOCOL_VERSION',
ERROR_BROWSER_NOT_SUPPORTED: 'ERROR_BROWSER_NOT_SUPPORTED',
} as const;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Weird diff!!!!

the only thing added here is the ERROR_BROWSER_NOT_SUPPORTED error that is used to indicate when we suspect the adapter/wallet is used on an incompatible browser.

type SolanaMobileWalletAdapterErrorCodeEnum =
typeof SolanaMobileWalletAdapterErrorCode[keyof typeof SolanaMobileWalletAdapterErrorCode];

type ErrorDataTypeMap = {
[SolanaMobileWalletAdapterErrorCode.ERROR_ASSOCIATION_PORT_OUT_OF_RANGE]: {
port: number;
};
[SolanaMobileWalletAdapterErrorCode.ERROR_REFLECTOR_ID_OUT_OF_RANGE]: {
id: number;
};
[SolanaMobileWalletAdapterErrorCode.ERROR_FORBIDDEN_WALLET_BASE_URL]: undefined;
[SolanaMobileWalletAdapterErrorCode.ERROR_SECURE_CONTEXT_REQUIRED]: undefined;
[SolanaMobileWalletAdapterErrorCode.ERROR_SESSION_CLOSED]: {
closeEvent: CloseEvent;
};
[SolanaMobileWalletAdapterErrorCode.ERROR_SESSION_TIMEOUT]: undefined;
[SolanaMobileWalletAdapterErrorCode.ERROR_WALLET_NOT_FOUND]: undefined;
[SolanaMobileWalletAdapterErrorCode.ERROR_INVALID_PROTOCOL_VERSION]: undefined;
[SolanaMobileWalletAdapterErrorCode.ERROR_BROWSER_NOT_SUPPORTED]: undefined;
};

export class SolanaMobileWalletAdapterError<TErrorCode extends SolanaMobileWalletAdapterErrorCodeEnum> extends Error {
data: ErrorDataTypeMap[TErrorCode] | undefined;
code: TErrorCode;
constructor(
...args: ErrorDataTypeMap[TErrorCode] extends Record<string, unknown>
? [code: TErrorCode, message: string, data: ErrorDataTypeMap[TErrorCode]]
: [code: TErrorCode, message: string]
) {
const [code, message, data] = args;
super(message);
this.code = code;
this.data = data;
this.name = 'SolanaMobileWalletAdapterError';
}
}

type JSONRPCErrorCode = number;

// Typescript `enums` thwart tree-shaking. See https://bargsten.org/jsts/enums/
export const SolanaMobileWalletAdapterProtocolErrorCode = {
// Keep these in sync with `mobilewalletadapter/common/ProtocolContract.java`.
ERROR_AUTHORIZATION_FAILED: -1,
ERROR_INVALID_PAYLOADS: -2,
ERROR_NOT_SIGNED: -3,
ERROR_NOT_SUBMITTED: -4,
ERROR_TOO_MANY_PAYLOADS: -5,
ERROR_ATTEST_ORIGIN_ANDROID: -100,
} as const;
type SolanaMobileWalletAdapterProtocolErrorCodeEnum =
typeof SolanaMobileWalletAdapterProtocolErrorCode[keyof typeof SolanaMobileWalletAdapterProtocolErrorCode];

type ProtocolErrorDataTypeMap = {
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_AUTHORIZATION_FAILED]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_INVALID_PAYLOADS]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_NOT_SIGNED]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_NOT_SUBMITTED]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_TOO_MANY_PAYLOADS]: undefined;
[SolanaMobileWalletAdapterProtocolErrorCode.ERROR_ATTEST_ORIGIN_ANDROID]: {
attest_origin_uri: string;
challenge: string;
context: string;
};
};

export class SolanaMobileWalletAdapterProtocolError<
TErrorCode extends SolanaMobileWalletAdapterProtocolErrorCodeEnum,
> extends Error {
data: ProtocolErrorDataTypeMap[TErrorCode] | undefined;
code: TErrorCode | JSONRPCErrorCode;
jsonRpcMessageId: number;
constructor(
...args: ProtocolErrorDataTypeMap[TErrorCode] extends Record<string, unknown>
? [
jsonRpcMessageId: number,
code: TErrorCode | JSONRPCErrorCode,
message: string,
data: ProtocolErrorDataTypeMap[TErrorCode],
]
: [jsonRpcMessageId: number, code: TErrorCode | JSONRPCErrorCode, message: string]
) {
const [jsonRpcMessageId, code, message, data] = args;
super(message);
this.code = code;
this.data = data;
this.jsonRpcMessageId = jsonRpcMessageId;
this.name = 'SolanaMobileWalletAdapterProtocolError';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function getDetectionPromise() {
const timeoutId = setTimeout(() => {
cleanup();
reject();
}, 2000);
}, 3000);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

changed the timeout for the MWA intent navigation from 2s to 3s. Helps with UX when the browser asks if the user wants to navigate away.

});
}

Expand Down
95 changes: 70 additions & 25 deletions js/packages/mobile-wallet-adapter-protocol/src/transact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@ import { decryptJsonRpcMessage, encryptJsonRpcMessage } from './jsonRpcMessage.j
import parseHelloRsp, { SharedSecret } from './parseHelloRsp.js';
import parseSessionProps from './parseSessionProps.js';
import { startSession } from './startSession.js';
import { AssociationKeypair, MobileWallet, RemoteMobileWallet, RemoteWalletAssociationConfig, SessionProperties, WalletAssociationConfig } from './types.js';
import {
AssociationKeypair,
MobileWallet,
RemoteMobileWallet,
RemoteScenario,
RemoteWalletAssociationConfig,
SessionProperties,
WalletAssociationConfig
} from './types.js';

const WEBSOCKET_CONNECTION_CONFIG = {
/**
Expand Down Expand Up @@ -326,10 +334,31 @@ export async function transactRemote<TReturn>(
callback: (wallet: RemoteMobileWallet) => TReturn,
config: RemoteWalletAssociationConfig,
): Promise<{associationUrl: URL, result: Promise<TReturn>}> {
return startRemoteScenario(config).then((scenario) => {
return {
associationUrl: scenario.associationUrl,
result: scenario.wallet.then((wallet) =>{
return callback(new Proxy(wallet as RemoteMobileWallet, {
get<TMethodName extends keyof RemoteMobileWallet>(target: RemoteMobileWallet, p: TMethodName) {
if (p == 'terminateSession') {
return async function () {
scenario.close();
return;
};
} else return target[p];
},
}));
}),
};
});
}

export async function startRemoteScenario(
config: RemoteWalletAssociationConfig,
): Promise<RemoteScenario> {
assertSecureContext();
const associationKeypair = await generateAssociationKeypair();
const websocketURL = `wss://${config?.remoteHostAuthority}/reflect`;

let connectionStartTime: number;
const getNextRetryDelayMs = (() => {
const schedule = [...WEBSOCKET_CONNECTION_CONFIG.retryDelayScheduleMs];
Expand All @@ -339,6 +368,8 @@ export async function transactRemote<TReturn>(
let lastKnownInboundSequenceNumber = 0;
let encoding: PROTOCOL_ENCODING;
let state: RemoteState = { __type: 'disconnected' };
let socket: WebSocket;
let disposeSocket: () => void;
let decodeBytes = async (evt: MessageEvent<string | Blob>) => {
if (encoding == 'base64') { // base64 encoding
const message = await evt.data as string;
Expand All @@ -347,8 +378,10 @@ export async function transactRemote<TReturn>(
return await (evt.data as Blob).arrayBuffer();
}
};
const { associationUrl, socket, disposeSocket } = await new Promise<{ associationUrl: URL, socket: WebSocket, disposeSocket: () => void }>((resolve, reject) => {
let socket: WebSocket;
// Reflector Connection Phase
// here we connect to the reflector and wait for the REFLECTOR_ID message
// so we build the association URL and return that back to the caller
const associationUrl = await new Promise<URL>((resolve, reject) => {
const handleOpen = async () => {
if (state.__type !== 'connecting') {
console.warn(
Expand Down Expand Up @@ -395,7 +428,7 @@ export async function transactRemote<TReturn>(
attemptSocketConnection();
}
};
const handleMessage = async (evt: MessageEvent<string | Blob>) => {
const handleReflectorIdMessage = async (evt: MessageEvent<string | Blob>) => {
const responseBuffer = await decodeBytes(evt);
if (state.__type === 'connecting') {
if (responseBuffer.byteLength == 0) {
Expand All @@ -412,11 +445,10 @@ export async function transactRemote<TReturn>(
reflectorId,
config?.baseUri
);
socket.removeEventListener('message', handleMessage);
resolve({ associationUrl, socket, disposeSocket });
socket.removeEventListener('message', handleReflectorIdMessage);
resolve(associationUrl);
}
};
let disposeSocket: () => void;
let retryWaitTimeoutId: number;
const attemptSocketConnection = () => {
if (disposeSocket) {
Expand All @@ -431,21 +463,29 @@ export async function transactRemote<TReturn>(
socket.addEventListener('open', handleOpen);
socket.addEventListener('close', handleClose);
socket.addEventListener('error', handleError);
socket.addEventListener('message', handleMessage);
socket.addEventListener('message', handleReflectorIdMessage);
disposeSocket = () => {
window.clearTimeout(retryWaitTimeoutId);
socket.removeEventListener('open', handleOpen);
socket.removeEventListener('close', handleClose);
socket.removeEventListener('error', handleError);
socket.removeEventListener('message', handleMessage);
socket.removeEventListener('message', handleReflectorIdMessage);
};
};
attemptSocketConnection();
});
return { associationUrl, result: new Promise((resolve, reject) => {
// Wallet Connection Phase
// here we return the association URL (containing the reflector ID) to the caller +
// a promise that will resolve the MobileWallet object once the wallet connects.
let sessionEstablished = false;
let handleClose: () => void;
return { associationUrl, close: () => {
socket.close();
handleClose();
}, wallet: new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const jsonRpcResponsePromises: JsonResponsePromises<any> = {};
socket.addEventListener('message', async (evt: MessageEvent<string | Blob>) => {
const handleMessage = async (evt: MessageEvent<string | Blob>) => {
const responseBuffer = await decodeBytes(evt);
switch (state.__type) {
case 'reflector_id_received':
Expand Down Expand Up @@ -548,24 +588,29 @@ export async function transactRemote<TReturn>(
};
});
})
sessionEstablished = true;
try {
resolve(await callback(new Proxy(wallet as RemoteMobileWallet, {
get<TMethodName extends keyof RemoteMobileWallet>(target: RemoteMobileWallet, p: TMethodName) {
if (p == 'terminateSession') {
return async function () {
disposeSocket();
socket.close();
return;
};
} else return target[p];
},
})))
resolve(wallet);
} catch (e) {
reject(e);
}
break;
}
}
})
}
socket.addEventListener('message', handleMessage);
handleClose = () => {
socket.removeEventListener('message', handleMessage);
disposeSocket();
if (!sessionEstablished) {
reject(
new SolanaMobileWalletAdapterError(
SolanaMobileWalletAdapterErrorCode.ERROR_SESSION_CLOSED,
`The wallet session was closed before connection.`,
{ closeEvent: new CloseEvent('socket was closed before connection') },
),
);
}
};
})};
}
}
Loading
Loading