From 557b213a79f457df6d185bc58275d3a4a9b3cbdb Mon Sep 17 00:00:00 2001 From: Keyan Zhang Date: Tue, 11 Apr 2023 11:13:26 -0700 Subject: [PATCH 1/8] Initial polling implementation + tests --- packages/ethers/src/index.ts | 8 +- packages/http/src/__tests__/async-test.ts | 171 ++++++++++++++++++++++ packages/http/src/async.ts | 87 +++++++++++ packages/http/src/index.ts | 2 + 4 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 packages/http/src/__tests__/async-test.ts create mode 100644 packages/http/src/async.ts diff --git a/packages/ethers/src/index.ts b/packages/ethers/src/index.ts index ab45123ef..ffd0754aa 100644 --- a/packages/ethers/src/index.ts +++ b/packages/ethers/src/index.ts @@ -226,16 +226,10 @@ export class TurnkeySigner extends ethers.Signer { } } -export function assertNonNull(input: T | null | undefined): T { +function assertNonNull(input: T | null | undefined): T { if (input == null) { throw new Error(`Got unexpected ${JSON.stringify(input)}`); } return input; } - -export function assertNever(input: never, message?: string): never { - throw new Error( - message != null ? message : `Unexpected case: ${JSON.stringify(input)}` - ); -} diff --git a/packages/http/src/__tests__/async-test.ts b/packages/http/src/__tests__/async-test.ts new file mode 100644 index 000000000..5382fae38 --- /dev/null +++ b/packages/http/src/__tests__/async-test.ts @@ -0,0 +1,171 @@ +import fetch, { Response } from "node-fetch"; +import { test, expect, jest, beforeEach } from "@jest/globals"; +import { PublicApiService, init, withAsyncPolling } from "../index"; +import { readFixture } from "../__fixtures__/shared"; +import type { definitions } from "../__generated__/services/coordinator/public/v1/public_api.types"; + +type TActivity = definitions["v1Activity"]; + +jest.mock("node-fetch"); + +beforeEach(async () => { + jest.resetAllMocks(); + const { privateKey, publicKey } = await readFixture(); + + init({ + apiPublicKey: publicKey, + apiPrivateKey: privateKey, + baseUrl: "https://mocked.turnkey.io", + }); +}); + +test("`withAsyncPolling` should poll until reaching a terminal state", async () => { + const mutation = withAsyncPolling({ + request: PublicApiService.postCreatePrivateKeys, + }); + + const mockedFetch = fetch as jest.MockedFunction; + + const { expectedCallCount } = chainMockResponseSequence(mockedFetch, [ + { + activity: { + status: "ACTIVITY_STATUS_CREATED", + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + }, + }, + { + activity: { + status: "ACTIVITY_STATUS_CREATED", + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + }, + }, + { + activity: { + status: "ACTIVITY_STATUS_COMPLETED", + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + }, + }, + ]); + + const result = await mutation({ + body: { + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + parameters: { + privateKeys: [ + { + privateKeyName: "hello", + curve: "CURVE_SECP256K1", + addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], + privateKeyTags: [], + }, + ], + }, + organizationId: "89881fc7-6ff3-4b43-b962-916698f8ff58", + timestampMs: String(Date.now()), + }, + }); + + expect(fetch).toHaveBeenCalledTimes(expectedCallCount); + expect(result).toMatchInlineSnapshot(` + { + "status": "ACTIVITY_STATUS_COMPLETED", + "type": "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + } + `); +}); + +test("`withAsyncPolling` should also work with synchronous activity endpoints", async () => { + const mutation = withAsyncPolling({ + request: PublicApiService.postSignTransaction, + }); + + const mockedFetch = fetch as jest.MockedFunction; + + const { expectedCallCount } = chainMockResponseSequence(mockedFetch, [ + { + activity: { + status: "ACTIVITY_STATUS_COMPLETED", + type: "ACTIVITY_TYPE_SIGN_TRANSACTION", + }, + }, + ]); + + const result = await mutation({ + body: { + type: "ACTIVITY_TYPE_SIGN_TRANSACTION", + parameters: { + privateKeyId: "9725c4f7-8387-4990-9128-1d2218bef256", + type: "TRANSACTION_TYPE_ETHEREUM", + unsignedTransaction: + "02e801808459682f008509d4ae542e8252089440f008f4c17075efca092ae650655f6693aeced00180c0", + }, + organizationId: "89881fc7-6ff3-4b43-b962-916698f8ff58", + timestampMs: String(Date.now()), + }, + }); + + expect(fetch).toHaveBeenCalledTimes(expectedCallCount); + expect(result).toMatchInlineSnapshot(` + { + "status": "ACTIVITY_STATUS_COMPLETED", + "type": "ACTIVITY_TYPE_SIGN_TRANSACTION", + } + `); +}); + +test("typecheck only: `withAsyncPolling` only works with mutations that return an activity", () => { + // Legit ones + withAsyncPolling({ request: PublicApiService.postCreateApiKeys }); + withAsyncPolling({ request: PublicApiService.postCreateInvitations }); + withAsyncPolling({ request: PublicApiService.postCreatePolicy }); + withAsyncPolling({ request: PublicApiService.postCreatePrivateKeys }); + withAsyncPolling({ request: PublicApiService.postDeleteApiKeys }); + withAsyncPolling({ request: PublicApiService.postDeleteInvitation }); + withAsyncPolling({ request: PublicApiService.postDeletePolicy }); + withAsyncPolling({ request: PublicApiService.postSignRawPayload }); + withAsyncPolling({ request: PublicApiService.postSignTransaction }); + withAsyncPolling({ request: PublicApiService.postGetActivity }); + + // Invalid ones + // @ts-expect-error + withAsyncPolling({ request: PublicApiService.postGetOrganization }); + // @ts-expect-error + withAsyncPolling({ request: PublicApiService.postGetPolicy }); + // @ts-expect-error + withAsyncPolling({ request: PublicApiService.postGetUser }); + // @ts-expect-error + withAsyncPolling({ request: PublicApiService.postGetActivities }); + // @ts-expect-error + withAsyncPolling({ request: PublicApiService.postGetPolicies }); + // @ts-expect-error + withAsyncPolling({ request: PublicApiService.postGetPrivateKeys }); + // @ts-expect-error + withAsyncPolling({ request: PublicApiService.postGetUsers }); + // @ts-expect-error + withAsyncPolling({ request: PublicApiService.postGetWhoami }); + // @ts-expect-error + withAsyncPolling({ request: PublicApiService.postGetPrivateKey }); +}); + +function createMockResponse(result: { activity: Partial }) { + const response = new Response(); + response.status = 200; + response.ok = true; + response.json = async () => result; + return Promise.resolve(response); +} + +function chainMockResponseSequence( + mockedFetch: jest.MockedFunction, + responseList: Array<{ activity: Partial }> +): { expectedCallCount: number } { + let cursor = mockedFetch; + + for (const item of responseList) { + cursor = cursor.mockReturnValueOnce(createMockResponse(item)); + } + + return { + expectedCallCount: responseList.length, + }; +} diff --git a/packages/http/src/async.ts b/packages/http/src/async.ts new file mode 100644 index 000000000..c568992f9 --- /dev/null +++ b/packages/http/src/async.ts @@ -0,0 +1,87 @@ +import { PublicApiService } from "./__generated__/barrel"; +import type { definitions } from "./__generated__/services/coordinator/public/v1/public_api.types"; + +const DEFAULT_REFRESH_INTERVAL_MS = 500; + +type TActivity = definitions["v1Activity"]; +type TActivityResponse = definitions["v1ActivityResponse"]; +// type TActivityType = definitions["v1ActivityType"]; +// type TActivityId = TActivity["id"]; + +/** + * TODO: document this helper + */ +export function withAsyncPolling< + O extends TActivityResponse, + I extends { body: unknown } +>(params: { + request: (input: I) => Promise; + refreshIntervalMs?: number; +}): (input: I) => Promise { + const { request, refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS } = params; + + return async (input: I) => { + const initialResponse: TActivityResponse = await request(input); + let activity: TActivity = initialResponse.activity; + + while (true) { + switch (activity.status) { + case "ACTIVITY_STATUS_COMPLETED": { + // TODO refine the output here + return activity; + } + case "ACTIVITY_STATUS_CREATED": { + // Async pending state -- keep polling + break; + } + case "ACTIVITY_STATUS_PENDING": { + // Async pending state -- keep polling + break; + } + case "ACTIVITY_STATUS_CONSENSUS_NEEDED": { + // If the activity requires consensus, we shouldn't be polling forever. + // You can catch the error and use the error's TODO, + throw new Error("TODO"); + } + case "ACTIVITY_STATUS_FAILED": { + // Activity failed + throw new Error("TODO"); + } + case "ACTIVITY_STATUS_REJECTED": { + // Activity was rejected + throw new Error("TODO"); + } + default: { + // Make sure the switch block is exhaustive + assertNever(activity.status); + } + } + + await sleep(refreshIntervalMs); + + const pollingResponse: TActivityResponse = + await PublicApiService.postGetActivity({ + body: { + activityId: activity.id, + organizationId: activity.organizationId, + }, + }); + + activity = pollingResponse.activity; + } + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} + +function assertNever(input: never, message?: string): never { + throw new Error( + message != null ? message : `Unexpected case: ${JSON.stringify(input)}` + ); +} diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index d77647ae6..cf78b8569 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,3 +1,5 @@ export * from "./__generated__/barrel"; export { init } from "./config"; + +export { withAsyncPolling } from "./async"; From 9ed631b8a977a73e3e0e725b52764fa510426400 Mon Sep 17 00:00:00 2001 From: Keyan Zhang Date: Tue, 11 Apr 2023 11:43:41 -0700 Subject: [PATCH 2/8] Make sure typecheck covers tests --- packages/ethers/package.json | 2 +- packages/ethers/tsconfig.typecheck.json | 7 +++++++ packages/http/package.json | 2 +- packages/http/src/__tests__/async-test.ts | 6 +++--- packages/http/src/__tests__/request-test.ts | 2 +- packages/http/tsconfig.typecheck.json | 7 +++++++ 6 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 packages/ethers/tsconfig.typecheck.json create mode 100644 packages/http/tsconfig.typecheck.json diff --git a/packages/ethers/package.json b/packages/ethers/package.json index 85b978b95..b66feddb3 100644 --- a/packages/ethers/package.json +++ b/packages/ethers/package.json @@ -35,7 +35,7 @@ "build": "tsc", "clean": "rimraf ./dist", "test": "jest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc -p tsconfig.typecheck.json" }, "peerDependencies": { "ethers": "^5.0.0" diff --git a/packages/ethers/tsconfig.typecheck.json b/packages/ethers/tsconfig.typecheck.json new file mode 100644 index 000000000..2e19cb241 --- /dev/null +++ b/packages/ethers/tsconfig.typecheck.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.js"] +} diff --git a/packages/http/package.json b/packages/http/package.json index 71878c103..0f69190db 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -33,7 +33,7 @@ "build": "tsc", "clean": "rimraf ./dist", "test": "jest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc -p tsconfig.typecheck.json" }, "dependencies": { "@types/node-fetch": "^2.6.2", diff --git a/packages/http/src/__tests__/async-test.ts b/packages/http/src/__tests__/async-test.ts index 5382fae38..5e55e92b6 100644 --- a/packages/http/src/__tests__/async-test.ts +++ b/packages/http/src/__tests__/async-test.ts @@ -157,15 +157,15 @@ function createMockResponse(result: { activity: Partial }) { function chainMockResponseSequence( mockedFetch: jest.MockedFunction, - responseList: Array<{ activity: Partial }> + responseSequence: Array<{ activity: Partial }> ): { expectedCallCount: number } { let cursor = mockedFetch; - for (const item of responseList) { + for (const item of responseSequence) { cursor = cursor.mockReturnValueOnce(createMockResponse(item)); } return { - expectedCallCount: responseList.length, + expectedCallCount: responseSequence.length, }; } diff --git a/packages/http/src/__tests__/request-test.ts b/packages/http/src/__tests__/request-test.ts index 287920c89..365a86bbe 100644 --- a/packages/http/src/__tests__/request-test.ts +++ b/packages/http/src/__tests__/request-test.ts @@ -31,6 +31,6 @@ test("requests are stamped after initialization", async () => { expect(fetch).toHaveBeenCalledTimes(1); - const stamp = mockedFetch.mock.lastCall![1]?.headers?.["X-Stamp"]; + const stamp = (mockedFetch.mock.lastCall![1]?.headers as any)?.["X-Stamp"]; expect(stamp).toBeTruthy(); }); diff --git a/packages/http/tsconfig.typecheck.json b/packages/http/tsconfig.typecheck.json new file mode 100644 index 000000000..2e19cb241 --- /dev/null +++ b/packages/http/tsconfig.typecheck.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.js"] +} From 18ba3556d0fd564e4d12214d2b7e8972d416b816 Mon Sep 17 00:00:00 2001 From: Keyan Zhang Date: Tue, 11 Apr 2023 11:51:26 -0700 Subject: [PATCH 3/8] Move `TurnkeyActivityError` to `@turnkey/http` --- packages/ethers/src/index.ts | 37 +++++++----------------------------- packages/http/src/async.ts | 7 +------ packages/http/src/index.ts | 2 ++ packages/http/src/shared.ts | 31 ++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 36 deletions(-) create mode 100644 packages/http/src/shared.ts diff --git a/packages/ethers/src/index.ts b/packages/ethers/src/index.ts index ffd0754aa..754c86e1c 100644 --- a/packages/ethers/src/index.ts +++ b/packages/ethers/src/index.ts @@ -1,34 +1,9 @@ import { ethers, type UnsignedTransaction, type Bytes } from "ethers"; -import { PublicApiService, init as httpInit } from "@turnkey/http"; - -type TActivity = PublicApiService.TPostGetActivityResponse["activity"]; -type TActivityId = TActivity["id"]; -type TActivityStatus = TActivity["status"]; -type TActivityType = TActivity["type"]; - -export class TurnkeyActivityError extends Error { - activityId: TActivityId | null; - activityStatus: TActivityStatus | null; - activityType: TActivityType | null; - cause: Error | null; - - constructor(input: { - message: string; - cause?: Error | null; - activityId?: TActivityId | null; - activityStatus?: TActivityStatus | null; - activityType?: TActivityType | null; - }) { - const { message, cause, activityId, activityStatus, activityType } = input; - super(message); - - this.name = "TurnkeyActivityError"; - this.activityId = activityId ?? null; - this.activityStatus = activityStatus ?? null; - this.activityType = activityType ?? null; - this.cause = cause ?? null; - } -} +import { + PublicApiService, + TurnkeyActivityError, + init as httpInit, +} from "@turnkey/http"; type TConfig = { apiPublicKey: string; @@ -226,6 +201,8 @@ export class TurnkeySigner extends ethers.Signer { } } +export { TurnkeyActivityError }; + function assertNonNull(input: T | null | undefined): T { if (input == null) { throw new Error(`Got unexpected ${JSON.stringify(input)}`); diff --git a/packages/http/src/async.ts b/packages/http/src/async.ts index c568992f9..e5d78733b 100644 --- a/packages/http/src/async.ts +++ b/packages/http/src/async.ts @@ -1,13 +1,8 @@ import { PublicApiService } from "./__generated__/barrel"; -import type { definitions } from "./__generated__/services/coordinator/public/v1/public_api.types"; +import type { TActivity, TActivityResponse } from "./shared"; const DEFAULT_REFRESH_INTERVAL_MS = 500; -type TActivity = definitions["v1Activity"]; -type TActivityResponse = definitions["v1ActivityResponse"]; -// type TActivityType = definitions["v1ActivityType"]; -// type TActivityId = TActivity["id"]; - /** * TODO: document this helper */ diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index cf78b8569..ca266a481 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -2,4 +2,6 @@ export * from "./__generated__/barrel"; export { init } from "./config"; +export { TurnkeyActivityError } from "./shared"; + export { withAsyncPolling } from "./async"; diff --git a/packages/http/src/shared.ts b/packages/http/src/shared.ts new file mode 100644 index 000000000..4db91e744 --- /dev/null +++ b/packages/http/src/shared.ts @@ -0,0 +1,31 @@ +import type { definitions } from "./__generated__/services/coordinator/public/v1/public_api.types"; + +export type TActivity = definitions["v1Activity"]; +export type TActivityResponse = definitions["v1ActivityResponse"]; +export type TActivityId = TActivity["id"]; +export type TActivityStatus = TActivity["status"]; +export type TActivityType = TActivity["type"]; + +export class TurnkeyActivityError extends Error { + activityId: TActivityId | null; + activityStatus: TActivityStatus | null; + activityType: TActivityType | null; + cause: Error | null; + + constructor(input: { + message: string; + cause?: Error | null; + activityId?: TActivityId | null; + activityStatus?: TActivityStatus | null; + activityType?: TActivityType | null; + }) { + const { message, cause, activityId, activityStatus, activityType } = input; + super(message); + + this.name = "TurnkeyActivityError"; + this.activityId = activityId ?? null; + this.activityStatus = activityStatus ?? null; + this.activityType = activityType ?? null; + this.cause = cause ?? null; + } +} From 2659c70ab015ae9c709a8e8638a55e351150d7ff Mon Sep 17 00:00:00 2001 From: Keyan Zhang Date: Tue, 11 Apr 2023 12:19:55 -0700 Subject: [PATCH 4/8] Throw rich error `TurnkeyActivityError` + tests --- packages/http/src/__tests__/async-test.ts | 201 +++++++++++++++++++--- packages/http/src/async.ts | 27 ++- 2 files changed, 203 insertions(+), 25 deletions(-) diff --git a/packages/http/src/__tests__/async-test.ts b/packages/http/src/__tests__/async-test.ts index 5e55e92b6..4c281cdb6 100644 --- a/packages/http/src/__tests__/async-test.ts +++ b/packages/http/src/__tests__/async-test.ts @@ -1,6 +1,11 @@ import fetch, { Response } from "node-fetch"; import { test, expect, jest, beforeEach } from "@jest/globals"; -import { PublicApiService, init, withAsyncPolling } from "../index"; +import { + PublicApiService, + init, + withAsyncPolling, + TurnkeyActivityError, +} from "../index"; import { readFixture } from "../__fixtures__/shared"; import type { definitions } from "../__generated__/services/coordinator/public/v1/public_api.types"; @@ -19,7 +24,26 @@ beforeEach(async () => { }); }); -test("`withAsyncPolling` should poll until reaching a terminal state", async () => { +const sampleCreatePrivateKeysInput: PublicApiService.TPostCreatePrivateKeysInput = + { + body: { + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + parameters: { + privateKeys: [ + { + privateKeyName: "hello", + curve: "CURVE_SECP256K1", + addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], + privateKeyTags: [], + }, + ], + }, + organizationId: "89881fc7-6ff3-4b43-b962-916698f8ff58", + timestampMs: String(Date.now()), + }, + }; + +test("`withAsyncPolling` should return data after activity competition", async () => { const mutation = withAsyncPolling({ request: PublicApiService.postCreatePrivateKeys, }); @@ -35,7 +59,7 @@ test("`withAsyncPolling` should poll until reaching a terminal state", async () }, { activity: { - status: "ACTIVITY_STATUS_CREATED", + status: "ACTIVITY_STATUS_PENDING", type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", }, }, @@ -47,23 +71,7 @@ test("`withAsyncPolling` should poll until reaching a terminal state", async () }, ]); - const result = await mutation({ - body: { - type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", - parameters: { - privateKeys: [ - { - privateKeyName: "hello", - curve: "CURVE_SECP256K1", - addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], - privateKeyTags: [], - }, - ], - }, - organizationId: "89881fc7-6ff3-4b43-b962-916698f8ff58", - timestampMs: String(Date.now()), - }, - }); + const result = await mutation(sampleCreatePrivateKeysInput); expect(fetch).toHaveBeenCalledTimes(expectedCallCount); expect(result).toMatchInlineSnapshot(` @@ -74,6 +82,159 @@ test("`withAsyncPolling` should poll until reaching a terminal state", async () `); }); +test("`withAsyncPolling` should throw a rich error when activity requires consensus", async () => { + const mutation = withAsyncPolling({ + request: PublicApiService.postCreatePrivateKeys, + }); + + const mockedFetch = fetch as jest.MockedFunction; + + const { expectedCallCount } = chainMockResponseSequence(mockedFetch, [ + { + activity: { + status: "ACTIVITY_STATUS_PENDING", + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + id: "ee916c38-8151-460d-91c0-8bdbf5a9b20e", + }, + }, + { + activity: { + status: "ACTIVITY_STATUS_CONSENSUS_NEEDED", + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + id: "ee916c38-8151-460d-91c0-8bdbf5a9b20e", + }, + }, + ]); + + try { + await mutation(sampleCreatePrivateKeysInput); + + expect("the mutation above must throw").toEqual("an error"); + } catch (error) { + expect(error).toBeInstanceOf(TurnkeyActivityError); + const richError = error as TurnkeyActivityError; + const { message, activityId, activityStatus, activityType } = richError; + + expect({ + message, + activityId, + activityStatus, + activityType, + }).toMatchInlineSnapshot(` + { + "activityId": "ee916c38-8151-460d-91c0-8bdbf5a9b20e", + "activityStatus": "ACTIVITY_STATUS_CONSENSUS_NEEDED", + "activityType": "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + "message": "Consensus needed for activity ee916c38-8151-460d-91c0-8bdbf5a9b20e", + } + `); + } + + expect(fetch).toHaveBeenCalledTimes(expectedCallCount); +}); + +test("`withAsyncPolling` should throw a rich error when activity is rejected", async () => { + const mutation = withAsyncPolling({ + request: PublicApiService.postCreatePrivateKeys, + }); + + const mockedFetch = fetch as jest.MockedFunction; + + const { expectedCallCount } = chainMockResponseSequence(mockedFetch, [ + { + activity: { + status: "ACTIVITY_STATUS_PENDING", + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + id: "ee916c38-8151-460d-91c0-8bdbf5a9b20e", + }, + }, + { + activity: { + status: "ACTIVITY_STATUS_PENDING", + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + id: "ee916c38-8151-460d-91c0-8bdbf5a9b20e", + }, + }, + { + activity: { + status: "ACTIVITY_STATUS_REJECTED", + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + id: "ee916c38-8151-460d-91c0-8bdbf5a9b20e", + }, + }, + ]); + + try { + await mutation(sampleCreatePrivateKeysInput); + + expect("the mutation above must throw").toEqual("an error"); + } catch (error) { + expect(error).toBeInstanceOf(TurnkeyActivityError); + const richError = error as TurnkeyActivityError; + const { message, activityId, activityStatus, activityType } = richError; + + expect({ + message, + activityId, + activityStatus, + activityType, + }).toMatchInlineSnapshot(` + { + "activityId": "ee916c38-8151-460d-91c0-8bdbf5a9b20e", + "activityStatus": "ACTIVITY_STATUS_REJECTED", + "activityType": "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + "message": "Activity ee916c38-8151-460d-91c0-8bdbf5a9b20e was rejected", + } + `); + } + + expect(fetch).toHaveBeenCalledTimes(expectedCallCount); +}); + +test("`withAsyncPolling` should throw a rich error when activity fails", async () => { + const mutation = withAsyncPolling({ + request: PublicApiService.postCreatePrivateKeys, + }); + + const mockedFetch = fetch as jest.MockedFunction; + + const { expectedCallCount } = chainMockResponseSequence(mockedFetch, [ + { + activity: { + status: "ACTIVITY_STATUS_FAILED", + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + id: "ee916c38-8151-460d-91c0-8bdbf5a9b20e", + }, + }, + ]); + + try { + await mutation(sampleCreatePrivateKeysInput); + + expect("the mutation above must throw").toEqual("an error"); + } catch (error) { + expect(error).toBeInstanceOf(TurnkeyActivityError); + const richError = error as TurnkeyActivityError; + const { message, activityId, activityStatus, activityType } = richError; + + expect({ + message, + activityId, + activityStatus, + activityType, + }).toMatchInlineSnapshot(` + { + "activityId": "ee916c38-8151-460d-91c0-8bdbf5a9b20e", + "activityStatus": "ACTIVITY_STATUS_FAILED", + "activityType": "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + "message": "Activity ee916c38-8151-460d-91c0-8bdbf5a9b20e failed", + } + `); + } + + expect(fetch).toHaveBeenCalledTimes(expectedCallCount); +}); + test("`withAsyncPolling` should also work with synchronous activity endpoints", async () => { const mutation = withAsyncPolling({ request: PublicApiService.postSignTransaction, diff --git a/packages/http/src/async.ts b/packages/http/src/async.ts index e5d78733b..03a3326ad 100644 --- a/packages/http/src/async.ts +++ b/packages/http/src/async.ts @@ -1,5 +1,5 @@ import { PublicApiService } from "./__generated__/barrel"; -import type { TActivity, TActivityResponse } from "./shared"; +import { TActivity, TActivityResponse, TurnkeyActivityError } from "./shared"; const DEFAULT_REFRESH_INTERVAL_MS = 500; @@ -35,16 +35,33 @@ export function withAsyncPolling< } case "ACTIVITY_STATUS_CONSENSUS_NEEDED": { // If the activity requires consensus, we shouldn't be polling forever. - // You can catch the error and use the error's TODO, - throw new Error("TODO"); + // You can read the `TurnkeyActivityError` thrown to get the `activityId`, + // store it somewhere, then re-fetch the activity via `.postGetActivity(...)` + // when the required approvals/rejections are in place. + throw new TurnkeyActivityError({ + message: `Consensus needed for activity ${activity.id}`, + activityId: activity.id, + activityStatus: activity.status, + activityType: activity.type, + }); } case "ACTIVITY_STATUS_FAILED": { // Activity failed - throw new Error("TODO"); + throw new TurnkeyActivityError({ + message: `Activity ${activity.id} failed`, + activityId: activity.id, + activityStatus: activity.status, + activityType: activity.type, + }); } case "ACTIVITY_STATUS_REJECTED": { // Activity was rejected - throw new Error("TODO"); + throw new TurnkeyActivityError({ + message: `Activity ${activity.id} was rejected`, + activityId: activity.id, + activityStatus: activity.status, + activityType: activity.type, + }); } default: { // Make sure the switch block is exhaustive From 6731668a278ca606b59fbc0bf0ca5654c79531e5 Mon Sep 17 00:00:00 2001 From: Keyan Zhang Date: Tue, 11 Apr 2023 12:46:42 -0700 Subject: [PATCH 5/8] Update all examples to use `withAsyncPolling` --- .../src/createNewEthereumPrivateKey.ts | 145 +++++------------- .../src/createNewEthereumPrivateKey.ts | 145 +++++------------- .../src/createNewEthereumPrivateKey.ts | 145 +++++------------- 3 files changed, 111 insertions(+), 324 deletions(-) diff --git a/examples/deployer/src/createNewEthereumPrivateKey.ts b/examples/deployer/src/createNewEthereumPrivateKey.ts index 4514412c3..8e7a08d75 100644 --- a/examples/deployer/src/createNewEthereumPrivateKey.ts +++ b/examples/deployer/src/createNewEthereumPrivateKey.ts @@ -1,9 +1,13 @@ -import { PublicApiService, init as httpInit } from "@turnkey/http"; +import { + PublicApiService, + init as httpInit, + withAsyncPolling, +} from "@turnkey/http"; import { TurnkeyActivityError } from "@turnkey/ethers"; - -const POLLING_INTERVAL_MS = 250; +import * as crypto from "crypto"; export async function createNewEthereumPrivateKey() { + // Initialize `@turnkey/http` with your credentials httpInit({ apiPublicKey: process.env.API_PUBLIC_KEY!, apiPrivateKey: process.env.API_PRIVATE_KEY!, @@ -14,12 +18,37 @@ export async function createNewEthereumPrivateKey() { "`process.env.PRIVATE_KEY_ID` not found; creating a new Ethereum private key on Turnkey...\n" ); - const privateKeyName = `ETH Key ${String( - Math.floor(Math.random() * 10000) - ).padStart(4, "0")}`; + // Use `withAsyncPolling` to handle async activity polling. + // In this example, it polls every 250ms until the activity reaches a terminal state. + const mutation = withAsyncPolling({ + request: PublicApiService.postCreatePrivateKeys, + refreshIntervalMs: 250, + }); + + const privateKeyName = `ETH Key ${crypto.randomBytes(2).toString("hex")}`; try { - const privateKeyId = await withPolling(privateKeyName); + const activity = await mutation({ + body: { + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + organizationId: process.env.ORGANIZATION_ID!, + parameters: { + privateKeys: [ + { + privateKeyName, + curve: "CURVE_SECP256K1", + addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], + privateKeyTags: [], + }, + ], + }, + timestampMs: String(Date.now()), // millisecond timestamp + }, + }); + + const privateKeyId = refineNonNull( + activity.result.createPrivateKeysResult?.privateKeyIds?.[0] + ); // Success! console.log( @@ -32,6 +61,7 @@ export async function createNewEthereumPrivateKey() { ].join("\n") ); } catch (error) { + // If needed, you can read from `TurnkeyActivityError` to find out why the activity didn't succeed if (error instanceof TurnkeyActivityError) { throw error; } @@ -43,107 +73,6 @@ export async function createNewEthereumPrivateKey() { } } -// Turnkey activities are async by nature (because we fully support consensus), -// so here's a little helper for polling the status -async function withPolling(privateKeyName: string): Promise { - const organizationId = process.env.ORGANIZATION_ID!; - - let { activity } = await PublicApiService.postCreatePrivateKeys({ - body: { - type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", - organizationId, - parameters: { - privateKeys: [ - { - privateKeyName, - curve: "CURVE_SECP256K1", - - addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], - privateKeyTags: [], - }, - ], - }, - timestampMs: String(Date.now()), // millisecond timestamp - }, - }); - - while (true) { - switch (activity.status) { - case "ACTIVITY_STATUS_COMPLETED": { - // Success! - return refineNonNull( - activity.result.createPrivateKeysResult?.privateKeyIds?.[0] - ); - } - case "ACTIVITY_STATUS_CREATED": { - // Async pending state -- keep polling - break; - } - case "ACTIVITY_STATUS_PENDING": { - // Async pending state -- keep polling - break; - } - case "ACTIVITY_STATUS_CONSENSUS_NEEDED": { - // If the activity requires consensus, we shouldn't be pooling forever. - // You can store the activity ID and ask for activity status later, - // But that's out of scope for this simple example for now. - throw new TurnkeyActivityError({ - message: `Consensus needed for activity ${activity.id}`, - activityId: activity.id, - activityStatus: activity.status, - activityType: activity.type, - }); - } - case "ACTIVITY_STATUS_FAILED": { - // Activity failed - throw new TurnkeyActivityError({ - message: `Activity ${activity.id} failed`, - activityId: activity.id, - activityStatus: activity.status, - activityType: activity.type, - }); - } - case "ACTIVITY_STATUS_REJECTED": { - // Activity was rejected - throw new TurnkeyActivityError({ - message: `Activity ${activity.id} was rejected`, - activityId: activity.id, - activityStatus: activity.status, - activityType: activity.type, - }); - } - default: { - // Make sure the switch block is exhaustive - assertNever(activity.status); - } - } - - await sleep(POLLING_INTERVAL_MS); - - // Now fetch the latest activity status - const response = await PublicApiService.postGetActivity({ - body: { - activityId: activity.id, - organizationId, - }, - }); - - activity = response.activity; - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, ms); - }); -} - -function assertNever(input: never, errorMessage?: string): never { - throw new Error(errorMessage ?? `Unexpected input: ${JSON.stringify(input)}`); -} - export function refineNonNull( input: T | null | undefined, errorMessage?: string diff --git a/examples/with-ethers/src/createNewEthereumPrivateKey.ts b/examples/with-ethers/src/createNewEthereumPrivateKey.ts index 4514412c3..8e7a08d75 100644 --- a/examples/with-ethers/src/createNewEthereumPrivateKey.ts +++ b/examples/with-ethers/src/createNewEthereumPrivateKey.ts @@ -1,9 +1,13 @@ -import { PublicApiService, init as httpInit } from "@turnkey/http"; +import { + PublicApiService, + init as httpInit, + withAsyncPolling, +} from "@turnkey/http"; import { TurnkeyActivityError } from "@turnkey/ethers"; - -const POLLING_INTERVAL_MS = 250; +import * as crypto from "crypto"; export async function createNewEthereumPrivateKey() { + // Initialize `@turnkey/http` with your credentials httpInit({ apiPublicKey: process.env.API_PUBLIC_KEY!, apiPrivateKey: process.env.API_PRIVATE_KEY!, @@ -14,12 +18,37 @@ export async function createNewEthereumPrivateKey() { "`process.env.PRIVATE_KEY_ID` not found; creating a new Ethereum private key on Turnkey...\n" ); - const privateKeyName = `ETH Key ${String( - Math.floor(Math.random() * 10000) - ).padStart(4, "0")}`; + // Use `withAsyncPolling` to handle async activity polling. + // In this example, it polls every 250ms until the activity reaches a terminal state. + const mutation = withAsyncPolling({ + request: PublicApiService.postCreatePrivateKeys, + refreshIntervalMs: 250, + }); + + const privateKeyName = `ETH Key ${crypto.randomBytes(2).toString("hex")}`; try { - const privateKeyId = await withPolling(privateKeyName); + const activity = await mutation({ + body: { + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + organizationId: process.env.ORGANIZATION_ID!, + parameters: { + privateKeys: [ + { + privateKeyName, + curve: "CURVE_SECP256K1", + addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], + privateKeyTags: [], + }, + ], + }, + timestampMs: String(Date.now()), // millisecond timestamp + }, + }); + + const privateKeyId = refineNonNull( + activity.result.createPrivateKeysResult?.privateKeyIds?.[0] + ); // Success! console.log( @@ -32,6 +61,7 @@ export async function createNewEthereumPrivateKey() { ].join("\n") ); } catch (error) { + // If needed, you can read from `TurnkeyActivityError` to find out why the activity didn't succeed if (error instanceof TurnkeyActivityError) { throw error; } @@ -43,107 +73,6 @@ export async function createNewEthereumPrivateKey() { } } -// Turnkey activities are async by nature (because we fully support consensus), -// so here's a little helper for polling the status -async function withPolling(privateKeyName: string): Promise { - const organizationId = process.env.ORGANIZATION_ID!; - - let { activity } = await PublicApiService.postCreatePrivateKeys({ - body: { - type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", - organizationId, - parameters: { - privateKeys: [ - { - privateKeyName, - curve: "CURVE_SECP256K1", - - addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], - privateKeyTags: [], - }, - ], - }, - timestampMs: String(Date.now()), // millisecond timestamp - }, - }); - - while (true) { - switch (activity.status) { - case "ACTIVITY_STATUS_COMPLETED": { - // Success! - return refineNonNull( - activity.result.createPrivateKeysResult?.privateKeyIds?.[0] - ); - } - case "ACTIVITY_STATUS_CREATED": { - // Async pending state -- keep polling - break; - } - case "ACTIVITY_STATUS_PENDING": { - // Async pending state -- keep polling - break; - } - case "ACTIVITY_STATUS_CONSENSUS_NEEDED": { - // If the activity requires consensus, we shouldn't be pooling forever. - // You can store the activity ID and ask for activity status later, - // But that's out of scope for this simple example for now. - throw new TurnkeyActivityError({ - message: `Consensus needed for activity ${activity.id}`, - activityId: activity.id, - activityStatus: activity.status, - activityType: activity.type, - }); - } - case "ACTIVITY_STATUS_FAILED": { - // Activity failed - throw new TurnkeyActivityError({ - message: `Activity ${activity.id} failed`, - activityId: activity.id, - activityStatus: activity.status, - activityType: activity.type, - }); - } - case "ACTIVITY_STATUS_REJECTED": { - // Activity was rejected - throw new TurnkeyActivityError({ - message: `Activity ${activity.id} was rejected`, - activityId: activity.id, - activityStatus: activity.status, - activityType: activity.type, - }); - } - default: { - // Make sure the switch block is exhaustive - assertNever(activity.status); - } - } - - await sleep(POLLING_INTERVAL_MS); - - // Now fetch the latest activity status - const response = await PublicApiService.postGetActivity({ - body: { - activityId: activity.id, - organizationId, - }, - }); - - activity = response.activity; - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, ms); - }); -} - -function assertNever(input: never, errorMessage?: string): never { - throw new Error(errorMessage ?? `Unexpected input: ${JSON.stringify(input)}`); -} - export function refineNonNull( input: T | null | undefined, errorMessage?: string diff --git a/examples/with-gnosis/src/createNewEthereumPrivateKey.ts b/examples/with-gnosis/src/createNewEthereumPrivateKey.ts index 4514412c3..8e7a08d75 100644 --- a/examples/with-gnosis/src/createNewEthereumPrivateKey.ts +++ b/examples/with-gnosis/src/createNewEthereumPrivateKey.ts @@ -1,9 +1,13 @@ -import { PublicApiService, init as httpInit } from "@turnkey/http"; +import { + PublicApiService, + init as httpInit, + withAsyncPolling, +} from "@turnkey/http"; import { TurnkeyActivityError } from "@turnkey/ethers"; - -const POLLING_INTERVAL_MS = 250; +import * as crypto from "crypto"; export async function createNewEthereumPrivateKey() { + // Initialize `@turnkey/http` with your credentials httpInit({ apiPublicKey: process.env.API_PUBLIC_KEY!, apiPrivateKey: process.env.API_PRIVATE_KEY!, @@ -14,12 +18,37 @@ export async function createNewEthereumPrivateKey() { "`process.env.PRIVATE_KEY_ID` not found; creating a new Ethereum private key on Turnkey...\n" ); - const privateKeyName = `ETH Key ${String( - Math.floor(Math.random() * 10000) - ).padStart(4, "0")}`; + // Use `withAsyncPolling` to handle async activity polling. + // In this example, it polls every 250ms until the activity reaches a terminal state. + const mutation = withAsyncPolling({ + request: PublicApiService.postCreatePrivateKeys, + refreshIntervalMs: 250, + }); + + const privateKeyName = `ETH Key ${crypto.randomBytes(2).toString("hex")}`; try { - const privateKeyId = await withPolling(privateKeyName); + const activity = await mutation({ + body: { + type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", + organizationId: process.env.ORGANIZATION_ID!, + parameters: { + privateKeys: [ + { + privateKeyName, + curve: "CURVE_SECP256K1", + addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], + privateKeyTags: [], + }, + ], + }, + timestampMs: String(Date.now()), // millisecond timestamp + }, + }); + + const privateKeyId = refineNonNull( + activity.result.createPrivateKeysResult?.privateKeyIds?.[0] + ); // Success! console.log( @@ -32,6 +61,7 @@ export async function createNewEthereumPrivateKey() { ].join("\n") ); } catch (error) { + // If needed, you can read from `TurnkeyActivityError` to find out why the activity didn't succeed if (error instanceof TurnkeyActivityError) { throw error; } @@ -43,107 +73,6 @@ export async function createNewEthereumPrivateKey() { } } -// Turnkey activities are async by nature (because we fully support consensus), -// so here's a little helper for polling the status -async function withPolling(privateKeyName: string): Promise { - const organizationId = process.env.ORGANIZATION_ID!; - - let { activity } = await PublicApiService.postCreatePrivateKeys({ - body: { - type: "ACTIVITY_TYPE_CREATE_PRIVATE_KEYS", - organizationId, - parameters: { - privateKeys: [ - { - privateKeyName, - curve: "CURVE_SECP256K1", - - addressFormats: ["ADDRESS_FORMAT_ETHEREUM"], - privateKeyTags: [], - }, - ], - }, - timestampMs: String(Date.now()), // millisecond timestamp - }, - }); - - while (true) { - switch (activity.status) { - case "ACTIVITY_STATUS_COMPLETED": { - // Success! - return refineNonNull( - activity.result.createPrivateKeysResult?.privateKeyIds?.[0] - ); - } - case "ACTIVITY_STATUS_CREATED": { - // Async pending state -- keep polling - break; - } - case "ACTIVITY_STATUS_PENDING": { - // Async pending state -- keep polling - break; - } - case "ACTIVITY_STATUS_CONSENSUS_NEEDED": { - // If the activity requires consensus, we shouldn't be pooling forever. - // You can store the activity ID and ask for activity status later, - // But that's out of scope for this simple example for now. - throw new TurnkeyActivityError({ - message: `Consensus needed for activity ${activity.id}`, - activityId: activity.id, - activityStatus: activity.status, - activityType: activity.type, - }); - } - case "ACTIVITY_STATUS_FAILED": { - // Activity failed - throw new TurnkeyActivityError({ - message: `Activity ${activity.id} failed`, - activityId: activity.id, - activityStatus: activity.status, - activityType: activity.type, - }); - } - case "ACTIVITY_STATUS_REJECTED": { - // Activity was rejected - throw new TurnkeyActivityError({ - message: `Activity ${activity.id} was rejected`, - activityId: activity.id, - activityStatus: activity.status, - activityType: activity.type, - }); - } - default: { - // Make sure the switch block is exhaustive - assertNever(activity.status); - } - } - - await sleep(POLLING_INTERVAL_MS); - - // Now fetch the latest activity status - const response = await PublicApiService.postGetActivity({ - body: { - activityId: activity.id, - organizationId, - }, - }); - - activity = response.activity; - } -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, ms); - }); -} - -function assertNever(input: never, errorMessage?: string): never { - throw new Error(errorMessage ?? `Unexpected input: ${JSON.stringify(input)}`); -} - export function refineNonNull( input: T | null | undefined, errorMessage?: string From 9c38b3b1047dd659d24d080a4c8d51a7ade5b79b Mon Sep 17 00:00:00 2001 From: Keyan Zhang Date: Tue, 11 Apr 2023 14:38:48 -0700 Subject: [PATCH 6/8] Update README --- packages/ethers/README.md | 10 +++++ packages/http/README.md | 52 +++++++++++++++++++++++ packages/http/src/__tests__/async-test.ts | 4 +- packages/http/src/async.ts | 5 ++- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/ethers/README.md b/packages/ethers/README.md index 117b55b73..f85d7929b 100644 --- a/packages/ethers/README.md +++ b/packages/ethers/README.md @@ -76,3 +76,13 @@ main().catch((error) => { process.exit(1); }); ``` + +## More Examples + +| Example | Description | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| [`with-ethers`](../../examples/with-ethers/) | Create a new Ethereum address, then sign and broadcast a transaction using the Ethers signer with Infura | +| [`with-gnosis`](../../examples/with-gnosis/) | Create new Ethereum addresses, configure a 3/3 Gnosis safe, and create + execute a transaction from it | +| [`with-uniswap`](../../examples/with-uniswap/) | Sign and broadcast a Uniswap v3 trade using the Ethers signer with Infura | +| [`sweeper`](../../examples/sweeper/) | Sweep funds from one address to a different address | +| [`deployer`](../../examples/deployer/) | Compile and deploy a smart contract | diff --git a/packages/http/README.md b/packages/http/README.md index 48e5bd8ab..40dde2c26 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -30,3 +30,55 @@ const data = await PublicApiService.postGetWhoami({ }, }); ``` + +## HTTP fetchers + +`@turnkey/http` provides fully typed http fetchers for interacting with the Turnkey API. You can find all available methods [here](./src/__generated__/services/coordinator/public/v1/public_api.fetcher.ts). The types of input parameters and output responses are also exported for convenience. + +The OpenAPI spec that generates all fetchers is also [included](./src/__generated__/services/coordinator/public/v1/public_api.swagger.json) in the package. + +## `withAsyncPolling(...)` helper + +All Turnkey mutation endpoints are asynchronous (with the exception of signing endpoints) because we support consensus via policy on all write actions. To help you simplify async mutations, `@turnkey/http` provides a `withAsyncPolling(...)` wrapper. Here's a quick example: + +```typescript +import { + PublicApiService, + withAsyncPolling, + TurnkeyActivityError, +} from "@turnkey/http"; + +// Use `withAsyncPolling(...)` to wrap & create a fetcher with built-in async polling support +const fetcher = withAsyncPolling({ + request: PublicApiService.postCreatePrivateKeys, + refreshIntervalMs: 500, +}); + +// The fetcher remains fully typed. After submitting the request, +// it'll poll until the activity reaches a terminal state. +try { + const activity = await fetcher({ + body: { + /* ... */ + }, + }); + + // Success! + console.log(activity.result.createPrivateKeysResult?.privateKeyIds?.[0]); +} catch (error) { + if (error instanceof TurnkeyActivityError) { + // In case the activity is rejected, failed, or requires consensus, + // a rich `TurnkeyActivityError` will be thrown. You can read from + // `TurnkeyActivityError` to find out why the activity didn't succeed. + // + // For instance, if your activity requires consensus and doesn't have + // enough approvals, you can get the `activityId` from `TurnkeyActivityError`, + // store it somewhere, then re-fetch the activity via `.postGetActivity(...)` + // when the required approvals/rejections are in place. + } +} +``` + +## More Examples + +See [`createNewEthereumPrivateKey.ts`](../../examples/with-ethers/src/createNewEthereumPrivateKey.ts) in the [`with-ethers`](../../examples/with-ethers/) example. diff --git a/packages/http/src/__tests__/async-test.ts b/packages/http/src/__tests__/async-test.ts index 4c281cdb6..9c18fb46c 100644 --- a/packages/http/src/__tests__/async-test.ts +++ b/packages/http/src/__tests__/async-test.ts @@ -7,9 +7,7 @@ import { TurnkeyActivityError, } from "../index"; import { readFixture } from "../__fixtures__/shared"; -import type { definitions } from "../__generated__/services/coordinator/public/v1/public_api.types"; - -type TActivity = definitions["v1Activity"]; +import type { TActivity } from "../shared"; jest.mock("node-fetch"); diff --git a/packages/http/src/async.ts b/packages/http/src/async.ts index 03a3326ad..a6e1280d7 100644 --- a/packages/http/src/async.ts +++ b/packages/http/src/async.ts @@ -4,7 +4,9 @@ import { TActivity, TActivityResponse, TurnkeyActivityError } from "./shared"; const DEFAULT_REFRESH_INTERVAL_MS = 500; /** - * TODO: document this helper + * Wraps a request to create a fetcher with built-in async polling support. + * + * {@link https://github.com/tkhq/sdk/blob/main/packages/http/README.md#withasyncpolling-helper} */ export function withAsyncPolling< O extends TActivityResponse, @@ -22,7 +24,6 @@ export function withAsyncPolling< while (true) { switch (activity.status) { case "ACTIVITY_STATUS_COMPLETED": { - // TODO refine the output here return activity; } case "ACTIVITY_STATUS_CREATED": { From d5840c963999fe10b9fa3db90656dd2563a7e223 Mon Sep 17 00:00:00 2001 From: Keyan Zhang Date: Tue, 11 Apr 2023 15:55:16 -0700 Subject: [PATCH 7/8] Make it explicit that `refreshIntervalMs` is optional --- examples/deployer/src/createNewEthereumPrivateKey.ts | 1 - examples/with-ethers/src/createNewEthereumPrivateKey.ts | 2 +- examples/with-gnosis/src/createNewEthereumPrivateKey.ts | 1 - packages/http/README.md | 1 - packages/http/src/__tests__/async-test.ts | 2 +- 5 files changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/deployer/src/createNewEthereumPrivateKey.ts b/examples/deployer/src/createNewEthereumPrivateKey.ts index 8e7a08d75..05cea801c 100644 --- a/examples/deployer/src/createNewEthereumPrivateKey.ts +++ b/examples/deployer/src/createNewEthereumPrivateKey.ts @@ -22,7 +22,6 @@ export async function createNewEthereumPrivateKey() { // In this example, it polls every 250ms until the activity reaches a terminal state. const mutation = withAsyncPolling({ request: PublicApiService.postCreatePrivateKeys, - refreshIntervalMs: 250, }); const privateKeyName = `ETH Key ${crypto.randomBytes(2).toString("hex")}`; diff --git a/examples/with-ethers/src/createNewEthereumPrivateKey.ts b/examples/with-ethers/src/createNewEthereumPrivateKey.ts index 8e7a08d75..6c55a8792 100644 --- a/examples/with-ethers/src/createNewEthereumPrivateKey.ts +++ b/examples/with-ethers/src/createNewEthereumPrivateKey.ts @@ -22,7 +22,7 @@ export async function createNewEthereumPrivateKey() { // In this example, it polls every 250ms until the activity reaches a terminal state. const mutation = withAsyncPolling({ request: PublicApiService.postCreatePrivateKeys, - refreshIntervalMs: 250, + refreshIntervalMs: 250, // defaults to 500ms }); const privateKeyName = `ETH Key ${crypto.randomBytes(2).toString("hex")}`; diff --git a/examples/with-gnosis/src/createNewEthereumPrivateKey.ts b/examples/with-gnosis/src/createNewEthereumPrivateKey.ts index 8e7a08d75..05cea801c 100644 --- a/examples/with-gnosis/src/createNewEthereumPrivateKey.ts +++ b/examples/with-gnosis/src/createNewEthereumPrivateKey.ts @@ -22,7 +22,6 @@ export async function createNewEthereumPrivateKey() { // In this example, it polls every 250ms until the activity reaches a terminal state. const mutation = withAsyncPolling({ request: PublicApiService.postCreatePrivateKeys, - refreshIntervalMs: 250, }); const privateKeyName = `ETH Key ${crypto.randomBytes(2).toString("hex")}`; diff --git a/packages/http/README.md b/packages/http/README.md index 40dde2c26..251f67019 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -51,7 +51,6 @@ import { // Use `withAsyncPolling(...)` to wrap & create a fetcher with built-in async polling support const fetcher = withAsyncPolling({ request: PublicApiService.postCreatePrivateKeys, - refreshIntervalMs: 500, }); // The fetcher remains fully typed. After submitting the request, diff --git a/packages/http/src/__tests__/async-test.ts b/packages/http/src/__tests__/async-test.ts index 9c18fb46c..63c1d97d8 100644 --- a/packages/http/src/__tests__/async-test.ts +++ b/packages/http/src/__tests__/async-test.ts @@ -41,7 +41,7 @@ const sampleCreatePrivateKeysInput: PublicApiService.TPostCreatePrivateKeysInput }, }; -test("`withAsyncPolling` should return data after activity competition", async () => { +test("`withAsyncPolling` should return data after activity completion", async () => { const mutation = withAsyncPolling({ request: PublicApiService.postCreatePrivateKeys, }); From c5991b47f198c447171e7753941aee184f7d91a3 Mon Sep 17 00:00:00 2001 From: Keyan Zhang Date: Tue, 11 Apr 2023 16:30:37 -0700 Subject: [PATCH 8/8] Update README --- packages/http/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/http/README.md b/packages/http/README.md index 251f67019..90f7d842d 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -39,7 +39,7 @@ The OpenAPI spec that generates all fetchers is also [included](./src/__generate ## `withAsyncPolling(...)` helper -All Turnkey mutation endpoints are asynchronous (with the exception of signing endpoints) because we support consensus via policy on all write actions. To help you simplify async mutations, `@turnkey/http` provides a `withAsyncPolling(...)` wrapper. Here's a quick example: +All Turnkey mutation endpoints are asynchronous (with the exception of signing endpoints). To help you simplify async mutations, `@turnkey/http` provides a `withAsyncPolling(...)` wrapper. Here's a quick example: ```typescript import {