diff --git a/.vscode/settings.json b/.vscode/settings.json index a4a8998a1..ab2d991bc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,8 +2,7 @@ "eslint.format.enable": true, "prettier.enable": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": - true + "source.fixAll.eslint": "explicit" }, "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/examples/angular/src/app/components/content/content.component.html b/examples/angular/src/app/components/content/content.component.html index d7263f413..bb273a517 100644 --- a/examples/angular/src/app/components/content/content.component.html +++ b/examples/angular/src/app/components/content/content.component.html @@ -20,5 +20,6 @@ + diff --git a/examples/angular/src/app/components/content/content.component.ts b/examples/angular/src/app/components/content/content.component.ts index 31b010090..3d5079e56 100644 --- a/examples/angular/src/app/components/content/content.component.ts +++ b/examples/angular/src/app/components/content/content.component.ts @@ -112,6 +112,13 @@ export class ContentComponent implements OnInit, OnDestroy { this.modal.show(); } + signInMessage() { + const message = "test message to sign"; + const nonce = Buffer.from(Array.from(Array(32).keys())); + const recipient = "guest-book.testnet"; + this.modal.signInMessage({ message, nonce, recipient }); + } + async signOut() { const wallet = await this.selector.wallet(); @@ -211,14 +218,14 @@ export class ContentComponent implements OnInit, OnDestroy { const publicKey = urlParams.get("publicKey") as string; const signature = urlParams.get("signature") as string; - if (!accId && !publicKey && !signature) { - return; - } - const message: SignMessageParams = JSON.parse( localStorage.getItem("message") as string ); + if ((!accId && !publicKey && !signature) || !message) { + return; + } + const signedMessage = { accountId: accId, publicKey, @@ -328,7 +335,7 @@ export class ContentComponent implements OnInit, OnDestroy { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion signerId: this.accountId!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - receiverId: contract!.contractId, + receiverId: contract?.contractId || CONTRACT_ID, actions: [ { type: "FunctionCall", diff --git a/examples/react/components/Content.tsx b/examples/react/components/Content.tsx index 271442fa9..0efb00039 100644 --- a/examples/react/components/Content.tsx +++ b/examples/react/components/Content.tsx @@ -135,6 +135,13 @@ const Content: React.FC = () => { modal.show(); }; + const handleSignInMessage = () => { + const message = "test message to sign"; + const nonce = Buffer.from(Array.from(Array(32).keys())); + const recipient = "guest-book.testnet"; + modal.signInMessage({ message, nonce, recipient }); + }; + const handleSignOut = async () => { const wallet = await selector.wallet(); @@ -192,7 +199,7 @@ const Content: React.FC = () => { for (let i = 0; i < 2; i += 1) { transactions.push({ signerId: accountId!, - receiverId: contract!.contractId, + receiverId: contract?.contractId || CONTRACT_ID, actions: [ { type: "FunctionCall", @@ -275,14 +282,14 @@ const Content: React.FC = () => { const publicKey = urlParams.get("publicKey") as string; const signature = urlParams.get("signature") as string; - if (!accId && !publicKey && !signature) { - return; - } - const message: SignMessageParams = JSON.parse( localStorage.getItem("message")! ); + if ((!accId && !publicKey && !signature) || !message) { + return; + } + const signedMessage = { accountId: accId, publicKey, @@ -378,6 +385,7 @@ const Content: React.FC = () => {
+
diff --git a/packages/core/docs/api/selector.md b/packages/core/docs/api/selector.md index 818dd2e8f..e84a1bf75 100644 --- a/packages/core/docs/api/selector.md +++ b/packages/core/docs/api/selector.md @@ -153,6 +153,29 @@ Programmatically change active account which will be used to sign and send trans selector.setActiveAccount("sometestaccount.testnet"); ``` +### `.signInType()` + +**Parameters** + +- N/A + +**Returns** + +- `SignInType` + +**Description** + +Programmatically check the sign-in type, if signed-in with `wallet.signIn(params)` returns "key" if signed in with `wallet.signInMessage(params)` returns "message" + +> Note: This function will throw when calling without being signed in. + +**Example** + +```ts +const signInType = selector.signInType(); + +``` + ### `.on(event, callback)` **Parameters** @@ -183,7 +206,7 @@ subscription.remove(); **Parameters** -- `event` (`string`): Name of the event. This can be: `signedIn | signedOut | accountsChanged | networkChanged | uriChanged`. +- `event` (`string`): Name of the event. This can be: `signedIn | signedInMessage | signedOut | accountsChanged | networkChanged | uriChanged`. - `callback` (`Function`): Original handler passed to `.on(event, callback)`. **Returns** diff --git a/packages/core/docs/api/state.md b/packages/core/docs/api/state.md index f9537acf4..f1cd4accf 100644 --- a/packages/core/docs/api/state.md +++ b/packages/core/docs/api/state.md @@ -93,3 +93,45 @@ Returns ID-s of 5 recently signed in wallets. const { recentlySignedInWallets } = selector.store.getState(); console.log(recentlySignedInWallets); // ["my-near-wallet", "sender", ...] ``` + +### `.message` + +**Returns** + +- `SignInMessageParams | null` + - `message` (`string`): The message that wants to be transmitted. + - `nonce` (`Buffer`): A nonce that uniquely identifies this instance of the message, denoted as a 32 bytes array (a fixed `Buffer` in JS/TS). + - `recipient` (`string`): The recipient to whom the message is destined (e.g. "alice.near" or "myapp.com"). + - `callbackUrl` (`string?`): Optional, applicable to browser wallets (e.g. MyNearWallet). The URL to call after the signing process. + - `state` (`string?`): Optional, applicable to browser wallets (e.g. MyNearWallet). A state for authentication purposes. + + +**Description** + +Returns the original message that was used for `signInMessage`. + +**Example** + +```ts +const { message } = selector.store.getState(); +console.log(message); // { message: "test", nonce: [0...31], recipient: "myapp.com" } +``` + +### `.signedInMessageAccount` + +**Returns** + +- `Account | null` + - `accountId` (`string`): The account name to which the publicKey corresponds as plain text (e.g. "alice.near"). + - `publicKey` (`string`): The public counterpart of the key used to sign, expressed as a string with format ":" (e.g. "ed25519:6TupyNrcHGTt5XRLmHTc2KGaiSbjhQi1KHtCXTgbcr4Y") + +**Description** + +Returns the `Account` that was signed with `signInMessage`. + +**Example** + +```ts +const { signedMessage } = selector.store.getState(); +console.log(signedMessage); // { accountId: "alice.near", publicKey: "ed25519:6TupyNrcHGTt5XRLmHTc2KGaiSbjhQi1KHtCXTgbcr4Y" } +``` diff --git a/packages/core/docs/api/wallet.md b/packages/core/docs/api/wallet.md index c7eb30170..9de6af43b 100644 --- a/packages/core/docs/api/wallet.md +++ b/packages/core/docs/api/wallet.md @@ -326,3 +326,36 @@ Allows users to sign a message for a specific recipient using their NEAR account await wallet.signMessage({ message, recipient, nonce }); })(); ``` + +### `.signInMessage(params)` + +**Parameters** +- `params` (`object`) + - `message` (`string`): The message that wants to be transmitted. + - `nonce` (`Buffer`): A nonce that uniquely identifies this instance of the message, denoted as a 32 bytes array (a fixed `Buffer` in JS/TS). + - `recipient` (`string`): The recipient to whom the message is destined (e.g. "alice.near" or "myapp.com"). + - `callbackUrl` (`string?`): Optional, applicable to browser wallets (e.g. MyNearWallet). The URL to call after the signing process. Defaults to `window.location.href`. + - `state` (`string?`): Optional, applicable to browser wallets (e.g. MyNearWallet). A state for authentication purposes. + +**Returns** +- `Promise` + +**Description** + +Allows users to sign-in (login) to a dApp without creating a LAK by signing a message for a specific recipient using their NEAR account, based on the [NEP413](https://github.com/near/NEPs/blob/master/neps/nep-0413.md). + +**Example** + +```ts +// MyNearWallet +(async () => { + const wallet = await selector.wallet("my-near-wallet"); + const message = "test message for verification"; + let nonceArray: Uint8Array = new Uint8Array(32); + nonceArray = crypto.getRandomValues(nonceArray); + const nonce = Buffer.from(nonceArray); + const recipient = "myapp.com"; + + await wallet.signInMessage({ message, nonce, recipient }); +})(); +``` diff --git a/packages/core/docs/guides/custom-wallets.md b/packages/core/docs/guides/custom-wallets.md index 59d27f9d2..69b5e95a3 100644 --- a/packages/core/docs/guides/custom-wallets.md +++ b/packages/core/docs/guides/custom-wallets.md @@ -74,6 +74,12 @@ const MyWallet: WalletBehaviourFactory = ({ // that allows users to sign a message for a specific receiver using their NEAR account return await wallet.signMessage({ message, nonce, recipient, callbackUrl, state }); }, + async signInMessage({ message, nonce, recipient, callbackUrl, state }) { + // Sign in to My Wallet withotut creating a LAK for access to account(s). + // Signs the message, verifies it and returns the `SignedMessage`. + + return []; + }, }; }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c827337b..23fd58eff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ export type { WalletSelectorParams, WalletSelectorEvents, WalletSelectorStore, + SignInType, } from "./lib/wallet-selector.types"; export { setupWalletSelector } from "./lib/wallet-selector"; @@ -70,6 +71,7 @@ export type { AccountImportData, SignedMessage, SignMessageParams, + SignInMessageParams, } from "./lib/wallet"; export type { FinalExecutionOutcome } from "near-api-js/lib/providers"; @@ -81,6 +83,7 @@ export { verifyFullKeyBelongsToUser, verifySignature, serializeNep413, + verifyMessageNEP413, } from "./lib/helpers"; export { translate, allowOnlyLanguage } from "./lib/translate/translate"; diff --git a/packages/core/src/lib/constants.ts b/packages/core/src/lib/constants.ts index b7d204beb..586c0d7fc 100644 --- a/packages/core/src/lib/constants.ts +++ b/packages/core/src/lib/constants.ts @@ -6,3 +6,7 @@ export const PENDING_CONTRACT = "contract:pending"; export const SELECTED_WALLET_ID = `selectedWalletId`; export const PENDING_SELECTED_WALLET_ID = `selectedWalletId:pending`; + +export const SIGN_IN_MESSAGE = "message"; +export const PENDING_SIGN_IN_MESSAGE = "message:pending"; +export const SIGNED_IN_MESSAGE_ACCOUNT = "signedInMessageAccount"; diff --git a/packages/core/src/lib/helpers/verify-signature/verify-signature.ts b/packages/core/src/lib/helpers/verify-signature/verify-signature.ts index 9487cc09e..8ff77046e 100644 --- a/packages/core/src/lib/helpers/verify-signature/verify-signature.ts +++ b/packages/core/src/lib/helpers/verify-signature/verify-signature.ts @@ -8,6 +8,8 @@ import type { } from "./verify-signature.types"; import { Payload, payloadSchema } from "./payload"; import type { AccessKeyView } from "near-api-js/lib/providers/provider"; +import type { SignedMessage, SignMessageParams } from "../../wallet"; +import type { Network } from "../../options.types"; export const verifySignature = ({ publicKey, @@ -64,3 +66,34 @@ export const verifyFullKeyBelongsToUser = async ({ return permission === "FullAccess"; }; + +export const verifyMessageNEP413 = async ( + message: SignMessageParams, + signedMessage: SignedMessage, + network: Network +) => { + const isSignatureValid = verifySignature({ + message: message.message, + nonce: message.nonce, + recipient: message.recipient, + publicKey: signedMessage.publicKey, + signature: signedMessage.signature, + callbackUrl: message.callbackUrl, + }); + + if (!isSignatureValid) { + throw Error("Failed to verify the signature"); + } + + const isFullAccess = await verifyFullKeyBelongsToUser({ + publicKey: signedMessage.publicKey, + accountId: signedMessage.accountId, + network, + }); + + if (!isFullAccess) { + throw Error("Message was not signed with full access key"); + } + + return true; +}; diff --git a/packages/core/src/lib/services/wallet-modules/wallet-modules.service.ts b/packages/core/src/lib/services/wallet-modules/wallet-modules.service.ts index 61f37f585..b8d14ccf8 100644 --- a/packages/core/src/lib/services/wallet-modules/wallet-modules.service.ts +++ b/packages/core/src/lib/services/wallet-modules/wallet-modules.service.ts @@ -20,10 +20,11 @@ import { PACKAGE_NAME, PENDING_CONTRACT, PENDING_SELECTED_WALLET_ID, + PENDING_SIGN_IN_MESSAGE, } from "../../constants"; import { JsonStorage } from "../storage/json-storage.service"; import type { ProviderService } from "../provider/provider.service.types"; -import type { SignMessageMethod } from "../../wallet"; +import type { SignMessageMethod, SignInMessageParams } from "../../wallet"; export class WalletModules { private factories: Array; @@ -82,11 +83,21 @@ export class WalletModules { PENDING_CONTRACT ); - if (pendingSelectedWalletId && pendingContract) { + const pendingSignInMessage = await jsonStorage.getItem( + `${pendingSelectedWalletId}:${PENDING_SIGN_IN_MESSAGE}` + ); + + if ( + (pendingSelectedWalletId && pendingContract) || + (pendingSelectedWalletId && pendingSignInMessage) + ) { const accounts = await this.validateWallet(pendingSelectedWalletId); await jsonStorage.removeItem(PENDING_SELECTED_WALLET_ID); await jsonStorage.removeItem(PENDING_CONTRACT); + await jsonStorage.removeItem( + `${pendingSelectedWalletId}:${PENDING_SIGN_IN_MESSAGE}` + ); if (accounts.length) { const { selectedWalletId } = this.store.getState(); @@ -107,11 +118,14 @@ export class WalletModules { contract: pendingContract, selectedWalletId: pendingSelectedWalletId, recentlySignedInWallets: recentlySignedInWalletsFromPending, + message: pendingSignInMessage, + signedInMessageAccount: accounts[0], }; } } - const { contract, selectedWalletId } = this.store.getState(); + const { contract, selectedWalletId, message, signedInMessageAccount } = + this.store.getState(); const accounts = await this.validateWallet(selectedWalletId); const recentlySignedInWallets = await jsonStorage.getItem>( @@ -124,6 +138,8 @@ export class WalletModules { contract: null, selectedWalletId: null, recentlySignedInWallets: recentlySignedInWallets || [], + message: null, + signedInMessageAccount: null, }; } @@ -132,6 +148,8 @@ export class WalletModules { contract, selectedWalletId, recentlySignedInWallets: recentlySignedInWallets || [], + message, + signedInMessageAccount, }; } @@ -200,7 +218,14 @@ export class WalletModules { this.store.dispatch({ type: "WALLET_CONNECTED", - payload: { walletId, contract, accounts, recentlySignedInWallets }, + payload: { + walletId, + contract, + accounts, + recentlySignedInWallets, + message: null, + signedInMessageAccount: null, + }, }); this.emitter.emit("signedIn", { @@ -211,6 +236,60 @@ export class WalletModules { }); } + private async onWalletSignedInMessage( + walletId: string, + { accounts, message, signedMessage }: WalletEvents["signedInMessage"] + ) { + const { selectedWalletId } = this.store.getState(); + const jsonStorage = new JsonStorage(this.storage, PACKAGE_NAME); + + if (!accounts.length) { + const module = this.getModule(walletId)!; + // We can't guarantee the user will actually sign in with browser wallets. + // Best we can do is set in storage and validate on init. + if (module.type === "browser") { + const locationUrl = + typeof window !== "undefined" ? window.location.href : undefined; + await jsonStorage.setItem(PENDING_SELECTED_WALLET_ID, walletId); + await jsonStorage.setItem(`${module.id}:${PENDING_SIGN_IN_MESSAGE}`, { + ...message, + callbackUrl: message.callbackUrl || locationUrl, + }); + } + + return; + } + + if (selectedWalletId && selectedWalletId !== walletId) { + await this.signOutWallet(selectedWalletId); + } + + const recentlySignedInWallets = await this.setWalletAsRecentlySignedIn( + walletId + ); + + const { accountId, publicKey } = signedMessage; + + this.store.dispatch({ + type: "WALLET_CONNECTED", + payload: { + walletId, + contract: null, + accounts, + recentlySignedInWallets, + message, + signedInMessageAccount: { accountId, publicKey }, + }, + }); + + this.emitter.emit("signedInMessage", { + walletId, + message, + signedMessage, + accounts, + }); + } + private onWalletSignedOut(walletId: string) { this.store.dispatch({ type: "WALLET_DISCONNECTED", @@ -231,6 +310,10 @@ export class WalletModules { this.onWalletSignedIn(module.id, event); }); + emitter.on("signedInMessage", (event) => { + this.onWalletSignedInMessage(module.id, event); + }); + emitter.on("accountsChanged", async ({ accounts }) => { this.emitter.emit("accountsChanged", { walletId: module.id, accounts }); @@ -279,6 +362,7 @@ export class WalletModules { const _signIn = wallet.signIn; const _signOut = wallet.signOut; const _signMessage = wallet.signMessage; + const _signInMessage = wallet.signInMessage; wallet.signIn = async (params: never) => { const accounts = await _signIn(params); @@ -310,6 +394,33 @@ export class WalletModules { return await _signMessage(params); }; + wallet.signInMessage = async (params: never) => { + if (_signInMessage === undefined) { + throw Error( + `The signInMessage method is not supported by ${wallet.metadata.name}` + ); + } + + const message = params as SignInMessageParams; + const signedInMessage = await _signInMessage(message); + const accounts: Array = []; + + if (signedInMessage) { + accounts.push({ + accountId: signedInMessage.accountId, + publicKey: signedInMessage.publicKey, + }); + } + + await this.onWalletSignedInMessage(wallet.id, { + accounts, + message, + signedMessage: signedInMessage!, + }); + + return signedInMessage; + }; + return wallet; } @@ -411,8 +522,14 @@ export class WalletModules { this.modules = modules; - const { accounts, contract, selectedWalletId, recentlySignedInWallets } = - await this.resolveStorageState(); + const { + accounts, + contract, + selectedWalletId, + recentlySignedInWallets, + message, + signedInMessageAccount, + } = await this.resolveStorageState(); this.store.dispatch({ type: "SETUP_WALLET_MODULES", @@ -422,6 +539,8 @@ export class WalletModules { contract, selectedWalletId, recentlySignedInWallets, + message: message, + signedInMessageAccount, }, }); diff --git a/packages/core/src/lib/store.ts b/packages/core/src/lib/store.ts index 48c22ee82..ae0b12911 100644 --- a/packages/core/src/lib/store.ts +++ b/packages/core/src/lib/store.ts @@ -12,6 +12,8 @@ import { CONTRACT, SELECTED_WALLET_ID, RECENTLY_SIGNED_IN_WALLETS, + SIGN_IN_MESSAGE, + SIGNED_IN_MESSAGE_ACCOUNT, } from "./constants"; const reducer = ( @@ -28,6 +30,8 @@ const reducer = ( contract, selectedWalletId, recentlySignedInWallets, + message, + signedInMessageAccount, } = action.payload; const accountStates = accounts.map((account, i) => { @@ -44,11 +48,19 @@ const reducer = ( contract, selectedWalletId, recentlySignedInWallets, + message: message, + signedInMessageAccount, }; } case "WALLET_CONNECTED": { - const { walletId, contract, accounts, recentlySignedInWallets } = - action.payload; + const { + walletId, + contract, + accounts, + recentlySignedInWallets, + message, + signedInMessageAccount, + } = action.payload; if (!accounts.length) { return state; @@ -71,6 +83,8 @@ const reducer = ( accounts: accountStates, selectedWalletId: walletId, recentlySignedInWallets, + message, + signedInMessageAccount, }; } case "WALLET_DISCONNECTED": { @@ -85,6 +99,8 @@ const reducer = ( contract: null, accounts: [], selectedWalletId: null, + message: null, + signedInMessageAccount: null, }; } case "ACCOUNTS_CHANGED": { @@ -143,6 +159,10 @@ export const createStore = async (storage: StorageService): Promise => { selectedWalletId: await jsonStorage.getItem(SELECTED_WALLET_ID), recentlySignedInWallets: (await jsonStorage.getItem(RECENTLY_SIGNED_IN_WALLETS)) || [], + message: await jsonStorage.getItem(SIGN_IN_MESSAGE), + signedInMessageAccount: await jsonStorage.getItem( + SIGNED_IN_MESSAGE_ACCOUNT + ), }; const state$ = new BehaviorSubject(initialState); @@ -178,6 +198,13 @@ export const createStore = async (storage: StorageService): Promise => { RECENTLY_SIGNED_IN_WALLETS, "recentlySignedInWallets" ); + syncStorage(prevState, state, SIGN_IN_MESSAGE, "message"); + syncStorage( + prevState, + state, + SIGNED_IN_MESSAGE_ACCOUNT, + "signedInMessageAccount" + ); prevState = state; }); diff --git a/packages/core/src/lib/store.types.ts b/packages/core/src/lib/store.types.ts index a5696a146..5463efa4b 100644 --- a/packages/core/src/lib/store.types.ts +++ b/packages/core/src/lib/store.types.ts @@ -1,7 +1,11 @@ import type { BehaviorSubject, Observable } from "rxjs"; -import type { Wallet, Account } from "./wallet"; -import type { SignMessageMethod } from "./wallet"; +import type { + Wallet, + Account, + SignInMessageParams, + SignMessageMethod, +} from "./wallet"; export interface ContractState { /** @@ -61,6 +65,14 @@ export interface WalletSelectorState { * Returns ID-s of 5 recently signed in wallets. */ recentlySignedInWallets: Array; + /** + * The original message that was used to "sign-in" via signInMessages + */ + message: SignInMessageParams | null; + /** + * The Account that was used to sign the message + */ + signedInMessageAccount: Account | null; } export type WalletSelectorAction = @@ -72,15 +84,19 @@ export type WalletSelectorAction = contract: ContractState | null; selectedWalletId: string | null; recentlySignedInWallets: Array; + message: SignInMessageParams | null; + signedInMessageAccount: Account | null; }; } | { type: "WALLET_CONNECTED"; payload: { walletId: string; - contract: ContractState; + contract: ContractState | null; accounts: Array; recentlySignedInWallets: Array; + message: SignInMessageParams | null; + signedInMessageAccount: Account | null; }; } | { diff --git a/packages/core/src/lib/wallet-selector.ts b/packages/core/src/lib/wallet-selector.ts index 0ea7a36d1..c9d320ce4 100644 --- a/packages/core/src/lib/wallet-selector.ts +++ b/packages/core/src/lib/wallet-selector.ts @@ -54,6 +54,14 @@ const createSelector = ( return Boolean(accounts.length); }, + signInType() { + const { contract, signedInMessageAccount } = store.getState(); + + if (!contract && !signedInMessageAccount) { + throw Error("Wallet not signed in"); + } + return contract ? "key" : "message"; + }, on: (eventName, callback) => { return emitter.on(eventName, callback); }, diff --git a/packages/core/src/lib/wallet-selector.types.ts b/packages/core/src/lib/wallet-selector.types.ts index a50079c14..552f51d29 100644 --- a/packages/core/src/lib/wallet-selector.types.ts +++ b/packages/core/src/lib/wallet-selector.types.ts @@ -8,6 +8,9 @@ import type { Network, NetworkId, Options } from "./options.types"; import type { Subscription, StorageService } from "./services"; import type { SupportedLanguage } from "./translate/translate"; import type { SignMessageMethod } from "./wallet/wallet.types"; +import type { SignedMessage, SignInMessageParams } from "./wallet/wallet.types"; + +export type SignInType = "key" | "message"; export interface WalletSelectorParams { /** @@ -57,6 +60,12 @@ export type WalletSelectorEvents = { methodNames: Array; accounts: Array; }; + signedInMessage: { + walletId: string; + message: SignInMessageParams; + signedMessage: SignedMessage; + accounts: Array; + }; signedOut: { walletId: string; }; @@ -94,6 +103,11 @@ export interface WalletSelector { */ setActiveAccount(accountId: string): void; + /** + * Returns the sign-in type, it can be either "message" or "key". + */ + signInType(): SignInType; + /** * Attach an event handler to important events. */ diff --git a/packages/core/src/lib/wallet/wallet.types.ts b/packages/core/src/lib/wallet/wallet.types.ts index 586e4c1bc..f0cd5f2a6 100644 --- a/packages/core/src/lib/wallet/wallet.types.ts +++ b/packages/core/src/lib/wallet/wallet.types.ts @@ -99,6 +99,14 @@ export type SignMessageMethod = { signMessage(params: SignMessageParams): Promise; }; +export interface SignInMessageParams { + message: string; + recipient: string; + nonce: Buffer; + callbackUrl?: string; + state?: string; +} + interface SignAndSendTransactionParams { /** * Account ID used to sign the transaction. Defaults to the first account. @@ -154,7 +162,16 @@ interface BaseWalletBehaviour { signAndSendTransactions( params: SignAndSendTransactionsParams ): Promise>; + + /** + * Signs a message for a specific recipient using their NEAR account, based on the [NEP413](https://github.com/near/NEPs/blob/master/neps/nep-0413.md). + */ signMessage?(params: SignMessageParams): Promise; + /** + * Signs a message for a specific recipient using their NEAR account, based on the [NEP413](https://github.com/near/NEPs/blob/master/neps/nep-0413.md). + * Sets the wallet selector's isSignedIn state to true after the message is signed and verified. + */ + signInMessage?(params: SignInMessageParams): Promise; } type BaseWallet< @@ -182,6 +199,11 @@ export type WalletEvents = { methodNames: Array; accounts: Array; }; + signedInMessage: { + message: SignInMessageParams; + signedMessage: SignedMessage; + accounts: Array; + }; signedOut: null; accountsChanged: { accounts: Array }; networkChanged: { networkId: string }; diff --git a/packages/here-wallet/src/lib/selector.ts b/packages/here-wallet/src/lib/selector.ts index 6d810183a..fc047895d 100644 --- a/packages/here-wallet/src/lib/selector.ts +++ b/packages/here-wallet/src/lib/selector.ts @@ -1,4 +1,5 @@ -import type { NetworkId } from "@near-wallet-selector/core"; +import type { Account, NetworkId } from "@near-wallet-selector/core"; +import { verifyMessageNEP413 } from "@near-wallet-selector/core"; import { HereWallet } from "@here-wallet/core"; import type BN from "bn.js"; @@ -18,19 +19,28 @@ export const initHereWallet: SelectorInit = async (config) => { async function getAccounts() { logger.log("HereWallet:getAccounts"); const accountIds = await here.getAccounts(); - const accounts = []; - - for (let i = 0; i < accountIds.length; i++) { - accounts.push({ - accountId: accountIds[i], - publicKey: ( - await here.signer.getPublicKey( - accountIds[i], - options.network.networkId - ) - ).toString(), - }); + const accounts: Array = []; + const { signedInMessageAccount } = store.getState(); + + if (accountIds.length > 0) { + for (let i = 0; i < accountIds.length; i++) { + accounts.push({ + accountId: accountIds[i], + publicKey: ( + await here.signer.getPublicKey( + accountIds[i], + options.network.networkId + ) + ).toString(), + }); + } + return accounts; } + + if (signedInMessageAccount) { + return [{ ...signedInMessageAccount }]; + } + return accounts; } @@ -89,8 +99,10 @@ export const initHereWallet: SelectorInit = async (config) => { }, async signOut() { - logger.log("HereWallet:signOut"); - await here.signOut(); + if (await here.isSignedIn()) { + logger.log("HereWallet:signOut"); + await here.signOut(); + } }, async getAccounts() { @@ -122,8 +134,31 @@ export const initHereWallet: SelectorInit = async (config) => { return await here.signMessage(data); }, + async signInMessage(data) { + logger.log("HereWallet:signInMessage", data); + + const signedMessage = await here.signMessage(data); + + const isMessageVerified = await verifyMessageNEP413( + data, + signedMessage, + options.network + ); + + if (!isMessageVerified) { + throw new Error(`Failed to verify the message`); + } + + return signedMessage; + }, + async signAndSendTransactions(data) { logger.log("HereWallet:signAndSendTransactions", data); + const { contract } = store.getState(); + if (!here.isSignedIn || !contract) { + throw new Error("Wallet not signed in"); + } + return await here.signAndSendTransactions(data); }, }; diff --git a/packages/meteor-wallet/src/lib/meteor-wallet.ts b/packages/meteor-wallet/src/lib/meteor-wallet.ts index f0c7f0b15..342eb9be7 100644 --- a/packages/meteor-wallet/src/lib/meteor-wallet.ts +++ b/packages/meteor-wallet/src/lib/meteor-wallet.ts @@ -5,6 +5,7 @@ import type { WalletBehaviourFactory, WalletModuleFactory, } from "@near-wallet-selector/core"; +import { verifyMessageNEP413 } from "@near-wallet-selector/core"; import type { MeteorWalletParams_Injected, MeteorWalletState, @@ -48,21 +49,26 @@ const createMeteorWalletInjected: WalletBehaviourFactory< const getAccounts = async (): Promise> => { const accountId = _state.wallet.getAccountId(); const account = _state.wallet.account(); + const { signedInMessageAccount } = store.getState(); + + if (accountId && account) { + const publicKey = await account.connection.signer.getPublicKey( + account.accountId, + options.network.networkId + ); + return [ + { + accountId, + publicKey: publicKey ? publicKey.toString() : "", + }, + ]; + } - if (!accountId || !account) { - return []; + if (signedInMessageAccount) { + return [{ ...signedInMessageAccount }]; } - const publicKey = await account.connection.signer.getPublicKey( - account.accountId, - options.network.networkId - ); - return [ - { - accountId, - publicKey: publicKey ? publicKey.toString() : "", - }, - ]; + return []; }; return { @@ -150,6 +156,30 @@ const createMeteorWalletInjected: WalletBehaviourFactory< } }, + async signInMessage(message) { + logger.log("MeteorWallet:signInMessage", message); + const accountId = _state.wallet.getAccountId(); + const response = await _state.wallet.signMessage({ + ...message, + accountId, + }); + if (response.success) { + const isMessageVerified = await verifyMessageNEP413( + message, + response.payload, + options.network + ); + + if (!isMessageVerified) { + throw new Error(`Failed to verify the message`); + } + + return response.payload; + } else { + throw new Error(`Couldn't sign message owner: ${response.message}`); + } + }, + async signAndSendTransaction({ signerId, receiverId, actions }) { logger.log("MeteorWallet:signAndSendTransaction", { signerId, diff --git a/packages/modal-ui-js/docs/api/modal.md b/packages/modal-ui-js/docs/api/modal.md index 16e66bbe4..e7aab1dae 100644 --- a/packages/modal-ui-js/docs/api/modal.md +++ b/packages/modal-ui-js/docs/api/modal.md @@ -40,6 +40,36 @@ Closes the modal. modal.hide(); ``` +### `.signInMessage(params)` + +**Parameters** + +- `params` (`object`) + - `message` (`string`): The message that wants to be transmitted. + - `nonce` (`Buffer`): A nonce that uniquely identifies this instance of the message, denoted as a 32 bytes array (a fixed `Buffer` in JS/TS). + - `recipient` (`string`): The recipient to whom the message is destined (e.g. "alice.near" or "myapp.com"). + - `callbackUrl` (`string?`): Optional, applicable to browser wallets (e.g. MyNearWallet). The URL to call after the signing process. Defaults to `window.location.href`. + - `state` (`string?`): Optional, applicable to browser wallets (e.g. MyNearWallet). A state for authentication purposes. + + +**Returns** + +- `void` + +**Description** + +Opens the modal for users to sign in to their preferred wallet with `signInMessage` [here](../../../core/docs/api/wallet.md#signinmessageparams). + +**Example** + +```ts +const message = "test message to sign"; +const nonce = Buffer.from(Array.from(Array(32).keys())); +const recipient = "guest-book.testnet"; + +modal.signInMessage({ message, nonce, recipient }); +``` + ### `.on(event, callback)` diff --git a/packages/modal-ui-js/src/lib/components/LedgerAccountsOverviewList.ts b/packages/modal-ui-js/src/lib/components/LedgerAccountsOverviewList.ts index d5f7b6bed..7a01c0b6e 100644 --- a/packages/modal-ui-js/src/lib/components/LedgerAccountsOverviewList.ts +++ b/packages/modal-ui-js/src/lib/components/LedgerAccountsOverviewList.ts @@ -69,14 +69,20 @@ export async function renderLedgerAccountsOverviewList( } const wallet = await module.wallet(); - wallet.signIn({ - contractId: modalState.options.contractId, - methodNames: modalState.options.methodNames, - accounts: selectedAccounts, - }); + + if (modalState.message) { + await wallet.signInMessage!(modalState.message); + } else { + await wallet.signIn({ + contractId: modalState.options.contractId, + methodNames: modalState.options.methodNames, + accounts: selectedAccounts, + }); + } modalState.container.children[0].classList.remove("open"); modalState.emitter.emit("onHide", { hideReason: "wallet-navigation" }); + modalState.message = undefined; } catch (err) { await renderWalletConnectionFailed(module, err as Error); } diff --git a/packages/modal-ui-js/src/lib/modal.ts b/packages/modal-ui-js/src/lib/modal.ts index 668a5e3fa..6fc681579 100644 --- a/packages/modal-ui-js/src/lib/modal.ts +++ b/packages/modal-ui-js/src/lib/modal.ts @@ -1,6 +1,7 @@ import type { EventEmitterService, ModuleState, + SignInMessageParams, Wallet, WalletSelector, } from "@near-wallet-selector/core"; @@ -28,6 +29,7 @@ type ModalState = { modules: Array>; derivationPath: string; emitter: EventEmitterService; + message?: SignInMessageParams; }; export let modalState: ModalState | null = null; @@ -99,36 +101,46 @@ export const setupModal = ( } modalState.container.children[0].classList.remove("open"); modalState.emitter.emit("onHide", { hideReason: "user-triggered" }); + modalState.message = undefined; } }; window.addEventListener("keydown", close); renderModal(); + const showUI = (state: ModalState) => { + allowOnlyLanguage(state.selector.options.languageCode); + renderModal(); + const selectedWalletId = state.selector.store.getState().selectedWalletId; + if (selectedWalletId) { + const module = state.modules.find((m) => m.id === selectedWalletId); + renderWalletAccount(module); + } else { + renderWhatIsAWallet(); + } + state.container.children[0].classList.add("open"); + }; + if (!modalInstance) { modalInstance = { show: () => { if (!modalState) { return; } - allowOnlyLanguage(modalState.selector.options.languageCode); - renderModal(); - const selectedWalletId = - modalState.selector.store.getState().selectedWalletId; - if (selectedWalletId) { - const module = modalState.modules.find( - (m) => m.id === selectedWalletId - ); - renderWalletAccount(module); - } else { - renderWhatIsAWallet(); + showUI(modalState); + }, + signInMessage(message: SignInMessageParams) { + if (!modalState) { + return; } - modalState.container.children[0].classList.add("open"); + modalState.message = message; + showUI(modalState); }, hide: () => { if (!modalState) { return; } + modalState.message = undefined; modalState.container.children[0].classList.remove("open"); }, on: (eventName, callback) => { diff --git a/packages/modal-ui-js/src/lib/modal.types.ts b/packages/modal-ui-js/src/lib/modal.types.ts index 714f4f8e5..8acc39718 100644 --- a/packages/modal-ui-js/src/lib/modal.types.ts +++ b/packages/modal-ui-js/src/lib/modal.types.ts @@ -1,4 +1,4 @@ -import type { Wallet } from "@near-wallet-selector/core"; +import type { SignInMessageParams, Wallet } from "@near-wallet-selector/core"; import type { ModuleState } from "@near-wallet-selector/core"; import type { Subscription } from "@near-wallet-selector/core"; @@ -20,6 +20,7 @@ export type ModalEvents = { export interface WalletSelectorModal { show(): void; hide(): void; + signInMessage(message: SignInMessageParams): void; on( eventName: EventName, callback: (event: ModalEvents[EventName]) => void diff --git a/packages/modal-ui-js/src/lib/render-modal.ts b/packages/modal-ui-js/src/lib/render-modal.ts index f33968ee4..585b32ff5 100644 --- a/packages/modal-ui-js/src/lib/render-modal.ts +++ b/packages/modal-ui-js/src/lib/render-modal.ts @@ -82,7 +82,6 @@ export async function connectToWallet( if (selectedWalletId === module.id) { renderWalletAccount(module); - return; } try { @@ -116,48 +115,63 @@ export async function connectToWallet( } if (wallet.type === "bridge") { - const subscription = modalState.selector.on("uriChanged", ({ uri }) => { - renderScanQRCode(module, { - uri, - handleOpenDefaultModal: () => { - connectToWallet(module, true); - }, + if (modalState.message) { + await wallet.signInMessage!(modalState.message); + } else { + const subscription = modalState.selector.on("uriChanged", ({ uri }) => { + renderScanQRCode(module, { + uri, + handleOpenDefaultModal: () => { + connectToWallet(module, true); + }, + }); }); - }); - await wallet.signIn({ - contractId: modalState.options.contractId, - methodNames: modalState.options.methodNames, - qrCodeModal, - }); + await wallet.signIn({ + contractId: modalState.options.contractId, + methodNames: modalState.options.methodNames, + qrCodeModal, + }); + + subscription.remove(); + } - subscription.remove(); modalState.container.children[0].classList.remove("open"); modalState.emitter.emit("onHide", { hideReason: "wallet-navigation" }); + modalState.message = undefined; return; } if (wallet.type === "browser") { - await wallet.signIn({ - contractId: modalState.options.contractId, - methodNames: modalState.options.methodNames, - successUrl: wallet.metadata.successUrl, - failureUrl: wallet.metadata.failureUrl, - }); + if (modalState.message) { + await wallet.signInMessage!(modalState.message); + } else { + await wallet.signIn({ + contractId: modalState.options.contractId, + methodNames: modalState.options.methodNames, + successUrl: wallet.metadata.successUrl, + failureUrl: wallet.metadata.failureUrl, + }); + } modalState.container.children[0].classList.remove("open"); modalState.emitter.emit("onHide", { hideReason: "wallet-navigation" }); - + modalState.message = undefined; return; } - await wallet.signIn({ - contractId: modalState.options.contractId, - methodNames: modalState.options.methodNames, - }); + if (modalState.message) { + await wallet.signInMessage!(modalState.message); + } else { + await wallet.signIn({ + contractId: modalState.options.contractId, + methodNames: modalState.options.methodNames, + }); + } modalState.container.children[0].classList.remove("open"); modalState.emitter.emit("onHide", { hideReason: "wallet-navigation" }); + modalState.message = undefined; } catch (err) { const { name } = module.metadata; const message = @@ -325,6 +339,7 @@ export function renderModal() { modalState.container.children[0].classList.remove("open"); modalState.emitter.emit("onHide", { hideReason: "user-triggered" }); + modalState.message = undefined; }); // TODO: Better handle `click` event listener for close-button. @@ -340,6 +355,7 @@ export function renderModal() { modalState.container.children[0].classList.remove("open"); modalState.emitter.emit("onHide", { hideReason: "user-triggered" }); + modalState.message = undefined; } }); initialRender = false; diff --git a/packages/modal-ui/docs/api/modal.md b/packages/modal-ui/docs/api/modal.md index 9a81b627a..de9aadcee 100644 --- a/packages/modal-ui/docs/api/modal.md +++ b/packages/modal-ui/docs/api/modal.md @@ -40,6 +40,36 @@ Closes the modal. modal.hide(); ``` +### `.signInMessage(params)` + +**Parameters** + +- `params` (`object`) + - `message` (`string`): The message that wants to be transmitted. + - `nonce` (`Buffer`): A nonce that uniquely identifies this instance of the message, denoted as a 32 bytes array (a fixed `Buffer` in JS/TS). + - `recipient` (`string`): The recipient to whom the message is destined (e.g. "alice.near" or "myapp.com"). + - `callbackUrl` (`string?`): Optional, applicable to browser wallets (e.g. MyNearWallet). The URL to call after the signing process. Defaults to `window.location.href`. + - `state` (`string?`): Optional, applicable to browser wallets (e.g. MyNearWallet). A state for authentication purposes. + + +**Returns** + +- `void` + +**Description** + +Opens the modal for users to sign in to their preferred wallet with `signInMessage` [here](../../../core/docs/api/wallet.md#signinmessageparams). + +**Example** + +```ts +const message = "test message to sign"; +const nonce = Buffer.from(Array.from(Array(32).keys())); +const recipient = "guest-book.testnet"; + +modal.signInMessage({ message, nonce, recipient }); +``` + ### `.on(event, callback)` **Parameters** diff --git a/packages/modal-ui/src/lib/components/DerivationPath.tsx b/packages/modal-ui/src/lib/components/DerivationPath.tsx index 621f7a1e2..1bdc1dd92 100644 --- a/packages/modal-ui/src/lib/components/DerivationPath.tsx +++ b/packages/modal-ui/src/lib/components/DerivationPath.tsx @@ -2,6 +2,7 @@ import React, { Fragment, useState } from "react"; import type { HardwareWallet, HardwareWalletAccount, + SignInMessageParams, Wallet, WalletSelector, } from "@near-wallet-selector/core"; @@ -22,8 +23,9 @@ interface DerivationPathProps { onBack: () => void; onConnected: () => void; params: DerivationPathModalRouteParams; - onError: (message: string, wallet: Wallet) => void; + onError: (errorMessage: string, wallet: Wallet) => void; onCloseModal: () => void; + message: SignInMessageParams | null; } export type HardwareWalletAccountState = HardwareWalletAccount & { @@ -48,6 +50,7 @@ export const DerivationPath: React.FC = ({ params, onError, onCloseModal, + message, }) => { const [route, setRoute] = useState("EnterDerivationPath"); const [derivationPath, setDerivationPath] = useState( @@ -143,12 +146,12 @@ export const DerivationPath: React.FC = ({ } } catch (err) { setConnecting(false); - const message = + const errorMessage = err && typeof err === "object" && "message" in err ? (err as { message: string }).message : "Something went wrong"; - onError(message, wallet); + onError(errorMessage, wallet); } finally { setConnecting(false); } @@ -175,11 +178,11 @@ export const DerivationPath: React.FC = ({ setRoute("OverviewAccounts"); } catch (err) { setConnecting(false); - const message = + const errorMessage = err && typeof err === "object" && "message" in err ? (err as { message: string }).message : "Something went wrong"; - onError(message, hardwareWallet!); + onError(errorMessage, hardwareWallet!); } finally { setConnecting(false); } @@ -196,16 +199,24 @@ export const DerivationPath: React.FC = ({ } ); - return hardwareWallet! - .signIn({ - contractId: options.contractId, - methodNames: options.methodNames, - accounts: mapAccounts, - }) - .then(() => onConnected()) - .catch((err) => { - onError(`Error: ${err.message}`, hardwareWallet!); - }); + if (message) { + return hardwareWallet!.signInMessage!(message) + .then(() => onConnected()) + .catch((err) => { + onError(`Error: ${err.message}`, hardwareWallet!); + }); + } else { + return hardwareWallet! + .signIn({ + contractId: options.contractId, + methodNames: options.methodNames, + accounts: mapAccounts, + }) + .then(() => onConnected()) + .catch((err) => { + onError(`Error: ${err.message}`, hardwareWallet!); + }); + } }; const handleOnBackButtonClick = () => { diff --git a/packages/modal-ui/src/lib/components/Modal.tsx b/packages/modal-ui/src/lib/components/Modal.tsx index afcce63da..2ddc59099 100644 --- a/packages/modal-ui/src/lib/components/Modal.tsx +++ b/packages/modal-ui/src/lib/components/Modal.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react"; import type { EventEmitterService, ModuleState, + SignInMessageParams, WalletSelector, } from "@near-wallet-selector/core"; @@ -30,6 +31,7 @@ interface ModalProps { visible: boolean; hide: () => void; emitter: EventEmitterService; + message: SignInMessageParams | null; } const getThemeClass = (theme?: Theme) => { @@ -49,6 +51,7 @@ export const Modal: React.FC = ({ visible, hide, emitter, + message, }) => { const [route, setRoute] = useState({ name: "WalletHome", @@ -137,7 +140,6 @@ export const Modal: React.FC = ({ module, }, }); - return; } try { @@ -193,11 +195,15 @@ export const Modal: React.FC = ({ }); }); - await wallet.signIn({ - contractId: options.contractId, - methodNames: options.methodNames, - qrCodeModal, - }); + if (message) { + await wallet.signInMessage!(message); + } else { + await wallet.signIn({ + contractId: options.contractId, + methodNames: options.methodNames, + qrCodeModal, + }); + } subscription.remove(); handleDismissClick({ hideReason: "wallet-navigation" }); @@ -205,33 +211,41 @@ export const Modal: React.FC = ({ } if (wallet.type === "browser") { - await wallet.signIn({ - contractId: options.contractId, - methodNames: options.methodNames, - successUrl: wallet.metadata.successUrl, - failureUrl: wallet.metadata.failureUrl, - }); + if (message) { + await wallet.signInMessage!(message); + } else { + await wallet.signIn({ + contractId: options.contractId, + methodNames: options.methodNames, + successUrl: wallet.metadata.successUrl, + failureUrl: wallet.metadata.failureUrl, + }); + } handleDismissClick({ hideReason: "wallet-navigation" }); return; } - await wallet.signIn({ - contractId: options.contractId, - methodNames: options.methodNames, - }); + if (message) { + await wallet.signInMessage!(message); + } else { + await wallet.signIn({ + contractId: options.contractId, + methodNames: options.methodNames, + }); + } handleDismissClick({ hideReason: "wallet-navigation" }); } catch (err) { const { name } = module.metadata; - const message = + const errorMessage = err && typeof err === "object" && "message" in err ? (err as { message: string }).message : "Something went wrong"; - setAlertMessage(`Failed to sign in with ${name}: ${message}`); + setAlertMessage(`Failed to sign in with ${name}: ${errorMessage}`); setRoute({ name: "AlertMessage", params: { @@ -302,13 +316,13 @@ export const Modal: React.FC = ({ name: "WalletHome", }) } - onError={(message, wallet) => { + onError={(errorMessage, wallet) => { const { modules } = selector.store.getState(); const findModule = modules.find( (module) => module.id === wallet.id ); - setAlertMessage(message); + setAlertMessage(errorMessage); setRoute({ name: "AlertMessage", params: { @@ -319,6 +333,7 @@ export const Modal: React.FC = ({ onCloseModal={() => handleDismissClick({ hideReason: "user-triggered" }) } + message={message} /> )} {route.name === "WalletNetworkChanged" && ( diff --git a/packages/modal-ui/src/lib/modal.tsx b/packages/modal-ui/src/lib/modal.tsx index 8e8550581..82fde05f6 100644 --- a/packages/modal-ui/src/lib/modal.tsx +++ b/packages/modal-ui/src/lib/modal.tsx @@ -1,7 +1,10 @@ import React from "react"; import type { Root } from "react-dom/client"; import { createRoot } from "react-dom/client"; -import type { WalletSelector } from "@near-wallet-selector/core"; +import type { + SignInMessageParams, + WalletSelector, +} from "@near-wallet-selector/core"; import type { WalletSelectorModal, ModalOptions } from "./modal.types"; import { Modal } from "./components/Modal"; @@ -34,7 +37,10 @@ export const setupModal = ( const emitter = new EventEmitter(); - const render = (visible = false) => { + const render = ( + visible = false, + message: SignInMessageParams | null = null + ) => { root!.render( render(false)} emitter={emitter} + message={message} /> ); }; @@ -54,6 +61,9 @@ export const setupModal = ( hide: () => { render(false); }, + signInMessage(message: SignInMessageParams) { + render(true, message); + }, on: (eventName, callback) => { return emitter.on(eventName, callback); }, diff --git a/packages/modal-ui/src/lib/modal.types.ts b/packages/modal-ui/src/lib/modal.types.ts index f9c97f978..77206e825 100644 --- a/packages/modal-ui/src/lib/modal.types.ts +++ b/packages/modal-ui/src/lib/modal.types.ts @@ -1,4 +1,7 @@ -import type { Subscription } from "@near-wallet-selector/core"; +import type { + SignInMessageParams, + Subscription, +} from "@near-wallet-selector/core"; export type Theme = "dark" | "light" | "auto"; @@ -27,6 +30,7 @@ export interface WalletSelectorModal { /** * Attach an event handler to important events. */ + signInMessage(params: SignInMessageParams): void; on( eventName: EventName, callback: (event: ModalEvents[EventName]) => void diff --git a/packages/my-near-wallet/src/lib/my-near-wallet.ts b/packages/my-near-wallet/src/lib/my-near-wallet.ts index 5a3721567..4e294586e 100644 --- a/packages/my-near-wallet/src/lib/my-near-wallet.ts +++ b/packages/my-near-wallet/src/lib/my-near-wallet.ts @@ -7,7 +7,11 @@ import type { Optional, Network, Account, + JsonStorageService, + SignInMessageParams, + SignMessageParams, } from "@near-wallet-selector/core"; +import { verifyMessageNEP413 } from "@near-wallet-selector/core"; import { createAction } from "@near-wallet-selector/wallet-utils"; import icon from "./icon"; @@ -22,6 +26,7 @@ export interface MyNearWalletParams { interface MyNearWalletState { wallet: nearAPI.WalletConnection; keyStore: nearAPI.keyStores.BrowserLocalStorageKeyStore; + signedInMessageAccount: Account | null; } interface MyNearWalletExtraOptions { @@ -43,9 +48,49 @@ const resolveWalletUrl = (network: Network, walletUrl?: string) => { } }; +const getSignedInMessageAccount = async ( + storage: JsonStorageService, + network: Network +): Promise => { + const urlParams = new URLSearchParams( + window.location.hash.substring(1) // skip the first char (#) + ); + const accountId = urlParams.get("accountId") as string; + const publicKey = urlParams.get("publicKey") as string; + const signature = urlParams.get("signature") as string; + let message = await storage.getItem("message:pending"); + + if ((!accountId && !publicKey && !signature) || !message) { + return null; + } + + message = { ...message, nonce: Buffer.from(message.nonce) }; + const signedMessage = { + accountId, + publicKey, + signature, + }; + + try { + const isMessageVerified = await verifyMessageNEP413( + message, + signedMessage, + network + ); + if (!isMessageVerified) { + return null; + } + + return { accountId, publicKey }; + } catch (_e) { + return null; + } +}; + const setupWalletState = async ( params: MyNearWalletExtraOptions, - network: Network + network: Network, + storage: JsonStorageService ): Promise => { const keyStore = new nearAPI.keyStores.BrowserLocalStorageKeyStore(); @@ -58,35 +103,50 @@ const setupWalletState = async ( const wallet = new nearAPI.WalletConnection(near, "near_app"); + const signedInMessageAccount = await getSignedInMessageAccount( + storage, + network + ); + return { wallet, keyStore, + signedInMessageAccount, }; }; const MyNearWallet: WalletBehaviourFactory< BrowserWallet, { params: MyNearWalletExtraOptions } -> = async ({ metadata, options, store, params, logger, id }) => { - const _state = await setupWalletState(params, options.network); +> = async ({ metadata, options, store, params, logger, id, storage }) => { + const _state = await setupWalletState(params, options.network, storage); const getAccounts = async (): Promise> => { const accountId = _state.wallet.getAccountId(); const account = _state.wallet.account(); + const { signedInMessageAccount } = store.getState(); + + if (accountId && account) { + const publicKey = await account.connection.signer.getPublicKey( + account.accountId, + options.network.networkId + ); + return [ + { + accountId, + publicKey: publicKey ? publicKey.toString() : "", + }, + ]; + } - if (!accountId || !account) { - return []; + if (_state.signedInMessageAccount) { + return [{ ..._state.signedInMessageAccount }]; } - const publicKey = await account.connection.signer.getPublicKey( - account.accountId, - options.network.networkId - ); - return [ - { - accountId, - publicKey: publicKey ? publicKey.toString() : "", - }, - ]; + if (signedInMessageAccount) { + return [{ ...signedInMessageAccount }]; + } + + return []; }; const transformTransactions = async ( @@ -128,6 +188,35 @@ const MyNearWallet: WalletBehaviourFactory< ); }; + const signMessage = ({ + message, + nonce, + recipient, + callbackUrl, + state, + }: SignMessageParams) => { + const locationUrl = + typeof window !== "undefined" ? window.location.href : ""; + + const url = callbackUrl || locationUrl; + + if (!url) { + throw new Error(`The callbackUrl is missing for ${metadata.name}`); + } + + const href = new URL(params.walletUrl); + href.pathname = "sign-message"; + href.searchParams.append("message", message); + href.searchParams.append("nonce", nonce.toString("base64")); + href.searchParams.append("recipient", recipient); + href.searchParams.append("callbackUrl", url); + if (state) { + href.searchParams.append("state", state); + } + + window.location.replace(href.toString()); + }; + return { async signIn({ contractId, methodNames, successUrl, failureUrl }) { const existingAccounts = await getAccounts(); @@ -160,7 +249,7 @@ const MyNearWallet: WalletBehaviourFactory< throw new Error(`Method not supported by ${metadata.name}`); }, - async signMessage({ message, nonce, recipient, callbackUrl, state }) { + async signMessage(message) { logger.log("sign message", { message }); if (id !== "my-near-wallet") { @@ -168,27 +257,21 @@ const MyNearWallet: WalletBehaviourFactory< `The signMessage method is not supported by ${metadata.name}` ); } + signMessage(message); - const locationUrl = - typeof window !== "undefined" ? window.location.href : ""; - - const url = callbackUrl || locationUrl; + return; + }, - if (!url) { - throw new Error(`The callbackUrl is missing for ${metadata.name}`); - } + async signInMessage(message) { + logger.log("signInMessage", { message }); - const href = new URL(params.walletUrl); - href.pathname = "sign-message"; - href.searchParams.append("message", message); - href.searchParams.append("nonce", nonce.toString("base64")); - href.searchParams.append("recipient", recipient); - href.searchParams.append("callbackUrl", url); - if (state) { - href.searchParams.append("state", state); + if (id !== "my-near-wallet") { + throw Error( + `The signInMessage method is not supported by ${metadata.name}` + ); } - window.location.replace(href.toString()); + signMessage(message); return; }, diff --git a/packages/near-mobile-wallet/src/lib/init.wallet.ts b/packages/near-mobile-wallet/src/lib/init.wallet.ts index 17565d2de..9f4e170b2 100644 --- a/packages/near-mobile-wallet/src/lib/init.wallet.ts +++ b/packages/near-mobile-wallet/src/lib/init.wallet.ts @@ -1,6 +1,8 @@ import { NearMobileWallet } from "@peersyst/near-mobile-signer/dist/src/wallet/NearMobileWallet"; import type { NearMobileWalletInit } from "./near-mobile-wallet.types"; import type { Network } from "@peersyst/near-mobile-signer/dist/src/common/models"; +import type { Account } from "@near-wallet-selector/core"; +import { verifyMessageNEP413 } from "@near-wallet-selector/core"; export const initNearMobileWallet: NearMobileWalletInit = async (config) => { const { store, options, logger, dAppMetadata } = config; @@ -14,19 +16,28 @@ export const initNearMobileWallet: NearMobileWalletInit = async (config) => { async function getAccounts() { logger.log("[NearMobileWallet]:getAccounts"); const accountIds = await nearMobileWallet.getAccounts(); - const accounts = []; - - for (let i = 0; i < accountIds.length; i++) { - accounts.push({ - accountId: accountIds[i], - publicKey: ( - await nearMobileWallet.signer.getPublicKey( - accountIds[i], - options.network.networkId - ) - ).toString(), - }); + const accounts: Array = []; + const { signedInMessageAccount } = store.getState(); + + if (accountIds.length > 0) { + for (let i = 0; i < accountIds.length; i++) { + accounts.push({ + accountId: accountIds[i], + publicKey: ( + await nearMobileWallet.signer.getPublicKey( + accountIds[i], + options.network.networkId + ) + ).toString(), + }); + } + return accounts; } + + if (signedInMessageAccount) { + return [{ ...signedInMessageAccount }]; + } + return accounts; } @@ -45,7 +56,11 @@ export const initNearMobileWallet: NearMobileWalletInit = async (config) => { async signOut() { logger.log("[NearMobileWallet]: signOut"); - await nearMobileWallet.signOut(); + const { signedInMessageAccount } = store.getState(); + + if (!signedInMessageAccount) { + await nearMobileWallet.signOut(); + } }, async getAccounts() { @@ -87,8 +102,37 @@ export const initNearMobileWallet: NearMobileWalletInit = async (config) => { }; }, + async signInMessage(data) { + logger.log("[NearMobileWallet]: signInMessage", data); + const { recipient, nonce, ...rest } = data; + const signedMessage = await nearMobileWallet.signMessage({ + ...rest, + receiver: recipient, + nonce: Array.from(nonce), + }); + + const message = { message: data.message, nonce, recipient }; + const isMessageVerified = await verifyMessageNEP413( + message, + signedMessage, + options.network + ); + + if (!isMessageVerified) { + throw new Error("Failed to verify the message"); + } + + return signedMessage; + }, + async signAndSendTransactions(data) { logger.log("[NearMobileWallet]: signAndSendTransactions", data); + + const { contract } = store.getState(); + if (!contract) { + throw new Error("Wallet not signed in"); + } + return await nearMobileWallet.signAndSendTransactions(data); }, }; diff --git a/packages/near-snap/src/lib/selector.ts b/packages/near-snap/src/lib/selector.ts index ba49ea261..464525141 100644 --- a/packages/near-snap/src/lib/selector.ts +++ b/packages/near-snap/src/lib/selector.ts @@ -4,6 +4,7 @@ import type { WalletBehaviourFactory, } from "@near-wallet-selector/core"; import { NearSnap, NearSnapAccount } from "@near-snap/sdk"; +import { verifyMessageNEP413 } from "@near-wallet-selector/core"; export const snap = new NearSnap(); @@ -67,6 +68,36 @@ export const initNearSnap: WalletBehaviourFactory = async ( return await account.signMessage({ message, nonce, recipient }); }, + async signInMessage({ message, nonce, recipient }) { + let snapAccount: NearSnapAccount | null = null; + if (account == null) { + snapAccount = await NearSnapAccount.connect({ + contractId: undefined, + methods: [], + network, + snap, + }); + } + + const currentAccount = account || snapAccount; + const signedMessage = await currentAccount!.signMessage({ + message, + nonce, + recipient, + }); + const isMessageVerified = await verifyMessageNEP413( + { message, nonce, recipient }, + signedMessage, + options.network + ); + + if (!isMessageVerified) { + throw new Error(`Failed to verify the message`); + } + + return signedMessage; + }, + async verifyOwner() { throw Error("NearSnap:verifyOwner is not released yet"); }, @@ -74,7 +105,9 @@ export const initNearSnap: WalletBehaviourFactory = async ( async signAndSendTransactions({ transactions }) { logger.log("NearSnap:signAndSendTransactions", { transactions }); - if (account == null) { + const { contract } = store.getState(); + + if (account == null || !contract) { throw new Error("Wallet not signed in"); } diff --git a/packages/nightly/src/lib/nightly.ts b/packages/nightly/src/lib/nightly.ts index 48d7e9056..11557244f 100644 --- a/packages/nightly/src/lib/nightly.ts +++ b/packages/nightly/src/lib/nightly.ts @@ -9,7 +9,7 @@ import type { WalletEvents, Account, } from "@near-wallet-selector/core"; -import { waitFor } from "@near-wallet-selector/core"; +import { verifyMessageNEP413, waitFor } from "@near-wallet-selector/core"; import { signTransactions } from "@near-wallet-selector/wallet-utils"; import { isMobile } from "is-mobile"; import type { Signer } from "near-api-js"; @@ -147,12 +147,6 @@ const Nightly: WalletBehaviourFactory = async ({ return { async signIn() { - const existingAccounts = getAccounts(); - - if (existingAccounts.length) { - return existingAccounts; - } - await _state.wallet.connect((newAcc) => { if (!newAcc) { emitter.emit("signedOut", null); @@ -207,6 +201,28 @@ const Nightly: WalletBehaviourFactory = async ({ return signature; }, + async signInMessage(message) { + logger.log("Nightly:signInMessage", message); + + if (!_state.wallet.isConnected) { + await _state.wallet.connect(); + } + + const signedMessage = await _state.wallet.signMessage(message); + + const isMessageVerified = await verifyMessageNEP413( + message, + signedMessage, + options.network + ); + + if (!isMessageVerified) { + throw new Error(`Failed to verify the message`); + } + + return signedMessage; + }, + async signAndSendTransaction({ signerId, receiverId, actions }) { logger.log("signAndSendTransaction", { signerId, receiverId, actions }); diff --git a/packages/sender/src/lib/sender.ts b/packages/sender/src/lib/sender.ts index cd52cf6a1..3c9085ebe 100644 --- a/packages/sender/src/lib/sender.ts +++ b/packages/sender/src/lib/sender.ts @@ -9,7 +9,7 @@ import type { Optional, Account, } from "@near-wallet-selector/core"; -import { waitFor } from "@near-wallet-selector/core"; +import { verifyMessageNEP413, waitFor } from "@near-wallet-selector/core"; import type { InjectedSender } from "./injected-sender"; import icon from "./icon"; @@ -105,8 +105,8 @@ const Sender: WalletBehaviourFactory = async ({ // Add extra wait to ensure Sender's sign in status is read from the // browser extension background env. // Check for isSignedIn() in only if selectedWalletId is set. - const { selectedWalletId } = store.getState(); - if (selectedWalletId) { + const { selectedWalletId, signedInMessageAccount } = store.getState(); + if (selectedWalletId && !signedInMessageAccount) { await waitFor(() => !!_state.wallet?.isSignedIn(), { timeout: 1000, }).catch(); @@ -114,30 +114,34 @@ const Sender: WalletBehaviourFactory = async ({ const accountId = _state.wallet.getAccountId(); - if (!accountId) { - return []; - } + if (accountId) { + await waitFor(() => !!_state.wallet.account(), { timeout: 100 }); + + const account = _state.wallet.account(); - await waitFor(() => !!_state.wallet.account(), { timeout: 100 }); + // When wallet is locked signer is empty an object {}. + if (!account!.connection.signer.getPublicKey) { + return [{ accountId, publicKey: undefined }]; + } - const account = _state.wallet.account(); + const publicKey = await account!.connection.signer.getPublicKey( + account!.accountId, + options.network.networkId + ); - // When wallet is locked signer is empty an object {}. - if (!account!.connection.signer.getPublicKey) { - return [{ accountId, publicKey: undefined }]; + return [ + { + accountId, + publicKey: publicKey ? publicKey.toString() : undefined, + }, + ]; } - const publicKey = await account!.connection.signer.getPublicKey( - account!.accountId, - options.network.networkId - ); + if (signedInMessageAccount) { + return [{ ...signedInMessageAccount }]; + } - return [ - { - accountId, - publicKey: publicKey ? publicKey.toString() : undefined, - }, - ]; + return []; }; const isValidActions = ( @@ -175,12 +179,6 @@ const Sender: WalletBehaviourFactory = async ({ return { async signIn({ contractId, methodNames }) { - const existingAccounts = await getAccounts(); - - if (existingAccounts.length) { - return existingAccounts; - } - const { accessKey, error } = await _state.wallet.requestSignIn({ contractId, methodNames, @@ -266,6 +264,28 @@ const Sender: WalletBehaviourFactory = async ({ }); }, + async signInMessage(message) { + const response = await _state.wallet.signMessage(message); + if (response.error) { + throw new Error(response.error); + } + + if (!response?.response) { + throw new Error("Invalid response"); + } + + const isMessageVerified = await verifyMessageNEP413( + message, + response.response, + options.network + ); + + if (!isMessageVerified) { + throw new Error(`Failed to verify the message`); + } + return response.response; + }, + async signAndSendTransaction({ signerId, receiverId, actions }) { logger.log("signAndSendTransaction", { signerId, receiverId, actions }); diff --git a/packages/welldone-wallet/src/lib/welldone.ts b/packages/welldone-wallet/src/lib/welldone.ts index e8831f112..a07a131ef 100644 --- a/packages/welldone-wallet/src/lib/welldone.ts +++ b/packages/welldone-wallet/src/lib/welldone.ts @@ -15,6 +15,7 @@ import type { import { isCurrentBrowserSupported, serializeNep413, + verifyMessageNEP413, waitFor, } from "@near-wallet-selector/core"; import type { @@ -108,14 +109,21 @@ const WelldoneWallet: WalletBehaviourFactory = async ({ }; const getAccounts = (): Array => { - return _state.account - ? [ - { - accountId: _state.account.accountId, - publicKey: _state.account.publicKey, - }, - ] - : []; + const { signedInMessageAccount } = store.getState(); + if (_state.account) { + return [ + { + accountId: _state.account.accountId, + publicKey: _state.account.publicKey, + }, + ]; + } + + if (signedInMessageAccount) { + return [{ ...signedInMessageAccount }]; + } + + return []; }; const signOut = async () => { @@ -144,6 +152,36 @@ const WelldoneWallet: WalletBehaviourFactory = async ({ } }; + const signMessage = async ( + message: SignMessageParams + ): Promise => { + if (!_state.wallet) { + throw new Error("Wallet is not installed"); + } + + const account = await _getAccounts(); + const accountId = account[0]; + + if (!accountId) { + throw new Error("Failed to find account for signing"); + } + + const serializedTx = serializeNep413(message); + const signed = await _state.wallet.request("near", { + method: "dapp:signMessage", + params: ["0x" + serializedTx.toString("hex")], + }); + + return { + accountId, + publicKey: signed[0].publicKey, + signature: Buffer.from(signed[0].signature.substr(2), "hex").toString( + "base64" + ), + state: message?.state, + }; + }; + const signer: Signer = { createKey: () => { throw new Error("Not implemented"); @@ -219,12 +257,6 @@ const WelldoneWallet: WalletBehaviourFactory = async ({ return { async signIn() { - const existingAccounts = getAccounts(); - - if (existingAccounts.length) { - return existingAccounts; - } - if (_state.account) { await signOut(); } @@ -301,38 +333,24 @@ const WelldoneWallet: WalletBehaviourFactory = async ({ }; }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any async signMessage(message: SignMessageParams): Promise { - if (!_state.wallet) { - throw new Error("Wallet is not installed"); - } - - const account = await _getAccounts(); - const accountId = account[0]; - - if (!accountId) { - throw new Error("Failed to find account for signing"); - } + return await signMessage(message); + }, - const serializedTx = serializeNep413(message); - const signed = await _state.wallet.request("near", { - method: "dapp:signMessage", - params: ["0x" + serializedTx.toString("hex")], - }); + async signInMessage(message: SignMessageParams): Promise { + const signedMessage = await signMessage(message); - const result = { - accountId, - publicKey: signed[0].publicKey, - signature: Buffer.from(signed[0].signature.substr(2), "hex").toString( - "base64" - ), - }; + const isMessageVerified = await verifyMessageNEP413( + message, + signedMessage, + options.network + ); - if (message.state) { - return { ...result, state: message.state }; + if (!isMessageVerified) { + throw new Error(`Failed to verify the message`); } - return result; + return signedMessage; }, async signAndSendTransaction({ signerId, receiverId, actions }) {