diff --git a/packages/wallet/dapp-client/src/ChainSessionManager.ts b/packages/wallet/dapp-client/src/ChainSessionManager.ts index 229db1ea30..095d071072 100644 --- a/packages/wallet/dapp-client/src/ChainSessionManager.ts +++ b/packages/wallet/dapp-client/src/ChainSessionManager.ts @@ -37,12 +37,14 @@ import { TransportMode, GuardConfig, CreateNewSessionPayload, + EthAuthSettings, ModifyExplicitSessionPayload, SessionResponse, AddExplicitSessionPayload, FeeOption, OperationFailedStatus, OperationStatus, + ETHAuthProof, } from './types/index.js' import { CACHE_DB_NAME, VALUE_FORWARDER_ADDRESS } from './utils/constants.js' import { ExplicitSession, ImplicitSession, ExplicitSessionConfig } from './index.js' @@ -285,6 +287,7 @@ export class ChainSessionManager { preferredLoginMethod?: LoginMethod email?: string includeImplicitSession?: boolean + ethAuth?: EthAuthSettings } = {}, ): Promise { if (this.isInitialized) { @@ -311,6 +314,7 @@ export class ChainSessionManager { origin, session: completeSession as ExplicitSession | undefined, includeImplicitSession: options.includeImplicitSession ?? false, + ethAuth: options.ethAuth, preferredLoginMethod: options.preferredLoginMethod, email: options.preferredLoginMethod === 'email' ? options.email : undefined, } @@ -377,6 +381,10 @@ export class ChainSessionManager { this.guard = guard } + if (payload.ethAuth) { + await this._saveEthAuthProofIfProvided(connectResponse.ethAuthProof) + } + if (this.transport.mode === TransportMode.POPUP) { this.transport.closeWallet() } @@ -596,6 +604,10 @@ export class ChainSessionManager { this.userEmail = userEmail ?? null this.guard = guard } + + if (savedPayload?.ethAuth) { + await this._saveEthAuthProofIfProvided(connectResponse.ethAuthProof) + } } else if (response.action === RequestActionType.ADD_EXPLICIT_SESSION) { if (!this.walletAddress || !Address.isEqual(receivedAddress, this.walletAddress)) { throw new InitializationError('Received an explicit session for a wallet that is not active.') @@ -1104,6 +1116,13 @@ export class ChainSessionManager { await this.sequenceStorage.clearSessionlessConnection() } + private async _saveEthAuthProofIfProvided(ethAuthProof?: ETHAuthProof): Promise { + if (!ethAuthProof) { + return + } + await this.sequenceStorage.saveEthAuthProof(ethAuthProof) + } + private _getCachedSignedCall(calls: Payload.Call[]): { to: Address.Address; data: Hex.Hex } | null { if (!this.lastSignedCallCache) { return null diff --git a/packages/wallet/dapp-client/src/DappClient.ts b/packages/wallet/dapp-client/src/DappClient.ts index 3c5a29cba9..a2b73afd7e 100644 --- a/packages/wallet/dapp-client/src/DappClient.ts +++ b/packages/wallet/dapp-client/src/DappClient.ts @@ -14,8 +14,10 @@ import { GetFeeTokensResponse, GuardConfig, LoginMethod, + EthAuthSettings, RandomPrivateKeyFn, RequestActionType, + ETHAuthProof, SendWalletTransactionPayload, SequenceSessionStorage, SignMessagePayload, @@ -407,6 +409,13 @@ export class DappClient { } } + /** + * Returns the latest persisted ETHAuth proof, if one has been received from the wallet. + */ + public async getEthAuthProof(): Promise { + return this.sequenceStorage.getEthAuthProof() + } + /** * Restores a sessionless connection that was previously persisted via {@link disconnect} or a connect flow. * @returns A promise that resolves to true if a sessionless connection was applied. @@ -559,6 +568,7 @@ export class DappClient { preferredLoginMethod?: LoginMethod email?: string includeImplicitSession?: boolean + ethAuth?: EthAuthSettings } = {}, ): Promise { if (this.isInitialized) { @@ -614,6 +624,7 @@ export class DappClient { preferredLoginMethod?: LoginMethod email?: string includeImplicitSession?: boolean + ethAuth?: EthAuthSettings } = {}, ): Promise { if (!this.isInitialized || !this.hasSessionlessConnection || !this.walletAddress) { diff --git a/packages/wallet/dapp-client/src/index.ts b/packages/wallet/dapp-client/src/index.ts index c2315b1384..ce976c13d4 100644 --- a/packages/wallet/dapp-client/src/index.ts +++ b/packages/wallet/dapp-client/src/index.ts @@ -24,6 +24,8 @@ export type { FeeToken, FeeOption, TransportMessage, + EthAuthSettings, + ETHAuthProof, } from './types/index.js' export { RequestActionType, TransportMode, MessageType } from './types/index.js' export { diff --git a/packages/wallet/dapp-client/src/types/index.ts b/packages/wallet/dapp-client/src/types/index.ts index 6a0eb08daf..0f023c2bb8 100644 --- a/packages/wallet/dapp-client/src/types/index.ts +++ b/packages/wallet/dapp-client/src/types/index.ts @@ -28,12 +28,32 @@ export interface GuardConfig { moduleAddresses: Map } +export interface EthAuthSettings { + app?: string + /** expiry number (in seconds) that is used for ETHAuth proof. Default is 1 week in seconds. */ + expiry?: number + /** origin hint of the dapp's host opening the wallet. This value will automatically + * be determined and verified for integrity, and can be omitted. */ + origin?: string + /** authorizeNonce is an optional number to be passed as ETHAuth's nonce claim for replay protection. **/ + nonce?: number +} + +export interface ETHAuthProof { + // eip712 typed-data payload for ETHAuth domain as input + typedData: Payload.TypedDataToSign + + // signature encoded in an ETHAuth proof string + ewtString: string +} + // --- Payloads for Transport --- export interface CreateNewSessionPayload { origin?: string session?: ExplicitSession includeImplicitSession?: boolean + ethAuth?: EthAuthSettings preferredLoginMethod?: LoginMethod email?: string } @@ -81,6 +101,7 @@ export interface CreateNewSessionResponse { userEmail?: string loginMethod?: LoginMethod guard?: GuardConfig + ethAuthProof?: ETHAuthProof } export interface SignatureResponse { diff --git a/packages/wallet/dapp-client/src/utils/storage.ts b/packages/wallet/dapp-client/src/utils/storage.ts index 8928d354e5..8a56015ba9 100644 --- a/packages/wallet/dapp-client/src/utils/storage.ts +++ b/packages/wallet/dapp-client/src/utils/storage.ts @@ -5,6 +5,7 @@ import { SignMessagePayload, SignTypedDataPayload, GuardConfig, + ETHAuthProof, SendWalletTransactionPayload, ModifyExplicitSessionPayload, CreateNewSessionPayload, @@ -81,6 +82,10 @@ export interface SequenceStorage { getSessionlessConnection(): Promise clearSessionlessConnection(): Promise + saveEthAuthProof(proof: ETHAuthProof): Promise + getEthAuthProof(): Promise + clearEthAuthProof(): Promise + saveSessionlessConnectionSnapshot?(sessionData: SessionlessConnectionData): Promise getSessionlessConnectionSnapshot?(): Promise clearSessionlessConnectionSnapshot?(): Promise @@ -94,6 +99,7 @@ const STORE_NAME = 'userKeys' const IMPLICIT_SESSIONS_IDB_KEY = 'SequenceImplicitSession' const EXPLICIT_SESSIONS_IDB_KEY = 'SequenceExplicitSession' const SESSIONLESS_CONNECTION_IDB_KEY = 'SequenceSessionlessConnection' +const ETH_AUTH_PROOF_IDB_KEY = 'SequenceEthAuthProof' const SESSIONLESS_CONNECTION_SNAPSHOT_IDB_KEY = 'SequenceSessionlessConnectionSnapshot' const PENDING_REDIRECT_REQUEST_KEY = 'SequencePendingRedirect' @@ -305,6 +311,15 @@ export class WebStorage implements SequenceStorage { } } + async saveEthAuthProof(proof: ETHAuthProof): Promise { + try { + await this.setIDBItem(ETH_AUTH_PROOF_IDB_KEY, proof) + } catch (error) { + console.error('Failed to save ETHAuth proof:', error) + throw error + } + } + async getSessionlessConnection(): Promise { try { return (await this.getIDBItem(SESSIONLESS_CONNECTION_IDB_KEY)) ?? null @@ -314,6 +329,15 @@ export class WebStorage implements SequenceStorage { } } + async getEthAuthProof(): Promise { + try { + return (await this.getIDBItem(ETH_AUTH_PROOF_IDB_KEY)) ?? null + } catch (error) { + console.error('Failed to retrieve ETHAuth proof:', error) + return null + } + } + async clearSessionlessConnection(): Promise { try { await this.deleteIDBItem(SESSIONLESS_CONNECTION_IDB_KEY) @@ -323,6 +347,15 @@ export class WebStorage implements SequenceStorage { } } + async clearEthAuthProof(): Promise { + try { + await this.deleteIDBItem(ETH_AUTH_PROOF_IDB_KEY) + } catch (error) { + console.error('Failed to clear ETHAuth proof:', error) + throw error + } + } + async saveSessionlessConnectionSnapshot(sessionData: SessionlessConnectionData): Promise { try { await this.setIDBItem(SESSIONLESS_CONNECTION_SNAPSHOT_IDB_KEY, sessionData) @@ -363,6 +396,7 @@ export class WebStorage implements SequenceStorage { await this.clearExplicitSessions() await this.clearImplicitSession() await this.clearSessionlessConnection() + await this.clearEthAuthProof() await this.clearSessionlessConnectionSnapshot() } catch (error) { console.error('Failed to clear all data:', error) diff --git a/packages/wallet/dapp-client/test/ethauth-proof.test.ts b/packages/wallet/dapp-client/test/ethauth-proof.test.ts new file mode 100644 index 0000000000..9d26306b7e --- /dev/null +++ b/packages/wallet/dapp-client/test/ethauth-proof.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { DappClient } from '../src/DappClient.js' +import { DappTransport } from '../src/DappTransport.js' +import { RequestActionType, TransportMode } from '../src/types/index.js' +import { WebStorage } from '../src/utils/storage.js' + +describe('ETHAuth proof persistence', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + const createSequenceStorageMock = () => + ({ + setPendingRedirectRequest: vi.fn().mockResolvedValue(undefined), + isRedirectRequestPending: vi.fn().mockResolvedValue(false), + saveTempSessionPk: vi.fn().mockResolvedValue(undefined), + getAndClearTempSessionPk: vi.fn().mockResolvedValue(null), + savePendingRequest: vi.fn().mockResolvedValue(undefined), + getAndClearPendingRequest: vi.fn().mockResolvedValue(null), + peekPendingRequest: vi.fn().mockResolvedValue(null), + saveExplicitSession: vi.fn().mockResolvedValue(undefined), + getExplicitSessions: vi.fn().mockResolvedValue([]), + clearExplicitSessions: vi.fn().mockResolvedValue(undefined), + saveImplicitSession: vi.fn().mockResolvedValue(undefined), + getImplicitSession: vi.fn().mockResolvedValue(null), + clearImplicitSession: vi.fn().mockResolvedValue(undefined), + saveSessionlessConnection: vi.fn().mockResolvedValue(undefined), + getSessionlessConnection: vi.fn().mockResolvedValue(null), + clearSessionlessConnection: vi.fn().mockResolvedValue(undefined), + saveEthAuthProof: vi.fn().mockResolvedValue(undefined), + getEthAuthProof: vi.fn().mockResolvedValue(null), + clearEthAuthProof: vi.fn().mockResolvedValue(undefined), + clearAllData: vi.fn().mockResolvedValue(undefined), + }) as any + + it('persists ETHAuth proof when connect requests ethAuth in redirect mode', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('window', { fetch: fetchMock } as any) + + const ethAuthProof = { + typedData: { + domain: {}, + types: {}, + message: {}, + }, + ewtString: 'proof-string', + } as any + + const sequenceStorage = createSequenceStorageMock() + const sendRequestMock = vi.spyOn(DappTransport.prototype, 'sendRequest').mockResolvedValue({ + walletAddress: '0x1111111111111111111111111111111111111111', + ethAuthProof, + } as any) + + const client = new DappClient('https://wallet.example', 'https://dapp.example', 'test-project-access-key', { + sequenceStorage, + transportMode: TransportMode.REDIRECT, + canUseIndexedDb: false, + redirectActionHandler: vi.fn(), + }) + + await client.connect(1, undefined, { + ethAuth: { + app: 'app-name', + }, + }) + + expect(sendRequestMock).toHaveBeenCalledWith( + RequestActionType.CREATE_NEW_SESSION, + 'https://dapp.example', + expect.objectContaining({ + ethAuth: { + app: 'app-name', + }, + }), + expect.any(Object), + ) + expect(sequenceStorage.saveEthAuthProof).toHaveBeenCalledWith(ethAuthProof) + }) + + it('persists ETHAuth proof when connect requests ethAuth in popup mode', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('window', { fetch: fetchMock } as any) + vi.stubGlobal('document', {} as any) + + const ethAuthProof = { + typedData: { + domain: {}, + types: {}, + message: {}, + }, + ewtString: 'proof-string', + } as any + + const sequenceStorage = createSequenceStorageMock() + const sendRequestMock = vi.spyOn(DappTransport.prototype, 'sendRequest').mockResolvedValue({ + walletAddress: '0x1111111111111111111111111111111111111111', + ethAuthProof, + } as any) + + const client = new DappClient('https://wallet.example', 'https://dapp.example', 'test-project-access-key', { + sequenceStorage, + transportMode: TransportMode.POPUP, + canUseIndexedDb: false, + }) + + await client.connect(1, undefined, { + ethAuth: { + app: 'app-name', + }, + }) + + expect(sendRequestMock).toHaveBeenCalledWith( + RequestActionType.CREATE_NEW_SESSION, + 'https://dapp.example', + expect.objectContaining({ + ethAuth: { + app: 'app-name', + }, + }), + expect.any(Object), + ) + expect(sequenceStorage.saveEthAuthProof).toHaveBeenCalledWith(ethAuthProof) + }) + + it('does not persist ETHAuth proof when connect does not request ethAuth', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('window', { fetch: fetchMock } as any) + + const ethAuthProof = { + typedData: { + domain: {}, + types: {}, + message: {}, + }, + ewtString: 'proof-string', + } as any + + const sequenceStorage = createSequenceStorageMock() + const sendRequestMock = vi.spyOn(DappTransport.prototype, 'sendRequest').mockResolvedValue({ + walletAddress: '0x1111111111111111111111111111111111111111', + ethAuthProof, + } as any) + + const client = new DappClient('https://wallet.example', 'https://dapp.example', 'test-project-access-key', { + sequenceStorage, + transportMode: TransportMode.REDIRECT, + canUseIndexedDb: false, + redirectActionHandler: vi.fn(), + }) + + await client.connect(1) + + expect(sendRequestMock).toHaveBeenCalledWith( + RequestActionType.CREATE_NEW_SESSION, + 'https://dapp.example', + expect.not.objectContaining({ + ethAuth: expect.anything(), + }), + expect.any(Object), + ) + expect(sequenceStorage.saveEthAuthProof).not.toHaveBeenCalled() + }) + + it('clears ETHAuth proof on disconnect', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('window', { fetch: fetchMock } as any) + + const ethAuthProof = { + typedData: { + domain: {}, + types: {}, + message: {}, + }, + ewtString: 'proof-string', + } as any + + vi.spyOn(DappTransport.prototype, 'sendRequest').mockResolvedValue({ + walletAddress: '0x1111111111111111111111111111111111111111', + ethAuthProof, + } as any) + + const client = new DappClient('https://wallet.example', 'https://dapp.example', 'test-project-access-key', { + sequenceStorage: new WebStorage(), + transportMode: TransportMode.REDIRECT, + canUseIndexedDb: false, + redirectActionHandler: vi.fn(), + }) + + await client.connect(1, undefined, { + ethAuth: { + app: 'app-name', + }, + }) + + expect(await client.getEthAuthProof()).toEqual(ethAuthProof) + + await client.disconnect() + + expect(await client.getEthAuthProof()).toBeNull() + }) +})