diff --git a/.gitignore b/.gitignore index 9ee93f0f..79211825 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ coverage .next/ out/ build +dist/ # misc .DS_Store diff --git a/packages/wallet-adapter-core/src/index.ts b/packages/wallet-adapter-core/src/index.ts index 1d3b5810..fe1b3c31 100644 --- a/packages/wallet-adapter-core/src/index.ts +++ b/packages/wallet-adapter-core/src/index.ts @@ -2,6 +2,7 @@ import { WALLET_ADAPTER_CORE_VERSION } from "./version"; export { type AnyAptosWallet, type DappConfig, WalletCore } from "./WalletCore"; export * from "./LegacyWalletPlugins"; +export * from "./new"; export * from "./constants"; export * from "./utils"; export * from "./AIP62StandardWallets"; diff --git a/packages/wallet-adapter-core/src/new/AdaptedWallet.ts b/packages/wallet-adapter-core/src/new/AdaptedWallet.ts new file mode 100644 index 00000000..d75a03f0 --- /dev/null +++ b/packages/wallet-adapter-core/src/new/AdaptedWallet.ts @@ -0,0 +1,228 @@ +import { Aptos, AptosConfig } from '@aptos-labs/ts-sdk'; +import { + AccountInfo, + AptosFeatures, + AptosSignAndSubmitTransactionInput, + AptosSignAndSubmitTransactionOutput, + AptosSignMessageInput, + AptosSignTransactionInputV1_1, + AptosSignTransactionOutput, + AptosSignTransactionOutputV1_1, + WalletIcon, +} from '@aptos-labs/wallet-standard'; +import { GA4 } from '../ga'; +import { WALLET_ADAPTER_CORE_VERSION } from '../version'; +import { AptosStandardWallet } from './AptosStandardWallet'; +import { Network } from './network'; +import { + aptosChainIdentifierToNetworkMap, + buildTransaction, + chainIdToStandardNetwork, + isFeatureMinorVersion, + mapUserResponse, + networkInfoToNetwork, +} from './utils'; + +type EventHandlers = { + accountDisconnected: (account: AccountInfo) => void; + activeAccountChanged: (account?: AccountInfo) => void; + activeNetworkChanged: (network: Network) => void; +} + +export interface AdaptedWalletConfig { + disableTelemetry?: boolean; +} + +/** + * A wallet instance adapted from an Aptos standard wallet that supports + * all required features with minimum version. + */ +export class AdaptedWallet { + readonly name: string; + readonly url: string; + readonly icon: WalletIcon; + readonly features: AptosFeatures; + + readonly availableNetworks: Network[]; + + // Google Analytics 4 module + private readonly ga4?: GA4; + + constructor(wallet: AptosStandardWallet, options: AdaptedWalletConfig = {}) { + this.name = wallet.name; + this.url = wallet.url; + this.icon = wallet.icon; + this.features = wallet.features; + + if (!options.disableTelemetry) { + this.ga4 = new GA4(); + } + + this.availableNetworks = []; + for (const chain of wallet.chains) { + const network = aptosChainIdentifierToNetworkMap[chain]; + if (network) { + this.availableNetworks.push(network); + } + } + } + + // TODO: revise event formats and names + private recordEvent(eventName: string, additionalInfo?: object) { + this.ga4?.gtag("event", `wallet_adapter_${eventName}`, { + wallet: this.name, + // network: this._network?.name, + // network_url: this._network?.url, + adapter_core_version: WALLET_ADAPTER_CORE_VERSION, + send_to: process.env.GAID, + ...additionalInfo, + }); + } + + // region Connection + + async connect() { + const feature = this.features['aptos:connect']; + return feature.connect(); + } + + async disconnect() { + // TODO: specify which account. defaults to active account + const feature = this.features['aptos:disconnect']; + await feature.disconnect(); + } + + // endregion + + // region Accounts + + async getConnectedAccounts(): Promise { + // TODO: add explicit `getConnectedAccounts` feature + const activeAccount = await this.getActiveAccount(); + return activeAccount ? [activeAccount] : []; + } + + // TODO + onAccountDisconnected(callback: (account: AccountInfo) => void) { + return () => { + }; + } + + async getActiveAccount(): Promise { + return this.features['aptos:account'].account() + .catch(() => undefined); + } + + onActiveAccountChanged(callback: (account?: AccountInfo) => void) { + const feature = this.features['aptos:onAccountChange']; + void feature.onAccountChange((newAccount) => { + callback(newAccount); + }); + return () => { + void feature.onAccountChange(() => { + }); + } + } + + // endregion + + // region Networks + + async getAvailableNetworks(): Promise { + // TODO: maybe add explicit `getAvailableNetworks` feature + return this.availableNetworks; + } + + async getActiveNetwork(): Promise { + const feature = this.features['aptos:network']; + const networkInfo = await feature.network(); + return networkInfoToNetwork(networkInfo); + } + + onActiveNetworkChanged(callback: (network: Network) => void) { + const feature = this.features['aptos:onNetworkChange']; + + void feature.onNetworkChange((networkInfo) => { + const network = networkInfoToNetwork(networkInfo); + callback(network); + }); + return () => { + void feature.onNetworkChange(() => { + }); + } + } + + // endregion + + // region Signature + + // TODO: improve message signature standard + async signMessage(input: AptosSignMessageInput) { + const feature = this.features['aptos:signMessage']; + return feature.signMessage(input); + } + + async signTransaction(input: AptosSignTransactionInputV1_1) { + const feature = this.features['aptos:signTransaction'] + + if (isFeatureMinorVersion(feature, "1.0")) { + const { signerAddress, feePayer } = input; + // This will throw an error if it requires an async call + const transaction = buildTransaction(input); + const asFeePayer = signerAddress?.toString() === feePayer?.address.toString(); + const response = await feature.signTransaction(transaction, asFeePayer); + + return mapUserResponse( + response, (authenticator) => ({ + authenticator, + rawTransaction: transaction, + })); + } + + return feature.signTransaction(input); + } + + async signAndSubmitTransaction(input: AptosSignAndSubmitTransactionInput) { + const feature = this.features['aptos:signAndSubmitTransaction'] + if (feature) { + return feature.signAndSubmitTransaction(input); + } + + const response = await this.signTransaction(input); + return mapUserResponse( + response, async ({ rawTransaction: transaction, authenticator }) => { + const { chainId } = transaction.rawTransaction.chain_id; + const network = chainIdToStandardNetwork(chainId); + const aptosConfig = new AptosConfig({ network }); + const aptosClient = new Aptos(aptosConfig); + + const { hash } = await aptosClient.transaction.submit.simple({ + transaction, + senderAuthenticator: authenticator, + }); + return { hash }; + }); + } + + // endregion + + // region Event handling + + on(eventName: EventName, callback: EventHandlers[EventName]) { + const handlers: { + [K in keyof EventHandlers]: (cb: EventHandlers[K]) => () => void; + } = { + accountDisconnected: (cb) => this.onAccountDisconnected(cb), + activeAccountChanged: (cb) => this.onActiveAccountChanged(cb), + activeNetworkChanged: (cb) => this.onActiveNetworkChanged(cb), + }; + + const handler = handlers[eventName]; + if (!handler) { + throw new Error('Unsupported event name'); + } + return handler(callback); + } + + // endregion +} diff --git a/packages/wallet-adapter-core/src/new/AptosStandardWallet.ts b/packages/wallet-adapter-core/src/new/AptosStandardWallet.ts new file mode 100644 index 00000000..6f873aa7 --- /dev/null +++ b/packages/wallet-adapter-core/src/new/AptosStandardWallet.ts @@ -0,0 +1,52 @@ +import { + AptosFeatures, + AptosWallet as AptosStandardWallet, + MinimallyRequiredFeatures, + Wallet, +} from '@aptos-labs/wallet-standard'; + +type FeatureVersion = `${number}.${number}` | `${number}.${number}.${number}`; +type TargetVersion = `${number}.${number}`; + +/** + * Required features with minimum versions. + * In the future, we might choose to slowly deprecate older versions to simplify the adapter's code. + */ +const requiredFeatures: [name: keyof MinimallyRequiredFeatures, version: TargetVersion][] = [ + ['aptos:account', '1.0'], + ['aptos:connect', '1.0'], + ['aptos:disconnect', '1.0'], + ['aptos:network', '1.0'], + ['aptos:onAccountChange', '1.0'], + ['aptos:onNetworkChange', '1.0'], + ['aptos:signMessage', '1.0'], + ['aptos:signTransaction', '1.0'], +]; + +/** + * Check whether the specified version is compatible with a target version + */ +function isVersionCompatible(value: FeatureVersion, target: TargetVersion) { + const [major, minor] = value.split('.').map(Number); + const [tgtMajor, tgtMinor] = target.split('.').map(Number); + return major === tgtMajor && minor >= tgtMinor; +} + +/** + * Check whether a generic wallet is an Aptos standard wallet. + * + * The wallet needs to implement all the required features with minimum version. + * @param wallet generic wallet to be considered compatible. + */ +export function isAptosStandardWallet(wallet: Wallet): wallet is AptosStandardWallet { + const features = wallet.features as Partial; + for (const [name, targetVersion] of requiredFeatures) { + const feature = features[name]; + if (!feature || !isVersionCompatible(feature.version, targetVersion)) { + return false; + } + } + return true; +} + +export type { AptosStandardWallet }; diff --git a/packages/wallet-adapter-core/src/new/WalletAdapter.ts b/packages/wallet-adapter-core/src/new/WalletAdapter.ts new file mode 100644 index 00000000..0c7fa4a4 --- /dev/null +++ b/packages/wallet-adapter-core/src/new/WalletAdapter.ts @@ -0,0 +1,110 @@ +import { getWallets } from '@aptos-labs/wallet-standard'; +import { type AptosStandardWallet, isAptosStandardWallet } from './AptosStandardWallet'; +import { AdaptedWallet } from './AdaptedWallet'; + +// Use a finalization registry to enable cleanup callbacks (closest things to destructors). +type CleanupCallback = () => void; +const supportsFinalizationRegistry = typeof FinalizationRegistry !== 'undefined'; +const finalizationRegistry = supportsFinalizationRegistry + ? new FinalizationRegistry((cleanup) => cleanup()) + : undefined; + +export type AdaptedWalletEventCallback = (wallet: AdaptedWallet) => unknown; +export type UnsubscribeCallback = () => void; + +export interface WalletAdapterConfig { + disableTelemetry?: boolean; +} + +export class WalletAdapter { + private readonly registeredWallets = new Map; + private readonly onRegisterListeners = new Set; + private readonly onUnregisterListeners = new Set; + + constructor(config: WalletAdapterConfig = {}) { + const api = getWallets(); + + // Use local references so that cleanup callback doesn't bind to `this` + const registeredWallets = this.registeredWallets; + const onRegisterListeners = this.onRegisterListeners; + const onUnregisterListeners = this.onUnregisterListeners; + + for (const wallet of api.get()) { + if (isAptosStandardWallet(wallet)) { + registeredWallets.set(wallet, new AdaptedWallet(wallet, config)); + } + } + + const offRegister = api.on('register', (...wallets) => { + for (const wallet of wallets) { + if (isAptosStandardWallet(wallet)) { + const adaptedWallet = new AdaptedWallet(wallet, config); + registeredWallets.set(wallet, adaptedWallet); + for (const callback of onRegisterListeners) { + callback(adaptedWallet); + } + } + } + }); + + const offUnregister = api.on('unregister', (...wallets) => { + for (const wallet of wallets) { + if (isAptosStandardWallet(wallet)) { + const adaptedWallet = registeredWallets.get(wallet); + if (adaptedWallet) { + registeredWallets.delete(wallet); + for (const callback of onUnregisterListeners) { + callback(adaptedWallet); + } + } + } + } + }); + + const cleanupCallback: CleanupCallback = () => { + offRegister(); + offUnregister(); + }; + + // Remove event listeners when this instance is garbage collected + finalizationRegistry?.register(this, cleanupCallback); + } + + get availableWallets() { + return [...this.registeredWallets.values()]; + } + + // region Event handlers + + private onRegister(callback: AdaptedWalletEventCallback): UnsubscribeCallback { + this.onRegisterListeners.add(callback); + return () => this.offRegister(callback); + } + + private offRegister(callback: AdaptedWalletEventCallback) { + this.onRegisterListeners.delete(callback); + } + + private onUnregister(callback: AdaptedWalletEventCallback): UnsubscribeCallback { + this.onUnregisterListeners.add(callback); + return () => this.offUnregister(callback); + } + + private offUnregister(callback: AdaptedWalletEventCallback) { + this.onUnregisterListeners.delete(callback); + } + + on(eventName: 'register' | 'unregister', callback: (wallet: AdaptedWallet) => unknown) { + return eventName === 'register' + ? this.onRegister(callback) + : this.onUnregister(callback); + } + + off(eventName: 'register' | 'unregister', callback: (wallet: AdaptedWallet) => unknown) { + return eventName === 'register' + ? this.offRegister(callback) + : this.offUnregister(callback); + } + + // endregion +} diff --git a/packages/wallet-adapter-core/src/new/index.ts b/packages/wallet-adapter-core/src/new/index.ts new file mode 100644 index 00000000..de3c9cfa --- /dev/null +++ b/packages/wallet-adapter-core/src/new/index.ts @@ -0,0 +1,11 @@ +export * from './utils/approval'; +export * from './utils/walletFromLegacyPlugin'; +export * from './AdaptedWallet'; +export * from './WalletAdapter'; + +// TODO: remove other `Network` definition +export { + type CustomNetwork, + type Network as NewNetwork, + StandardNetwork, +} from './network'; diff --git a/packages/wallet-adapter-core/src/new/network.ts b/packages/wallet-adapter-core/src/new/network.ts new file mode 100644 index 00000000..92bf903b --- /dev/null +++ b/packages/wallet-adapter-core/src/new/network.ts @@ -0,0 +1,35 @@ +import { NetworkToChainId, Network as AptosNetwork } from '@aptos-labs/ts-sdk'; +import { + APTOS_DEVNET_CHAIN, + APTOS_LOCALNET_CHAIN, + APTOS_MAINNET_CHAIN, + APTOS_TESTNET_CHAIN, + ChainsId, +} from '@aptos-labs/wallet-standard'; + +/** + * Subset of `Network` enum that only includes standard networks. + */ +export type StandardNetwork = Exclude; + +// Note: mapping to the original enum ensures the values are compatible. +export const StandardNetwork = { + MAINNET: AptosNetwork.MAINNET, + TESTNET: AptosNetwork.TESTNET, + DEVNET: AptosNetwork.DEVNET, + LOCAL: AptosNetwork.LOCAL, +} as const; + +/** + * Custom network configuration. Requires at least the full node URL to work properly. + */ +export interface CustomNetwork { + name: string; + // consider making this required + chainId?: number; + fullNodeURL: string; + indexerURL?: string; + faucetURL?: string; +} + +export type Network = StandardNetwork | CustomNetwork; diff --git a/packages/wallet-adapter-core/src/new/utils/approval.ts b/packages/wallet-adapter-core/src/new/utils/approval.ts new file mode 100644 index 00000000..68323ee3 --- /dev/null +++ b/packages/wallet-adapter-core/src/new/utils/approval.ts @@ -0,0 +1,31 @@ +import { + UserApproval, + UserRejection, + UserResponse, + UserResponseStatus, +} from '@aptos-labs/wallet-standard'; + +export function makeUserApproval(args: T): UserApproval { + return { + status: UserResponseStatus.APPROVED, + args, + }; +} + +export function makeUserRejection(): UserRejection { + return { status: UserResponseStatus.REJECTED }; +} + +export type MaybeAsync = T | Promise; + +export function mapUserResponse(response: UserResponse, mapFn: (src: Src) => Dst): UserResponse +export function mapUserResponse(response: UserResponse, mapFn: (src: Src) => Promise): Promise> +export function mapUserResponse(response: UserResponse, mapFn: (src: Src) => MaybeAsync): MaybeAsync> { + if (response.status === UserResponseStatus.REJECTED) { + return makeUserRejection(); + } + const mappedResponse = mapFn(response.args); + return mappedResponse instanceof Promise + ? mappedResponse.then((args) => makeUserApproval(args)) + : makeUserApproval(mappedResponse); +} diff --git a/packages/wallet-adapter-core/src/new/utils/buildTransaction.ts b/packages/wallet-adapter-core/src/new/utils/buildTransaction.ts new file mode 100644 index 00000000..d6e3b2ae --- /dev/null +++ b/packages/wallet-adapter-core/src/new/utils/buildTransaction.ts @@ -0,0 +1,52 @@ +import { + ChainId, + DEFAULT_MAX_GAS_AMOUNT, + MultiAgentTransaction, + Network, + NetworkToChainId, + RawTransaction, SimpleTransaction, +} from '@aptos-labs/ts-sdk'; +import { AptosSignTransactionInputV1_1 } from '@aptos-labs/wallet-standard'; + +/** + * Build a transaction from a transaction input, without async calls. + * Most wallets require browser popups, that might be blocked if a delay is introduced + * after the user interaction. + */ +export function buildTransaction(input: AptosSignTransactionInputV1_1) { + const senderAddress = input.sender?.address ?? input.signerAddress; + // could use active account if available + if (!senderAddress) { + throw new Error('Cannot determine sender address'); + } + + if (input.sequenceNumber === undefined) { + throw new Error('Cannot determine sequence number'); + } + + if (!('serialize' in input.payload)) { + throw new Error('Cannot generate payload from input'); + } + + const expirationTimestamp = input.expirationSecondsFromNow + ? Math.ceil(Date.now() / 1000) + input.expirationSecondsFromNow + : (input.expirationTimestamp ?? 0); + + // Can use active network if available + const chainId = NetworkToChainId[input.network ?? Network.MAINNET]; + + const rawTransaction = new RawTransaction( + senderAddress, + BigInt(input.sequenceNumber), + input.payload, + BigInt(input.maxGasAmount ?? DEFAULT_MAX_GAS_AMOUNT), + BigInt(input.gasUnitPrice ?? 100), + BigInt(expirationTimestamp), + new ChainId(chainId), + ); + + const secondarySignersAddresses = input.secondarySigners?.map((signer) => signer.address) ?? []; + return secondarySignersAddresses.length > 0 + ? new MultiAgentTransaction(rawTransaction, secondarySignersAddresses, input.feePayer?.address) + : new SimpleTransaction(rawTransaction, input.feePayer?.address); +} diff --git a/packages/wallet-adapter-core/src/new/utils/index.ts b/packages/wallet-adapter-core/src/new/utils/index.ts new file mode 100644 index 00000000..dbaf50e0 --- /dev/null +++ b/packages/wallet-adapter-core/src/new/utils/index.ts @@ -0,0 +1,5 @@ +export * from './approval'; +export * from './buildTransaction'; +export * from './network'; +export * from './version'; +export * from './walletFromLegacyPlugin'; diff --git a/packages/wallet-adapter-core/src/new/utils/network.ts b/packages/wallet-adapter-core/src/new/utils/network.ts new file mode 100644 index 00000000..349b9c34 --- /dev/null +++ b/packages/wallet-adapter-core/src/new/utils/network.ts @@ -0,0 +1,55 @@ +import { NetworkToChainId } from '@aptos-labs/ts-sdk'; +import { + APTOS_DEVNET_CHAIN, + APTOS_LOCALNET_CHAIN, + APTOS_MAINNET_CHAIN, + APTOS_TESTNET_CHAIN, + ChainsId, + NetworkInfo, +} from '@aptos-labs/wallet-standard'; +import { type Network, StandardNetwork } from '../network'; + +/** + * Map chain identifiers to standard networks. + * Useful to obtain available networks from the `chains` field + */ +export const aptosChainIdentifierToNetworkMap: Record = { + [APTOS_MAINNET_CHAIN]: StandardNetwork.MAINNET, + [APTOS_TESTNET_CHAIN]: StandardNetwork.TESTNET, + [APTOS_DEVNET_CHAIN]: StandardNetwork.DEVNET, + [APTOS_LOCALNET_CHAIN]: StandardNetwork.LOCAL, +} + +/** + * Try to obtain the standard network associated with a chain id. + * In case there's no exact match, we assume the network is Devnet since + * its chain id changes. + */ +export function chainIdToStandardNetwork(chainId: number): StandardNetwork { + for (const [name, cid] of Object.entries(NetworkToChainId)) { + if (cid === chainId) { + return name as StandardNetwork; + } + } + return StandardNetwork.DEVNET; +} + +/** + * Convert `NetworkInfo` from the wallet standard to the newer `Network` type. + * TODO: make `Network` the new standard type and remove `NetworkInfo` + */ +export function networkInfoToNetwork(info: NetworkInfo): Network { + if (info.name !== 'custom') { + return info.name; + } + + if (info.url === undefined) { + throw new Error('Missing fullNodeURL from custom network'); + } + + return { + name: info.name, + fullNodeURL: info.url, + chainId: info.chainId, + } +} diff --git a/packages/wallet-adapter-core/src/new/utils/version.ts b/packages/wallet-adapter-core/src/new/utils/version.ts new file mode 100644 index 00000000..c1799e0f --- /dev/null +++ b/packages/wallet-adapter-core/src/new/utils/version.ts @@ -0,0 +1,10 @@ +export function isFeatureMinorVersion< + FeatureVersion extends `${number}.${number}` | `${number}.${number}.${number}`, + TargetVersion extends `${number}.${number}`>( + feature: { version: FeatureVersion }, + targetVersion: TargetVersion, +): feature is FeatureVersion extends TargetVersion | `${TargetVersion}.${number}` ? { + version: FeatureVersion +} : never { + return feature.version.startsWith(targetVersion); +} diff --git a/packages/wallet-adapter-core/src/new/utils/walletFromLegacyPlugin.ts b/packages/wallet-adapter-core/src/new/utils/walletFromLegacyPlugin.ts new file mode 100644 index 00000000..dbeb4e3c --- /dev/null +++ b/packages/wallet-adapter-core/src/new/utils/walletFromLegacyPlugin.ts @@ -0,0 +1,264 @@ +import { + AccountAuthenticator, + Ed25519PublicKey, + Ed25519Signature, + MultiEd25519PublicKey, + MultiEd25519Signature, + NetworkToChainId, + NetworkToNodeAPI, + PublicKey, + Signature, +} from '@aptos-labs/ts-sdk'; +import { + AccountInfo, + APTOS_CHAINS, + AptosConnectFeature, + AptosConnectNamespace, + AptosDisconnectFeature, + AptosDisconnectNamespace, + AptosGetAccountFeature, + AptosGetAccountNamespace, + AptosGetNetworkFeature, + AptosGetNetworkNamespace, + AptosOnAccountChangeFeature, + AptosOnAccountChangeNamespace, + AptosOnNetworkChangeFeature, + AptosOnNetworkChangeNamespace, + AptosSignAndSubmitTransactionFeature, + AptosSignAndSubmitTransactionNamespace, + AptosSignMessageFeature, + AptosSignMessageNamespace, + AptosSignTransactionFeature, + AptosSignTransactionNamespace, + AptosWallet, + NetworkInfo, + UserResponseStatus, +} from '@aptos-labs/wallet-standard'; +import { + AccountInfo as LegacyAccountInfo, + AdapterPlugin as LegacyAdapterPlugin, + NetworkInfo as LegacyNetworkInfo, +} from '../../LegacyWalletPlugins'; +import { makeUserApproval } from './approval'; + +function normalizeAccountInfo(accountInfo: LegacyAccountInfo | AccountInfo) { + if ('serialize' in accountInfo) { + return accountInfo; + } + const { address, minKeysRequired, publicKey: serializedPublicKey, ansName } = accountInfo; + let publicKey: PublicKey; + if (Array.isArray(serializedPublicKey)) { + publicKey = new MultiEd25519PublicKey({ + publicKeys: serializedPublicKey.map( + (pk) => new Ed25519PublicKey(pk), + ), + threshold: minKeysRequired ?? 0, + }) + } else { + publicKey = new Ed25519PublicKey(serializedPublicKey); + } + return new AccountInfo({ + address, + publicKey, + ansName: ansName || undefined, + }) +} + +function normalizeNetworkInfo(networkInfo: LegacyNetworkInfo | NetworkInfo): NetworkInfo { + const { name, chainId, url } = networkInfo; + const resolvedNodeURL = url || NetworkToNodeAPI[name]; + const resolvedChainId = chainId ? Number(chainId) : NetworkToChainId[name]; + return { name, chainId: resolvedChainId, url: resolvedNodeURL }; +} + +export function connectFeatureFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): AptosConnectFeature { + const legacyConnect: () => Promise = legacyPlugin.connect; + return { + [AptosConnectNamespace]: { + version: '1.0.0', + connect: async () => { + // Note: dismissal will be considered as error + const accountInfo = await legacyConnect(); + return makeUserApproval(normalizeAccountInfo(accountInfo)); + }, + }, + }; +} + +export function disconnectFeatureFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): AptosDisconnectFeature { + const legacyDisconnect = legacyPlugin.disconnect; + return { + [AptosDisconnectNamespace]: { + version: '1.0.0', + disconnect: async () => { + await legacyDisconnect(); + }, + }, + }; +} + +export function getAccountFeatureFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): AptosGetAccountFeature { + const legacyGetAccount = legacyPlugin.account; + if (legacyGetAccount === undefined) { + throw new Error('Required `account` feature is not available'); + } + return { + [AptosGetAccountNamespace]: { + version: '1.0.0', + account: async () => { + const accountInfo = await legacyGetAccount(); + return normalizeAccountInfo(accountInfo); + }, + }, + }; +} + +export function getNetworkFeatureFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): AptosGetNetworkFeature { + const legacyGetNetwork: () => Promise = legacyPlugin.network; + return { + [AptosGetNetworkNamespace]: { + version: '1.0.0', + network: async () => { + const networkInfo = await legacyGetNetwork(); + return normalizeNetworkInfo(networkInfo); + }, + }, + }; +} + +export function onAccountChangeFeatureFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): AptosOnAccountChangeFeature { + const legacyMethod = legacyPlugin.onAccountChange; + return { + [AptosOnAccountChangeNamespace]: { + version: '1.0.0', + onAccountChange: async (callback) => { + void legacyMethod(async (accountInfo: LegacyAccountInfo | AccountInfo) => { + callback(normalizeAccountInfo(accountInfo)); + }); + }, + }, + }; +} + +export function onNetworkChangeFeatureFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): AptosOnNetworkChangeFeature { + const legacyMethod = legacyPlugin.onNetworkChange; + return { + [AptosOnNetworkChangeNamespace]: { + version: '1.0.0', + onNetworkChange: async (callback) => { + void legacyMethod(async (networkInfo: LegacyNetworkInfo | NetworkInfo) => { + callback(normalizeNetworkInfo(networkInfo)); + }); + }, + }, + }; +} + +export function signMessageFeatureFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): AptosSignMessageFeature { + const legacyMethod = legacyPlugin.signMessage; + return { + [AptosSignMessageNamespace]: { + version: '1.0.0', + signMessage: async (input) => { + const response = await legacyMethod(input); + if ('status' in response) { + return response; + } + + const { signature: serializedSignature, bitmap, ...rest } = response; + + let signature: Signature; + if (typeof serializedSignature === 'string') { + signature = new Ed25519Signature(serializedSignature); + } else if (Array.isArray(serializedSignature)) { + if (!bitmap) { + throw new Error('Multi-ed22519 signature bitmap not provided'); + } + signature = new MultiEd25519Signature({ + signatures: serializedSignature.map( + (sig) => new Ed25519Signature(sig), + ), + bitmap, + }); + } else { + signature = serializedSignature; + } + + return makeUserApproval({ + signature, + ...rest, + }); + }, + }, + }; +} + +export function signTransactionFeatureFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): AptosSignTransactionFeature { + const legacyMethod = legacyPlugin.signTransaction; + if (legacyMethod === undefined) { + throw new Error('Required `signTransaction` feature is not available'); + } + return { + [AptosSignTransactionNamespace]: { + version: '1.0.0', + signTransaction: async (input) => { + // Note: dismissal will be considered as error + const accountAuthenticator: AccountAuthenticator = await legacyMethod(input); + return makeUserApproval(accountAuthenticator); + }, + }, + }; +} + +export function signAndSubmitTransactionFeatureFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): Partial { + const legacyMethod = legacyPlugin.signAndSubmitTransaction; + if (legacyMethod === undefined) { + return {}; + } + return { + [AptosSignAndSubmitTransactionNamespace]: { + version: '1.1.0', + signAndSubmitTransaction: async (input) => { + const { payload, maxGasAmount, gasUnitPrice } = input; + const response = await legacyMethod({ + data: payload, + options: { + maxGasAmount, + gasUnitPrice, + }, + }); + if ('status' in response && ( + response.status === UserResponseStatus.APPROVED || response.status === UserResponseStatus.REJECTED)) { + return response; + } + const { hash } = response; + return makeUserApproval({ hash }); + }, + }, + }; +} + +/** + * Attempt to convert a legacy plugin into an Aptos wallet compatible with the Aptos wallet standard + */ +export function walletFromLegacyPlugin(legacyPlugin: LegacyAdapterPlugin): AptosWallet { + return { + name: legacyPlugin.name, + version: '1.0.0', + icon: legacyPlugin.icon, + chains: APTOS_CHAINS, + url: legacyPlugin.url, + features: { + ...connectFeatureFromLegacyPlugin(legacyPlugin), + ...disconnectFeatureFromLegacyPlugin(legacyPlugin), + ...getAccountFeatureFromLegacyPlugin(legacyPlugin), + ...getNetworkFeatureFromLegacyPlugin(legacyPlugin), + ...onAccountChangeFeatureFromLegacyPlugin(legacyPlugin), + ...onNetworkChangeFeatureFromLegacyPlugin(legacyPlugin), + ...signMessageFeatureFromLegacyPlugin(legacyPlugin), + ...signTransactionFeatureFromLegacyPlugin(legacyPlugin), + ...signAndSubmitTransactionFeatureFromLegacyPlugin(legacyPlugin), + }, + accounts: [], + } +} diff --git a/packages/wallet-adapter-core/tsconfig.json b/packages/wallet-adapter-core/tsconfig.json index 4c70a555..41ba0d53 100644 --- a/packages/wallet-adapter-core/tsconfig.json +++ b/packages/wallet-adapter-core/tsconfig.json @@ -3,8 +3,8 @@ "include": ["."], "exclude": ["dist", "build", "node_modules"], "compilerOptions": { - "target": "es5", - "lib": ["es2020", "dom"], + "target": "es2020", + "lib": ["es2021", "dom"], "rootDir": "src", "outDir": "dist" } diff --git a/packages/wallet-adapter-react/package.json b/packages/wallet-adapter-react/package.json index c4441778..ed4cafcc 100644 --- a/packages/wallet-adapter-react/package.json +++ b/packages/wallet-adapter-react/package.json @@ -46,10 +46,12 @@ }, "dependencies": { "@aptos-labs/wallet-adapter-core": "workspace:*", + "@aptos-labs/wallet-standard": "^0.2.0", "@radix-ui/react-slot": "^1.0.2" }, "peerDependencies": { - "react": "^18" + "react": "^18", + "@aptos-labs/ts-sdk": "^1.33.1" }, "files": [ "dist", diff --git a/packages/wallet-adapter-react/src/new/Example.tsx b/packages/wallet-adapter-react/src/new/Example.tsx new file mode 100644 index 00000000..ebc77b2f --- /dev/null +++ b/packages/wallet-adapter-react/src/new/Example.tsx @@ -0,0 +1,94 @@ +import { UserResponseStatus } from '@aptos-labs/wallet-standard'; +import { useState } from 'react'; +import { ConnectedWallet, useActiveWallet } from './useActiveWallet'; +import { setActiveWalletId } from './useActiveWalletId'; +import { useAvailableWallets } from './useAvailableWallets'; + +type Wallet = ReturnType[0]; + +export function WalletSelector() { + const [isModalOpen, setIsModalOpen] = useState(false) + const onToggle = () => { + setIsModalOpen((prev) => !prev); + }; + return ( +
+ + {isModalOpen ? : null} +
+ ); +} + +export function WalletSelectorModal() { + const wallets = useAvailableWallets(); + + const onConnect = async (wallet: Wallet) => { + const response = await wallet.connect(); + if (response.status === UserResponseStatus.APPROVED) { + setActiveWalletId(wallet.name); + } + }; + + return ( +
+ {wallets.map((wallet) => ( + + ))} +
+ ); +} + +interface ActiveAccountNavItemProps { + wallet: ConnectedWallet +} + +export function ActiveAccountNavItem({ wallet }: ActiveAccountNavItemProps) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const onToggle = () => { + setIsDropdownOpen((prev) => !prev); + }; + + const onDisconnect = async () => { + await wallet.disconnect(); + }; + + return ( + + ) +} + + +function Navbar() { + const wallet = useActiveWallet(); + return ( + + ); +} + +function Body() { + const wallet = useActiveWallet(); + + const onSign = async () => { + if (!wallet.isConnected) { + return; + } + const response = await wallet.signTransaction({} as any); + }; + + return wallet.isConnected ? : +
Please connect to wallet first
; +} diff --git a/packages/wallet-adapter-react/src/new/WalletAdapterContext.tsx b/packages/wallet-adapter-react/src/new/WalletAdapterContext.tsx new file mode 100644 index 00000000..bd256548 --- /dev/null +++ b/packages/wallet-adapter-react/src/new/WalletAdapterContext.tsx @@ -0,0 +1,16 @@ +import { WalletAdapter, WalletAdapterConfig } from '@aptos-labs/wallet-adapter-core'; +import { createContext, PropsWithChildren, useMemo } from 'react'; + +export const WalletAdapterContext = createContext(null); + +export interface WalletAdapterProviderProps extends PropsWithChildren { + config?: WalletAdapterConfig; +} + +export function WalletAdapterProvider({ children, config }: WalletAdapterProviderProps) { + const adapter = useMemo(() => { + return new WalletAdapter(config); + }, [config]); + + return {children} +} diff --git a/packages/wallet-adapter-react/src/new/useActiveWallet.ts b/packages/wallet-adapter-react/src/new/useActiveWallet.ts new file mode 100644 index 00000000..8044dde9 --- /dev/null +++ b/packages/wallet-adapter-react/src/new/useActiveWallet.ts @@ -0,0 +1,118 @@ +import { AdaptedWallet, NewNetwork as Network, StandardNetwork } from '@aptos-labs/wallet-adapter-core'; +import { + AccountInfo, + AptosFeatures, + AptosSignAndSubmitTransactionInput, + AptosSignMessageInput, + AptosSignTransactionInputV1_1, + WalletIcon, +} from '@aptos-labs/wallet-standard'; +import { useEffect, useMemo, useState } from 'react'; +import { useActiveWalletId } from './useActiveWalletId'; +import { useAvailableWallets } from './useAvailableWallets'; + +export interface UninitializedWallet { + isInitialized: false; + isConnected: false; + activeAccount?: undefined; + activeNetwork?: undefined; +} + +export interface DisconnectedWallet { + isInitialized: true; + isConnected: false; + activeAccount?: undefined; + activeNetwork?: undefined; +} + +export interface ConnectedWallet { + isInitialized: true; + isConnected: true; + activeAccount: AccountInfo; + activeNetwork: Network; + name: string; + icon: WalletIcon; + features: AptosFeatures; + disconnect: () => Promise; + signMessage: (input: AptosSignMessageInput) + => ReturnType; + signTransaction: (input: Omit) + => ReturnType; + signAndSubmitTransaction: (input: AptosSignAndSubmitTransactionInput) + => ReturnType; +} + +type UseActiveWalletResult = + | UninitializedWallet + | DisconnectedWallet + | ConnectedWallet; + +export function useActiveWallet(): UseActiveWalletResult { + const availableWallets = useAvailableWallets(); + const activeWalletId = useActiveWalletId(); + const activeWallet = activeWalletId + ? availableWallets.find((w) => w.name === activeWalletId) + : undefined; + + const [isInitialized, setIsInitialized] = useState(activeWallet === undefined); + const [activeAccount, setActiveAccount] = useState(); + const [activeNetwork, setActiveNetwork] = useState(StandardNetwork.MAINNET); + + useEffect(() => { + if (!activeWallet) { + setIsInitialized(true); + return; + } + + setIsInitialized(false); + Promise.all([ + activeWallet.getActiveAccount(), + activeWallet.getActiveNetwork(), + ]).then(([newAccount, newNetwork]) => { + setActiveAccount(newAccount); + setActiveNetwork(newNetwork); + setIsInitialized(true); + }); + + const listeners = [ + activeWallet.on('activeAccountChanged', setActiveAccount), + activeWallet.on('activeNetworkChanged', setActiveNetwork), + ]; + + return () => { + for (const unsubscribe of listeners) { + unsubscribe(); + } + }; + }, [activeWallet]); + + return useMemo(() => { + if (!isInitialized) { + return { + isInitialized: false, + isConnected: false, + } satisfies UninitializedWallet; + } + + if (!activeWallet || !activeAccount) { + return { + isInitialized: true, + isConnected: false, + } satisfies DisconnectedWallet; + } + + return { + isInitialized: true, + isConnected: true, + activeAccount, + activeNetwork, + name: activeWallet.name, + icon: activeWallet.icon, + features: activeWallet.features, + disconnect: async () => activeWallet.disconnect(), + signMessage: async (input) => activeWallet.signMessage(input), + signTransaction: async (input) => activeWallet.signTransaction(input), + signAndSubmitTransaction: async (input) => activeWallet.signAndSubmitTransaction(input), + } satisfies ConnectedWallet; + }, [activeAccount, activeNetwork, activeWallet, isInitialized]); +} diff --git a/packages/wallet-adapter-react/src/new/useActiveWalletId.ts b/packages/wallet-adapter-react/src/new/useActiveWalletId.ts new file mode 100644 index 00000000..c0011a48 --- /dev/null +++ b/packages/wallet-adapter-react/src/new/useActiveWalletId.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react'; + +const activeWalletIdStorageKey = '@aptos-labs/wallet-adapter/active-wallet'; + +export function getActiveWalletId() { + return window.localStorage.getItem(activeWalletIdStorageKey) ?? undefined; +} + +export function setActiveWalletId(walletId: string | undefined) { + if (walletId) { + window.localStorage.setItem(activeWalletIdStorageKey, walletId); + } else { + window.localStorage.removeItem(activeWalletIdStorageKey); + } +} + +export function useActiveWalletId() { + const [activeWalletId, setActiveWalletId] = useState(getActiveWalletId()) + useEffect(() => { + const onStorageEvent = (event: StorageEvent) => { + if (event.storageArea !== window.localStorage || event.key !== activeWalletIdStorageKey) { + return; + } + setActiveWalletId(event.newValue ?? undefined); + }; + window.addEventListener('storage', onStorageEvent); + return () => window.removeEventListener('storage', onStorageEvent); + }, []); + return activeWalletId; +} diff --git a/packages/wallet-adapter-react/src/new/useAvailableWallets.ts b/packages/wallet-adapter-react/src/new/useAvailableWallets.ts new file mode 100644 index 00000000..db1a1499 --- /dev/null +++ b/packages/wallet-adapter-react/src/new/useAvailableWallets.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; +import { useWalletAdapter } from './useWalletAdapter'; + +export function useAvailableWallets() { + const adapter = useWalletAdapter(); + + const [availableWallets, setAvailableWallets] = useState(adapter.availableWallets); + useEffect(() => { + const refreshAvailableWallets = () => { + setAvailableWallets(adapter.availableWallets); + }; + + adapter.on('register', refreshAvailableWallets); + adapter.on('unregister', refreshAvailableWallets); + + return () => { + adapter.off('register', refreshAvailableWallets); + adapter.off('unregister', refreshAvailableWallets); + }; + }, [adapter]); + + return availableWallets; +} diff --git a/packages/wallet-adapter-react/src/new/useWalletAdapter.tsx b/packages/wallet-adapter-react/src/new/useWalletAdapter.tsx new file mode 100644 index 00000000..4fc2e6e7 --- /dev/null +++ b/packages/wallet-adapter-react/src/new/useWalletAdapter.tsx @@ -0,0 +1,20 @@ +import { WalletAdapter } from '@aptos-labs/wallet-adapter-core'; +import { useContext } from 'react'; +import { WalletAdapterContext } from './WalletAdapterContext'; + +let _defaultWalletAdapter: WalletAdapter | undefined; + +// Initialize default wallet adapter only if needed +function getDefaultWalletAdapter() { + if (!_defaultWalletAdapter) { + _defaultWalletAdapter = new WalletAdapter(); + } + return _defaultWalletAdapter; +} + +export function useWalletAdapter() { + const walletAdapter = useContext(WalletAdapterContext); + + // If no adapter context was provided, return the default wallet adapter + return walletAdapter ?? getDefaultWalletAdapter(); +} diff --git a/packages/wallet-adapter-react/tsconfig.json b/packages/wallet-adapter-react/tsconfig.json index 3d316f5b..a2a58ac3 100644 --- a/packages/wallet-adapter-react/tsconfig.json +++ b/packages/wallet-adapter-react/tsconfig.json @@ -3,6 +3,7 @@ "include": ["."], "exclude": ["dist", "build", "node_modules"], "compilerOptions": { + "lib": ["es2020", "dom"], "rootDir": "src", "outDir": "dist" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f712da38..9031a1f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -422,9 +422,15 @@ importers: packages/wallet-adapter-react: dependencies: + '@aptos-labs/ts-sdk': + specifier: ^1.33.1 + version: 1.33.1 '@aptos-labs/wallet-adapter-core': specifier: workspace:* version: link:../wallet-adapter-core + '@aptos-labs/wallet-standard': + specifier: ^0.2.0 + version: 0.2.0(@aptos-labs/ts-sdk@1.33.1)(@wallet-standard/core@1.0.3) '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.1.0(@types/react@18.3.5)(react@18.3.1) @@ -764,8 +770,8 @@ packages: - debug dev: false - /@aptos-labs/wallet-adapter-core@4.23.1(@aptos-labs/ts-sdk@1.27.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3)(aptos@1.21.0): - resolution: {integrity: sha512-GAT7bmKlpwPxXc/Lryl2hl2VKW6NHBsiEQ4fezCiOIsCfxT1tDSV3EgGURTFtPB+UOvZEBHo0dihS16T4S6yVw==} + /@aptos-labs/wallet-adapter-core@4.24.0(@aptos-labs/ts-sdk@1.27.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3)(aptos@1.21.0): + resolution: {integrity: sha512-Ve7kcUAO8vMh2QlxJ5oXk4dfhaBKQALR5y1oKWEFNxTd3BoMaYogHpIxSXa+4uSe2M+MsK0UEGfu7nvrqKMVZw==} peerDependencies: '@aptos-labs/ts-sdk': ^1.33.1 aptos: ^1.21.0 @@ -787,8 +793,8 @@ packages: - debug dev: false - /@aptos-labs/wallet-adapter-core@4.23.1(@aptos-labs/ts-sdk@1.33.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3)(aptos@1.21.0): - resolution: {integrity: sha512-GAT7bmKlpwPxXc/Lryl2hl2VKW6NHBsiEQ4fezCiOIsCfxT1tDSV3EgGURTFtPB+UOvZEBHo0dihS16T4S6yVw==} + /@aptos-labs/wallet-adapter-core@4.24.0(@aptos-labs/ts-sdk@1.33.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3)(aptos@1.21.0): + resolution: {integrity: sha512-Ve7kcUAO8vMh2QlxJ5oXk4dfhaBKQALR5y1oKWEFNxTd3BoMaYogHpIxSXa+4uSe2M+MsK0UEGfu7nvrqKMVZw==} peerDependencies: '@aptos-labs/ts-sdk': ^1.33.1 aptos: ^1.21.0 @@ -3401,7 +3407,7 @@ packages: /@msafe/aptos-wallet-adapter@1.1.3(@aptos-labs/ts-sdk@1.27.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3): resolution: {integrity: sha512-/5ftbNac9j2Vc6YOqET4IdkhiJnMzuy9LcnGP8ptLWHVuye5P/pAjIpv0A07gOM4/siUJQzlXkBxXdLYF9p8wQ==} dependencies: - '@aptos-labs/wallet-adapter-core': 4.23.1(@aptos-labs/ts-sdk@1.27.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3)(aptos@1.21.0) + '@aptos-labs/wallet-adapter-core': 4.24.0(@aptos-labs/ts-sdk@1.27.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3)(aptos@1.21.0) '@msafe/aptos-wallet': 6.1.1 aptos: 1.21.0 transitivePeerDependencies: @@ -3416,7 +3422,7 @@ packages: /@msafe/aptos-wallet-adapter@1.1.3(@aptos-labs/ts-sdk@1.33.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3): resolution: {integrity: sha512-/5ftbNac9j2Vc6YOqET4IdkhiJnMzuy9LcnGP8ptLWHVuye5P/pAjIpv0A07gOM4/siUJQzlXkBxXdLYF9p8wQ==} dependencies: - '@aptos-labs/wallet-adapter-core': 4.23.1(@aptos-labs/ts-sdk@1.33.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3)(aptos@1.21.0) + '@aptos-labs/wallet-adapter-core': 4.24.0(@aptos-labs/ts-sdk@1.33.1)(@mizuwallet-sdk/core@1.4.0)(@mizuwallet-sdk/protocol@0.0.6)(@telegram-apps/bridge@1.2.1)(@wallet-standard/core@1.0.3)(aptos@1.21.0) '@msafe/aptos-wallet': 6.1.1 aptos: 1.21.0 transitivePeerDependencies: