Skip to main content

Quickstart Guide

This guide provides a step-by-step walkthrough of integrating with the Yellow Network using the Nitrolite SDK. We will build a script to connect to the network, authenticate, manage state channels, and transfer funds.

Prerequisites

Setup

  1. Install Dependencies

    npm install
  2. Environment Variables

    Create a .env file in your project root:

    # .env
    PRIVATE_KEY=your_sepolia_private_key_here
    ALCHEMY_RPC_URL=your_alchemy_rpc_url_here

1. Getting Funds

Before we write code, you need test tokens (ytest.usd). In the Sandbox, these tokens land in your Unified Balance (Off-Chain), which sits in the Yellow Network's clearing layer.

Request tokens via the Faucet:

curl -XPOST https://clearnet-sandbox.yellow.com/faucet/requestTokens \
-H "Content-Type: application/json" \
-d '{"userAddress":"<your_wallet_address>"}'

2. Initialization

First, we setup the NitroliteClient with Viem. This client handles all communication with the Yellow Network nodes and smart contracts.

import { NitroliteClient, WalletStateSigner, createECDSAMessageSigner } from '@erc7824/nitrolite';
import { createPublicClient, createWalletClient, http } from 'viem';
import { sepolia } from 'viem/chains';
import { privateKeyToAccount } from 'viem/accounts';
import WebSocket from 'ws';
import 'dotenv/config';

// Setup Viem Clients
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const publicClient = createPublicClient({ chain: sepolia, transport: http(process.env.ALCHEMY_RPC_URL) });
const walletClient = createWalletClient({ chain: sepolia, transport: http(), account });

// Initialize Nitrolite Client
const client = new NitroliteClient({
publicClient,
walletClient,
stateSigner: new WalletStateSigner(walletClient),
addresses: {
custody: '0x019B65A265EB3363822f2752141b3dF16131b262',
adjudicator: '0x7c7ccbc98469190849BCC6c926307794fDfB11F2',
},
chainId: sepolia.id,
challengeDuration: 3600n,
});

// Connect to Sandbox Node
const ws = new WebSocket('wss://clearnet-sandbox.yellow.com/ws');

3. Authentication

Authentication involves generating a temporary Session Key and verifying your identity using your main wallet (EIP-712).

// Generate temporary session key
const sessionPrivateKey = generatePrivateKey();
const sessionSigner = createECDSAMessageSigner(sessionPrivateKey);
const sessionAccount = privateKeyToAccount(sessionPrivateKey);

// Send auth request
const authRequestMsg = await createAuthRequestMessage({
address: account.address,
application: 'Test app',
session_key: sessionAccount.address,
allowances: [{ asset: 'ytest.usd', amount: '1000000000' }],
expires_at: BigInt(Math.floor(Date.now() / 1000) + 3600), // 1 hour
scope: 'test.app',
});
ws.send(authRequestMsg);

// Handle Challenge (in ws.onmessage)
if (type === 'auth_challenge') {
const challenge = response.res[2].challenge_message;
// Sign with MAIN wallet
const signer = createEIP712AuthMessageSigner(walletClient, authParams, { name: 'Test app' });
const verifyMsg = await createAuthVerifyMessageFromChallenge(signer, challenge);
ws.send(verifyMsg);
}

4. Channel Lifecycle

Creating a Channel

If no channel exists, we request the Node to open one.

const createChannelMsg = await createCreateChannelMessage(
sessionSigner, // Sign with session key
{
chain_id: 11155111, // Sepolia
token: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', // ytest.usd
}
);
ws.send(createChannelMsg);

// Listen for 'create_channel' response, then submit to chain
const createResult = await client.createChannel({
channel,
unsignedInitialState,
serverSignature,
});

Funding (Resizing)

To fund the channel, we perform a "Resize". Since your funds are in your Unified Balance (from the Faucet), we use allocate_amount to move them into the Channel.

