Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: "22"
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
7 changes: 7 additions & 0 deletions src/commands/shield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,11 @@ export function registerShieldCommand(program: Command): void {
quietNonInteractive(opts.nonInteractive)
);
const quiet = quietNonInteractive(opts.nonInteractive);
const eip7702Tor = {
rpcUrl,
walletDir,
withoutTor: !!opts.withoutTor,
};
const broadcastTransactions: BroadcastTxResultJson[] = [];
try {
await withTor(
Expand Down Expand Up @@ -653,6 +658,7 @@ export function registerShieldCommand(program: Command): void {
senderAddress,
calls,
privateKey: senderPrivateKey,
...eip7702Tor,
});
} else {
const call = calls[0]!;
Expand Down Expand Up @@ -759,6 +765,7 @@ export function registerShieldCommand(program: Command): void {
privateKey: senderPrivateKey,
chainId,
calls,
...eip7702Tor,
}),
(t) =>
`UserOp mined: ${t.txHash}${
Expand Down
8 changes: 8 additions & 0 deletions src/commands/transact-raw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,11 @@ export function registerTransactRawCommand(program: Command): void {
value: tx.value.toString(),
}));
const batchAsUserOp = rawTxs.length > 1;
const eip7702Tor = {
rpcUrl,
walletDir,
withoutTor: !!opts.withoutTor,
};

// Single EOA: eth_call each tx. Multi-call UserOp: skip isolated eth_calls —
// later payloads often depend on earlier ones (e.g. approve then spend).
Expand Down Expand Up @@ -423,6 +428,7 @@ export function registerTransactRawCommand(program: Command): void {
senderAddress,
calls: rawTxs,
privateKey: senderPrivateKey,
...eip7702Tor,
});
} else {
const parts: FeePreview[] = [];
Expand Down Expand Up @@ -490,6 +496,7 @@ export function registerTransactRawCommand(program: Command): void {
senderAddress,
calls: rawTxs,
privateKey: senderPrivateKey,
...eip7702Tor,
});
await maybeConfirm(
!!opts.nonInteractive,
Expand All @@ -509,6 +516,7 @@ export function registerTransactRawCommand(program: Command): void {
privateKey: senderPrivateKey,
chainId,
calls: rawTxs,
...eip7702Tor,
}),
(t) =>
`UserOp mined: ${t.txHash}${
Expand Down
177 changes: 90 additions & 87 deletions src/commands/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,9 +680,8 @@ export function registerTransferCommand(program: Command): void {
return;
}

let fees: FeePreview;
if (!batchAsUserOp) {
fees = await estimateEoaTxFeePreview(
const fees = await estimateEoaTxFeePreview(
client,
{
to: txs[0]!.to,
Expand All @@ -696,10 +695,9 @@ export function registerTransferCommand(program: Command): void {
if (dryRun) {
if (opts.nonInteractive) {
logCliJson({
stealth: !!stealthPlan,
stealth: false,
stealthMetaAddressURI: stealthMetaURI ?? undefined,
recipient: recipientAddress,
ephemeralPublicKey: stealthPlan?.ephemeralPublicKey,
amount: amount.toString(),
token: tokenMeta.isEth ? "eth" : tokenMeta.tokenAddress,
fees,
Expand All @@ -719,119 +717,124 @@ export function registerTransferCommand(program: Command): void {
}
return;
}
}

if (!senderPrivateKey) {
cliError(
"Cannot sign: no private key for this --from (use a saved public/stealth account or --from-priv with --broadcast)."
);
return;
}
if (!senderPrivateKey) {
cliError(
"Cannot sign: no private key for this --from (use a saved public/stealth account or --from-priv with --broadcast)."
);
return;
}

if (batchAsUserOp) {
let fees: FeePreview;
const sent = await withTor(
!opts.withoutTor,
{ rpcUrl, walletDir },
async () => {
fees = await estimateEip7702BatchUserOpFee({
client,
chainId,
senderAddress,
calls: txs,
privateKey: senderPrivateKey,
...eip7702Tor,
});
await maybeConfirm(
!!opts.nonInteractive,
`Submit stealth transfer of ${amountPreview} as one EIP-7702 UserOp (transfer + announce) from ${senderAddress}?\n ${feeConfirmLine(fees)}`
);
await maybeConfirm(
!!opts.nonInteractive,
`Send transfer: ${amountPreview} from ${senderAddress} to ${recipientAddress}?\n ${feeConfirmLine(fees)}`
);

return runQuietSpinner(
quiet,
txSpinner,
{
start:
"Submitting EIP-7702 stealth UserOp (transfer + announce)...",
failure: "EIP-7702 stealth UserOp failed.",
},
async () =>
sendEip7702BatchUserOperation({
client,
privateKey: senderPrivateKey,
chainId,
calls: txs,
...eip7702Tor,
}),
(t) =>
`UserOp mined: ${t.txHash}${
t.delegatedInUserOp ? " (delegation included)" : ""
}`
);
}
const walletClient = makeWalletClient(senderPrivateKey, client, rpcUrl);
const hash = await runQuietSpinner(
quiet,
txSpinner,
{ start: "Sending transfer...", failure: "Transfer failed." },
async () =>
sendTransactionAndWait(walletClient, client, {
to: txs[0]!.to,
data: txs[0]!.data,
value: txs[0]!.value,
}),
(h) => `Mined: ${h}`
);

if (opts.nonInteractive) {
logCliJson({
stealth: true,
mode: "eip7702-userop",
implementation: sent.implementation,
delegation: sent.delegatedInUserOp
? "included-in-userop"
: "already-set",
userOpHash: sent.userOpHash,
txHash: sent.txHash,
explorer: etherscanTxUrl(chainId, sent.txHash),
stealth: false,
hashes: [hash],
explorers: [etherscanTxUrl(chainId, hash)],
from: senderAddress,
to: recipientAddress,
stealthMetaAddressURI: stealthMetaURI ?? undefined,
ephemeralPublicKey: stealthPlan?.ephemeralPublicKey,
amount: amount.toString(),
token: tokenMeta.isEth ? "eth" : tokenMeta.tokenAddress,
calls: payloads,
fees: fees!,
});
} else {
console.log();
console.log(chalk.green("✔ Stealth transfer complete (batched UserOp)."));
console.log(chalk.dim(etherscanTxUrl(chainId, sent.txHash)));
console.log(chalk.green("✔ Transfer complete."));
console.log(chalk.dim(etherscanTxUrl(chainId, hash)));
}
return;
}

await maybeConfirm(
!!opts.nonInteractive,
`Send transfer: ${amountPreview} from ${senderAddress} to ${recipientAddress}?\n ${feeConfirmLine(fees)}`
);
if (!senderPrivateKey) {
cliError(
"Cannot sign: no private key for this --from (use a saved public/stealth account or --from-priv with --broadcast)."
);
return;
}

const walletClient = makeWalletClient(senderPrivateKey, client, rpcUrl);
const hash = await runQuietSpinner(
quiet,
txSpinner,
{ start: "Sending transfer...", failure: "Transfer failed." },
async () =>
sendTransactionAndWait(walletClient, client, {
to: txs[0]!.to,
data: txs[0]!.data,
value: txs[0]!.value,
}),
(h) => `Mined: ${h}`
let fees: FeePreview;
const sent = await withTor(
!opts.withoutTor,
{ rpcUrl, walletDir },
async () => {
fees = await estimateEip7702BatchUserOpFee({
client,
chainId,
senderAddress,
calls: txs,
privateKey: senderPrivateKey,
...eip7702Tor,
});
await maybeConfirm(
!!opts.nonInteractive,
`Submit stealth transfer of ${amountPreview} as one EIP-7702 UserOp (transfer + announce) from ${senderAddress}?\n ${feeConfirmLine(fees)}`
);

return runQuietSpinner(
quiet,
txSpinner,
{
start:
"Submitting EIP-7702 stealth UserOp (transfer + announce)...",
failure: "EIP-7702 stealth UserOp failed.",
},
async () =>
sendEip7702BatchUserOperation({
client,
privateKey: senderPrivateKey,
chainId,
calls: txs,
...eip7702Tor,
}),
(t) =>
`UserOp mined: ${t.txHash}${
t.delegatedInUserOp ? " (delegation included)" : ""
}`
);
}
);

if (opts.nonInteractive) {
logCliJson({
stealth: false,
hashes: [hash],
explorers: [etherscanTxUrl(chainId, hash)],
stealth: true,
mode: "eip7702-userop",
implementation: sent.implementation,
delegation: sent.delegatedInUserOp
? "included-in-userop"
: "already-set",
userOpHash: sent.userOpHash,
txHash: sent.txHash,
explorer: etherscanTxUrl(chainId, sent.txHash),
from: senderAddress,
to: recipientAddress,
stealthMetaAddressURI: stealthMetaURI ?? undefined,
ephemeralPublicKey: stealthPlan.ephemeralPublicKey,
amount: amount.toString(),
token: tokenMeta.isEth ? "eth" : tokenMeta.tokenAddress,
calls: payloads,
fees: fees!,
});
} else {
console.log();
console.log(chalk.green("✔ Transfer complete."));
console.log(chalk.dim(etherscanTxUrl(chainId, hash)));
console.log(chalk.green("✔ Stealth transfer complete (batched UserOp)."));
console.log(chalk.dim(etherscanTxUrl(chainId, sent.txHash)));
}
} catch (e) {
cliErrorFromCaught(e);
Expand Down
54 changes: 1 addition & 53 deletions src/commands/unshield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ import {
tornadoDelegationConfig,
tornadoUnshieldConfirmExtraLines,
} from "../utils/tornado-unshield-delegation.js";
import { parseTailCalls } from "../utils/unshield-tail-calls.js";
import {
resolveWalletDir,
resolveWalletNameOrPrompt,
Expand Down Expand Up @@ -137,59 +138,6 @@ function as0xPrivateKey(priv: string): `0x${string}` {
return (priv.startsWith("0x") ? priv : `0x${priv}`) as `0x${string}`;
}

function parseTailCalls(raw: string): UnshieldTailCall[] {
const entries = raw.split(",").map((entry) => entry.trim());
if (entries.length === 0 || entries.some((entry) => !entry)) {
throw new Error(
"--tail-calls must contain comma-separated TARGET:CALLDATA or TARGET:CALLDATA:VALUE entries."
);
}

return entries.map((entry, index) => {
const parts = entry.split(":").map((part) => part.trim());
if (parts.length < 2 || parts.length > 3 || parts.some((part) => !part)) {
throw new Error(
`Invalid tail call at index ${index}: expected TARGET:CALLDATA or TARGET:CALLDATA:VALUE.`
);
}

const [target, data, valueRaw] = parts;
if (!isAddress(target!)) {
throw new Error(`Invalid tail call target at index ${index}: ${target}`);
}
if (!/^0x(?:[0-9a-fA-F]{2})*$/.test(data!)) {
throw new Error(
`Invalid tail call calldata at index ${index}: expected 0x-prefixed, byte-aligned hex.`
);
}

let value = 0n;
if (valueRaw !== undefined) {
if (!/^0x[0-9a-fA-F]+$/.test(valueRaw) && !/^[0-9]+$/.test(valueRaw)) {
throw new Error(
`Invalid tail call value at index ${index}: expected 0x-hex or decimal wei (${valueRaw}).`
);
}
try {
value = BigInt(valueRaw);
} catch {
throw new Error(
`Invalid tail call value at index ${index}: ${valueRaw}`
);
}
if (value < 0n) {
throw new Error(`Invalid tail call value at index ${index}: must be >= 0.`);
}
}

return {
to: getAddress(target!) as `0x${string}`,
data: data! as `0x${string}`,
value,
};
});
}

/** Next fresh public account without advancing storage (persist after successful broadcast). */
function takeNextFreshPublicAccount(
storage: ReturnType<typeof makePublicAccountsStorage>
Expand Down
3 changes: 3 additions & 0 deletions src/lib/shield-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,9 @@ export async function broadcastShield(opts: {
privateKey: sender.senderPrivateKey,
chainId: opts.chainId,
calls,
rpcUrl: opts.rpcUrl,
walletDir: opts.walletDir,
withoutTor: opts.withoutTor,
});
return [
{
Expand Down
Loading