Skip to content

Commit

Permalink
Merge pull-request #350
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewkmin committed Sep 17, 2024
2 parents 3434073 + aa16d4d commit 1e695f8
Show file tree
Hide file tree
Showing 33 changed files with 1,355 additions and 548 deletions.
5 changes: 5 additions & 0 deletions .changeset/friendly-parents-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@turnkey/solana": minor
---

Support awaiting consensus
8 changes: 8 additions & 0 deletions .changeset/gentle-wolves-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@turnkey/ethers": minor
---

Support awaiting consensus

- Add a few new helper functions:
- `serializeSignature` serializes a raw signature
12 changes: 12 additions & 0 deletions .changeset/heavy-cars-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@turnkey/viem": minor
---

Support awaiting consensus and improve error handling

- Add new error types that extend `BaseError` (and thus implement `error.walk`)
- `TurnkeyConsensusNeededError` wraps consensus-related errors
- `TurnkeyActivityError` wraps base Turnkey errors
- Add a few new helper functions:
- `serializeSignature` serializes a raw signature
- `isTurnkeyActivityConsensusNeededError` and `isTurnkeyActivityError` use `error.walk` to check the type of a Viem error
27 changes: 27 additions & 0 deletions .changeset/orange-beers-smash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@turnkey/sdk-browser": minor
"@turnkey/sdk-server": minor
---

Support activity polling (e.g. for awaiting consensus)

- [Breaking] Update the `activityPoller` parameter for configuring polling behavior
- Polling continues until either a max number of retries is reached, or if the activity hits a terminal status

The shape of the parameter has gone from:

```
{
duration: number;
timeout: number;
}
```

to

```
{
intervalMs: number;
numRetries: number;
}
```
12 changes: 12 additions & 0 deletions .changeset/tasty-feet-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@turnkey/http": minor
---

Add new helpers and update types and errors