Important: Do NOT use resize_amount unless you have deposited funds directly into the L1 Custody Contract.

const resizeMsg = await createResizeChannelMessage(
sessionSigner,
{
channel_id: channelId,
allocate_amount: 20n, // Moves 20 units from Unified Balance -> Channel
funds_destination: account.address,
}
);
ws.send(resizeMsg);

// Submit resize proof to chain
await client.resizeChannel({ resizeState, proofStates });

Closing & Withdrawing

Finally, we cooperatively close the channel. This settles the balance on the L1 Custody Contract, allowing you to withdraw.

// Close Channel
const closeMsg = await createCloseChannelMessage(sessionSigner, channelId, account.address);
ws.send(closeMsg);

// Submit close to chain
await client.closeChannel({ finalState, stateData });

// Withdraw from Custody Contract to Wallet
const withdrawalTx = await client.withdrawal(tokenAddress, withdrawableBalance);
console.log('Funds withdrawn:', withdrawalTx);

Troubleshooting

Here are common issues and solutions:

  • InsufficientBalance:

    • Cause: Trying to use resize_amount (L1 funds) without depositing first.
    • Fix: Use allocate_amount to fund from your Off-chain Unified Balance (Faucet).
  • DepositAlreadyFulfilled:

    • Cause: Double-submitting a funding request or channel creation.
    • Fix: Check if the channel is already open or funded before sending requests.
  • InvalidState:

    • Cause: Resizing a closed channel or version mismatch.
    • Fix: Ensure you are using the latest channel state from the Node.
  • operation denied: non-zero allocation:

    • Cause: Too many "stale" channels open.
    • Fix: Run the cleanup script npx tsx close_all.ts.
  • Timeout waiting for User to fund Custody:

    • Cause: Re-running scripts without closing channels accumulates balance requirements.
    • Fix: Run close_all.ts to reset.

Cleanup Script

If you get stuck, use this script to close all open channels:

npx tsx close_all.ts

Complete Code

index.ts

Click to view full index.ts
import {
NitroliteClient,
WalletStateSigner,
createTransferMessage,
createGetConfigMessage,
createECDSAMessageSigner,
createEIP712AuthMessageSigner,
createAuthVerifyMessageFromChallenge,
createCreateChannelMessage,
createResizeChannelMessage,
createGetLedgerBalancesMessage,
createAuthRequestMessage,
createCloseChannelMessage
} from '@erc7824/nitrolite';
import type {
RPCNetworkInfo,
RPCAsset,
RPCData
} from '@erc7824/nitrolite';
import { createPublicClient, createWalletClient, http } from 'viem';
import { sepolia } from 'viem/chains';
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
import WebSocket from 'ws';
import 'dotenv/config';
import * as readline from 'readline';

console.log('Starting script...');

// Helper to prompt for input
const askQuestion = (query: string): Promise<string> => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(resolve => rl.question(query, ans => {
rl.close();
resolve(ans);
}));
};

