diff --git a/demos/taco-demo/src/App.tsx b/demos/taco-demo/src/App.tsx index 2aa5f07a8..b13b8497e 100644 --- a/demos/taco-demo/src/App.tsx +++ b/demos/taco-demo/src/App.tsx @@ -3,7 +3,6 @@ import { decrypt, domains, encrypt, - getPorterUri, initialize, ThresholdMessageKit, toHexString, @@ -89,7 +88,7 @@ export default function App() { provider, domain, encryptedMessage, - getPorterUri(domain), + undefined, provider.getSigner(), ); diff --git a/demos/taco-nft-demo/src/App.tsx b/demos/taco-nft-demo/src/App.tsx index 48f934dc0..191276709 100644 --- a/demos/taco-nft-demo/src/App.tsx +++ b/demos/taco-nft-demo/src/App.tsx @@ -3,7 +3,6 @@ import { decrypt, domains, encrypt, - getPorterUri, initialize, ThresholdMessageKit, } from '@nucypher/taco'; @@ -78,8 +77,8 @@ export default function App() { provider, domain, encryptedMessage, - getPorterUri(domain), - provider.getSigner(), + undefined, + provider.getSigner() ); setDecryptedMessage(new TextDecoder().decode(decryptedMessage)); diff --git a/examples/taco/nextjs/src/hooks/useTaco.ts b/examples/taco/nextjs/src/hooks/useTaco.ts index 81ff0dc5d..cc2996bf8 100644 --- a/examples/taco/nextjs/src/hooks/useTaco.ts +++ b/examples/taco/nextjs/src/hooks/useTaco.ts @@ -4,7 +4,6 @@ import { Domain, EIP4361AuthProvider, encrypt, - getPorterUri, initialize, ThresholdMessageKit, } from '@nucypher/taco'; @@ -38,7 +37,6 @@ export default function useTaco({ domain, messageKit, authProvider, - getPorterUri(domain), ); }, [isInit, provider, domain], diff --git a/examples/taco/nodejs/src/index.ts b/examples/taco/nodejs/src/index.ts index 212906102..89b8bf25d 100644 --- a/examples/taco/nodejs/src/index.ts +++ b/examples/taco/nodejs/src/index.ts @@ -7,7 +7,6 @@ import { EIP4361AuthProvider, encrypt, fromBytes, - getPorterUri, initialize, isAuthorized, ThresholdMessageKit, @@ -119,7 +118,6 @@ const decryptFromBytes = async (encryptedBytes: Uint8Array) => { domain, messageKit, authProvider, - getPorterUri(domain), ); }; diff --git a/examples/taco/react/src/hooks/useTaco.ts b/examples/taco/react/src/hooks/useTaco.ts index 81ff0dc5d..cc2996bf8 100644 --- a/examples/taco/react/src/hooks/useTaco.ts +++ b/examples/taco/react/src/hooks/useTaco.ts @@ -4,7 +4,6 @@ import { Domain, EIP4361AuthProvider, encrypt, - getPorterUri, initialize, ThresholdMessageKit, } from '@nucypher/taco'; @@ -38,7 +37,6 @@ export default function useTaco({ domain, messageKit, authProvider, - getPorterUri(domain), ); }, [isInit, provider, domain], diff --git a/examples/taco/webpack-5/src/index.ts b/examples/taco/webpack-5/src/index.ts index 740153f5d..e372053ab 100644 --- a/examples/taco/webpack-5/src/index.ts +++ b/examples/taco/webpack-5/src/index.ts @@ -5,7 +5,7 @@ import { EIP4361AuthProvider, encrypt, fromBytes, - getPorterUri, + getPorterUris, initialize, toBytes, } from '@nucypher/taco'; @@ -67,7 +67,6 @@ const runExample = async () => { domain, messageKit, authProvider, - getPorterUri(domain), ); const decryptedMessage = fromBytes(decryptedBytes); console.log('Decrypted message:', decryptedMessage); diff --git a/packages/shared/package.json b/packages/shared/package.json index 58ca8c0e0..f08d2401d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -51,6 +51,7 @@ "zod": "*" }, "devDependencies": { + "@nucypher/test-utils": "workspace:*", "@typechain/ethers-v5": "^11.1.2", "@types/deep-equal": "^1.0.3", "@types/qs": "^6.9.15", diff --git a/packages/shared/src/porter.ts b/packages/shared/src/porter.ts index 7d78b5e65..388b8f5c0 100644 --- a/packages/shared/src/porter.ts +++ b/packages/shared/src/porter.ts @@ -6,20 +6,23 @@ import { RetrievalKit, TreasureMap, } from '@nucypher/nucypher-core'; -import axios, { AxiosResponse } from 'axios'; +import axios, { + AxiosRequestConfig, + AxiosResponse, + HttpStatusCode, +} from 'axios'; import qs from 'qs'; import { Base64EncodedBytes, ChecksumAddress, HexEncodedBytes } from './types'; import { fromBase64, fromHexString, toBase64, toHexString } from './utils'; -const porterUri: Record = { +const defaultPorterUri: Record = { mainnet: 'https://porter.nucypher.community', tapir: 'https://porter-tapir.nucypher.community', - oryx: 'https://porter-oryx.nucypher.community', lynx: 'https://porter-lynx.nucypher.community', }; -export type Domain = keyof typeof porterUri; +export type Domain = keyof typeof defaultPorterUri; export const domains: Record = { DEVNET: 'lynx', @@ -28,11 +31,20 @@ export const domains: Record = { }; export const getPorterUri = (domain: Domain): string => { - const uri = porterUri[domain]; + return getPorterUris(domain)[0]; +}; + +export const getPorterUris = ( + domain: Domain, + porterUris: string[] = [], +): string[] => { + const fullList = [...porterUris]; + const uri = defaultPorterUri[domain]; if (!uri) { throw new Error(`No default Porter URI found for domain: ${domain}`); } - return porterUri[domain]; + fullList.push(uri); + return fullList; }; // /get_ursulas @@ -120,10 +132,39 @@ export type TacoDecryptResult = { }; export class PorterClient { - readonly porterUrl: URL; + readonly porterUrls: URL[]; - constructor(porterUri: string) { - this.porterUrl = new URL(porterUri); + constructor(porterUris: string | string[]) { + if (porterUris instanceof Array) { + this.porterUrls = porterUris.map((uri) => new URL(uri)); + } else { + this.porterUrls = [new URL(porterUris)]; + } + } + + protected async tryAndCall( + config: AxiosRequestConfig, + ): Promise> { + let resp!: AxiosResponse; + let lastError = undefined; + for (const porterUrl of this.porterUrls) { + const localConfig = { ...config, baseURL: porterUrl.toString() }; + try { + resp = await axios.request(localConfig); + } catch (e) { + lastError = e; + continue; + } + if (resp.status === HttpStatusCode.Ok) { + return resp; + } + } + if (lastError !== undefined) { + throw lastError; + } + throw new Error( + 'Porter returns bad response: ${resp.status} - ${resp.data}', + ); } public async getUrsulas( @@ -136,15 +177,14 @@ export class PorterClient { exclude_ursulas: excludeUrsulas, include_ursulas: includeUrsulas, }; - const resp: AxiosResponse = await axios.get( - new URL('/get_ursulas', this.porterUrl).toString(), - { - params, - paramsSerializer: (params) => { - return qs.stringify(params, { arrayFormat: 'comma' }); - }, + const resp: AxiosResponse = await this.tryAndCall({ + url: '/get_ursulas', + method: 'get', + params: params, + paramsSerializer: (params) => { + return qs.stringify(params, { arrayFormat: 'comma' }); }, - ); + }); return resp.data.result.ursulas.map((u: UrsulaResponse) => ({ checksumAddress: u.checksum_address, uri: u.uri, @@ -170,10 +210,12 @@ export class PorterClient { bob_verifying_key: toHexString(bobVerifyingKey.toCompressedBytes()), context: conditionContextJSON, }; - const resp: AxiosResponse = await axios.post( - new URL('/retrieve_cfrags', this.porterUrl).toString(), - data, - ); + const resp: AxiosResponse = + await this.tryAndCall({ + url: '/retrieve_cfrags', + method: 'post', + data: data, + }); return resp.data.result.retrieval_results.map(({ cfrags, errors }) => { const parsed = Object.keys(cfrags).map((address) => [ @@ -198,10 +240,11 @@ export class PorterClient { ), threshold, }; - const resp: AxiosResponse = await axios.post( - new URL('/decrypt', this.porterUrl).toString(), - data, - ); + const resp: AxiosResponse = await this.tryAndCall({ + url: '/decrypt', + method: 'post', + data: data, + }); const { encrypted_decryption_responses, errors } = resp.data.result.decryption_results; diff --git a/packages/shared/test/porter.test.ts b/packages/shared/test/porter.test.ts new file mode 100644 index 000000000..83d65b252 --- /dev/null +++ b/packages/shared/test/porter.test.ts @@ -0,0 +1,97 @@ +import { fakeUrsulas } from '@nucypher/test-utils'; +import axios, { HttpStatusCode } from 'axios'; +import { SpyInstance, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + GetUrsulasResult, + PorterClient, + Ursula, + initialize, + toHexString, +} from '../src'; + +const fakePorterUris = [ + 'https://_this_should_crash.com/', + 'https://2_this_should_crash.com/', + 'https://_this_should_work.com/', +]; + +const mockGetUrsulas = (ursulas: Ursula[] = fakeUrsulas()): SpyInstance => { + const fakePorterUrsulas = ( + mockUrsulas: readonly Ursula[], + ): GetUrsulasResult => { + return { + result: { + ursulas: mockUrsulas.map(({ encryptingKey, uri, checksumAddress }) => ({ + encrypting_key: toHexString(encryptingKey.toCompressedBytes()), + uri: uri, + checksum_address: checksumAddress, + })), + }, + version: '5.2.0', + }; + }; + + return vi.spyOn(axios, 'request').mockImplementation(async (config) => { + switch (config.baseURL) { + case fakePorterUris[2]: + return Promise.resolve({ + status: HttpStatusCode.Ok, + data: fakePorterUrsulas(ursulas), + }); + case fakePorterUris[0]: + throw new Error(); + default: + throw Promise.resolve({ status: HttpStatusCode.BadRequest }); + } + }); +}; + +describe('PorterClient', () => { + beforeAll(async () => { + await initialize(); + }); + + it('should work when at least one ursula URI is valid', async () => { + const ursulas = fakeUrsulas(); + const getUrsulasSpy = mockGetUrsulas(ursulas); + const porterClient = new PorterClient(fakePorterUris); + const result = await porterClient.getUrsulas(ursulas.length); + + expect( + result.every((u: Ursula, index: number) => { + const expectedUrsula = ursulas[index]; + return ( + u.checksumAddress === expectedUrsula.checksumAddress && + u.uri === expectedUrsula.uri && + u.encryptingKey.equals(expectedUrsula.encryptingKey) + ); + }), + ).toBeTruthy(); + const params = { + method: 'get', + url: '/get_ursulas', + params: { + exclude_ursulas: [], + include_ursulas: [], + quantity: ursulas.length, + }, + }; + + expect(getUrsulasSpy).toBeCalledTimes(fakePorterUris.length); + fakePorterUris.forEach((value, index) => { + expect(getUrsulasSpy).toHaveBeenNthCalledWith( + index + 1, + expect.objectContaining({ ...params, baseURL: value }), + ); + }); + }); + + it('returns error in case all porters fail', async () => { + const ursulas = fakeUrsulas(); + mockGetUrsulas(ursulas); + let porterClient = new PorterClient([fakePorterUris[0], fakePorterUris[1]]); + expect(porterClient.getUrsulas(ursulas.length)).rejects.toThrowError(); + porterClient = new PorterClient([fakePorterUris[1], fakePorterUris[0]]); + expect(porterClient.getUrsulas(ursulas.length)).rejects.toThrowError(); + }); +}); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 0c3f3ec10..e17152bcf 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -6,4 +6,9 @@ "skipLibCheck": true, "resolveJsonModule": true, }, + "references": [ + { + "path": "../test-utils/tsconfig.es.json", + }, + ], } diff --git a/packages/taco/examples/encrypt-decrypt.ts b/packages/taco/examples/encrypt-decrypt.ts index 8d6b7f0f6..a039efe7b 100644 --- a/packages/taco/examples/encrypt-decrypt.ts +++ b/packages/taco/examples/encrypt-decrypt.ts @@ -7,7 +7,6 @@ import { domains, EIP4361AuthProvider, encrypt, - getPorterUri, initialize, ThresholdMessageKit, toBytes, @@ -55,7 +54,6 @@ const run = async () => { domains.TESTNET, messageKit, authProvider, - getPorterUri(domains.TESTNET), ); return decryptedMessage; }; diff --git a/packages/taco/src/index.ts b/packages/taco/src/index.ts index 8baccdad7..315229937 100644 --- a/packages/taco/src/index.ts +++ b/packages/taco/src/index.ts @@ -3,7 +3,7 @@ export { Domain, domains, fromBytes, - getPorterUri, + getPorterUris, initialize, toBytes, toHexString, diff --git a/packages/taco/src/taco.ts b/packages/taco/src/taco.ts index 28f629567..ce6572bf1 100644 --- a/packages/taco/src/taco.ts +++ b/packages/taco/src/taco.ts @@ -9,7 +9,7 @@ import { DkgCoordinatorAgent, Domain, fromHexString, - getPorterUri, + getPorterUris, GlobalAllowListAgent, toBytes, } from '@nucypher/shared'; @@ -130,7 +130,7 @@ export const encryptWithPublicKey = async ( * Must match the `ritualId`. * @param {ThresholdMessageKit} messageKit - The kit containing the message to be decrypted * @param authProvider - The authentication provider that will be used to provide the authorization - * @param {string} [porterUri] - The URI for the Porter service. If not provided, a value will be obtained + * @param {string} [porterUri] - The URI(s) for the Porter service. If not provided, a value will be obtained * from the Domain * @param {Record} [customParameters] - Optional custom parameters that may be required * depending on the condition used @@ -145,12 +145,10 @@ export const decrypt = async ( domain: Domain, messageKit: ThresholdMessageKit, authProvider?: EIP4361AuthProvider, - porterUri?: string, + porterUris: string[] = [], customParameters?: Record, ): Promise => { - if (!porterUri) { - porterUri = getPorterUri(domain); - } + const porterUrisFull: string[] = getPorterUris(domain, porterUris); const ritualId = await DkgCoordinatorAgent.getRitualIdFromPublicKey( provider, @@ -166,7 +164,7 @@ export const decrypt = async ( return retrieveAndDecrypt( provider, domain, - porterUri, + porterUrisFull, messageKit, ritualId, ritual.sharesNum, diff --git a/packages/taco/src/tdec.ts b/packages/taco/src/tdec.ts index cfe516519..8c7008b71 100644 --- a/packages/taco/src/tdec.ts +++ b/packages/taco/src/tdec.ts @@ -61,7 +61,7 @@ export const encryptMessage = async ( export const retrieveAndDecrypt = async ( provider: ethers.providers.Provider, domain: Domain, - porterUri: string, + porterUris: string[], thresholdMessageKit: ThresholdMessageKit, ritualId: number, sharesNum: number, @@ -72,7 +72,7 @@ export const retrieveAndDecrypt = async ( const decryptionShares = await retrieve( provider, domain, - porterUri, + porterUris, thresholdMessageKit, ritualId, sharesNum, @@ -88,7 +88,7 @@ export const retrieveAndDecrypt = async ( const retrieve = async ( provider: ethers.providers.Provider, domain: Domain, - porterUri: string, + porterUris: string[], thresholdMessageKit: ThresholdMessageKit, ritualId: number, sharesNum: number, @@ -114,7 +114,7 @@ const retrieve = async ( thresholdMessageKit, ); - const porter = new PorterClient(porterUri); + const porter = new PorterClient(porterUris); const { encryptedResponses, errors } = await porter.tacoDecrypt( encryptedRequests, threshold, diff --git a/packages/taco/test/taco.test.ts b/packages/taco/test/taco.test.ts index f965fa80c..8fd2545a0 100644 --- a/packages/taco/test/taco.test.ts +++ b/packages/taco/test/taco.test.ts @@ -93,7 +93,7 @@ describe('taco', () => { domains.DEVNET, messageKit, authProvider, - fakePorterUri, + [fakePorterUri], ); expect(decryptedMessage).toEqual(toBytes(message)); expect(getParticipantsSpy).toHaveBeenCalled(); diff --git a/packages/test-utils/src/utils.ts b/packages/test-utils/src/utils.ts index 9a52848eb..07ed9dc1c 100644 --- a/packages/test-utils/src/utils.ts +++ b/packages/test-utils/src/utils.ts @@ -31,16 +31,13 @@ import { import { ChecksumAddress, DkgCoordinatorAgent, - GetUrsulasResult, PorterClient, RetrieveCFragsResult, TacoDecryptResult, - toHexString, Ursula, zip, } from '@nucypher/shared'; import { EIP4361_AUTH_METHOD, EIP4361AuthProvider } from '@nucypher/taco-auth'; -import axios from 'axios'; import { ethers, providers, Wallet } from 'ethers'; import { expect, SpyInstance, vi } from 'vitest'; @@ -133,23 +130,8 @@ export const fakeUrsulas = (n = 4): Ursula[] => export const mockGetUrsulas = ( ursulas: Ursula[] = fakeUrsulas(), ): SpyInstance => { - const fakePorterUrsulas = ( - mockUrsulas: readonly Ursula[], - ): GetUrsulasResult => { - return { - result: { - ursulas: mockUrsulas.map(({ encryptingKey, uri, checksumAddress }) => ({ - encrypting_key: toHexString(encryptingKey.toCompressedBytes()), - uri: uri, - checksum_address: checksumAddress, - })), - }, - version: '5.2.0', - }; - }; - - return vi.spyOn(axios, 'get').mockImplementation(async () => { - return Promise.resolve({ data: fakePorterUrsulas(ursulas) }); + return vi.spyOn(PorterClient.prototype, 'getUrsulas').mockImplementation(async () => { + return Promise.resolve(ursulas); }); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a714b2441..3ba5006ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -516,6 +516,9 @@ importers: specifier: '*' version: 3.23.8 devDependencies: + '@nucypher/test-utils': + specifier: workspace:* + version: link:../test-utils '@typechain/ethers-v5': specifier: ^11.1.2 version: 11.1.2(@ethersproject/abi@5.7.0)(@ethersproject/providers@5.7.2(bufferutil@4.0.8)(utf-8-validate@5.0.10))(ethers@5.7.2(bufferutil@4.0.8)(utf-8-validate@5.0.10))(typechain@8.3.2(typescript@5.4.5))(typescript@5.4.5)