- `getSignatureFromActivity` returns the signature corresponding to a completed activity
- `getSignedTransactionFromActivity` returns the signed transaction corresponding to a completed activity
- `assertActivityCompleted` checks the state of an activity and throws an error if the activity either requires consensus or is otherwise not yet completed
- `TERMINAL_ACTIVITY_STATUSES` is a const containing all terminal activity statuses. Useful for checking on an activity
- `TurnkeyActivityError` now uses `undefined` instead of `null`
- Export some additional types: `TActivity`, `TActivityId`, `TActivityStatus`, `TActivityType`
11 changes: 11 additions & 0 deletions examples/with-ethers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,17 @@ Wrapped 0.00001 ETH:
https://sepolia.etherscan.io/tx/0x7f98c1b2c7ff7f8ab876b27fdcd794653d8b7f728dbeec3b1d403789c38bcb71
```

Note: if you have a consensus-related policy resembling the following

```
{
"effect": "EFFECT_ALLOW",
"consensus": "approvers.count() >= 2"
}
```

then the script will await consensus to be met. Specifically, the script will attempt to poll for activity completion per the `activityPoller` config passed to the `TurnkeyServerSDK`. If consensus still isn't met during this period, then the resulting `Consensus Needed` error will be caught, and the script will prompt the user to indicate when consensus has been met. At that point, the script will continue.

```bash
$ pnpm start-legacy-sepolia
```
Expand Down
8 changes: 6 additions & 2 deletions examples/with-ethers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@turnkey/api-key-stamper": "workspace:*",
"@turnkey/ethers": "workspace:*",
"@turnkey/http": "workspace:*",
"@turnkey/api-key-stamper": "workspace:*",
"@turnkey/sdk-server": "workspace:*",
"dotenv": "^16.0.3",
"ethers": "^6.10.0"
"ethers": "^6.10.0",
"prompts": "^2.4.2"
},
"devDependencies": {
"@types/prompts": "^2.4.2"
}
}
209 changes: 182 additions & 27 deletions examples/with-ethers/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import * as path from "path";
import * as dotenv from "dotenv";
import prompts, { PromptType } from "prompts";
import { ethers } from "ethers";

// Load environment variables from `.env.local`
dotenv.config({ path: path.resolve(process.cwd(), ".env.local") });

import { TurnkeySigner } from "@turnkey/ethers";
import { ethers } from "ethers";
import {
getSignatureFromActivity,
getSignedTransactionFromActivity,
TurnkeyActivityConsensusNeededError,
TERMINAL_ACTIVITY_STATUSES,
TActivity,
} from "@turnkey/http";
import { TurnkeySigner, serializeSignature } from "@turnkey/ethers";
import { Turnkey as TurnkeyServerSDK } from "@turnkey/sdk-server";
import { createNewWallet } from "./createNewWallet";
import { print, assertEqual } from "./util";
Expand All @@ -25,6 +33,16 @@ async function main() {
apiPrivateKey: process.env.API_PRIVATE_KEY!,
apiPublicKey: process.env.API_PUBLIC_KEY!,
defaultOrganizationId: process.env.ORGANIZATION_ID!,
// The following config is useful in contexts where an activity requires consensus.
// By default, if the activity is not initially successful, it will poll a maximum
// of 3 times with an interval of 1000 milliseconds. Otherwise, use the values below.
//
// -----
//
// activityPoller: {
// intervalMs: 5_000,
// numRetries: 10,
// },
});

// Initialize a Turnkey Signer
Expand All @@ -43,56 +61,128 @@ async function main() {

const chainId = (await connectedSigner.provider?.getNetwork())?.chainId ?? 0;
const address = await connectedSigner.getAddress();
const balance = (await connectedSigner.provider?.getBalance(address)) ?? 0;
const transactionCount = await connectedSigner.provider?.getTransactionCount(
address
);
let balance = (await connectedSigner.provider?.getBalance(address)) ?? 0;

print("Network:", `${network} (chain ID ${chainId})`);
print("Address:", address);
print("Balance:", `${ethers.formatEther(balance)} Ether`);
print("Transaction count:", `${transactionCount}`);

// 1. Sign a raw payload (`eth_sign` style)
const message = "Hello Turnkey";
const signature = await connectedSigner.signMessage(message);
const { message } = await prompts([
{
type: "text" as PromptType,
name: "message",
message: "Message to sign",
initial: "Hello Turnkey",
},
]);

let signature;
try {
signature = await connectedSigner.signMessage(message);
} catch (error: any) {
signature = await handleActivityError(error).then(
async (activity?: TActivity) => {
if (!activity) {
throw error;
}

return serializeSignature(getSignatureFromActivity(activity));
}
);
}

const recoveredAddress = ethers.verifyMessage(message, signature);

print("Turnkey-powered signature:", `${signature}`);
print("Recovered address:", `${recoveredAddress}`);
assertEqual(recoveredAddress, address);

// Create a simple send transaction
const transactionAmount = "0.00001";
const destinationAddress = "0x2Ad9eA1E677949a536A270CEC812D6e868C88108";
// 2. Create a simple send transaction
const { amount, destination } = await prompts([
{
type: "number" as PromptType,
name: "amount",
message: "Amount to send (wei). Default to 0.0000001 ETH",
initial: 100000000000,
},
{
type: "text" as PromptType,
name: "destination",
message: "Destination address (default to TKHQ warchest)",
initial: "0x08d2b0a37F869FF76BACB5Bab3278E26ab7067B7",
},
]);
const transactionRequest = {
to: destinationAddress,
value: ethers.parseEther(transactionAmount),
to: destination,
value: amount,
type: 2,
};

const signedTx = await connectedSigner.signTransaction(transactionRequest);
let signedTx;
try {
signedTx = await connectedSigner.signTransaction(transactionRequest);
} catch (error: any) {
signedTx = await handleActivityError(error).then(
async (activity?: TActivity) => {
if (!activity) {
throw error;
}

return getSignedTransactionFromActivity(activity);
}
);
}

print("Turnkey-signed transaction:", `${signedTx}`);

if (balance === 0) {
let warningMessage =
"The transaction won't be broadcasted because your account balance is zero.\n";
if (network === "sepolia") {
warningMessage +=
"Use https://sepoliafaucet.com/ to request funds on Sepolia, then run the script again.\n";
}
while (balance === 0) {
console.log(
[
`\n💸 Your onchain balance is at 0! To continue this demo you'll need testnet funds! You can use:`,
`- Any online faucet (e.g. https://www.alchemy.com/faucets/)`,
`\nTo check your balance: https://${network}.etherscan.io/address/${address}`,
`\n--------`,
].join("\n")
);