// Your wallet private key (use environment variables in production!)
let PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`;

if (!PRIVATE_KEY) {
console.log('PRIVATE_KEY not found in .env');
const inputKey = await askQuestion('Please enter your Private Key: ');
if (!inputKey) {
throw new Error('Private Key is required');
}
PRIVATE_KEY = inputKey.startsWith('0x') ? inputKey as `0x${string}` : `0x${inputKey}` as `0x${string}`;
}

const account = privateKeyToAccount(PRIVATE_KEY);

// Create viem clients
const ALCHEMY_RPC_URL = process.env.ALCHEMY_RPC_URL;
const FALLBACK_RPC_URL = 'https://1rpc.io/sepolia'; // Public fallback

const publicClient = createPublicClient({
chain: sepolia,
transport: http(ALCHEMY_RPC_URL || FALLBACK_RPC_URL),
});

const walletClient = createWalletClient({
chain: sepolia,
transport: http(),
account,
});

interface Config {
assets?: RPCAsset[];
networks?: RPCNetworkInfo[];
[key: string]: any;
}

async function fetchConfig(): Promise<Config> {
const signer = createECDSAMessageSigner(PRIVATE_KEY);
const message = await createGetConfigMessage(signer);

const ws = new WebSocket('wss://clearnet-sandbox.yellow.com/ws');

return new Promise((resolve, reject) => {
ws.onopen = () => {
ws.send(message);
};

ws.onmessage = (event) => {
try {
const response = JSON.parse(event.data.toString());
// Response format: [requestId, method, result, timestamp]
// or NitroliteRPCMessage structure depending on implementation
// Based on types: NitroliteRPCMessage { res: RPCData }
// RPCData: [RequestID, RPCMethod, object, Timestamp?]

if (response.res && response.res[2]) {
resolve(response.res[2] as Config);
ws.close();
} else if (response.error) {
reject(new Error(response.error.message || 'Unknown RPC error'));
ws.close();
}
} catch (err) {
reject(err);
ws.close();
}
};

ws.onerror = (error) => {
reject(error);
ws.close();
};
});
}

// Initialize Nitrolite client
console.log('Fetching configuration...');
const config = await fetchConfig();
console.log('Configuration fetched. Assets count:', config.assets?.length);

const client = new NitroliteClient({
publicClient,
walletClient,
// Use WalletStateSigner for signing states
stateSigner: new WalletStateSigner(walletClient),
// Contract addresses
addresses: {
custody: '0x019B65A265EB3363822f2752141b3dF16131b262',
adjudicator: '0x7c7ccbc98469190849BCC6c926307794fDfB11F2',
},
chainId: sepolia.id,
challengeDuration: 3600n, // 1 hour challenge period
});

console.log('✓ Client initialized');
console.log(' Wallet Address:', account.address);
console.log(' (Please ensure this address has Sepolia ETH)');

// Connect to Clearnode WebSocket (using sandbox for testing)
const ws = new WebSocket('wss://clearnet-sandbox.yellow.com/ws');

// Step 1: Generate session keypair locally
const sessionPrivateKey = generatePrivateKey();
const sessionAccount = privateKeyToAccount(sessionPrivateKey);
const sessionAddress = sessionAccount.address;

// Helper: Create a signer for the session key
const sessionSigner = createECDSAMessageSigner(sessionPrivateKey);

// Step 2: Send auth_request
const authParams = {
session_key: sessionAddress, // Session key you generated
allowances: [{ // Add allowance for ytest.usd
asset: 'ytest.usd',
amount: '1000000000' // Large amount
}],
expires_at: BigInt(Math.floor(Date.now() / 1000) + 3600), // 1 hour in seconds
scope: 'test.app',
};

const authRequestMsg = await createAuthRequestMessage({
address: account.address, // Your main wallet address
application: 'Test app', // Match domain name
...authParams
});

// We need to capture channelId to close it.
let activeChannelId: string | undefined;

// Helper function to trigger resize
const triggerResize = async (channelId: string, token: string, skipResize: boolean = false) => {
console.log(' Using existing channel:', channelId);

// Add delay to ensure Node indexes the channel
console.log(' Waiting 5s for Node to index channel...');
await new Promise(resolve => setTimeout(resolve, 5000));

// For withdrawal, we don't need to check user balance or allowance
// because the Node (counterparty) is the one depositing funds.


// For withdrawal, we don't deposit (we are withdrawing off-chain funds).
// -------------------------------------------------------------------
// 3. Fund Channel (Resize)
// -------------------------------------------------------------------
// We use 'allocate_amount' to move funds from the User's Unified Balance (off-chain)
// into the Channel. This assumes the user has funds in their Unified Balance (e.g. from faucet).

const amountToFund = 20n;
if (!skipResize) console.log('\nRequesting resize to fund channel with 20 tokens...');

if (!skipResize) {
const resizeMsg = await createResizeChannelMessage(
sessionSigner,
{
channel_id: channelId as `0x${string}`,
// resize_amount: 10n, // <-- This requires L1 funds in Custody (which we don't have)
allocate_amount: amountToFund, // <-- This pulls from Unified Balance (Faucet) (Variable name adjusted)
funds_destination: account.address,
}
);

ws.send(resizeMsg);

// Wait for resize confirmation
console.log(' Waiting for resize confirmation...');
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Resize timeout')), 30000);
const handler = (data: any) => {
const msg = JSON.parse(data.toString());
if (msg.res && msg.res[1] === 'resize_channel') {
const payload = msg.res[2];
if (payload.channel_id === channelId) {
clearTimeout(timeout);
ws.off('message', handler);
resolve();
}
}
};
ws.on('message', handler);
});

// Wait for balance update
await new Promise(r => setTimeout(r, 2000));
console.log('✓ Resize complete.');
} else {
console.log(' Skipping resize step (already funded).');
}

// Verify Channel Balance
const channelBalances = await publicClient.readContract({
address: client.addresses.custody,
abi: [{
name: 'getChannelBalances',
type: 'function',
stateMutability: 'view',
inputs: [{ name: 'channelId', type: 'bytes32' }, { name: 'tokens', type: 'address[]' }],
outputs: [{ name: 'balances', type: 'uint256[]' }]
}],
functionName: 'getChannelBalances',
args: [channelId as `0x${string}`, [token as `0x${string}`]],
}) as bigint[];
console.log(`✓ Channel funded with ${channelBalances[0]} USDC`);

// Check User Balance again
let finalUserBalance = 0n;
try {
const result = await publicClient.readContract({
address: client.addresses.custody,
abi: [{
type: 'function',
name: 'getAccountsBalances',
inputs: [{ name: 'users', type: 'address[]' }, { name: 'tokens', type: 'address[]' }],
outputs: [{ type: 'uint256[]' }],
stateMutability: 'view'
}] as const,
functionName: 'getAccountsBalances',
args: [[client.account.address], [token as `0x${string}`]],
}) as bigint[];
finalUserBalance = result[0];
console.log(`✓ User Custody Balance after resize: ${finalUserBalance}`);
} catch (e) {
console.warn(' Error checking final user balance:', e);
}

// -------------------------------------------------------------------
// 4. Off-Chain Transfer
// -------------------------------------------------------------------
};

// State to prevent infinite auth loops
let isAuthenticated = false;

// Step 3: Sign the challenge with your MAIN wallet (EIP-712)
ws.onmessage = async (event) => {
const response = JSON.parse(event.data.toString());
console.log('Received WS message:', JSON.stringify(response, null, 2));

if (response.error) {
console.error('RPC Error:', response.error);
process.exit(1); // Exit on error to prevent infinite loops
}

if (response.res && response.res[1] === 'auth_challenge') {
if (isAuthenticated) {
console.log(' Ignoring auth_challenge (already authenticated)');
return;
}

const challenge = response.res[2].challenge_message;

// Create EIP-712 typed data signature with main wallet
const signer = createEIP712AuthMessageSigner(
walletClient,
authParams,
{ name: 'Test app' }
);

// Send auth_verify using builder
// We sign with the MAIN wallet for the first verification
const verifyMsg = await createAuthVerifyMessageFromChallenge(
signer,
challenge
);

ws.send(verifyMsg);
}

if (response.res && response.res[1] === 'auth_verify') {
console.log('✓ Authenticated successfully');
isAuthenticated = true; // Mark as authenticated
const sessionKey = response.res[2].session_key;
console.log(' Session key:', sessionKey);
console.log(' JWT token received');

// Query Ledger Balances
const ledgerMsg = await createGetLedgerBalancesMessage(
sessionSigner,
account.address,
Date.now()
);
ws.send(ledgerMsg);
console.log(' Sent get_ledger_balances request...');

// Wait for 'channels' message to proceed

}

if (response.res && response.res[1] === 'channels') {
const channels = response.res[2].channels;
const openChannel = channels.find((c: any) => c.status === 'open');

// Derive token
const chainId = sepolia.id;
const supportedAsset = (config.assets as any)?.find((a: any) => a.chain_id === chainId);
const token = supportedAsset ? supportedAsset.token : '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';

if (openChannel) {
console.log('✓ Found existing open channel');

// CORRECT: Check if channel is already funded
const currentAmount = BigInt(openChannel.amount || 0); // Need to parse amount
// Wait, standard RPC returns strings. Let's rely on openChannel structure.
// openChannel object from logs: { ..., amount: "40", ... }

if (BigInt(openChannel.amount) >= 20n) {
console.log(` Channel already funded with ${openChannel.amount} USDC.`);
console.log(' Skipping resize to avoid "Insufficient Balance" errors.');
// Call triggerResize but indicate skipping actual resize
await triggerResize(openChannel.channel_id, token, true);
} else {
await triggerResize(openChannel.channel_id, token, false);
}
} else {
console.log(' No existing open channel found, creating new one...');
console.log(' Using token:', token, 'for chain:', chainId);

// Request channel creation
const createChannelMsg = await createCreateChannelMessage(
sessionSigner,
{
chain_id: 11155111, // Sepolia
token: token,
}
);
ws.send(createChannelMsg);
}
}

if (response.res && response.res[1] === 'create_channel') {
const { channel_id, channel, state, server_signature } = response.res[2];
activeChannelId = channel_id;

console.log('✓ Channel prepared:', channel_id);
console.log(' State object:', JSON.stringify(state, null, 2));

// Transform state object to match UnsignedState interface
const unsignedInitialState = {
intent: state.intent,
version: BigInt(state.version),
data: state.state_data, // Map state_data to data
allocations: state.allocations.map((a: any) => ({
destination: a.destination,
token: a.token,
amount: BigInt(a.amount),
})),
};

// Submit to blockchain
const createResult = await client.createChannel({
channel,
unsignedInitialState,
serverSignature: server_signature,
});

// createChannel returns an object { txHash, ... } or just hash depending on version.
// Based on logs: { channelId: ..., initialState: ..., txHash: ... }
// We need to handle both or just the object.
const txHash = typeof createResult === 'string' ? createResult : createResult.txHash;

console.log('✓ Channel created on-chain:', txHash);
console.log(' Waiting for transaction confirmation...');
await publicClient.waitForTransactionReceipt({ hash: txHash });
console.log('✓ Transaction confirmed');

// Retrieve token from allocations

const token = state.allocations[0].token;
await triggerResize(channel_id, token, false);
}

if (response.res && response.res[1] === 'resize_channel') {
const { channel_id, state, server_signature } = response.res[2];

console.log('✓ Resize prepared');
console.log(' Server returned allocations:', JSON.stringify(state.allocations, null, 2));

// Construct the resize state object expected by the SDK
const resizeState = {
intent: state.intent,
version: BigInt(state.version),
data: state.state_data || state.data, // Handle potential naming differences
allocations: state.allocations.map((a: any) => ({
destination: a.destination,
token: a.token,
amount: BigInt(a.amount),
})),
channelId: channel_id,
serverSignature: server_signature,
};

console.log('DEBUG: resizeState:', JSON.stringify(resizeState, (key, value) =>
typeof value === 'bigint' ? value.toString() : value, 2));

let proofStates: any[] = [];
try {
const onChainData = await client.getChannelData(channel_id as `0x${string}`);
console.log('DEBUG: On-chain channel data:', JSON.stringify(onChainData, (key, value) =>
typeof value === 'bigint' ? value.toString() : value, 2));
if (onChainData.lastValidState) {
proofStates = [onChainData.lastValidState];
}
} catch (e) {
console.log('DEBUG: Failed to fetch on-chain data:', e);
}

// Calculate total required for the token
const token = resizeState.allocations[0].token;
const requiredAmount = resizeState.allocations.reduce((sum: bigint, a: any) => {
if (a.token === token) return sum + BigInt(a.amount);
return sum;
}, 0n);

console.log(` Waiting for channel funding (Required: ${requiredAmount})...`);

// Poll for User's Custody Balance (since User allocation is increasing)
let userBalance = 0n;
let retries = 0;
const userAddress = client.account.address;

console.log(` Checking User Custody Balance for ${userAddress}... [v2]`);

// Check initial balance first
try {
const result = await publicClient.readContract({
address: client.addresses.custody,
abi: [
{
type: 'function',
name: 'getAccountsBalances',
inputs: [
{ name: 'users', type: 'address[]' },
{ name: 'tokens', type: 'address[]' }
],
outputs: [{ type: 'uint256[]' }],
stateMutability: 'view'
}
] as const,
functionName: 'getAccountsBalances',
args: [[userAddress], [token as `0x${string}`]],
}) as bigint[];
userBalance = result[0];
} catch (e) {
console.warn(' Error checking initial user balance:', e);
}

console.log(' Skipping L1 deposit (using off-chain faucet funds)...');

if (true) { // Skip the wait loop as we just deposited
// Define ABI fragment for getAccountsBalances
const custodyAbiFragment = [
{
type: 'function',
name: 'getAccountsBalances',
inputs: [
{ name: 'users', type: 'address[]' },
{ name: 'tokens', type: 'address[]' }
],
outputs: [{ type: 'uint256[]' }],
stateMutability: 'view'
}
] as const;

while (retries < 30) { // Wait up to 60 seconds
try {
const result = await publicClient.readContract({
address: client.addresses.custody,
abi: custodyAbiFragment,
functionName: 'getAccountsBalances',
args: [[userAddress], [token as `0x${string}`]],
}) as bigint[];

userBalance = result[0];
} catch (e) {
console.warn(' Error checking user balance:', e);
}

if (userBalance >= requiredAmount) {
console.log(`✓ User funded in Custody (Balance: ${userBalance})`);
break;
}
await new Promise(r => setTimeout(r, 2000));
retries++;
if (retries % 5 === 0) console.log(` User Custody Balance: ${userBalance}, Waiting...`);
}

if (userBalance < requiredAmount) {
console.error('Timeout waiting for User to fund Custody account');
console.warn('Proceeding with resize despite low user balance...');
}
} else {
console.log(`✓ User funded in Custody (Balance: ${userBalance})`);
}

console.log(' Submitting resize to chain...');
// Submit to blockchain
const { txHash } = await client.resizeChannel({
resizeState,
proofStates: proofStates,
});

console.log('✓ Channel resized on-chain:', txHash);
console.log('✓ Channel funded with 20 USDC');

// Skip Transfer for debugging
console.log(' Skipping transfer to verify withdrawal amount...');
console.log(' Debug: channel_id =', channel_id);

// Wait for server to sync state
await new Promise(r => setTimeout(r, 3000));

if (channel_id) {
console.log(' Closing channel:', channel_id);
const closeMsg = await createCloseChannelMessage(
sessionSigner,
channel_id as `0x${string}`,
account.address
);
ws.send(closeMsg);
} else {
console.log(' No channel ID available to close.');
}
}
// const secondaryAddress = '0x7df1fef832b57e46de2e1541951289c04b2781aa';
// console.log(` Attempting Transfer to Secondary Wallet: ${secondaryAddress}...`);

// const transferMsg = await createTransferMessage(
// sessionSigner,
// {
// destination: secondaryAddress,
// allocations: [{
// asset: 'ytest.usd',
// amount: '10'
// }]
// },
// Date.now()
// );
// ws.send(transferMsg);
// console.log(' Sent transfer request...');

// if (response.res && response.res[1] === 'transfer') {
// console.log('✓ Transfer complete!');
// console.log(' Amount: 10 USDC');

// if (activeChannelId) {
// console.log(' Closing channel:', activeChannelId);
// const closeMsg = await createCloseChannelMessage(
// sessionSigner,
// activeChannelId as `0x${string}`,
// account.address
// );
// ws.send(closeMsg);
// } else {
// console.log(' No active channel ID to close.');
// }
// }

if (response.res && response.res[1] === 'close_channel') {
const { channel_id, state, server_signature } = response.res[2];
console.log('✓ Close prepared');
console.log(' Submitting close to chain...');

// Submit to blockchain
const txHash = await client.closeChannel({
finalState: {
intent: state.intent,
version: BigInt(state.version),
data: state.state_data || state.data,
allocations: state.allocations.map((a: any) => ({
destination: a.destination,
token: a.token,
amount: BigInt(a.amount),
})),
channelId: channel_id,
serverSignature: server_signature,
},
stateData: state.state_data || state.data || '0x',
});

console.log('✓ Channel closed on-chain:', txHash);

// Withdraw funds
console.log(' Withdrawing funds...');
const token = state.allocations[0].token;

await new Promise(r => setTimeout(r, 2000)); // Wait for close to settle

let withdrawableBalance = 0n;
try {
const result = await publicClient.readContract({
address: client.addresses.custody,
abi: [{
type: 'function',
name: 'getAccountsBalances',
inputs: [{ name: 'users', type: 'address[]' }, { name: 'tokens', type: 'address[]' }],
outputs: [{ type: 'uint256[]' }],
stateMutability: 'view'
}] as const,
functionName: 'getAccountsBalances',
args: [[client.account.address], [token as `0x${string}`]],
}) as bigint[];
withdrawableBalance = result[0];
console.log(`✓ User Custody Balance (Withdrawable): ${withdrawableBalance}`);
} catch (e) {
console.warn(' Error checking withdrawable balance:', e);
}

if (withdrawableBalance > 0n) {
console.log(` Withdrawing ${withdrawableBalance} of ${token}...`);
const withdrawalTx = await client.withdrawal(token as `0x${string}`, withdrawableBalance);
console.log('✓ Funds withdrawn:', withdrawalTx);
} else {
console.log(' No funds to withdraw.');
}

process.exit(0);
}
};

// Start the flow
if (ws.readyState === WebSocket.OPEN) {
ws.send(authRequestMsg);
} else {
ws.on('open', () => {
ws.send(authRequestMsg);
});
}

close_all.ts

Click to view full close_all.ts
import {
NitroliteClient,
WalletStateSigner,
createECDSAMessageSigner,
createEIP712AuthMessageSigner,
createAuthRequestMessage,
createAuthVerifyMessageFromChallenge,
createCloseChannelMessage,
} from '@erc7824/nitrolite';
import { createPublicClient, createWalletClient, http } from 'viem';
import { sepolia } from 'viem/chains';
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
import WebSocket from 'ws';
import 'dotenv/config';
import * as readline from 'readline';

// Helper to prompt for input
const askQuestion = (query: string): Promise<string> => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(resolve => rl.question(query, ans => {
rl.close();
resolve(ans);
}));
};

// Configuration
const WS_URL = 'wss://clearnet-sandbox.yellow.com/ws';

async function main() {
console.log('Starting cleanup script...');

// Setup Viem Clients
let PRIVATE_KEY = process.env.PRIVATE_KEY as `0x${string}`;

if (!PRIVATE_KEY) {
console.log('PRIVATE_KEY not found in .env');
const inputKey = await askQuestion('Please enter your Private Key: ');
if (!inputKey) {
throw new Error('Private Key is required');
}
PRIVATE_KEY = inputKey.startsWith('0x') ? inputKey as `0x${string}` : `0x${inputKey}` as `0x${string}`;
}

const account = privateKeyToAccount(PRIVATE_KEY);

const ALCHEMY_RPC_URL = process.env.ALCHEMY_RPC_URL;
const FALLBACK_RPC_URL = 'https://1rpc.io/sepolia'; // Public fallback
const RPC_URL = ALCHEMY_RPC_URL || FALLBACK_RPC_URL;
const publicClient = createPublicClient({
chain: sepolia,
transport: http(RPC_URL),
});
const walletClient = createWalletClient({
account,
chain: sepolia,
transport: http(RPC_URL),
});

// Initialize Nitrolite Client
const client = new NitroliteClient({
publicClient,
walletClient,
addresses: {
custody: '0x019B65A265EB3363822f2752141b3dF16131b262',
adjudicator: '0x7c7ccbc98469190849BCC6c926307794fDfB11F2',
},
challengeDuration: 3600n,
chainId: sepolia.id,
stateSigner: new WalletStateSigner(walletClient),
});

// Connect to WebSocket
const ws = new WebSocket(WS_URL);
const sessionPrivateKey = generatePrivateKey();
const sessionSigner = createECDSAMessageSigner(sessionPrivateKey);
const sessionAccount = privateKeyToAccount(sessionPrivateKey);

await new Promise<void>((resolve, reject) => {
ws.on('open', () => resolve());
ws.on('error', (err) => reject(err));
});
console.log('✓ Connected to WebSocket');

// Authenticate
const authParams = {
session_key: sessionAccount.address,
allowances: [{ asset: 'ytest.usd', amount: '1000000000' }],
expires_at: BigInt(Math.floor(Date.now() / 1000) + 3600),
scope: 'test.app',
};

const authRequestMsg = await createAuthRequestMessage({
address: account.address,
application: 'Test app',
...authParams
});
ws.send(authRequestMsg);

ws.on('message', async (data) => {
const response = JSON.parse(data.toString());

if (response.res) {
const type = response.res[1];

if (type === 'auth_challenge') {
const challenge = response.res[2].challenge_message;
const signer = createEIP712AuthMessageSigner(walletClient, authParams, { name: 'Test app' });
const verifyMsg = await createAuthVerifyMessageFromChallenge(signer, challenge);
ws.send(verifyMsg);
}

if (type === 'auth_verify') {
console.log('✓ Authenticated');

// Fetch open channels from L1 Contract
console.log('Fetching open channels from L1...');
try {
const openChannelsL1 = await client.getOpenChannels();
console.log(`Found ${openChannelsL1.length} open channels on L1.`);

if (openChannelsL1.length === 0) {
console.log('No open channels on L1 to close.');
process.exit(0);
}

// Iterate and close
for (const channelId of openChannelsL1) {
console.log(`Attempting to close channel ${channelId}...`);

// Send close request to Node
const closeMsg = await createCloseChannelMessage(
sessionSigner,
channelId,
account.address
);
ws.send(closeMsg);

// Small delay to avoid rate limits
await new Promise(r => setTimeout(r, 500));
}

} catch (e) {
console.error('Error fetching L1 channels:', e);
process.exit(1);
}
}

if (type === 'close_channel') {
const { channel_id, state, server_signature } = response.res[2];
console.log(`✓ Node signed close for ${channel_id}`);

const finalState = {
intent: state.intent,
version: BigInt(state.version),
data: state.state_data,
allocations: state.allocations.map((a: any) => ({
destination: a.destination,
token: a.token,
amount: BigInt(a.amount),
})),
channelId: channel_id,
serverSignature: server_signature,
};

try {
console.log(` Submitting close to L1 for ${channel_id}...`);
const txHash = await client.closeChannel({
finalState,
stateData: finalState.data
});
console.log(`✓ Closed on-chain: ${txHash}`);
} catch (e) {
// If it fails (e.g. already closed or race condition), just log and continue
console.error(`Failed to close ${channel_id} on-chain:`, e);
}
}

if (response.error) {
console.error('WS Error:', response.error);
}
}
});
}

main();