Sending AA Transaction with SDK
Prepare, sign, and relay EIP-7702 ERC-4337 UserOperations using the Tachyon SDK.
The Tachyon SDK wraps the EIP-7702 and ERC-4337 work required to execute a UserOperation through a delegated EOA.
The SDK provides two signing flows:
| Flow | Methods | Use when |
|---|---|---|
| Automatic signing | signAndRelayUserOp | A viem wallet client is available |
| External signing | getUserOpAndHash and relayUserOp | Signing happens outside the SDK |
Prerequisites
- Node.js 18+
- A Tachyon API key
- A funded EOA wallet
- An EIP-7702-compatible network
- An ERC-4337-compatible delegate implementation
Automatic Signing
Use signAndRelayUserOp when the SDK can access a viem wallet client. The SDK
checks delegation, creates the authorization when required, prepares and signs
the UserOperation, encodes handleOps, and relays the transaction.
Install Dependencies
npm install viem @rathfi/tachyonConfigure the Clients
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
http,
parseAbi,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
import { Tachyon } from "@rathfi/tachyon";
const account = privateKeyToAccount(
process.env.PRIVATE_KEY as `0x${string}`
);
const publicClient = createPublicClient({
chain: base,
transport: http(),
});
const walletClient = createWalletClient({
account,
chain: base,
transport: http(),
});
const tachyon = new Tachyon({
apiKey: process.env.TACHYON_API_KEY!,
});Encode the Target Call
const delegateContract =
"0xd6CEDDe84be40893d153Be9d467CD6aD37875b28";
const beneficiary =
"0x4C16955d8A0DcB2e7826d50f4114990c787b21E7";
const target =
"0xA7A833e6641D7901F30EaD6f27d4Ee2C9bb670a7";
const callData = encodeFunctionData({
abi: parseAbi(["function sayHello(string message)"]),
functionName: "sayHello",
args: ["Hello from Tachyon!"],
});Pass only the target contract calldata. The SDK encodes the delegated
account's execute call internally.
Sign and Relay
const taskId = await tachyon.signAndRelayUserOp({
publicClient,
walletClient,
delegateContract,
beneficiary,
target,
callData,
value: BigInt(0),
delegation: "auto",
label: "EIP-7702 UserOperation",
});With delegation: "auto", the SDK creates an EIP-7702 authorization only when
the EOA is not already delegated to delegateContract.
Wait for Execution
console.log("Task ID:", taskId);
const transaction = await tachyon.waitForExecutionHash(taskId, 30_000);
console.log("Transaction executed:", transaction);Complete Automatic Example
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
http,
parseAbi,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
import { Tachyon } from "@rathfi/tachyon";
const account = privateKeyToAccount(
process.env.PRIVATE_KEY as `0x${string}`
);
const publicClient = createPublicClient({
chain: base,
transport: http(),
});
const walletClient = createWalletClient({
account,
chain: base,
transport: http(),
});
const tachyon = new Tachyon({
apiKey: process.env.TACHYON_API_KEY!,
});
const delegateContract =
"0xd6CEDDe84be40893d153Be9d467CD6aD37875b28";
const beneficiary =
"0x4C16955d8A0DcB2e7826d50f4114990c787b21E7";
const target =
"0xA7A833e6641D7901F30EaD6f27d4Ee2C9bb670a7";
const callData = encodeFunctionData({
abi: parseAbi(["function sayHello(string message)"]),
functionName: "sayHello",
args: ["Hello from Tachyon!"],
});
async function main() {
const taskId = await tachyon.signAndRelayUserOp({
publicClient,
walletClient,
delegateContract,
beneficiary,
target,
callData,
value: BigInt(0),
delegation: "auto",
// nonce: BigInt(42), // Optional
label: "EIP-7702 UserOperation",
});
console.log("Task ID:", taskId);
const transaction = await tachyon.waitForExecutionHash(taskId, 30_000);
console.log("Transaction executed:", transaction);
}
main().catch(console.error);External Signing
Use this alternative when another wallet library, signing service, or hardware device must sign the UserOperation hash.
Skip this section when using signAndRelayUserOp.
Create the Authorization When Required
getUserOpAndHash does not receive a wallet client, so the caller must provide
an authorization list when the EOA is not already delegated.
let authorizationList;
const delegatedCode = await publicClient.getCode({
address: account.address,
});
const expectedDelegation =
`0xef0100${delegateContract.slice(2).toLowerCase()}`;
if (!delegatedCode?.toLowerCase().startsWith(expectedDelegation)) {
const authorization = await walletClient.signAuthorization({
contractAddress: delegateContract,
});
const authorizationNonce =
typeof authorization.nonce === "bigint"
? authorization.nonce
: BigInt(authorization.nonce);
if (authorizationNonce > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error("Authorization nonce exceeds MAX_SAFE_INTEGER");
}
authorizationList = [
{
chainId: authorization.chainId,
address: authorization.address,
nonce: Number(authorizationNonce),
r: authorization.r,
s: authorization.s,
v: Number(authorization.v),
yParity: Number(authorization.yParity) as 0 | 1,
},
];
}Prepare the UserOperation
const prepared = await tachyon.getUserOpAndHash({
publicClient,
account: account.address,
delegateContract,
beneficiary,
target,
callData,
value: BigInt(0),
delegation: "auto",
authorizationList,
});The result contains the prepared UserOperation and userOperationHash.
Sign the Hash
const signature = await walletClient.signMessage({
message: { raw: prepared.userOperationHash },
});Replace this call with your external signer when not using viem.
Relay the Signed UserOperation
const taskId = await tachyon.relayUserOp({
...prepared,
signature,
label: "EIP-7702 UserOperation external signing",
});Complete External-Signing Example
import {
createPublicClient,
createWalletClient,
encodeFunctionData,
http,
parseAbi,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
import { Tachyon } from "@rathfi/tachyon";
const account = privateKeyToAccount(
process.env.PRIVATE_KEY as `0x${string}`
);
const publicClient = createPublicClient({
chain: base,
transport: http(),
});
const walletClient = createWalletClient({
account,
chain: base,
transport: http(),
});
const tachyon = new Tachyon({
apiKey: process.env.TACHYON_API_KEY!,
});
const delegateContract =
"0xd6CEDDe84be40893d153Be9d467CD6aD37875b28";
const beneficiary =
"0x4C16955d8A0DcB2e7826d50f4114990c787b21E7";
const target =
"0xA7A833e6641D7901F30EaD6f27d4Ee2C9bb670a7";
const callData = encodeFunctionData({
abi: parseAbi(["function sayHello(string message)"]),
functionName: "sayHello",
args: ["Hello from manual UserOperation signing!"],
});
async function main() {
let authorizationList;
const delegatedCode = await publicClient.getCode({
address: account.address,
});
const expectedDelegation =
`0xef0100${delegateContract.slice(2).toLowerCase()}`;
if (!delegatedCode?.toLowerCase().startsWith(expectedDelegation)) {
const authorization = await walletClient.signAuthorization({
contractAddress: delegateContract,
});
const authorizationNonce =
typeof authorization.nonce === "bigint"
? authorization.nonce
: BigInt(authorization.nonce);
if (authorizationNonce > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error("Authorization nonce exceeds MAX_SAFE_INTEGER");
}
authorizationList = [
{
chainId: authorization.chainId,
address: authorization.address,
nonce: Number(authorizationNonce),
r: authorization.r,
s: authorization.s,
v: Number(authorization.v),
yParity: Number(authorization.yParity) as 0 | 1,
},
];
}
const prepared = await tachyon.getUserOpAndHash({
publicClient,
account: account.address,
delegateContract,
beneficiary,
target,
callData,
value: BigInt(0),
delegation: "auto",
authorizationList,
// nonce: BigInt(42), // Optional
});
const signature = await walletClient.signMessage({
message: { raw: prepared.userOperationHash },
});
const taskId = await tachyon.relayUserOp({
...prepared,
signature,
label: "EIP-7702 UserOperation manual signing",
});
console.log("Task ID:", taskId);
const transaction = await tachyon.waitForExecutionHash(taskId, 30_000);
console.log("Transaction executed:", transaction);
}
main().catch(console.error);Optional UserOperation Nonce
Both signAndRelayUserOp and getUserOpAndHash accept nonce?: bigint.
When omitted, the SDK fetches the pending nonce from EntryPoint v0.7 by calling
getNonce(account, 0). When provided, the SDK uses it directly and skips that
RPC call:
const prepared = await tachyon.getUserOpAndHash({
publicClient,
account: account.address,
delegateContract,
beneficiary,
target,
callData,
authorizationList,
nonce: BigInt(42),
});An incorrect, stale, or already-used nonce causes UserOperation validation to fail. The UserOperation nonce is separate from the EOA transaction nonce used by the EIP-7702 authorization.
Key Considerations
- After the first successful authorization, the EOA remains delegated until a later authorization changes or clears the delegation.
delegation: "auto"is recommended for normal use.getUserOpAndHashrequires an authorization list when delegation is missing.- Override the SDK gas defaults when the delegate implementation or target call requires different limits.
- Verify that the delegate implementation is trusted and compatible with the
SDK's
execute(bytes32,bytes)encoding.