console.warn(warningMessage);
return;
const { continue: _ } = await prompts([
{
type: "text" as PromptType,
name: "continue",
message: "Ready to continue? y/n",
initial: "y",
},
]);

balance = (await connectedSigner.provider?.getBalance(address))!;
}

// 2. Simple send tx
const sentTx = await connectedSigner.sendTransaction(transactionRequest);
// 3. Make a simple send tx (which calls `signTransaction` under the hood)
let sentTx;
try {
sentTx = await connectedSigner.sendTransaction(transactionRequest);
} catch (error: any) {
sentTx = await handleActivityError(error).then(
async (activity?: TActivity) => {
if (!activity) {
throw error;
}

return await connectedSigner.provider?.broadcastTransaction(
getSignedTransactionFromActivity(activity)
);
}
);
}

print(
`Sent ${ethers.formatEther(sentTx.value)} Ether to ${sentTx.to}:`,
`https://${network}.etherscan.io/tx/${sentTx.hash}`
`Sent ${ethers.formatEther(sentTx!.value)} Ether to ${sentTx!.to}:`,
`https://${network}.etherscan.io/tx/${sentTx!.hash}`
);

if (network === "sepolia") {
Expand All @@ -108,16 +198,81 @@ async function main() {

print("WETH Balance:", `${ethers.formatEther(wethBalance)} WETH`);

const { wrapAmount } = await prompts([
{
type: "number" as PromptType,
name: "wrapAmount",
message: "Amount to wrap (wei). Default to 0.0000001 ETH",
initial: 100000000000,
},
]);

// 3. Wrap ETH -> WETH
const depositTx = await wethContract?.deposit?.({
value: ethers.parseEther(transactionAmount),
});
let depositTx;
try {
depositTx = await wethContract?.deposit?.({
value: wrapAmount,
});
} catch (error: any) {
depositTx = await handleActivityError(error).then(
async (activity?: TActivity) => {
if (!activity) {
throw error;
}

return await connectedSigner.provider?.broadcastTransaction(
getSignedTransactionFromActivity(activity)
);
}
);
}

print(
`Wrapped ${ethers.formatEther(depositTx.value)} ETH:`,
`https://${network}.etherscan.io/tx/${depositTx.hash}`
);
}

async function handleActivityError(error: any) {
if (error instanceof TurnkeyActivityConsensusNeededError) {
const activityId = error["activityId"]!;
let activityStatus = error["activityStatus"]!;
let activity: TActivity | undefined;

while (!TERMINAL_ACTIVITY_STATUSES.includes(activityStatus)) {
console.log("\nWaiting for consensus...\n");

const { retry } = await prompts([
{
type: "text" as PromptType,
name: "retry",
message: "Consensus reached? y/n",
initial: "y",
},
]);

if (retry === "n") {
continue;
}

// Refresh activity status
activity = (
await turnkeyClient.apiClient().getActivity({
activityId,
organizationId: process.env.ORGANIZATION_ID!,
})
).activity;
activityStatus = activity.status;
}

console.log("\nConsensus reached! Moving on...\n");

return activity;
}

// Rethrow error
throw error;
}
}

main().catch((error) => {
Expand Down
4 changes: 4 additions & 0 deletions examples/with-ethers/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ export function refineNonNull<T>(

return input;
}

export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Loading

0 comments on commit 1e695f8

Please sign in to comment.