diff --git a/examples/testapp/src/components/RpcMethods/method/walletTxMethods.ts b/examples/testapp/src/components/RpcMethods/method/walletTxMethods.ts index f1ea6511..895f034a 100644 --- a/examples/testapp/src/components/RpcMethods/method/walletTxMethods.ts +++ b/examples/testapp/src/components/RpcMethods/method/walletTxMethods.ts @@ -18,6 +18,11 @@ const walletGetUserInfo: RpcRequestInput = { params: [], } +const walletGetContext: RpcRequestInput = { + method: 'wallet_getContext', + params: [], +} + const walletSendCalls: RpcRequestInput = { method: 'wallet_sendCalls', params: [ @@ -56,4 +61,5 @@ export const walletTxMethods = [ walletShowCallsStatus, walletSendCalls, walletGetUserInfo, + walletGetContext, ] diff --git a/package.json b/package.json index d54de86d..83acf968 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-sdk", - "version": "1.3.1", + "version": "1.4.1", "repository": "https://github.com/base/account-sdk", "author": "Base", "license": "MIT", diff --git a/packages/app-sdk/package.json b/packages/app-sdk/package.json index 3c1acd1a..76469267 100644 --- a/packages/app-sdk/package.json +++ b/packages/app-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@startale/app-sdk", - "version": "1.3.1", + "version": "1.4.1", "description": "Superapp SDK", "keywords": [ "startale", diff --git a/packages/app-sdk/src/core/rpc/wallet_connect.ts b/packages/app-sdk/src/core/rpc/wallet_connect.ts index 28cd35a3..5f6213b3 100644 --- a/packages/app-sdk/src/core/rpc/wallet_connect.ts +++ b/packages/app-sdk/src/core/rpc/wallet_connect.ts @@ -81,4 +81,9 @@ export type WalletConnectResponse = { authType: string userId: string } + context?: { + chain: string + user: { username: string } + startale: { starPoints: number; eoaWallets: string[] } + } } diff --git a/packages/app-sdk/src/sign/app-sdk/Signer.test.ts b/packages/app-sdk/src/sign/app-sdk/Signer.test.ts index 827b1933..c09f057e 100644 --- a/packages/app-sdk/src/sign/app-sdk/Signer.test.ts +++ b/packages/app-sdk/src/sign/app-sdk/Signer.test.ts @@ -410,6 +410,7 @@ describe('Signer', () => { }, subAccountConfig: undefined, userInfo: {}, + context: {}, })) }) @@ -1645,6 +1646,7 @@ describe('Signer', () => { version: '1.0.0', }, userInfo: {}, + context: {}, })) signer['accounts'] = [globalAccountAddress] @@ -1858,6 +1860,7 @@ describe('Signer', () => { name: 'Test User', authType: 'oauth', }, + context: {}, })) signer['accounts'] = [globalAccountAddress] @@ -2160,6 +2163,258 @@ describe('Signer', () => { name: 'Test User', authType: 'oauth', }, + context: {}, + })) + }) + }) + + describe('wallet_getContext', () => { + let stateSpy: MockInstance + + const mockContext = { + chain: 'soneium', + user: { username: 'tester' }, + startale: { + starPoints: 100, + eoaWallets: ['0xabc'], + }, + } + + beforeEach(() => { + stateSpy = vi.spyOn(store, 'getState').mockImplementation(() => ({ + account: { + accounts: [globalAccountAddress], + }, + chains: [], + keys: {}, + spendPermissions: [], + config: { + metadata: mockMetadata, + preference: { walletUrl: CB_KEYS_URL, options: 'all' }, + version: '1.0.0', + }, + userInfo: {}, + context: mockContext, + })) + + signer['accounts'] = [globalAccountAddress] + }) + + afterEach(() => { + stateSpy.mockRestore() + }) + + it('should return context when available', async () => { + const request = { + method: 'wallet_getContext', + params: [], + } + + const result = await signer.request(request) + + expect(result).toEqual(mockContext) + }) + + it('should return partial context when some fields are missing', async () => { + stateSpy.mockImplementation(() => ({ + account: { + accounts: [globalAccountAddress], + }, + chains: [], + keys: {}, + spendPermissions: [], + config: { + metadata: mockMetadata, + preference: { walletUrl: CB_KEYS_URL, options: 'all' }, + version: '1.0.0', + }, + userInfo: {}, + context: { + chain: 'soneium', + // user and startale are missing + }, + })) + + const request = { + method: 'wallet_getContext', + params: [], + } + + const result = await signer.request(request) + + expect(result).toEqual({ chain: 'soneium' }) + }) + + it('should throw unauthorized error when context is undefined', async () => { + stateSpy.mockImplementation(() => ({ + account: { + accounts: [globalAccountAddress], + }, + chains: [], + keys: {}, + spendPermissions: [], + config: { + metadata: mockMetadata, + preference: { walletUrl: CB_KEYS_URL, options: 'all' }, + version: '1.0.0', + }, + userInfo: {}, + context: undefined, + })) + + const request = { + method: 'wallet_getContext', + params: [], + } + + await expect(signer.request(request)).rejects.toThrow( + standardErrors.provider.unauthorized('No context found'), + ) + }) + + it('should throw unauthorized error when context is null', async () => { + stateSpy.mockImplementation(() => ({ + account: { + accounts: [globalAccountAddress], + }, + chains: [], + keys: {}, + spendPermissions: [], + config: { + metadata: mockMetadata, + preference: { walletUrl: CB_KEYS_URL, options: 'all' }, + version: '1.0.0', + }, + userInfo: {}, + context: null, + })) + + const request = { + method: 'wallet_getContext', + params: [], + } + + await expect(signer.request(request)).rejects.toThrow( + standardErrors.provider.unauthorized('No context found'), + ) + }) + + it('should return empty object when context is empty object', async () => { + stateSpy.mockImplementation(() => ({ + account: { + accounts: [globalAccountAddress], + }, + chains: [], + keys: {}, + spendPermissions: [], + config: { + metadata: mockMetadata, + preference: { walletUrl: CB_KEYS_URL, options: 'all' }, + version: '1.0.0', + }, + userInfo: {}, + context: {}, + })) + + const request = { + method: 'wallet_getContext', + params: [], + } + + // An empty object {} is truthy in JavaScript, so it will be returned + const result = await signer.request(request) + expect(result).toEqual({}) + }) + + it('should not make any network requests', async () => { + const request = { + method: 'wallet_getContext', + params: [], + } + + await signer.request(request) + + expect( + mockCommunicator.postRequestAndWaitForResponse, + ).not.toHaveBeenCalled() + expect(fetchRPCRequest).not.toHaveBeenCalled() + }) + + it('should handle context set from wallet_connect response', async () => { + // Remove the stateSpy to allow real store updates + stateSpy.mockRestore() + + // First, clean up and simulate wallet_connect setting context + await signer.cleanup() + + ;(decryptContent as Mock).mockResolvedValueOnce({ + result: { + value: null, + }, + }) + + await signer.handshake({ method: 'handshake' }) + + // Mock wallet_connect response with context + ;(decryptContent as Mock).mockResolvedValueOnce({ + result: { + value: { + accounts: [ + { + address: globalAccountAddress, + capabilities: {}, + }, + ], + context: { + chain: 'soneium', + user: { username: 'connected-user' }, + startale: { + starPoints: 500, + eoaWallets: ['0xdef'], + }, + }, + }, + }, + }) + + // Simulate wallet_connect + await signer.request({ + method: 'wallet_connect', + params: [], + }) + + // Now test wallet_getContext + const contextRequest = { + method: 'wallet_getContext', + params: [], + } + + const result = await signer.request(contextRequest) + + expect(result).toEqual({ + chain: 'soneium', + user: { username: 'connected-user' }, + startale: { + starPoints: 500, + eoaWallets: ['0xdef'], + }, + }) + + // Restore the mock for other tests + stateSpy = vi.spyOn(store, 'getState').mockImplementation(() => ({ + account: { + accounts: [globalAccountAddress], + }, + chains: [], + keys: {}, + spendPermissions: [], + config: { + metadata: mockMetadata, + preference: { walletUrl: CB_KEYS_URL, options: 'all' }, + version: '1.0.0', + }, + userInfo: {}, + context: mockContext, })) }) }) @@ -2191,6 +2446,7 @@ describe('Signer', () => { version: '1.0.0', }, userInfo: {}, + context: {}, })) ;(fetchRPCRequest as Mock).mockResolvedValue({ diff --git a/packages/app-sdk/src/sign/app-sdk/Signer.ts b/packages/app-sdk/src/sign/app-sdk/Signer.ts index 24f2d17c..00728fe9 100644 --- a/packages/app-sdk/src/sign/app-sdk/Signer.ts +++ b/packages/app-sdk/src/sign/app-sdk/Signer.ts @@ -262,6 +262,8 @@ export class Signer { return this.handleGetCapabilitiesRequest(request) case 'wallet_getUserInfo': return this.handleGetUserInfoRequest(request) + case 'wallet_getContext': + return this.handleGetContextRequest(request) case 'wallet_switchEthereumChain': return this.handleSwitchChainRequest(request) case 'eth_ecRecover': @@ -407,6 +409,9 @@ export class Signer { const userInfo = response.userInfo store.userInfo.set(userInfo) + const context = response.context + store.context.set(context) + const account = response.accounts.at(0) const capabilities = account?.capabilities @@ -554,6 +559,15 @@ export class Signer { return userInfo } + private async handleGetContextRequest(_request: RequestArguments) { + const context = store.getState().context + if (!context) { + throw standardErrors.provider.unauthorized('No context found') + } + + return context + } + private async sendEncryptedRequest( request: RequestArguments, ): Promise { diff --git a/packages/app-sdk/src/sign/app-sdk/utils.test.ts b/packages/app-sdk/src/sign/app-sdk/utils.test.ts index ae28c6f6..f7eccde3 100644 --- a/packages/app-sdk/src/sign/app-sdk/utils.test.ts +++ b/packages/app-sdk/src/sign/app-sdk/utils.test.ts @@ -381,6 +381,7 @@ describe('fillMissingParamsForFetchPermissions', () => { version: '1.0.0', }, userInfo: {}, + context: {}, })) const request = { method: 'coinbase_fetchPermissions', @@ -831,6 +832,81 @@ describe('getCachedWalletConnectResponse', () => { ], }) }) + + it('should include userInfo when stored userInfo has fields', async () => { + vi.spyOn(store.account, 'get').mockReturnValue({ accounts: ['0x123'] }) + const userInfo = { + userId: 'user-1', + email: 'user@example.com', + name: 'User One', + authType: 'oauth', + } + vi.spyOn(store.userInfo, 'get').mockReturnValue(userInfo) + + const result = await getCachedWalletConnectResponse() + expect(result?.userInfo).toEqual(userInfo) + }) + + it('should include context when stored context has fields', async () => { + vi.spyOn(store.account, 'get').mockReturnValue({ accounts: ['0x123'] }) + const context = { + chain: 'soneium', + user: { username: 'tester' }, + startale: { + starPoints: 42, + eoaWallets: ['0xabc'], + }, + } + vi.spyOn(store.context, 'get').mockReturnValue(context) + + const result = await getCachedWalletConnectResponse() + expect(result?.context).toEqual(context) + }) + + it('should omit userInfo and context when stored values are empty objects', async () => { + vi.spyOn(store.account, 'get').mockReturnValue({ accounts: ['0x123'] }) + vi.spyOn(store.userInfo, 'get').mockReturnValue({}) + vi.spyOn(store.context, 'get').mockReturnValue({}) + + const result = await getCachedWalletConnectResponse() + expect(result).not.toHaveProperty('userInfo') + expect(result).not.toHaveProperty('context') + }) + + it('should preserve userInfo and context across the cache round-trip', async () => { + vi.spyOn(store.account, 'get').mockReturnValue({ accounts: ['0x123'] }) + const userInfo = { + userId: 'user-1', + email: 'user@example.com', + name: 'User One', + authType: 'oauth', + } + const context = { + chain: 'soneium', + user: { username: 'tester' }, + startale: { + starPoints: 42, + eoaWallets: ['0xabc'], + }, + } + vi.spyOn(store.userInfo, 'get').mockReturnValue(userInfo) + vi.spyOn(store.context, 'get').mockReturnValue(context) + + const result = await getCachedWalletConnectResponse() + expect(result).toEqual({ + accounts: [ + { + address: '0x123', + capabilities: { + subAccounts: undefined, + spendPermissions: undefined, + }, + }, + ], + userInfo, + context, + }) + }) }) describe('addPaymasterToRequest', () => { diff --git a/packages/app-sdk/src/sign/app-sdk/utils.ts b/packages/app-sdk/src/sign/app-sdk/utils.ts index be49eea5..3b8716f6 100644 --- a/packages/app-sdk/src/sign/app-sdk/utils.ts +++ b/packages/app-sdk/src/sign/app-sdk/utils.ts @@ -616,6 +616,8 @@ export async function getCachedWalletConnectResponse(): Promise 0 + const hasContext = context && Object.keys(context).length > 0 + return { accounts: walletConnectAccounts, + ...(hasUserInfo + ? { userInfo: userInfo as WalletConnectResponse['userInfo'] } + : {}), + ...(hasContext + ? { context: context as WalletConnectResponse['context'] } + : {}), } } diff --git a/packages/app-sdk/src/store/store.ts b/packages/app-sdk/src/store/store.ts index 060ed2f4..d1e5b024 100644 --- a/packages/app-sdk/src/store/store.ts +++ b/packages/app-sdk/src/store/store.ts @@ -60,6 +60,12 @@ type UserInfo = { authType?: string } +type StartaleContext = { + chain?: string + user?: { username?: string } + startale?: { starPoints?: number; eoaWallets?: string[] } +} + const createChainSlice: StateCreator = () => { return { chains: [], @@ -163,6 +169,21 @@ const createUserInfoSlice: StateCreator< } } +type ContextSlice = { + context: StartaleContext +} + +const createContextSlice: StateCreator< + StoreState, + [], + [], + ContextSlice +> = () => { + return { + context: {}, + } +} + type MergeTypes = T extends [infer First, ...infer Rest] ? First & (Rest extends unknown[] ? MergeTypes : Record) @@ -178,6 +199,7 @@ export type StoreState = MergeTypes< SpendPermissionsSlice, ConfigSlice, UserInfoSlice, + ContextSlice, ] > @@ -192,6 +214,7 @@ export const sdkstore = createStore( ...createConfigSlice(...args), ...createSubAccountConfigSlice(...args), ...createUserInfoSlice(...args), + ...createContextSlice(...args), }), { name: 'startale-app-sdk.store', @@ -207,6 +230,7 @@ export const sdkstore = createStore( spendPermissions: state.spendPermissions, config: state.config, userInfo: state.userInfo, + context: state.context, } as StoreState }, }, @@ -316,6 +340,20 @@ export const userInfo = { }, } +export const context = { + get: () => sdkstore.getState().context, + set: (context: Partial | undefined) => { + sdkstore.setState((_state) => ({ + context, + })) + }, + clear: () => { + sdkstore.setState({ + context: {}, + }) + }, +} + const actions = { subAccounts, subAccountsConfig, @@ -325,6 +363,7 @@ const actions = { keys, config, userInfo, + context, } export const store = {