-
Notifications
You must be signed in to change notification settings - Fork 26
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
feat(frontend): sol-address-services (#3998)
# Motivation Implement sol-address-service (inspired from btc-address.services.ts). # Changes - implement sol-address-service - add typings - add @solana/addresses npm dependency # Tests Unit tests added. --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent
4453a6f
commit 405d36c
Showing
7 changed files
with
490 additions
and
14 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { NetworkSchema } from '$lib/schema/network.schema'; | ||
import { UrlSchema } from '$lib/validation/url.validation'; | ||
import { z } from 'zod'; | ||
|
||
const SolRpcSchema = z.object({ | ||
httpUrl: UrlSchema, | ||
wssUrl: UrlSchema | ||
}); | ||
|
||
export const SolNetworkSchema = z | ||
.object({ | ||
rpc: SolRpcSchema | ||
}) | ||
.merge(NetworkSchema); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
import { | ||
SOLANA_DEVNET_NETWORK_ID, | ||
SOLANA_KEY_ID, | ||
SOLANA_LOCAL_NETWORK_ID, | ||
SOLANA_MAINNET_NETWORK_ID | ||
} from '$env/networks/networks.sol.env'; | ||
import { SOLANA_TOKEN_ID } from '$env/tokens/tokens.sol.env'; | ||
import { | ||
getIdbSolAddressMainnet, | ||
setIdbSolAddressDevnet, | ||
setIdbSolAddressLocal, | ||
setIdbSolAddressMainnet, | ||
setIdbSolAddressTestnet, | ||
updateIdbSolAddressMainnetLastUsage | ||
} from '$lib/api/idb.api'; | ||
import { getSchnorrPublicKey } from '$lib/api/signer.api'; | ||
import { | ||
certifyAddress, | ||
loadIdbTokenAddress, | ||
loadTokenAddress, | ||
validateAddress, | ||
type LoadTokenAddressParams | ||
} from '$lib/services/address.services'; | ||
import { | ||
solAddressDevnetStore, | ||
solAddressLocalnetStore, | ||
solAddressMainnetStore, | ||
solAddressTestnetStore, | ||
type StorageAddressData | ||
} from '$lib/stores/address.store'; | ||
import type { SolAddress } from '$lib/types/address'; | ||
import type { CanisterApiFunctionParams } from '$lib/types/canister'; | ||
import { LoadIdbAddressError } from '$lib/types/errors'; | ||
import type { OptionIdentity } from '$lib/types/identity'; | ||
import type { TokenId } from '$lib/types/token'; | ||
import type { ResultSuccess } from '$lib/types/utils'; | ||
import { SOLANA_DERIVATION_PATH_PREFIX } from '$sol/constants/sol.constants'; | ||
import { SolanaNetworks } from '$sol/types/network'; | ||
import { getAddressDecoder } from '@solana/addresses'; | ||
|
||
const getSolanaPublicKey = async ( | ||
params: CanisterApiFunctionParams<{ derivationPath: string[] }> | ||
): Promise<Uint8Array | number[]> => | ||
await getSchnorrPublicKey({ | ||
...params, | ||
keyId: SOLANA_KEY_ID, | ||
derivationPath: [SOLANA_DERIVATION_PATH_PREFIX, ...params.derivationPath] | ||
}); | ||
|
||
const getSolAddress = async ({ | ||
identity, | ||
derivationPath | ||
}: { | ||
identity: OptionIdentity; | ||
derivationPath: string[]; | ||
}): Promise<SolAddress> => { | ||
const publicKey = await getSolanaPublicKey({ identity, derivationPath }); | ||
const decoder = getAddressDecoder(); | ||
return decoder.decode(Uint8Array.from(publicKey)); | ||
}; | ||
|
||
export const getSolAddressMainnet = async (identity: OptionIdentity): Promise<SolAddress> => | ||
await getSolAddress({ identity, derivationPath: [SolanaNetworks.MAINNET] }); | ||
|
||
export const getSolAddressTestnet = async (identity: OptionIdentity): Promise<SolAddress> => | ||
await getSolAddress({ identity, derivationPath: [SolanaNetworks.TESTNET] }); | ||
|
||
export const getSolAddressDevnet = async (identity: OptionIdentity): Promise<SolAddress> => | ||
await getSolAddress({ identity, derivationPath: [SolanaNetworks.DEVNET] }); | ||
|
||
export const getSolAddressLocal = async (identity: OptionIdentity): Promise<SolAddress> => | ||
await getSolAddress({ identity, derivationPath: [SolanaNetworks.LOCAL] }); | ||
|
||
const solanaMapper: Record< | ||
SolanaNetworks, | ||
Pick<LoadTokenAddressParams<SolAddress>, 'addressStore' | 'setIdbAddress' | 'getAddress'> | ||
> = { | ||
mainnet: { | ||
addressStore: solAddressMainnetStore, | ||
getAddress: getSolAddressMainnet, | ||
setIdbAddress: setIdbSolAddressMainnet | ||
}, | ||
testnet: { | ||
addressStore: solAddressTestnetStore, | ||
getAddress: getSolAddressTestnet, | ||
setIdbAddress: setIdbSolAddressTestnet | ||
}, | ||
devnet: { | ||
addressStore: solAddressDevnetStore, | ||
getAddress: getSolAddressDevnet, | ||
setIdbAddress: setIdbSolAddressDevnet | ||
}, | ||
local: { | ||
addressStore: solAddressLocalnetStore, | ||
getAddress: getSolAddressLocal, | ||
setIdbAddress: setIdbSolAddressLocal | ||
} | ||
}; | ||
|
||
const loadSolAddress = ({ | ||
tokenId, | ||
network | ||
}: { | ||
tokenId: TokenId; | ||
network: SolanaNetworks; | ||
}): Promise<ResultSuccess> => | ||
loadTokenAddress<SolAddress>({ | ||
tokenId, | ||
...solanaMapper[network] | ||
}); | ||
|
||
export const loadSolAddressMainnet = (): Promise<ResultSuccess> => | ||
loadSolAddress({ | ||
tokenId: SOLANA_MAINNET_NETWORK_ID as unknown as TokenId, | ||
network: SolanaNetworks.MAINNET | ||
}); | ||
|
||
export const loadSolAddressTestnet = (): Promise<ResultSuccess> => | ||
loadSolAddress({ | ||
tokenId: SOLANA_MAINNET_NETWORK_ID as unknown as TokenId, | ||
network: SolanaNetworks.TESTNET | ||
}); | ||
|
||
export const loadSolAddressDevnet = (): Promise<ResultSuccess> => | ||
loadSolAddress({ | ||
tokenId: SOLANA_DEVNET_NETWORK_ID as unknown as TokenId, | ||
network: SolanaNetworks.DEVNET | ||
}); | ||
|
||
export const loadSolAddressLocal = (): Promise<ResultSuccess> => | ||
loadSolAddress({ | ||
tokenId: SOLANA_LOCAL_NETWORK_ID as unknown as TokenId, | ||
network: SolanaNetworks.LOCAL | ||
}); | ||
|
||
export const loadIdbSolAddressMainnet = (): Promise<ResultSuccess<LoadIdbAddressError>> => | ||
loadIdbTokenAddress<SolAddress>({ | ||
tokenId: SOLANA_TOKEN_ID, | ||
getIdbAddress: getIdbSolAddressMainnet, | ||
updateIdbAddressLastUsage: updateIdbSolAddressMainnetLastUsage, | ||
addressStore: solAddressMainnetStore | ||
}); | ||
|
||
const certifySolAddressMainnet = (address: SolAddress): Promise<ResultSuccess<string>> => | ||
certifyAddress<SolAddress>({ | ||
tokenId: SOLANA_TOKEN_ID, | ||
address, | ||
getAddress: (identity: OptionIdentity) => getSolAddressMainnet(identity), | ||
updateIdbAddressLastUsage: updateIdbSolAddressMainnetLastUsage, | ||
addressStore: solAddressMainnetStore | ||
}); | ||
|
||
export const validateSolAddressMainnet = async ($addressStore: StorageAddressData<SolAddress>) => | ||
await validateAddress<SolAddress>({ | ||
$addressStore, | ||
certifyAddress: certifySolAddressMainnet | ||
}); |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import type { SolNetworkSchema } from '$sol/schema/network.schema'; | ||
import { z } from 'zod'; | ||
|
||
export type SolNetwork = z.infer<typeof SolNetworkSchema>; | ||
|
||
export enum SolanaNetworks { | ||
MAINNET = 'mainnet', | ||
TESTNET = 'testnet', | ||
DEVNET = 'devnet', | ||
LOCAL = 'local' | ||
} |
177 changes: 177 additions & 0 deletions
177
src/frontend/src/tests/sol/services/sol-address.services.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
import { SOLANA_KEY_ID } from '$env/networks/networks.sol.env'; | ||
import { SOLANA_TOKEN_ID } from '$env/tokens/tokens.sol.env'; | ||
import * as idbApi from '$lib/api/idb.api'; | ||
import * as signerApi from '$lib/api/signer.api'; | ||
import { | ||
solAddressDevnetStore, | ||
solAddressLocalnetStore, | ||
solAddressMainnetStore, | ||
solAddressTestnetStore | ||
} from '$lib/stores/address.store'; | ||
import { authStore } from '$lib/stores/auth.store'; | ||
import * as toastsStore from '$lib/stores/toasts.store'; | ||
import { LoadIdbAddressError } from '$lib/types/errors'; | ||
import { replacePlaceholders } from '$lib/utils/i18n.utils'; | ||
import { SOLANA_DERIVATION_PATH_PREFIX } from '$sol/constants/sol.constants'; | ||
import { | ||
getSolAddressDevnet, | ||
getSolAddressLocal, | ||
getSolAddressMainnet, | ||
getSolAddressTestnet, | ||
loadIdbSolAddressMainnet, | ||
loadSolAddressDevnet, | ||
loadSolAddressLocal, | ||
loadSolAddressMainnet, | ||
loadSolAddressTestnet, | ||
validateSolAddressMainnet | ||
} from '$sol/services/sol-address.services'; | ||
import { SolanaNetworks } from '$sol/types/network'; | ||
import en from '$tests/mocks/i18n.mock'; | ||
import { mockIdentity } from '$tests/mocks/identity.mock'; | ||
import { getAddressDecoder } from '@solana/addresses'; | ||
import { get } from 'svelte/store'; | ||
import type { MockInstance } from 'vitest'; | ||
|
||
vi.mock('@solana/addresses', () => ({ | ||
getAddressDecoder: vi.fn() | ||
})); | ||
|
||
describe('sol-address.services', () => { | ||
const mockSolAddress = 'solana123'; | ||
const mockPublicKey = new Uint8Array([1, 2, 3]); | ||
|
||
let spyGetSchnorrPublicKey: MockInstance; | ||
let spyGetIdbAddress: MockInstance; | ||
let spyUpdateIdbAddressLastUsage: MockInstance; | ||
let spyToastsError: MockInstance; | ||
let mockDecoder: { decode: MockInstance; read: MockInstance }; | ||
|
||
beforeEach(() => { | ||
vi.clearAllMocks(); | ||
|
||
authStore.setForTesting(mockIdentity); | ||
|
||
mockDecoder = { | ||
decode: vi.fn().mockReturnValue(mockSolAddress), | ||
read: vi.fn().mockReturnValue(mockSolAddress) | ||
}; | ||
vi.mocked(getAddressDecoder).mockReturnValue(mockDecoder as never); | ||
|
||
spyGetSchnorrPublicKey = vi.spyOn(signerApi, 'getSchnorrPublicKey'); | ||
spyGetIdbAddress = vi.spyOn(idbApi, 'getIdbSolAddressMainnet'); | ||
spyUpdateIdbAddressLastUsage = vi.spyOn(idbApi, 'updateIdbSolAddressMainnetLastUsage'); | ||
spyToastsError = vi.spyOn(toastsStore, 'toastsError'); | ||
}); | ||
|
||
describe('Generate Solana Addresses for Different Networks', () => { | ||
beforeEach(() => { | ||
spyGetSchnorrPublicKey.mockResolvedValue(mockPublicKey); | ||
}); | ||
|
||
const networkCases = [ | ||
['mainnet', getSolAddressMainnet, SolanaNetworks.MAINNET], | ||
['testnet', getSolAddressTestnet, SolanaNetworks.TESTNET], | ||
['devnet', getSolAddressDevnet, SolanaNetworks.DEVNET], | ||
['local', getSolAddressLocal, SolanaNetworks.LOCAL] | ||
] as const; | ||
|
||
it.each(networkCases)( | ||
'should generate valid %s address', | ||
// eslint-disable-next-line local-rules/prefer-object-params | ||
async (_, getAddress, networkType) => { | ||
const result = await getAddress(mockIdentity); | ||
expect(result).toBe(mockSolAddress); | ||
expect(spyGetSchnorrPublicKey).toHaveBeenCalledWith({ | ||
identity: mockIdentity, | ||
keyId: SOLANA_KEY_ID, | ||
derivationPath: [SOLANA_DERIVATION_PATH_PREFIX, networkType] | ||
}); | ||
} | ||
); | ||
}); | ||
|
||
describe('Load Solana Addresses into State', () => { | ||
beforeEach(() => { | ||
spyGetSchnorrPublicKey.mockResolvedValue(mockPublicKey); | ||
}); | ||
|
||
const loadCases = [ | ||
['mainnet', loadSolAddressMainnet, solAddressMainnetStore], | ||
['testnet', loadSolAddressTestnet, solAddressTestnetStore], | ||
['devnet', loadSolAddressDevnet, solAddressDevnetStore], | ||
['local', loadSolAddressLocal, solAddressLocalnetStore] | ||
] as const; | ||
|
||
// eslint-disable-next-line local-rules/prefer-object-params | ||
it.each(loadCases)('should load %s address into store', async (_, loadAddress, store) => { | ||
const result = await loadAddress(); | ||
expect(result).toEqual({ success: true }); | ||
expect(get(store)).toEqual({ | ||
data: mockSolAddress, | ||
certified: true | ||
}); | ||
}); | ||
|
||
it('should handle errors during address loading', async () => { | ||
const error = new Error('Failed to load address'); | ||
spyGetSchnorrPublicKey.mockRejectedValue(error); | ||
|
||
const result = await loadSolAddressMainnet(); | ||
|
||
expect(result).toEqual({ success: false }); | ||
expect(spyToastsError).toHaveBeenCalledWith({ | ||
msg: { | ||
text: replacePlaceholders(en.init.error.loading_address, { | ||
$symbol: SOLANA_TOKEN_ID.description ?? '' | ||
}) | ||
}, | ||
err: error | ||
}); | ||
}); | ||
}); | ||
|
||
describe('Load Address from IndexedDB', () => { | ||
it('should successfully load address from IDB', async () => { | ||
spyGetIdbAddress.mockResolvedValue({ address: mockSolAddress }); | ||
|
||
const result = await loadIdbSolAddressMainnet(); | ||
|
||
expect(result).toEqual({ success: true }); | ||
expect(get(solAddressMainnetStore)).toEqual({ | ||
data: mockSolAddress, | ||
certified: false | ||
}); | ||
expect(spyUpdateIdbAddressLastUsage).toHaveBeenCalledWith(mockIdentity.getPrincipal()); | ||
}); | ||
|
||
it('should handle missing IDB address', async () => { | ||
spyGetIdbAddress.mockResolvedValue(undefined); | ||
|
||
const result = await loadIdbSolAddressMainnet(); | ||
|
||
expect(result).toEqual({ | ||
success: false, | ||
err: new LoadIdbAddressError(SOLANA_TOKEN_ID) | ||
}); | ||
expect(spyUpdateIdbAddressLastUsage).not.toHaveBeenCalled(); | ||
}); | ||
}); | ||
|
||
describe('Validate and Certify Mainnet Address', () => { | ||
it('should validate and certify a matching address', async () => { | ||
spyGetSchnorrPublicKey.mockResolvedValue(mockPublicKey); | ||
|
||
const addressStore = { | ||
data: mockSolAddress, | ||
certified: false | ||
}; | ||
|
||
await validateSolAddressMainnet(addressStore); | ||
|
||
expect(get(solAddressMainnetStore)).toEqual({ | ||
data: mockSolAddress, | ||
certified: true | ||
}); | ||
}); | ||
}); | ||
}); |