Signing a Transaction
This guide covers the signTransactionWithEncryptedKey
function from the Wrapped Keys SDK. For an overview of what a Wrapped Key is and what can be done with it, please go here.
Using the signTransactionWithEncryptedKey
function, you can sign a transaction using a Wrapped Key. The Wrapped Keys SDK will look up the corresponding encryption metadata (ciphertext
and dataToEncryptHash
) for your PKP in Lit's private DynamoDB instance. If found, it well then use your provided PKP Session Signatures to authorize decryption of the private key, and will sign your provided message, returning the signed message. If the broadcast setting is enabled, then the signed transaction will also be broadcasted to the specified chain.
Below we will walk through an implementation of signTransactionWithEncryptedKey
. The full code implementation can be found here.
Overview of How it Works
- The Wrapped Keys SDK will use the provided Wrapped Key ID and PKP Session Signatures to fetch the encryption metadata for a specific Wrapped Key
- Using the PKP Session Signatures, the SDK will make a request to the Lit network to execute the Sign Transaction Lit Action
- Depending on the provided
network
, one of the following Lit Actions will be executed:- If
network
isethereum
, then the signTransactionWithEthereumEncryptedKey Lit Action is executed - If
network
issolana
, then the signTransactionWithSolanaEncryptedKey Lit Action is executed
- If
- Depending on the provided
- The Lit Action will verify the required transaction parameters were provided in order to sign the transaction
- The Lit Action will check the Access Control Conditions the plaintext private key was encrypted with to verify the PKP is authorized to decrypt the private key
- If authorized, the Wrapped Key will be decrypted within a Lit node's TEE. If not authorized, an error will be returned
- The Lit Action will use the decrypted Wrapped Key and the provided SignTransactionWithEncryptedKeyParams to sign the transaction
- If the
broadcast
parameter was set totrue
, the Lit Action will then broadcast the signed transaction to the specifiednetwork
, returning the transaction hash. Otherwise, the signed transaction will be returned
Prerequisites
Before continuing with this guide, you should have an understanding of:
signTransactionWithEncryptedKey
's Interface
- Signing a Transaction for EVM Based Networks
- Signing a Transaction for Solana
/**
* Signs a transaction inside the Lit Action using the previously persisted wrapped key associated with the current LIT PK.
* This method fetches the encrypted key from the wrapped keys service, then executes a Lit Action that decrypts the key inside the LIT action and uses
* the decrypted key to sign the provided transaction
* Optionally, if you pass `broadcast: true`, the LIT action will also submit the signed transaction to the associated RPC endpoint on your behalf
*/
async function signTransactionWithEncryptedKey(
params: {
pkpSessionSigs: SessionSigsMap;
litNodeClient: ILitNodeClient;
network: 'evm';
id: string;
broadcast: boolean;
unsignedTransaction: EthereumLitTransaction;
}
): Promise<string>
EthereumLitTransaction
has the following interface:
chain
must be one of the supported EVM networks.
/** EthereumLitTransaction must be provided to the `SignTransaction` endpoint when `network` is `evm`.
*
* @typedef EthereumLitTransaction
*
* @property { string } toAddress The address the transaction is 'to'
* @property { string } value The value of the transaction to be sent
* @property { number } chainId The chain ID of the target chain that the transaction will be executed on
* @property { string } [gasPrice] The exact gas price that you are willing to pay to execute the transaction
* @property { string } [gasLimit] The maximum gas price that you are willing to pay to execute the transaction
* @property { string } [dataHex] Data in hex format to be included in the transaction
*
*/
interface EthereumLitTransaction {
chain: string;
toAddress: string;
value: string;
chainId: number;
gasPrice?: string;
gasLimit?: number;
dataHex?: string;
}
/**
* Signs a transaction inside the Lit Action using the previously persisted wrapped key associated with the current LIT PK.
* This method fetches the encrypted key from the wrapped keys service, then executes a Lit Action that decrypts the key inside the LIT action and uses
* the decrypted key to sign the provided transaction
* Optionally, if you pass `broadcast: true`, the LIT action will also submit the signed transaction to the associated RPC endpoint on your behalf
*/
async function signTransactionWithEncryptedKey(
params: {
pkpSessionSigs: SessionSigsMap;
litNodeClient: ILitNodeClient;
network: 'solana';
id: string;
broadcast: boolean;
unsignedTransaction: SerializedTransaction;
}
): Promise<string>
SerializedTransaction
has the following interface:
chain
must be one of the following:
mainnet-beta
testnet
devnet
interface SerializedTransaction {
chain: string;
serializedTransaction: string;
}
Parameters
pkpSessionSigs
When a Wrapped Key is generated, it's encrypted with the following Access Control Conditions:
[
{
contractAddress: '',
standardContractType: '',
chain: CHAIN_ETHEREUM,
method: '',
parameters: [':userAddress'],
returnValueTest: {
comparator: '=',
value: pkpAddress,
},
},
];
where pkpAddress
is the addressed derived from the pkpSessionSigs
. This restricts the decryption of the Wrapped Key to only those whom can generate valid Authentication Signatures from the PKP which generated the Wrapped Key.
A valid pkpSessionSigs
object can be obtained using the getPkpSessionSigs helper method available on an instance of LitNodeClient. We dive deeper into obtaining a pkpSessionSigs
using getPkpSessionSigs
in the Generating PKP Session Signatures section of this guide.
litNodeClient
This is an instance of the LitNodeClient that is connected to a Lit network.
network
This parameter dictates what transaction signing Lit Action is used to sign unsignedTransaction
. It must be one of the supported Wrapped Keys Networks which currently consists of:
evm
This will use the signTransactionWithEthereumEncryptedKey Lit Action.- Use this network if your Wrapped Key is a private key derived from the ECDSA curve.
- Uses Ethers.js' signTransaction function to sign
unsignedTransaction
.
solana
This will use the signTransactionWithSolanaEncryptedKey Lit Action.- Use this network if your Wrapped Key is a private key derived from the Ed25519 curve.
- Uses the @solana/web3.js package to create a signer using the decrypted Wrapped Key, and a Transaction instance to sign the serialized unsigned transaction.
id
This is a unique identifier (UUID v4) generated by Lit for the Wrapped Key.
Because a PKP can have multiple Wrapped Keys attached to it, this ID is used to identify which Wrapped Key to use when calling other Wrapped Key methods such as signMessageWithEncryptedKey and signTransactionWithEncryptedKey.
broadcast
When this parameter is set to true
, after signing the transaction, the Wrapped Key Lit Action will broadcast the signed transaction to a network.
Which network the transaction is broadcasted to is determined one of two ways:
- If the
network
parameter is set toevm
, then thechain
property from theEthereumLitTransaction
object will be used to lookup the corresponding RPC URL to use for broadcasting the signed transaction. - If the
network
parameter is set tosolana
, then thechain
property from theSerializedTransaction
object will be used to create a Connection instance, connected to the specified network.
unsignedTransaction
This parameter is the unsigned transaction that the Wrapped Key will sign. Depending on the network
parameter, this object will be one of two options:
- Network parameter is set to evm
- Network parameter is set to solana
If the network
parameter is set to evm
, then unsignedTransaction
will implement the EthereumLitTransaction
interface:
/** EthereumLitTransaction must be provided to the `SignTransaction` endpoint when `network` is `evm`.
*
* @typedef EthereumLitTransaction
*
* @property { string } toAddress The address the transaction is 'to'
* @property { string } value The value of the transaction to be sent
* @property { number } chainId The chain ID of the target chain that the transaction will be executed on
* @property { string } [gasPrice] The exact gas price that you are willing to pay to execute the transaction
* @property { string } [gasLimit] The maximum gas price that you are willing to pay to execute the transaction
* @property { string } [dataHex] Data in hex format to be included in the transaction
*
*/
interface EthereumLitTransaction {
chain: string;
toAddress: string;
value: string;
chainId: number;
gasPrice?: string;
gasLimit?: number;
dataHex?: string;
}
Parameters
chain
chain
must be one of the supported EVM networks.
This parameters determines what chain will be used to the following:
- Get the latest
nonce
for the address associated with the Wrapped Key. - Get the current
gasPrice
for thechain
. - Get the estimated
gasLimit
forunsignedTransaction
on thechain
. - When
broadcast
is set totrue
, it will be thechain
that the signed transaction is broadcasted to.
toAddress
This parameter is the EVM based address used as the to
property of the transaction, and will be the recipient of the transaction's data
and value
.
value
This parameter is the amount of the native token on the chain
that will be transferred to toAddress
. Within the Wrapped Keys Lit Action, value
will be parsed using Ethers.js' parseEther, so this value should be given as the number of tokens expressed in full units, not in Wei (or whatever the smallest domination is for the chain
the transaction is being signed for).
For example, "1"
should be used to transfer a whole token, ".5"
for half a token, and ".01"
for a hundredth of a token.
chainId
This parameter is the EIP-155 chain id that will be used in the transaction object that is signed by the Wrapped Key.
You can check ChainList for your chain
's chainId
.
gasPrice
This parameter will set the gasPrice
of the transaction in wei
. If this parameter is omitted, the Wrapped Keys Lit Action will fetch the current gasPrice
for chain
for you.
gasLimit
This parameter will set the gasLimit
for the transaction. If this parameter is omitted, the Wrapped Keys Lit Action will attempt to estimate the gasLimit
on the specified chain
for you. Gas estimation is done using Ethers.js' estimateGas function.
There is the possibility that ethers fails to estimate the gas for your transaction, even when it's a valid transaction, and you will receive an error along the lines of Error: When estimating gas-...
. In this case, you can try manually setting the gasLimit
to circumvent ethers trying to estimate it.
dataHex
This parameter will set the data
property for the transaction. Data should be UTF-8 bytes represented as a hexadecimal string. You can use ethers.js' hexlify and toUtf8Bytes (or similar) methods to convert a UTF-8 string.
For example:
import { ethers } from 'ethers';
const dataHex = ethers.utils.hexlify(
ethers.utils.toUtf8Bytes('The answer to the Universe is 42.')
);
If the network
parameter is set to solana
, then the unsignedTransaction
will implement the SerializedTransaction
interface:
interface SerializedTransaction {
chain: string;
serializedTransaction: string;
}
Parameters
chain
This parameter will set the Solana network the transaction will be signed for and submitted to if broadcast
is set to true
. This parameter needs to be one of the following values:
mainnet-beta
testnet
devnet
serializedTransaction
This parameter is the complete unsigned Solana transaction that has been serialized and is ready to be signed. Using the @solana/web3.js SDK, the process of obtaining a serialized transaction might look like:
import {
Connection,
LAMPORTS_PER_SOL,
PublicKey,
SystemProgram,
Transaction,
clusterApiUrl,
} from '@solana/web3.js';
const chain = 'devnet';
const fromPublicKey = new PublicKey(process.env.SOLANA_PUBLIC_KEY);
const toPublicKey = new PublicKey(process.env.SOLANA_TRANSACTION_RECIPIENT_PUBLIC_KEY);
const solanaTransaction = new Transaction();
solanaTransaction.add(
SystemProgram.transfer({
fromPubkey: fromPublicKey,
toPubkey: toPublicKey,
lamports: LAMPORTS_PER_SOL / 100, // Transfer 0.01 SOL
})
);
solanaTransaction.feePayer = fromPublicKey;
const solanaConnection = new Connection(clusterApiUrl(chain), 'confirmed');
const { blockhash } = await solanaConnection.getLatestBlockhash();
solanaTransaction.recentBlockhash = blockhash;
const serializedTransaction = solanaTransaction
.serialize({
requireAllSignatures: false, // should be false as the transaction is not yet being signed
verifySignatures: false, // should be false as the transaction is not yet being signed
})
.toString('base64');
const unsignedTransaction: SerializedTransaction = {
serializedTransaction,
chain,
};
Return Value
Depending on what network
and broadcast
is set to, what signTransactionWithEncryptedKey
returns differs:
- Network parameter is set to evm
- Network parameter is set to solana
If network
is set to evm
and broadcast
is set to false
, then the return value of signTransactionWithEncryptedKey
will be Promise<string>
where the string
is the signed transaction.
If broadcast
is set to true
, then the signed transaction will be broadcasted to the chain
, and the return value of signTransactionWithEncryptedKey
will be Promise<string>
where the string
is the transaction hash.
If network
is set to solana
, then the return value of signTransactionWithEncryptedKey
will be Promise<string>
where the string
is the signed transaction.
If broadcast
is set to true
, then the signed transaction will be submitted to chain
, but signTransactionWithEncryptedKey
will still return the signed transaction and not the transaction hash. To view the status of or confirm the transaction, you can do:
const status = await solanaConnection.getSignatureStatus(signedTx); // { context: { apiVersion: '2.0.5', slot: 321490377 }, value: { confirmationStatus: 'confirmed', confirmations: 0, err: null, slot: 321490377, status: { Ok: null } } }
const confirmation = await solanaConnection.confirmTransaction(signedTx); // { context: { slot: 321490379 }, value: { err: null } }
To get the transaction hash or receipt from the broadcasted signed transaction, you can do the following using the @solana/web3.js SDK:
import {
Connection,
clusterApiUrl,
} from '@solana/web3.js';
const chain = 'devnet';
const transactionSignature = await signTransactionWithEncryptedKey({
// This parameter values are not included here for brevity,
// but follow the other code examples in this guide.
//
// pkpSessionSigs,
// network: 'solana',
// unsignedTransaction,
// broadcast: true,
// litNodeClient,
});
// Wait for confirmation and fetch the transaction details
const signatureBuffer = Buffer.from(transactionSignature, 'base64');
const solanaConnection = new Connection(clusterApiUrl(chain), 'confirmed');
const confirmation = await solanaConnection.confirmTransaction(signatureBuffer);
console.log('Transaction confirmation status:', confirmation.value);
const transactionReceipt = await solanaConnection.getTransaction(
signatureBuffer.toString('base64'),
{ commitment: 'confirmed' },
);
Example Implementation
Now that we know what the signTransactionWithEncryptedKey
function does, it's parameters, and it's return values, let's now dig into a complete implementation.
The full code implementation can be found here.
Installing the Required Dependencies
- npm
- yarn
npm install \
@lit-protocol/auth-helpers \
@lit-protocol/constants \
@lit-protocol/lit-auth-client \
@lit-protocol/lit-node-client \
@lit-protocol/wrapped-keys \
ethers@v5
yarn add \
@lit-protocol/auth-helpers \
@lit-protocol/constants \
@lit-protocol/lit-auth-client \
@lit-protocol/lit-node-client \
@lit-protocol/wrapped-keys \
ethers@v5
Instantiating an Ethers Signer
The ETHEREUM_PRIVATE_KEY
environment variable is required. The corresponding Ethereum address needs to have ownership of the PKP we will be using to generate the pkpSessionSigs
.
import * as ethers from 'ethers';
import { LIT_RPC } from "@lit-protocol/constants";
const ethersSigner = new ethers.Wallet(
process.env.ETHEREUM_PRIVATE_KEY,
new ethers.providers.JsonRpcProvider(LIT_RPC.CHRONICLE_YELLOWSTONE)
);
Instantiating a LitNodeClient
Here we are instantiating an instance of LitNodeClient
and connecting it to the datil-dev
Lit network.
import { LitNodeClient } from "@lit-protocol/lit-node-client";
import { LIT_NETWORK } from "@lit-protocol/constants";
const litNodeClient = new LitNodeClient({
litNetwork: LIT_NETWORK.DatilDev,
debug: false,
});
await litNodeClient.connect();
Generating PKP Session Signatures
The LIT_PKP_PUBLIC_KEY
environment variable is required. This PKP should be owned by the corresponding Ethereum address for the ETHEREUM_PRIVATE_KEY
environment variable.
The PKP's Ethereum address will be used for the Access Control Conditions used to encrypt the generated private key, and by default, will be the only entity able to authorize decryption of the private key.
The expiration
used for the Auth Method must be 10 minutes or less to be valid.
The Auth Method used in this example implementation is signing a Sign in With Ethereum (EIP-4361) message using an Externally Owned Account (EOA), but any Auth Method can be used to authenticate with Lit to get PKP Session Signatures.
import { EthWalletProvider } from "@lit-protocol/lit-auth-client";
import { LIT_ABILITY } from "@lit-protocol/constants";
import {
LitActionResource,
LitPKPResource,
} from "@lit-protocol/auth-helpers";
const pkpSessionSigs = await litNodeClient.getPkpSessionSigs({
pkpPublicKey: process.env.LIT_PKP_PUBLIC_KEY,
authMethods: [
await EthWalletProvider.authenticate({
signer: ethersSigner,
litNodeClient,
expiration: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes
}),
],
resourceAbilityRequests: [
{
resource: new LitActionResource("*"),
ability: LIT_ABILITY.LitActionExecution,
},
],
expiration: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes
});
Signing a Transaction With A Wrapped Key
Now that we know what the signTransactionWithEncryptedKey
function does, it's parameters, and it's return values, let's now dig into a complete implementation.
The full code implementation can be found here.
- Signing for an EVM Based Network
- Signing for Solana
import { api } from "@lit-protocol/wrapped-keys";
const { signTransactionWithEncryptedKey } = api;
const transactionHash = await signTransactionWithEncryptedKey({
pkpSessionSigs,
network: 'evm',
id: process.env.WRAPPED_KEY_ID,
unsignedTransaction: {
chain: "ethereum",
toAddress: process.env.ETHEREUM_TRANSACTION_RECIPIENT
value: "4.2" // This will be 4.2 ether
chainId: 1,
dataHex: ethers.utils.hexlify(
ethers.utils.toUtf8Bytes('The answer to the Universe is 42.')
)
},
broadcast: true,
litNodeClient,
});
import {
Connection,
LAMPORTS_PER_SOL,
PublicKey,
SystemProgram,
Transaction,
clusterApiUrl,
} from '@solana/web3.js';
import { api } from "@lit-protocol/wrapped-keys";
const { signTransactionWithEncryptedKey } = api;
const chain = 'devnet';
const fromPublicKey = new PublicKey(process.env.SOLANA_PUBLIC_KEY);
const toPublicKey = new PublicKey(process.env.SOLANA_TRANSACTION_RECIPIENT_PUBLIC_KEY);
const solanaTransaction = new Transaction();
solanaTransaction.add(
SystemProgram.transfer({
fromPubkey: fromPublicKey,
toPubkey: toPublicKey,
lamports: LAMPORTS_PER_SOL / 100, // Transfer 0.01 SOL
})
);
solanaTransaction.feePayer = fromPublicKey;
const solanaConnection = new Connection(clusterApiUrl(chain), 'confirmed');
const { blockhash } = await solanaConnection.getLatestBlockhash();
solanaTransaction.recentBlockhash = blockhash;
const serializedTransaction = solanaTransaction
.serialize({
requireAllSignatures: false, // should be false as the transaction is not yet being signed
verifySignatures: false, // should be false as the transaction is not yet being signed
})
.toString('base64');
const unsignedTransaction: SerializedTransaction = {
serializedTransaction,
chain,
};
const transactionSignature = await signTransactionWithEncryptedKey({
pkpSessionSigs,
network: 'solana',
id: process.env.WRAPPED_KEY_ID,
unsignedTransaction,
broadcast: true,
litNodeClient,
});
// Wait for confirmation and fetch the transaction details
const signatureBuffer = Buffer.from(transactionSignature, 'base64');
const confirmation = await solanaConnection.confirmTransaction(signatureBuffer);
console.log('Transaction confirmation status:', confirmation.value);
const transactionReceipt = await solanaConnection.getTransaction(
signatureBuffer.toString('base64'),
{ commitment: 'confirmed' },
);
Summary
The full code implementation can be found here.
After executing the example implementation above, you will have a signed transaction using the Wrapped Key that's associated with PKP derived from the provided pkpSessionSigs
. If broadcast
was set to true
, then the signed transaction was also broadcasted to the chain
.