Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

handle consensus #350

Merged
merged 29 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
bd39203
handle consensus
andrewkmin Sep 4, 2024
0b6c9a3
revised approach
andrewkmin Sep 4, 2024
eabeed2
wip: client lib support
andrewkmin Sep 5, 2024
e9e0a7a
add helpers for getting signatures
andrewkmin Sep 6, 2024
c4efb29
add activity poller config
andrewkmin Sep 6, 2024
6cf386e
add terminal activity statuses
andrewkmin Sep 6, 2024
2796eba
prettify
andrewkmin Sep 6, 2024
3f1f6c7
update client libraries again
andrewkmin Sep 6, 2024
50f1723
update ethers
andrewkmin Sep 6, 2024
27bce6b
misc cleanup
andrewkmin Sep 6, 2024
c988ed0
updated examples and added changeset
andrewkmin Sep 7, 2024
91fbb29
slight tweak to solana
andrewkmin Sep 9, 2024
8f4336c
improved error handling
andrewkmin Sep 9, 2024
f216f3d
wip: cleanup types
andrewkmin Sep 10, 2024
b4b2983
complete viem error handling
andrewkmin Sep 10, 2024
c6e861a
delete consensus scripts
andrewkmin Sep 10, 2024
848f8d3
revise changesets
andrewkmin Sep 10, 2024
9ef0c3a
feedback: move shared utility functions to http
andrewkmin Sep 11, 2024
65781ae
feedback: updated ethers
andrewkmin Sep 12, 2024
853ff85
move TERMINAL_ACTIVITY_STATUSES to http package; use undefined instea…
andrewkmin Sep 12, 2024
b039843
feedback: update viem
andrewkmin Sep 12, 2024
989e946
feedback: remove retry scripts
andrewkmin Sep 12, 2024
1975153
feedback: update solana example
andrewkmin Sep 12, 2024
c65c409
update example READMEs
andrewkmin Sep 12, 2024
0c0f79e
minor solana cleanup
andrewkmin Sep 12, 2024
bdded80
update all changesets
andrewkmin Sep 12, 2024
375518f
feedback: share signature, signatures, and signed transaction getters
andrewkmin Sep 13, 2024
9bc9b98
feedback: update errors and changelogs
andrewkmin Sep 13, 2024
aa16d4d
feedback: return full Activity in response
andrewkmin Sep 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading