Complete guide for Node.js integrating with QubiSafeGuardian Smart Contract
┌─────────────────────────────────────────────────────────┐
│ QubiSafeGuardian │
│ (UUPS Proxy Pattern) │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Access │ │ Multi-Sig │ │ Timelock │ │
│ │ Control │ │ Validation │ │ System │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Daily Limit │ │ Pausable │ │ Reentrancy │ │
│ │ Tracking │ │ Controls │ │ Guard │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
├─────────────────────────────────────────────────────────┤
│ Withdrawal Processing Flow │
│ │
│ 1. submitWithdrawal() │
│ ├─ Verify signatures │
│ ├─ Check daily limits │
│ ├─ Amount < instantThreshold? │
│ │ ├─ YES: Execute immediately │
│ │ └─ NO: Queue for timelock │
│ └─ Emit events │
│ │
│ 2. executeQueuedWithdrawal() [Operator only] │
│ ├─ Check timelock expired │
│ ├─ Verify request still valid │
│ └─ Execute transfer │
│ │
│ 3. cancelQueuedRequest() [Admin/User] │
│ └─ Cancel before execution │
│ │
└─────────────────────────────────────────────────────────┘
QubiSafeGuardian
├─ Initializable // Proxy initialization
├─ AccessControlUpgradeable // Role-based permissions
├─ PausableUpgradeable // Emergency pause
├─ ReentrancyGuardUpgradeable // Attack prevention
├─ UUPSUpgradeable // Upgradeable pattern
└─ EIP712Upgradeable // Signature standard
updateTreasurySettings() - Update all treasury settings (instant threshold, daily limits, timelock duration, required signers)updateSigner() - Add or remove authorized signers who can approve withdrawalsupdateTokenAllowlist() - Add or remove ERC20 tokens that can be withdrawn from treasurysubmitWithdrawal() - Main function to submit withdrawal request with multi-sig; executes instantly if ≤ threshold, queues if > thresholdexecuteQueuedWithdrawal() - (Operator only) Execute a queued withdrawal after timelock period expirescancelQueuedRequest() - Cancel a queued withdrawal before execution (Admin or recipient only)pause() - Emergency stop - blocks all withdrawals immediatelyunpause() - Resume normal operations after pauseemergencyWithdraw() - Extract funds bypassing all checks (emergency only - use with extreme caution)grantRole() - Give a role (Admin or Operator) to an addressrevokeRole() - Remove a role from an addresshasRole() - Check if an address has a specific rolegetBalance() - Check treasury balance for ETH or any ERC20 tokengetTreasurySettings() - Get all configuration settings in one call (threshold, limits, timelock, required signers)getUserDailyUsage() - Check how much a specific user has withdrawn today and when their limit resetsgetGlobalDailyUsage() - Check total amount withdrawn across all users todaypaused() - Check if treasury is currently pausedauthorizedSigners() - Check if an address is an authorized signertokenAllowlist() - Check if a token is allowed for withdrawalusedRequestIds() - Check if a request ID has already been used (prevents replay attacks)queuedRequests() - Get details of a queued withdrawal requestuserDailyWithdrawals() - Get user’s daily withdrawal amount and last reset timeglobalDailyWithdrawals() - Get global daily withdrawal amount and last reset timeinstantWithdrawalThreshold() - Get the threshold amount for instant withdrawalsperUserDailyLimit() - Get per-user daily withdrawal limitglobalDailyLimit() - Get global daily withdrawal limit for all userstimelockDuration() - Get timelock duration in seconds for queued withdrawalsrequiredSigners() - Get number of signatures required for withdrawalsDEFAULT_ADMIN_ROLE() - Get the admin role identifier constantOPERATOR_ROLE() - Get the operator role identifier constantupgradeToAndCall() - (Admin only) Upgrade contract implementation to new version (UUPS proxy pattern)instantWithdrawalThresholduint256 public instantWithdrawalThreshold; // e.g., 1000 ETH
Purpose: Determines when withdrawals need timelock
Default: 1000 ETH (1000 * 10^18 wei)
Logic:
Node.js Usage:
// Read threshold
const threshold = await treasury.instantWithdrawalThreshold();
console.log('Instant threshold:', ethers.formatEther(threshold), 'ETH');
// Update threshold (admin only)
const tx = await treasury.updateTreasurySettings(
ethers.parseEther("2000"), // New threshold: 2000 ETH
perUserLimit, // Keep existing
globalLimit, // Keep existing
timelockDuration, // Keep existing
requiredSigners // Keep existing
);
await tx.wait();
perUserDailyLimituint256 public perUserDailyLimit; // e.g., 5000 ETH
Purpose: Maximum amount a single user can withdraw per day
Default: 5000 ETH
Resets: Every 24 hours (rolling window)
Node.js Usage:
// Check user's remaining limit
const userAddress = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb";
const dailyUsage = await treasury.userDailyWithdrawals(userAddress);
const limit = await treasury.perUserDailyLimit();
const remaining = limit - dailyUsage.amount;
console.log('Daily limit:', ethers.formatEther(limit), 'ETH');
console.log('Used today:', ethers.formatEther(dailyUsage.amount), 'ETH');
console.log('Remaining:', ethers.formatEther(remaining), 'ETH');
console.log('Resets at:', new Date(Number(dailyUsage.lastResetTime) * 1000 + 86400000));
globalDailyLimituint256 public globalDailyLimit; // e.g., 50000 ETH
Purpose: Maximum total withdrawals across all users per day
Default: 50000 ETH
Use Case: Prevents mass exodus in case of compromise
Node.js Usage:
// Check global usage
const globalUsage = await treasury.globalDailyWithdrawals();
const globalLimit = await treasury.globalDailyLimit();
const globalRemaining = globalLimit - globalUsage.amount;
console.log('Global limit:', ethers.formatEther(globalLimit), 'ETH');
console.log('Used today:', ethers.formatEther(globalUsage.amount), 'ETH');
console.log('Remaining:', ethers.formatEther(globalRemaining), 'ETH');
timelockDurationuint256 public timelockDuration; // e.g., 86400 seconds (24 hours)
Purpose: How long large withdrawals must wait
Default: 86400 seconds (24 hours)
Security: Gives time to detect and cancel malicious requests
Node.js Usage:
// Read duration
const duration = await treasury.timelockDuration();
console.log('Timelock duration:', duration / 3600, 'hours');
// Calculate execution time for a queued request
const request = await treasury.queuedRequests(requestId);
const canExecuteAt = Number(request.queuedAt) + Number(duration);
const now = Math.floor(Date.now() / 1000);
const remainingTime = canExecuteAt - now;
console.log('Can execute at:', new Date(canExecuteAt * 1000));
console.log('Time remaining:', remainingTime / 3600, 'hours');
requiredSignersuint256 public requiredSigners; // e.g., 2
Purpose: How many signatures needed for withdrawals
Default: 2
Range: 1 to total number of signers
Node.js Usage:
// Check required signers
const required = await treasury.requiredSigners();
console.log('Required signatures:', required);
// Get all signers
const signerAddresses = []; // You need to track this off-chain
for (const addr of potentialSigners) {
const isSigner = await treasury.authorizedSigners(addr);
if (isSigner) {
signerAddresses.push(addr);
}
}
console.log('Total signers:', signerAddresses.length);
console.log('Required:', required, 'of', signerAddresses.length);
authorizedSignersmapping(address => bool) public authorizedSigners;
Purpose: Tracks who can sign withdrawal requests
Access: Public read, admin write
Node.js Usage:
// Check if address is a signer
const address = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb";
const isSigner = await treasury.authorizedSigners(address);
console.log(address, 'is signer:', isSigner);
// Add a signer (admin only)
const addTx = await treasury.updateSigner(address, true);
await addTx.wait();
console.log('Signer added');
// Remove a signer (admin only)
const removeTx = await treasury.updateSigner(address, false);
await removeTx.wait();
console.log('Signer removed');
tokenAllowlistmapping(address => bool) public tokenAllowlist;
Purpose: Which ERC20 tokens can be withdrawn
Special: Native ETH (address(0)) is always allowed
Node.js Usage:
// Check if token is allowed
const USDT = "0x55d398326f99059fF775485246999027B3197955"; // BSC USDT
const isAllowed = await treasury.tokenAllowlist(USDT);
console.log('USDT allowed:', isAllowed);
// Add token to allowlist (admin only)
const tx = await treasury.updateTokenAllowlist(USDT, true);
await tx.wait();
console.log('Token added to allowlist');
// For native ETH, use zero address
const ETH_ADDRESS = ethers.ZeroAddress;
// ETH is always allowed, no need to add
usedRequestIdsmapping(bytes32 => bool) public usedRequestIds;
Purpose: Prevents replay attacks (same request executed twice)
How it works: Each requestId can only be used once
Node.js Usage:
// Generate unique request ID
const requestId = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'uint256', 'uint256'],
[recipient, amount, Date.now()]
)
);
// Check if already used
const isUsed = await treasury.usedRequestIds(requestId);
if (isUsed) {
console.error('This request ID was already used!');
// Generate a new one
}
userDailyWithdrawalsmapping(address => DailyLimit) public userDailyWithdrawals;
struct DailyLimit {
uint256 amount;
uint256 lastResetTime;
}
Purpose: Tracks each user’s daily withdrawal amount
Auto-reset: After 24 hours
Node.js Usage:
// Get user's daily stats
const userStats = await treasury.userDailyWithdrawals(userAddress);
console.log('Amount withdrawn today:', ethers.formatEther(userStats.amount), 'ETH');
console.log('Last reset:', new Date(Number(userStats.lastResetTime) * 1000));
// Check if limit will reset soon
const now = Math.floor(Date.now() / 1000);
const timeSinceReset = now - Number(userStats.lastResetTime);
const willResetIn = 86400 - timeSinceReset; // seconds until reset
if (willResetIn < 3600) {
console.log('Limit resets in', Math.floor(willResetIn / 60), 'minutes');
}
globalDailyWithdrawalsDailyLimit public globalDailyWithdrawals;
Purpose: Tracks total withdrawals across all users
Same structure: Uses DailyLimit struct
Node.js Usage:
// Get global stats
const globalStats = await treasury.globalDailyWithdrawals();
const globalLimit = await treasury.globalDailyLimit();
const percentUsed = (Number(globalStats.amount) * 100) / Number(globalLimit);
console.log('Global usage:', percentUsed.toFixed(2), '%');
console.log('Amount:', ethers.formatEther(globalStats.amount), 'ETH');
console.log('Limit:', ethers.formatEther(globalLimit), 'ETH');
if (percentUsed > 80) {
console.warn('⚠️ Global limit almost reached!');
}
queuedRequestsmapping(bytes32 => WithdrawalRequest) public queuedRequests;
struct WithdrawalRequest {
address recipient;
address tokenAddress;
uint256 amount;
uint256 queuedAt;
bool executed;
bool cancelled;
}
Purpose: Stores large withdrawal requests waiting for timelock
Lifecycle: Created → Queued → Executed or Cancelled
Node.js Usage:
// Get queued request details
const requestId = "0x1234..."; // From event
const request = await treasury.queuedRequests(requestId);
console.log('Request Details:');
console.log('├─ Recipient:', request.recipient);
console.log('├─ Token:', request.tokenAddress === ethers.ZeroAddress ? 'ETH' : request.tokenAddress);
console.log('├─ Amount:', ethers.formatEther(request.amount));
console.log('├─ Queued at:', new Date(Number(request.queuedAt) * 1000));
console.log('├─ Executed:', request.executed);
console.log('└─ Cancelled:', request.cancelled);
// Check if ready to execute
const timelockDuration = await treasury.timelockDuration();
const canExecuteAt = Number(request.queuedAt) + Number(timelockDuration);
const now = Math.floor(Date.now() / 1000);
if (now >= canExecuteAt && !request.executed && !request.cancelled) {
console.log('✅ Ready to execute!');
} else if (request.executed) {
console.log('✅ Already executed');
} else if (request.cancelled) {
console.log('❌ Cancelled');
} else {
console.log('⏳ Waiting...', Math.floor((canExecuteAt - now) / 60), 'minutes remaining');
}
DEFAULT_ADMIN_ROLE & OPERATOR_ROLEbytes32 public constant DEFAULT_ADMIN_ROLE = 0x00;
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
Purpose: Access control identifiers
DEFAULT_ADMIN_ROLE: Can do everything (configure, pause, emergency withdraw)
OPERATOR_ROLE: Can execute queued withdrawals
Node.js Usage:
// Get role constants
const ADMIN_ROLE = await treasury.DEFAULT_ADMIN_ROLE();
const OPERATOR_ROLE = await treasury.OPERATOR_ROLE();
// Check if address has role
const hasAdmin = await treasury.hasRole(ADMIN_ROLE, address);
const hasOperator = await treasury.hasRole(OPERATOR_ROLE, address);
console.log(address);
console.log('├─ Is Admin:', hasAdmin);
console.log('└─ Is Operator:', hasOperator);
// Grant role (admin only)
const grantTx = await treasury.grantRole(OPERATOR_ROLE, operatorAddress);
await grantTx.wait();
console.log('Operator role granted');
// Revoke role (admin only)
const revokeTx = await treasury.revokeRole(OPERATOR_ROLE, operatorAddress);
await revokeTx.wait();
console.log('Operator role revoked');
initialize()function initialize() external initializer
Purpose: Sets up the contract on first deployment
Called by: Deployment script automatically
Can only be called: Once (initializer modifier)
What it does:
Node.js Usage:
// This is handled by deployment script
const Treasury = await ethers.getContractFactory("QubiSafeGuardian");
const treasury = await upgrades.deployProxy(
Treasury,
[], // initialize() has no parameters
{ kind: 'uups' }
);
await treasury.waitForDeployment();
// initialize() is called automatically
updateTreasurySettings()function updateTreasurySettings(
uint256 _instantWithdrawalThreshold,
uint256 _perUserDailyLimit,
uint256 _globalDailyLimit,
uint256 _timelockDuration,
uint256 _requiredSigners
) external onlyRole(DEFAULT_ADMIN_ROLE)
Purpose: Update all treasury configuration
Access: Admin only
Validates:
Node.js Example:
// Read current settings
const currentThreshold = await treasury.instantWithdrawalThreshold();
const currentUserLimit = await treasury.perUserDailyLimit();
const currentGlobalLimit = await treasury.globalDailyLimit();
const currentTimelock = await treasury.timelockDuration();
const currentSigners = await treasury.requiredSigners();
console.log('Current Settings:');
console.log('├─ Instant threshold:', ethers.formatEther(currentThreshold), 'ETH');
console.log('├─ Per-user daily:', ethers.formatEther(currentUserLimit), 'ETH');
console.log('├─ Global daily:', ethers.formatEther(currentGlobalLimit), 'ETH');
console.log('├─ Timelock:', currentTimelock / 3600, 'hours');
console.log('└─ Required signers:', currentSigners);
// Update settings (admin only)
const tx = await treasury.updateTreasurySettings(
ethers.parseEther("2000"), // New instant threshold: 2000 ETH
ethers.parseEther("10000"), // New user daily: 10000 ETH
ethers.parseEther("100000"),// New global daily: 100000 ETH
48 * 3600, // New timelock: 48 hours
3 // New required signers: 3
);
const receipt = await tx.wait();
console.log('Settings updated! Tx:', receipt.hash);
// Listen for event
treasury.on('TreasurySettingsUpdated',
(threshold, userLimit, globalLimit, timelock, signers) => {
console.log('Settings changed:');
console.log('├─ Threshold:', ethers.formatEther(threshold), 'ETH');
console.log('├─ User limit:', ethers.formatEther(userLimit), 'ETH');
console.log('├─ Global limit:', ethers.formatEther(globalLimit), 'ETH');
console.log('├─ Timelock:', timelock / 3600, 'hours');
console.log('└─ Signers:', signers);
}
);
updateSigner()function updateSigner(address signer, bool status)
external
onlyRole(DEFAULT_ADMIN_ROLE)
Purpose: Add or remove authorized signers
Access: Admin only
Parameters:
signer: Address to updatestatus: true to add, false to removeNode.js Example:
// Add a signer
const newSigner = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb";
const addTx = await treasury.updateSigner(newSigner, true);
await addTx.wait();
console.log('✅ Signer added:', newSigner);
// Remove a signer
const oldSigner = "0x1234...";
const removeTx = await treasury.updateSigner(oldSigner, false);
await removeTx.wait();
console.log('❌ Signer removed:', oldSigner);
// Check signer status
const isSigner = await treasury.authorizedSigners(newSigner);
console.log('Is authorized signer:', isSigner);
// Listen for events
treasury.on('SignerUpdated', (signer, status) => {
console.log(`Signer ${signer} ${status ? 'added' : 'removed'}`);
});
// Best practice: Track all signers
const allSigners = [];
const filter = treasury.filters.SignerUpdated();
const events = await treasury.queryFilter(filter);
for (const event of events) {
const { signer, status } = event.args;
if (status) {
if (!allSigners.includes(signer)) allSigners.push(signer);
} else {
const index = allSigners.indexOf(signer);
if (index > -1) allSigners.splice(index, 1);
}
}
console.log('All current signers:', allSigners);
updateTokenAllowlist()function updateTokenAllowlist(address tokenAddress, bool status)
external
onlyRole(DEFAULT_ADMIN_ROLE)
Purpose: Add or remove ERC20 tokens from allowlist
Access: Admin only
Note: Native ETH (address(0)) is always allowed
Node.js Example:
// Common BSC tokens
const tokens = {
USDT: "0x55d398326f99059fF775485246999027B3197955",
USDC: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",
BUSD: "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56",
DAI: "0x1AF3F329e8BE154074D8769D1FFa4eE058B1DBc3"
};
// Add USDT to allowlist
const addTx = await treasury.updateTokenAllowlist(tokens.USDT, true);
await addTx.wait();
console.log('✅ USDT allowed');
// Add multiple tokens
for (const [name, address] of Object.entries(tokens)) {
const tx = await treasury.updateTokenAllowlist(address, true);
await tx.wait();
console.log(`✅ ${name} allowed`);
}
// Check if token is allowed
const isUSDTAllowed = await treasury.tokenAllowlist(tokens.USDT);
console.log('USDT allowed:', isUSDTAllowed);
// Remove token
const removeTx = await treasury.updateTokenAllowlist(tokens.BUSD, false);
await removeTx.wait();
console.log('❌ BUSD removed from allowlist');
// Get token info using ethers
const ERC20_ABI = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function balanceOf(address) view returns (uint256)"
];
const tokenContract = new ethers.Contract(tokens.USDT, ERC20_ABI, provider);
const name = await tokenContract.name();
const symbol = await tokenContract.symbol();
const decimals = await tokenContract.decimals();
console.log(`Token: ${name} (${symbol}), Decimals: ${decimals}`);
submitWithdrawal()function submitWithdrawal(
bytes32 requestId,
address recipient,
address tokenAddress,
uint256 amount,
uint256 expiry,
bytes[] calldata signatures
) external nonReentrant whenNotPaused
Purpose: Main function to request a withdrawal
Access: Anyone (but needs valid signatures)
Flow:
Parameters Explained:
requestId: Unique identifier (prevents replay)recipient: Who receives the fundstokenAddress: Token to withdraw (address(0) for ETH)amount: How much to withdraw (in wei for ETH, or token’s smallest unit)expiry: Unix timestamp when request expiressignatures: Array of EIP-712 signatures from authorized signersNode.js Example - Instant Withdrawal (ETH):
const ethers = require('ethers');
// Setup
const provider = new ethers.JsonRpcProvider('https://data-seed-prebsc-1-s1.binance.org:8545/');
const adminWallet = new ethers.Wallet(ADMIN_PRIVATE_KEY, provider);
const signer1Wallet = new ethers.Wallet(SIGNER1_PRIVATE_KEY, provider);
const signer2Wallet = new ethers.Wallet(SIGNER2_PRIVATE_KEY, provider);
const treasury = new ethers.Contract(TREASURY_ADDRESS, TREASURY_ABI, adminWallet);
// Step 1: Create withdrawal request
const recipient = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb";
const amount = ethers.parseEther("500"); // 500 ETH (below 1000 threshold)
const tokenAddress = ethers.ZeroAddress; // ETH
const expiry = Math.floor(Date.now() / 1000) + 3600; // Valid for 1 hour
// Generate unique request ID
const requestId = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'address', 'uint256', 'uint256', 'uint256'],
[recipient, tokenAddress, amount, expiry, Date.now()]
)
);
console.log('Request ID:', requestId);
// Step 2: Create EIP-712 typed data
const domain = {
name: 'QubiSafeGuardian',
version: '2.0',
chainId: 97, // BSC Testnet
verifyingContract: TREASURY_ADDRESS
};
const types = {
WithdrawalRequest: [
{ name: 'requestId', type: 'bytes32' },
{ name: 'recipient', type: 'address' },
{ name: 'tokenAddress', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'expiry', type: 'uint256' }
]
};
const value = {
requestId,
recipient,
tokenAddress,
amount,
expiry
};
// Step 3: Get signatures from authorized signers
const signature1 = await signer1Wallet.signTypedData(domain, types, value);
const signature2 = await signer2Wallet.signTypedData(domain, types, value);
const signatures = [signature1, signature2];
console.log('Signatures collected');
// Step 4: Submit withdrawal
try {
const tx = await treasury.submitWithdrawal(
requestId,
recipient,
tokenAddress,
amount,
expiry,
signatures
);
console.log('Transaction sent:', tx.hash);
const receipt = await tx.wait();
console.log('✅ Withdrawal executed instantly!');
console.log('Gas used:', receipt.gasUsed.toString());
// Check for events
const events = receipt.logs
.map(log => {
try {
return treasury.interface.parseLog(log);
} catch {
return null;
}
})
.filter(event => event !== null);
for (const event of events) {
if (event.name === 'WithdrawalExecuted') {
console.log('Event: WithdrawalExecuted');
console.log('├─ Request ID:', event.args.requestId);
console.log('├─ Recipient:', event.args.recipient);
console.log('├─ Amount:', ethers.formatEther(event.args.amount), 'ETH');
console.log('└─ Instant:', event.args.instantExecution);
}
}
} catch (error) {
console.error('❌ Withdrawal failed:', error.message);
// Parse custom errors
if (error.data) {
try {
const decodedError = treasury.interface.parseError(error.data);
console.error('Error:', decodedError.name);
if (decodedError.name === 'InsufficientSigners') {
console.error('Need more signatures. Provided:', decodedError.args[0], 'Required:', decodedError.args[1]);
} else if (decodedError.name === 'PerUserDailyLimitExceeded') {
console.error('User daily limit exceeded');
console.error('Current:', ethers.formatEther(decodedError.args[0]), 'ETH');
console.error('Attempted:', ethers.formatEther(decodedError.args[1]), 'ETH');
console.error('Limit:', ethers.formatEther(decodedError.args[2]), 'ETH');
}
} catch {
console.error('Unknown error');
}
}
}
Node.js Example - Queued Withdrawal (Large Amount):
// Same setup as above...
// Create large withdrawal (>1000 ETH)
const largeAmount = ethers.parseEther("5000"); // 5000 ETH (above threshold)
// Generate request ID
const requestId = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'address', 'uint256', 'uint256', 'uint256'],
[recipient, tokenAddress, largeAmount, expiry, Date.now()]
)
);
// Get signatures (same process)
const value = {
requestId,
recipient,
tokenAddress,
amount: largeAmount,
expiry
};
const signature1 = await signer1Wallet.signTypedData(domain, types, value);
const signature2 = await signer2Wallet.signTypedData(domain, types, value);
// Submit
const tx = await treasury.submitWithdrawal(
requestId,
recipient,
tokenAddress,
largeAmount,
expiry,
[signature1, signature2]
);
const receipt = await tx.wait();
console.log('✅ Withdrawal queued for timelock');
// Check for WithdrawalQueued event
const queuedEvent = receipt.logs
.map(log => {
try { return treasury.interface.parseLog(log); } catch { return null; }
})
.find(e => e && e.name === 'WithdrawalQueued');
if (queuedEvent) {
const queuedAt = Number(queuedEvent.args.queuedAt);
const timelockDuration = await treasury.timelockDuration();
const executeAt = queuedAt + Number(timelockDuration);
console.log('Queued at:', new Date(queuedAt * 1000));
console.log('Can execute at:', new Date(executeAt * 1000));
console.log('Wait time:', Number(timelockDuration) / 3600, 'hours');
}
// Save request ID for later execution
console.log('Save this request ID:', requestId);
Node.js Example - ERC20 Withdrawal:
// Withdraw USDT
const USDT_ADDRESS = "0x55d398326f99059fF775485246999027B3197955";
const usdtAmount = ethers.parseUnits("10000", 18); // 10,000 USDT (18 decimals on BSC)
// First, check if token is in allowlist
const isAllowed = await treasury.tokenAllowlist(USDT_ADDRESS);
if (!isAllowed) {
console.error('❌ Token not in allowlist');
// Admin needs to add it first
const addTx = await treasury.connect(adminWallet).updateTokenAllowlist(USDT_ADDRESS, true);
await addTx.wait();
console.log('✅ Token added to allowlist');
}
// Check treasury balance
const tokenContract = new ethers.Contract(
USDT_ADDRESS,
['function balanceOf(address) view returns (uint256)'],
provider
);
const treasuryBalance = await tokenContract.balanceOf(TREASURY_ADDRESS);
console.log('Treasury USDT balance:', ethers.formatUnits(treasuryBalance, 18));
if (treasuryBalance < usdtAmount) {
console.error('❌ Insufficient treasury balance');
return;
}
// Create withdrawal request
const requestId = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'address', 'uint256', 'uint256', 'uint256'],
[recipient, USDT_ADDRESS, usdtAmount, expiry, Date.now()]
)
);
// Get signatures
const value = {
requestId,
recipient,
tokenAddress: USDT_ADDRESS,
amount: usdtAmount,
expiry
};
const sig1 = await signer1Wallet.signTypedData(domain, types, value);
const sig2 = await signer2Wallet.signTypedData(domain, types, value);
// Submit
const tx = await treasury.submitWithdrawal(
requestId,
recipient,
USDT_ADDRESS,
usdtAmount,
expiry,
[sig1, sig2]
);
await tx.wait();
console.log('✅ USDT withdrawal completed');
executeQueuedWithdrawal()function executeQueuedWithdrawal(bytes32 requestId)
external
onlyRole(OPERATOR_ROLE)
nonReentrant
whenNotPaused
Purpose: Execute a withdrawal after timelock expires
Access: Operator role only
Validates:
Node.js Example:
// Step 1: Get the request ID (from previous withdrawal)
const requestId = "0x1234..."; // From WithdrawalQueued event
// Step 2: Check request details
const request = await treasury.queuedRequests(requestId);
console.log('Request Details:');
console.log('├─ Recipient:', request.recipient);
console.log('├─ Token:', request.tokenAddress);
console.log('├─ Amount:', ethers.formatEther(request.amount), 'ETH');
console.log('├─ Queued at:', new Date(Number(request.queuedAt) * 1000));
console.log('├─ Executed:', request.executed);
console.log('└─ Cancelled:', request.cancelled);
// Step 3: Check if timelock has expired
const timelockDuration = await treasury.timelockDuration();
const canExecuteAt = Number(request.queuedAt) + Number(timelockDuration);
const now = Math.floor(Date.now() / 1000);
if (now < canExecuteAt) {
const remainingSeconds = canExecuteAt - now;
console.log('⏳ Timelock not expired yet');
console.log('Can execute in:', Math.floor(remainingSeconds / 60), 'minutes');
return;
}
if (request.executed) {
console.log('✅ Already executed');
return;
}
if (request.cancelled) {
console.log('❌ Request was cancelled');
return;
}
// Step 4: Execute (operator only)
const operatorWallet = new ethers.Wallet(OPERATOR_PRIVATE_KEY, provider);
const treasuryAsOperator = treasury.connect(operatorWallet);
try {
const tx = await treasuryAsOperator.executeQueuedWithdrawal(requestId);
console.log('Transaction sent:', tx.hash);
const receipt = await tx.wait();
console.log('✅ Queued withdrawal executed!');
console.log('Gas used:', receipt.gasUsed.toString());
// Parse event
const executedEvent = receipt.logs
.map(log => {
try { return treasury.interface.parseLog(log); } catch { return null; }
})
.find(e => e && e.name === 'WithdrawalExecuted');
if (executedEvent) {
console.log('Funds transferred:');
console.log('├─ To:', executedEvent.args.recipient);
console.log('├─ Amount:', ethers.formatEther(executedEvent.args.amount));
console.log('└─ Instant:', executedEvent.args.instantExecution); // false
}
} catch (error) {
console.error('❌ Execution failed:', error.message);
if (error.data) {
try {
const decodedError = treasury.interface.parseError(error.data);
if (decodedError.name === 'TimelockNotExpired') {
console.error('Timelock period not yet passed');
} else if (decodedError.name === 'RequestAlreadyExecuted') {
console.error('Request was already executed');
} else if (decodedError.name === 'RequestCancelled') {
console.error('Request was cancelled');
} else if (decodedError.name === 'InsufficientContractBalance') {
console.error('Treasury has insufficient balance');
}
} catch {
console.error('Unknown error');
}
}
}
Automated Executor Script:
// executor-service.js
// This script monitors queued requests and executes them automatically
const ethers = require('ethers');
async function monitorAndExecute() {
const provider = new ethers.JsonRpcProvider(RPC_URL);
const operatorWallet = new ethers.Wallet(OPERATOR_PRIVATE_KEY, provider);
const treasury = new ethers.Contract(TREASURY_ADDRESS, TREASURY_ABI, operatorWallet);
console.log('🤖 Executor service started');
console.log('Operator:', operatorWallet.address);
// Listen for new queued withdrawals
treasury.on('WithdrawalQueued', async (requestId, recipient, tokenAddress, amount, queuedAt) => {
console.log('\n📥 New withdrawal queued');
console.log('Request ID:', requestId);
console.log('Amount:', ethers.formatEther(amount), 'ETH');
const timelockDuration = await treasury.timelockDuration();
const executeAt = Number(queuedAt) + Number(timelockDuration);
const delay = (executeAt * 1000) - Date.now();
console.log('Will execute at:', new Date(executeAt * 1000));
console.log('Delay:', delay / 1000 / 60, 'minutes');
// Schedule execution
setTimeout(async () => {
try {
console.log('\n⚡ Executing queued withdrawal:', requestId);
const tx = await treasury.executeQueuedWithdrawal(requestId);
const receipt = await tx.wait();
console.log('✅ Executed! Tx:', receipt.hash);
} catch (error) {
console.error('❌ Execution failed:', error.message);
}
}, delay + 10000); // Add 10s buffer
});
// Also check for existing queued requests on startup
const filter = treasury.filters.WithdrawalQueued();
const events = await treasury.queryFilter(filter, -10000); // Last 10000 blocks
for (const event of events) {
const { requestId, queuedAt } = event.args;
const request = await treasury.queuedRequests(requestId);
if (!request.executed && !request.cancelled) {
const timelockDuration = await treasury.timelockDuration();
const executeAt = Number(queuedAt) + Number(timelockDuration);
const now = Math.floor(Date.now() / 1000);
if (now >= executeAt) {
console.log('\n⚡ Executing pending request:', requestId);
try {
const tx = await treasury.executeQueuedWithdrawal(requestId);
await tx.wait();
console.log('✅ Executed!');
} catch (error) {
console.error('❌ Failed:', error.message);
}
}
}
}
}
// Run service
monitorAndExecute().catch(console.error);
cancelQueuedRequest()function cancelQueuedRequest(bytes32 requestId) external
Purpose: Cancel a queued withdrawal before execution
Access: Admin or the request recipient
Use case: Detected suspicious activity, changed plans, etc.
Node.js Example:
// Check request first
const requestId = "0x1234...";
const request = await treasury.queuedRequests(requestId);
console.log('Request status:');
console.log('├─ Recipient:', request.recipient);
console.log('├─ Amount:', ethers.formatEther(request.amount));
console.log('├─ Executed:', request.executed);
console.log('└─ Cancelled:', request.cancelled);
if (request.executed) {
console.log('❌ Cannot cancel - already executed');
return;
}
if (request.cancelled) {
console.log('❌ Already cancelled');
return;
}
// Cancel as admin
try {
const tx = await treasury.cancelQueuedRequest(requestId);
console.log('Transaction sent:', tx.hash);
const receipt = await tx.wait();
console.log('✅ Request cancelled');
// Check event
const cancelEvent = receipt.logs
.map(log => {
try { return treasury.interface.parseLog(log); } catch { return null; }
})
.find(e => e && e.name === 'RequestCancelled');
if (cancelEvent) {
console.log('Cancelled request:', cancelEvent.args.requestId);
console.log('Cancelled by:', cancelEvent.args.cancelledBy);
}
} catch (error) {
console.error('❌ Cancellation failed:', error.message);
}
pause() / unpause()function pause() external onlyRole(DEFAULT_ADMIN_ROLE)
function unpause() external onlyRole(DEFAULT_ADMIN_ROLE)
Purpose: Emergency stop mechanism
Access: Admin only
Effect: When paused, all withdrawals are blocked
Node.js Example:
// Check if paused
const isPaused = await treasury.paused();
console.log('Treasury paused:', isPaused);
// Pause (emergency situation)
if (!isPaused) {
const pauseTx = await treasury.pause();
await pauseTx.wait();
console.log('🛑 Treasury paused!');
// Listen for event
treasury.on('Paused', (account) => {
console.log('Paused by:', account);
});
}
// Try withdrawal while paused (will fail)
try {
await treasury.submitWithdrawal(/*...*/);
} catch (error) {
console.log('❌ Withdrawal blocked - contract is paused');
}
// Unpause (when situation resolved)
if (isPaused) {
const unpauseTx = await treasury.unpause();
await unpauseTx.wait();
console.log('✅ Treasury unpaused');
treasury.on('Unpaused', (account) => {
console.log('Unpaused by:', account);
});
}
// Automated monitoring
async function monitorPauseStatus() {
treasury.on('Paused', async (account) => {
console.log('⚠️ ALERT: Treasury paused by', account);
// Send notification (email, Slack, etc.)
await sendAlert({
type: 'PAUSE',
message: `Treasury paused by ${account}`,
timestamp: new Date()
});
});
treasury.on('Unpaused', async (account) => {
console.log('✅ Treasury unpaused by', account);
await sendAlert({
type: 'UNPAUSE',
message: `Treasury unpaused by ${account}`,
timestamp: new Date()
});
});
}
emergencyWithdraw()function emergencyWithdraw(address tokenAddress, uint256 amount)
external
onlyRole(DEFAULT_ADMIN_ROLE)
Purpose: Extract funds in critical emergency (e.g., contract bug discovered)
Access: Admin only
Warning: Bypasses all security checks - use with extreme caution
Node.js Example:
// EMERGENCY ONLY - Use when contract is compromised
// This should be a last resort
const ADMIN_WALLET = new ethers.Wallet(ADMIN_PRIVATE_KEY, provider);
const treasury = new ethers.Contract(TREASURY_ADDRESS, TREASURY_ABI, ADMIN_WALLET);
// Step 1: Pause first (best practice)
await treasury.pause();
console.log('🛑 Treasury paused');
// Step 2: Emergency withdraw ETH
const ethBalance = await provider.getBalance(TREASURY_ADDRESS);
console.log('Emergency: Withdrawing', ethers.formatEther(ethBalance), 'ETH');
try {
const tx = await treasury.emergencyWithdraw(
ethers.ZeroAddress, // ETH
ethBalance
);
await tx.wait();
console.log('✅ Emergency ETH withdrawal complete');
} catch (error) {
console.error('❌ Emergency withdrawal failed:', error.message);
}
// Step 3: Emergency withdraw ERC20 tokens
const tokens = [
"0x55d398326f99059fF775485246999027B3197955", // USDT
"0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" // USDC
];
for (const tokenAddress of tokens) {
const tokenContract = new ethers.Contract(
tokenAddress,
['function balanceOf(address) view returns (uint256)', 'function symbol() view returns (string)'],
provider
);
const balance = await tokenContract.balanceOf(TREASURY_ADDRESS);
const symbol = await tokenContract.symbol();
if (balance > 0) {
console.log(`Emergency: Withdrawing ${symbol}`);
const tx = await treasury.emergencyWithdraw(tokenAddress, balance);
await tx.wait();
console.log(`✅ ${symbol} withdrawn`);
}
}
console.log('🚨 Emergency evacuation complete');
grantRole() / revokeRole()function grantRole(bytes32 role, address account) external
function revokeRole(bytes32 role, address account) external
Purpose: Manage access control roles
Access: Admin only (or role admin for that specific role)
Node.js Example:
// Get role identifiers
const ADMIN_ROLE = await treasury.DEFAULT_ADMIN_ROLE();
const OPERATOR_ROLE = await treasury.OPERATOR_ROLE();
console.log('Roles:');
console.log('├─ Admin:', ADMIN_ROLE);
console.log('└─ Operator:', OPERATOR_ROLE);
// Check who has roles
async function listRoleMembers(role, roleName) {
console.log(`\n${roleName} members:`);
// Get role member count (if available)
try {
const memberCount = await treasury.getRoleMemberCount(role);
for (let i = 0; i < memberCount; i++) {
const member = await treasury.getRoleMember(role, i);
console.log(`├─ ${member}`);
}
} catch {
// If getRoleMemberCount not available, track via events
const filter = treasury.filters.RoleGranted(role);
const events = await treasury.queryFilter(filter);
const members = new Set();
for (const event of events) {
members.add(event.args.account);
}
members.forEach(member => console.log(`├─ ${member}`));
}
}
await listRoleMembers(ADMIN_ROLE, 'Admin');
await listRoleMembers(OPERATOR_ROLE, 'Operator');
// Grant operator role
const newOperator = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb";
const hasRole = await treasury.hasRole(OPERATOR_ROLE, newOperator);
if (!hasRole) {
const tx = await treasury.grantRole(OPERATOR_ROLE, newOperator);
await tx.wait();
console.log('✅ Operator role granted to', newOperator);
// Listen for event
treasury.on('RoleGranted', (role, account, sender) => {
if (role === OPERATOR_ROLE) {
console.log(`Operator role granted to ${account} by ${sender}`);
}
});
}
// Revoke operator role
const oldOperator = "0x1234...";
if (await treasury.hasRole(OPERATOR_ROLE, oldOperator)) {
const tx = await treasury.revokeRole(OPERATOR_ROLE, oldOperator);
await tx.wait();
console.log('❌ Operator role revoked from', oldOperator);
}
// Check if address has specific role
async function checkRoles(address) {
const roles = {
'Admin': await treasury.hasRole(ADMIN_ROLE, address),
'Operator': await treasury.hasRole(OPERATOR_ROLE, address)
};
console.log(`\nRoles for ${address}:`);
for (const [role, has] of Object.entries(roles)) {
console.log(`├─ ${role}: ${has ? '✅' : '❌'}`);
}
return roles;
}
await checkRoles("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb");
getBalance()function getBalance(address tokenAddress) external view returns (uint256)
Purpose: Check treasury balance for ETH or ERC20 tokens
Access: Public (anyone can read)
Node.js Example:
// Check ETH balance
const ethBalance = await treasury.getBalance(ethers.ZeroAddress);
console.log('Treasury ETH:', ethers.formatEther(ethBalance), 'ETH');
// Check ERC20 balance
const USDT = "0x55d398326f99059fF775485246999027B3197955";
const usdtBalance = await treasury.getBalance(USDT);
console.log('Treasury USDT:', ethers.formatUnits(usdtBalance, 18));
// Get all token balances
const tokens = {
'ETH': ethers.ZeroAddress,
'USDT': "0x55d398326f99059fF775485246999027B3197955",
'USDC': "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",
'BUSD': "0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56"
};
console.log('\n💰 Treasury Balances:');
for (const [symbol, address] of Object.entries(tokens)) {
try {
const balance = await treasury.getBalance(address);
const formatted = symbol === 'ETH'
? ethers.formatEther(balance)
: ethers.formatUnits(balance, 18);
console.log(`├─ ${symbol}: ${formatted}`);
} catch (error) {
console.log(`├─ ${symbol}: Error`);
}
}
// Monitor balance changes
async function monitorBalances() {
let previousBalances = {};
setInterval(async () => {
for (const [symbol, address] of Object.entries(tokens)) {
const balance = await treasury.getBalance(address);
const balanceStr = balance.toString();
if (previousBalances[symbol] && previousBalances[symbol] !== balanceStr) {
const diff = balance - BigInt(previousBalances[symbol]);
const formatted = symbol === 'ETH'
? ethers.formatEther(diff)
: ethers.formatUnits(diff, 18);
console.log(`\n💸 ${symbol} balance changed: ${formatted > 0 ? '+' : ''}${formatted}`);
}
previousBalances[symbol] = balanceStr;
}
}, 30000); // Check every 30 seconds
}
getTreasurySettings()function getTreasurySettings() external view returns (
uint256 _instantWithdrawalThreshold,
uint256 _perUserDailyLimit,
uint256 _globalDailyLimit,
uint256 _timelockDuration,
uint256 _requiredSigners
)
Purpose: Get all configuration settings at once
Access: Public
Node.js Example:
// Get all settings
const settings = await treasury.getTreasurySettings();
console.log('📋 Treasury Settings:');
console.log('├─ Instant Threshold:', ethers.formatEther(settings._instantWithdrawalThreshold), 'ETH');
console.log('├─ Per-User Daily Limit:', ethers.formatEther(settings._perUserDailyLimit), 'ETH');
console.log('├─ Global Daily Limit:', ethers.formatEther(settings._globalDailyLimit), 'ETH');
console.log('├─ Timelock Duration:', settings._timelockDuration / 3600, 'hours');
console.log('└─ Required Signers:', settings._requiredSigners.toString());
// Use destructuring
const {
_instantWithdrawalThreshold,
_perUserDailyLimit,
_globalDailyLimit,
_timelockDuration,
_requiredSigners
} = await treasury.getTreasurySettings();
// Format for display
function formatTreasurySettings(settings) {
return {
instantThreshold: `${ethers.formatEther(settings._instantWithdrawalThreshold)} ETH`,
userDailyLimit: `${ethers.formatEther(settings._perUserDailyLimit)} ETH`,
globalDailyLimit: `${ethers.formatEther(settings._globalDailyLimit)} ETH`,
timelockHours: Number(settings._timelockDuration) / 3600,
requiredSigners: Number(settings._requiredSigners)
};
}
const formatted = formatTreasurySettings(settings);
console.log(JSON.stringify(formatted, null, 2));
getUserDailyUsage()function getUserDailyUsage(address user) external view returns (uint256, uint256)
Purpose: Get user’s daily withdrawal amount and reset time
Returns: (amount withdrawn today, last reset timestamp)
Node.js Example:
// Check user's daily usage
const userAddress = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb";
const [amount, lastReset] = await treasury.getUserDailyUsage(userAddress);
const limit = await treasury.perUserDailyLimit();
const remaining = limit - amount;
const percentUsed = (Number(amount) * 100) / Number(limit);
console.log(`\n👤 Daily Usage for ${userAddress}:`);
console.log('├─ Used:', ethers.formatEther(amount), 'ETH');
console.log('├─ Limit:', ethers.formatEther(limit), 'ETH');
console.log('├─ Remaining:', ethers.formatEther(remaining), 'ETH');
console.log('├─ Percent:', percentUsed.toFixed(2), '%');
console.log('├─ Last reset:', new Date(Number(lastReset) * 1000));
// Calculate when it resets
const now = Math.floor(Date.now() / 1000);
const nextReset = Number(lastReset) + 86400;
const hoursUntilReset = (nextReset - now) / 3600;
console.log('└─ Resets in:', hoursUntilReset.toFixed(1), 'hours');
// Check if user can make withdrawal
async function canUserWithdraw(userAddress, amount) {
const [used, lastReset] = await treasury.getUserDailyUsage(userAddress);
const limit = await treasury.perUserDailyLimit();
const available = limit - used;
if (amount <= available) {
console.log(`✅ User can withdraw ${ethers.formatEther(amount)} ETH`);
return true;
} else {
console.log(`❌ Insufficient daily limit`);
console.log(`Requested: ${ethers.formatEther(amount)} ETH`);
console.log(`Available: ${ethers.formatEther(available)} ETH`);
// Check when they can withdraw after reset
const now = Math.floor(Date.now() / 1000);
const nextReset = Number(lastReset) + 86400;
if (now >= nextReset) {
console.log('✅ Limit will reset immediately on next transaction');
} else {
console.log(`⏳ Wait ${((nextReset - now) / 3600).toFixed(1)} hours for reset`);
}
return false;
}
}
await canUserWithdraw(userAddress, ethers.parseEther("1000"));
getGlobalDailyUsage()function getGlobalDailyUsage() external view returns (uint256, uint256)
Purpose: Get global daily withdrawal amount and reset time
Returns: (total amount withdrawn today, last reset timestamp)
Node.js Example:
// Check global daily usage
const [globalAmount, globalLastReset] = await treasury.getGlobalDailyUsage();
const globalLimit = await treasury.globalDailyLimit();
const globalRemaining = globalLimit - globalAmount;
const globalPercent = (Number(globalAmount) * 100) / Number(globalLimit);
console.log('\n🌍 Global Daily Usage:');
console.log('├─ Used:', ethers.formatEther(globalAmount), 'ETH');
console.log('├─ Limit:', ethers.formatEther(globalLimit), 'ETH');
console.log('├─ Remaining:', ethers.formatEther(globalRemaining), 'ETH');
console.log('├─ Percent:', globalPercent.toFixed(2), '%');
console.log('└─ Last reset:', new Date(Number(globalLastReset) * 1000));
// Warning if approaching limit
if (globalPercent > 80) {
console.warn('⚠️ WARNING: Global daily limit almost reached!');
}
if (globalPercent > 95) {
console.error('🚨 CRITICAL: Global daily limit nearly exhausted!');
}
// Dashboard stats
async function getDashboardStats() {
const [globalUsed] = await treasury.getGlobalDailyUsage();
const globalLimit = await treasury.globalDailyLimit();
const ethBalance = await treasury.getBalance(ethers.ZeroAddress);
const isPaused = await treasury.paused();
return {
treasury: {
ethBalance: ethers.formatEther(ethBalance),
paused: isPaused
},
dailyLimits: {
used: ethers.formatEther(globalUsed),
limit: ethers.formatEther(globalLimit),
remaining: ethers.formatEther(globalLimit - globalUsed),
percentUsed: (Number(globalUsed) * 100) / Number(globalLimit)
},
status: isPaused ? '🛑 PAUSED' : '✅ ACTIVE'
};
}
const stats = await getDashboardStats();
console.log('\n📊 Dashboard:');
console.log(JSON.stringify(stats, null, 2));
// withdrawal-service.js
const ethers = require('ethers');
class WithdrawalService {
constructor(treasuryAddress, provider, adminWallet) {
this.treasury = new ethers.Contract(
treasuryAddress,
TREASURY_ABI,
adminWallet
);
this.provider = provider;
this.adminWallet = adminWallet;
}
// Generate unique request ID
generateRequestId(recipient, tokenAddress, amount, expiry) {
return ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'address', 'uint256', 'uint256', 'uint256'],
[recipient, tokenAddress, amount, expiry, Date.now()]
)
);
}
// Create EIP-712 typed data
async getTypedData(requestId, recipient, tokenAddress, amount, expiry) {
const chainId = (await this.provider.getNetwork()).chainId;
const domain = {
name: 'QubiSafeGuardian',
version: '2.0',
chainId: Number(chainId),
verifyingContract: await this.treasury.getAddress()
};
const types = {
WithdrawalRequest: [
{ name: 'requestId', type: 'bytes32' },
{ name: 'recipient', type: 'address' },
{ name: 'tokenAddress', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'expiry', type: 'uint256' }
]
};
const value = {
requestId,
recipient,
tokenAddress,
amount,
expiry
};
return { domain, types, value };
}
// Collect signatures from multiple signers
async collectSignatures(signerWallets, typedData) {
const signatures = [];
for (const wallet of signerWallets) {
const signature = await wallet.signTypedData(
typedData.domain,
typedData.types,
typedData.value
);
signatures.push(signature);
}
return signatures;
}
// Pre-flight checks
async validateWithdrawal(recipient, tokenAddress, amount) {
const errors = [];
// Check if paused
const isPaused = await this.treasury.paused();
if (isPaused) {
errors.push('Treasury is paused');
}
// Check token allowlist
if (tokenAddress !== ethers.ZeroAddress) {
const isAllowed = await this.treasury.tokenAllowlist(tokenAddress);
if (!isAllowed) {
errors.push('Token not in allowlist');
}
}
// Check treasury balance
const balance = await this.treasury.getBalance(tokenAddress);
if (balance < amount) {
errors.push(`Insufficient treasury balance. Has: ${ethers.formatEther(balance)}, Need: ${ethers.formatEther(amount)}`);
}
// Check user daily limit
const [userUsed] = await this.treasury.getUserDailyUsage(recipient);
const userLimit = await this.treasury.perUserDailyLimit();
if (userUsed + amount > userLimit) {
errors.push(`User daily limit exceeded. Used: ${ethers.formatEther(userUsed)}, Limit: ${ethers.formatEther(userLimit)}`);
}
// Check global daily limit
const [globalUsed] = await this.treasury.getGlobalDailyUsage();
const globalLimit = await this.treasury.globalDailyLimit();
if (globalUsed + amount > globalLimit) {
errors.push(`Global daily limit exceeded. Used: ${ethers.formatEther(globalUsed)}, Limit: ${ethers.formatEther(globalLimit)}`);
}
return {
valid: errors.length === 0,
errors
};
}
// Main withdrawal function
async submitWithdrawal(recipient, tokenAddress, amount, signerWallets, expiryMinutes = 60) {
console.log('\n🔄 Starting withdrawal process...');
// Step 1: Validate
console.log('1️⃣ Validating withdrawal...');
const validation = await this.validateWithdrawal(recipient, tokenAddress, amount);
if (!validation.valid) {
console.error('❌ Validation failed:');
validation.errors.forEach(err => console.error(' -', err));
throw new Error('Validation failed: ' + validation.errors.join(', '));
}
console.log('✅ Validation passed');
// Step 2: Generate request ID
const expiry = Math.floor(Date.now() / 1000) + (expiryMinutes * 60);
const requestId = this.generateRequestId(recipient, tokenAddress, amount, expiry);
console.log('2️⃣ Request ID:', requestId);
// Step 3: Check if request ID already used
const isUsed = await this.treasury.usedRequestIds(requestId);
if (isUsed) {
throw new Error('Request ID already used');
}
// Step 4: Create typed data
console.log('3️⃣ Creating typed data...');
const typedData = await this.getTypedData(requestId, recipient, tokenAddress, amount, expiry);
// Step 5: Collect signatures
console.log('4️⃣ Collecting signatures...');
const signatures = await this.collectSignatures(signerWallets, typedData);
console.log(`✅ Collected ${signatures.length} signatures`);
// Step 6: Submit transaction
console.log('5️⃣ Submitting withdrawal...');
const tx = await this.treasury.submitWithdrawal(
requestId,
recipient,
tokenAddress,
amount,
expiry,
signatures
);
console.log('📤 Transaction sent:', tx.hash);
// Step 7: Wait for confirmation
console.log('6️⃣ Waiting for confirmation...');
const receipt = await tx.wait();
console.log('✅ Transaction confirmed!');
console.log('Gas used:', receipt.gasUsed.toString());
// Step 8: Parse events
const events = receipt.logs
.map(log => {
try { return this.treasury.interface.parseLog(log); } catch { return null; }
})
.filter(e => e !== null);
const result = { requestId, txHash: receipt.hash, events: [] };
for (const event of events) {
if (event.name === 'WithdrawalExecuted') {
console.log('\n✅ INSTANT WITHDRAWAL EXECUTED');
console.log('├─ Recipient:', event.args.recipient);
console.log('├─ Amount:', ethers.formatEther(event.args.amount));
console.log('└─ Token:', event.args.tokenAddress);
result.status = 'executed';
result.events.push({ type: 'executed', data: event.args });
} else if (event.name === 'WithdrawalQueued') {
console.log('\n⏳ WITHDRAWAL QUEUED FOR TIMELOCK');
console.log('├─ Recipient:', event.args.recipient);
console.log('├─ Amount:', ethers.formatEther(event.args.amount));
console.log('├─ Queued at:', new Date(Number(event.args.queuedAt) * 1000));
const timelockDuration = await this.treasury.timelockDuration();
const executeAt = Number(event.args.queuedAt) + Number(timelockDuration);
console.log('└─ Can execute at:', new Date(executeAt * 1000));
result.status = 'queued';
result.executeAt = executeAt;
result.events.push({ type: 'queued', data: event.args });
}
}
return result;
}
}
// Usage example
async function main() {
const provider = new ethers.JsonRpcProvider(RPC_URL);
const adminWallet = new ethers.Wallet(ADMIN_PRIVATE_KEY, provider);
const signer1 = new ethers.Wallet(SIGNER1_PRIVATE_KEY, provider);
const signer2 = new ethers.Wallet(SIGNER2_PRIVATE_KEY, provider);
const service = new WithdrawalService(TREASURY_ADDRESS, provider, adminWallet);
// Submit withdrawal
const result = await service.submitWithdrawal(
"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", // recipient
ethers.ZeroAddress, // ETH
ethers.parseEther("500"), // 500 ETH
[signer1, signer2], // signers
60 // 60 minute expiry
);
console.log('\n📋 Final Result:');
console.log(JSON.stringify(result, null, 2));
}
main().catch(console.error);
The current contract always requires multi-signature. But you can make it easier:
requiredSigners to 1 (Easiest)This effectively disables multi-sig - only 1 signature needed:
// Configure for single signature (no multi-sig headache)
const tx = await treasury.updateTreasurySettings(
ethers.parseEther("1000"), // instant threshold
ethers.parseEther("5000"), // user daily limit
ethers.parseEther("50000"), // global daily limit
86400, // timelock (24 hours)
1 // ⬅️ ONLY 1 SIGNER REQUIRED!
);
await tx.wait();
console.log('✅ Multi-sig disabled - only 1 signature needed');
// Now you only need 1 signature
const signer1 = new ethers.Wallet(SIGNER1_PRIVATE_KEY, provider);
const typedData = await getTypedData(requestId, recipient, tokenAddress, amount, expiry);
const signature = await signer1.signTypedData(
typedData.domain,
typedData.types,
typedData.value
);
// Submit with just ONE signature
await treasury.submitWithdrawal(
requestId,
recipient,
tokenAddress,
amount,
expiry,
[signature] // ⬅️ Only 1 signature in array!
);
Admin can pre-sign requests for operators to submit later:
// admin-signer-service.js
// This service runs on admin's secure server
// It auto-signs withdrawal requests and stores signatures in database
const express = require('express');
const app = express();
// Admin wallet (kept secure)
const adminWallet = new ethers.Wallet(ADMIN_PRIVATE_KEY, provider);
// API endpoint to get admin signature
app.post('/api/sign-withdrawal', async (req, res) => {
const { requestId, recipient, tokenAddress, amount, expiry } = req.body;
// Optional: Add approval logic here
// e.g., check amount limits, whitelist recipients, etc.
if (amount > ethers.parseEther("10000")) {
return res.status(403).json({ error: 'Amount too high - needs manual approval' });
}
// Create typed data
const domain = {
name: 'QubiSafeGuardian',
version: '2.0',
chainId: 97,
verifyingContract: TREASURY_ADDRESS
};
const types = {
WithdrawalRequest: [
{ name: 'requestId', type: 'bytes32' },
{ name: 'recipient', type: 'address' },
{ name: 'tokenAddress', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'expiry', type: 'uint256' }
]
};
const value = { requestId, recipient, tokenAddress, amount, expiry };
// Sign it
const signature = await adminWallet.signTypedData(domain, types, value);
// Log for audit
console.log('Admin signed withdrawal:', { requestId, recipient, amount });
res.json({ signature, signer: adminWallet.address });
});
app.listen(3000, () => console.log('Admin signer service running on port 3000'));
// operator-client.js
// Operators call this API to get admin signature automatically
async function submitWithdrawalWithAutoSign(recipient, tokenAddress, amount) {
const expiry = Math.floor(Date.now() / 1000) + 3600;
const requestId = generateRequestId(recipient, tokenAddress, amount, expiry);
// Get admin signature automatically from API
const response = await fetch('http://admin-server:3000/api/sign-withdrawal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, recipient, tokenAddress, amount, expiry })
});
const { signature: adminSignature } = await response.json();
// Operator adds their own signature
const operatorWallet = new ethers.Wallet(OPERATOR_PRIVATE_KEY, provider);
const typedData = await getTypedData(requestId, recipient, tokenAddress, amount, expiry);
const operatorSignature = await operatorWallet.signTypedData(
typedData.domain,
typedData.types,
typedData.value
);
// Submit with both signatures
const tx = await treasury.submitWithdrawal(
requestId,
recipient,
tokenAddress,
amount,
expiry,
[adminSignature, operatorSignature]
);
await tx.wait();
console.log('✅ Withdrawal submitted with auto-signed admin signature');
}
If you trust your backend, use a single wallet that’s both signer and submitter:
// single-wallet-withdrawal.js
// One wallet does everything
const trustedWallet = new ethers.Wallet(TRUSTED_PRIVATE_KEY, provider);
const treasury = new ethers.Contract(TREASURY_ADDRESS, TREASURY_ABI, trustedWallet);
// Make sure this wallet is an authorized signer
await treasury.updateSigner(trustedWallet.address, true);
// Set required signers to 1
await treasury.updateTreasurySettings(
ethers.parseEther("1000"),
ethers.parseEther("5000"),
ethers.parseEther("50000"),
86400,
1 // Only 1 signature needed
);
// Now you can submit withdrawals easily
async function easyWithdrawal(recipient, amount) {
const requestId = generateRequestId(recipient, ethers.ZeroAddress, amount, expiry);
const expiry = Math.floor(Date.now() / 1000) + 3600;
const typedData = await getTypedData(requestId, recipient, ethers.ZeroAddress, amount, expiry);
// Sign with the same wallet
const signature = await trustedWallet.signTypedData(
typedData.domain,
typedData.types,
typedData.value
);
// Submit
await treasury.submitWithdrawal(
requestId,
recipient,
ethers.ZeroAddress,
amount,
expiry,
[signature] // Just one signature!
);
console.log('✅ Withdrawal submitted easily');
}
await easyWithdrawal("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb", ethers.parseEther("100"));
The contract has automatic decision-making based on amount:
But you want more control! Here’s how to implement flags for auto/manual:
// withdrawal-manager.js
// This adds auto/manual flags in your Node.js backend
class WithdrawalManager {
constructor(treasury, provider) {
this.treasury = treasury;
this.provider = provider;
this.db = {}; // Use real database in production
}
/**
* Submit withdrawal with auto/manual flag
* @param {Object} params
* @param {string} params.recipient - Recipient address
* @param {string} params.tokenAddress - Token address (use ZeroAddress for ETH)
* @param {BigInt} params.amount - Amount to withdraw
* @param {Array} params.signerWallets - Signer wallets
* @param {boolean} params.autoExecute - TRUE = automatic, FALSE = manual approval needed
* @param {boolean} params.bypassTimelock - TRUE = force instant even if large amount
*/
async submitWithdrawal({
recipient,
tokenAddress,
amount,
signerWallets,
autoExecute = true,
bypassTimelock = false
}) {
const expiry = Math.floor(Date.now() / 1000) + 3600;
const requestId = this.generateRequestId(recipient, tokenAddress, amount, expiry);
console.log('\n🔄 Withdrawal Request:');
console.log('├─ Recipient:', recipient);
console.log('├─ Amount:', ethers.formatEther(amount), 'ETH');
console.log('├─ Auto-execute:', autoExecute ? '✅ YES' : '❌ NO (Manual)');
console.log('└─ Bypass timelock:', bypassTimelock ? '✅ YES' : '❌ NO');
// MANUAL FLAG: Store in database for approval workflow
if (!autoExecute) {
await this.storeForManualApproval({
requestId,
recipient,
tokenAddress,
amount,
status: 'PENDING_APPROVAL',
createdAt: Date.now(),
approvedBy: null
});
console.log('⏸️ Withdrawal stored for MANUAL approval');
console.log('An admin must approve this in the dashboard');
return {
status: 'pending_manual_approval',
requestId,
message: 'Waiting for admin approval in dashboard'
};
}
// AUTO FLAG: Proceed automatically
console.log('🤖 Proceeding with AUTOMATIC withdrawal');
// Check if amount would normally be queued
const threshold = await this.treasury.instantWithdrawalThreshold();
if (amount > threshold && !bypassTimelock) {
console.log('⚠️ Amount exceeds instant threshold');
console.log('This will be queued for timelock (24 hours)');
// You can choose to:
// A) Still proceed (let it queue)
// B) Ask for manual approval
// C) Cancel
const userChoice = bypassTimelock ? 'proceed' : 'queue';
if (userChoice === 'queue') {
console.log('📋 Proceeding with QUEUED withdrawal');
// Fallthrough to submission
}
}
// Create typed data and signatures
const typedData = await this.getTypedData(requestId, recipient, tokenAddress, amount, expiry);
const signatures = await this.collectSignatures(signerWallets, typedData);
// Submit to blockchain
const tx = await this.treasury.submitWithdrawal(
requestId,
recipient,
tokenAddress,
amount,
expiry,
signatures
);
const receipt = await tx.wait();
// Check if instant or queued
const events = receipt.logs
.map(log => {
try { return this.treasury.interface.parseLog(log); } catch { return null; }
})
.filter(e => e !== null);
for (const event of events) {
if (event.name === 'WithdrawalExecuted') {
console.log('✅ AUTOMATIC INSTANT EXECUTION');
return {
status: 'executed',
requestId,
txHash: receipt.hash,
executionType: 'instant'
};
} else if (event.name === 'WithdrawalQueued') {
const executeAt = Number(event.args.queuedAt) + Number(await this.treasury.timelockDuration());
console.log('⏳ AUTOMATIC QUEUED (needs operator execution after timelock)');
// Store in database for auto-executor service
await this.storeQueuedRequest({
requestId,
executeAt,
autoExecute: true // Flag for auto-executor
});
return {
status: 'queued',
requestId,
txHash: receipt.hash,
executeAt: new Date(executeAt * 1000),
executionType: 'queued_auto'
};
}
}
}
// Store withdrawal for manual approval
async storeForManualApproval(data) {
this.db[data.requestId] = {
...data,
type: 'manual_approval_pending'
};
// In production: Save to database
// await db.withdrawals.create(data);
// Send notification to admins
await this.notifyAdmins({
type: 'WITHDRAWAL_NEEDS_APPROVAL',
requestId: data.requestId,
amount: ethers.formatEther(data.amount),
recipient: data.recipient
});
}
// Admin approves manual withdrawal
async approveManualWithdrawal(requestId, adminAddress) {
const withdrawal = this.db[requestId];
if (!withdrawal) {
throw new Error('Withdrawal not found');
}
if (withdrawal.status !== 'PENDING_APPROVAL') {
throw new Error('Withdrawal already processed');
}
console.log(`\n👍 Admin ${adminAddress} approved withdrawal ${requestId}`);
// Update status
withdrawal.status = 'APPROVED';
withdrawal.approvedBy = adminAddress;
withdrawal.approvedAt = Date.now();
// Now submit to blockchain
console.log('🚀 Submitting approved withdrawal to blockchain...');
// Get signatures and submit
// (Implementation same as above)
return { status: 'approved_and_submitted' };
}
// Admin rejects manual withdrawal
async rejectManualWithdrawal(requestId, adminAddress, reason) {
const withdrawal = this.db[requestId];
if (!withdrawal) {
throw new Error('Withdrawal not found');
}
withdrawal.status = 'REJECTED';
withdrawal.rejectedBy = adminAddress;
withdrawal.rejectedAt = Date.now();
withdrawal.rejectionReason = reason;
console.log(`\n❌ Admin ${adminAddress} rejected withdrawal ${requestId}`);
console.log('Reason:', reason);
return { status: 'rejected' };
}
// Helper methods
generateRequestId(recipient, tokenAddress, amount, expiry) {
return ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'address', 'uint256', 'uint256', 'uint256'],
[recipient, tokenAddress, amount, expiry, Date.now()]
)
);
}
async getTypedData(requestId, recipient, tokenAddress, amount, expiry) {
const chainId = (await this.provider.getNetwork()).chainId;
return {
domain: {
name: 'QubiSafeGuardian',
version: '2.0',
chainId: Number(chainId),
verifyingContract: await this.treasury.getAddress()
},
types: {
WithdrawalRequest: [
{ name: 'requestId', type: 'bytes32' },
{ name: 'recipient', type: 'address' },
{ name: 'tokenAddress', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'expiry', type: 'uint256' }
]
},
value: { requestId, recipient, tokenAddress, amount, expiry }
};
}
async collectSignatures(signerWallets, typedData) {
const signatures = [];
for (const wallet of signerWallets) {
const sig = await wallet.signTypedData(
typedData.domain,
typedData.types,
typedData.value
);
signatures.push(sig);
}
return signatures;
}
async notifyAdmins(notification) {
// Implement your notification system
console.log('📧 Notification sent to admins:', notification);
// e.g., send email, Slack message, push notification
}
async storeQueuedRequest(data) {
this.db[data.requestId] = {
...this.db[data.requestId],
...data,
type: 'queued_for_execution'
};
}
}
// Usage Examples
async function examples() {
const provider = new ethers.JsonRpcProvider(RPC_URL);
const treasury = new ethers.Contract(TREASURY_ADDRESS, TREASURY_ABI, provider);
const manager = new WithdrawalManager(treasury, provider);
const signer1 = new ethers.Wallet(SIGNER1_PRIVATE_KEY, provider);
const signer2 = new ethers.Wallet(SIGNER2_PRIVATE_KEY, provider);
// Example 1: AUTOMATIC small withdrawal
console.log('\n📝 Example 1: Automatic Small Withdrawal');
await manager.submitWithdrawal({
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
tokenAddress: ethers.ZeroAddress,
amount: ethers.parseEther("100"),
signerWallets: [signer1, signer2],
autoExecute: true // ✅ AUTOMATIC
});
// Example 2: MANUAL approval required
console.log('\n📝 Example 2: Manual Approval Required');
const result = await manager.submitWithdrawal({
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
tokenAddress: ethers.ZeroAddress,
amount: ethers.parseEther("100"),
signerWallets: [signer1, signer2],
autoExecute: false // ❌ MANUAL - needs approval
});
console.log('Result:', result);
// { status: 'pending_manual_approval', requestId: '0x...', message: '...' }
// Admin approves it later
await manager.approveManualWithdrawal(result.requestId, ADMIN_ADDRESS);
// Example 3: AUTOMATIC large withdrawal (will be queued)
console.log('\n📝 Example 3: Automatic Large Withdrawal (Queued)');
await manager.submitWithdrawal({
recipient: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb",
tokenAddress: ethers.ZeroAddress,
amount: ethers.parseEther("5000"), // Above threshold
signerWallets: [signer1, signer2],
autoExecute: true, // ✅ AUTOMATIC
bypassTimelock: false // ❌ Don't bypass - let it queue
});
// Example 4: FORCE instant execution (bypass timelock)
// WARNING: This doesn't actually work with current contract!
// You'd need to modify the contract to support this
console.log('\n📝 Example 4: Force Instant (Not supported by contract)');
console.log('⚠️ Current contract always queues amounts > threshold');
console.log('To support this, you need to modify the smart contract');
}
examples().catch(console.error);
// auto-executor-service.js
// This service automatically executes queued withdrawals after timelock
class AutoExecutorService {
constructor(treasury, operatorWallet) {
this.treasury = treasury.connect(operatorWallet);
this.operatorWallet = operatorWallet;
this.queuedRequests = new Map();
}
async start() {
console.log('🤖 Auto-Executor Service Started');
console.log('Operator:', this.operatorWallet.address);
// Listen for new queued withdrawals
this.treasury.on('WithdrawalQueued', async (requestId, recipient, tokenAddress, amount, queuedAt) => {
console.log('\n📥 New queued withdrawal detected');
console.log('Request ID:', requestId);
const timelockDuration = await this.treasury.timelockDuration();
const executeAt = Number(queuedAt) + Number(timelockDuration);
// Check if this should be auto-executed
// (Check your database flag)
const shouldAutoExecute = true; // Get from DB
if (shouldAutoExecute) {
this.scheduleExecution(requestId, executeAt);
} else {
console.log('⏸️ Manual execution required - skipping auto-execute');
}
});
// Check for existing queued requests
await this.checkExistingQueued();
// Periodic check every 5 minutes
setInterval(() => this.checkExistingQueued(), 5 * 60 * 1000);
}
scheduleExecution(requestId, executeAt) {
const now = Math.floor(Date.now() / 1000);
const delay = (executeAt - now + 30) * 1000; // Add 30s buffer
console.log(`⏰ Scheduled execution for ${new Date(executeAt * 1000)}`);
console.log(` Delay: ${delay / 1000 / 60} minutes`);
if (delay < 0) {
// Already executable
this.executeNow(requestId);
} else {
// Schedule for later
const timeoutId = setTimeout(() => {
this.executeNow(requestId);
}, delay);
this.queuedRequests.set(requestId, {
executeAt,
timeoutId,
status: 'scheduled'
});
}
}
async executeNow(requestId) {
try {
console.log(`\n⚡ Executing queued withdrawal: ${requestId}`);
// Double-check it's ready
const request = await this.treasury.queuedRequests(requestId);
if (request.executed) {
console.log('✅ Already executed');
return;
}
if (request.cancelled) {
console.log('❌ Cancelled');
return;
}
const timelockDuration = await this.treasury.timelockDuration();
const canExecuteAt = Number(request.queuedAt) + Number(timelockDuration);
const now = Math.floor(Date.now() / 1000);
if (now < canExecuteAt) {
console.log('⏳ Timelock not expired yet - rescheduling');
this.scheduleExecution(requestId, canExecuteAt);
return;
}
// Execute
const tx = await this.treasury.executeQueuedWithdrawal(requestId);
console.log('📤 Transaction sent:', tx.hash);
const receipt = await tx.wait();
console.log('✅ Executed successfully!');
console.log('Gas used:', receipt.gasUsed.toString());
// Remove from scheduled
this.queuedRequests.delete(requestId);
// Update database
// await db.withdrawals.update({ requestId }, { status: 'EXECUTED' });
} catch (error) {
console.error('❌ Execution failed:', error.message);
// Retry logic
console.log('🔄 Will retry in 5 minutes');
setTimeout(() => this.executeNow(requestId), 5 * 60 * 1000);
}
}
async checkExistingQueued() {
console.log('\n🔍 Checking for existing queued withdrawals...');
const filter = this.treasury.filters.WithdrawalQueued();
const events = await this.treasury.queryFilter(filter, -50000); // Last 50k blocks
for (const event of events) {
const { requestId, queuedAt } = event.args;
// Skip if already scheduled
if (this.queuedRequests.has(requestId)) continue;
// Check if still pending
const request = await this.treasury.queuedRequests(requestId);
if (!request.executed && !request.cancelled) {
const timelockDuration = await this.treasury.timelockDuration();
const executeAt = Number(queuedAt) + Number(timelockDuration);
console.log(`Found pending request: ${requestId}`);
this.scheduleExecution(requestId, executeAt);
}
}
}
stop() {
console.log('🛑 Stopping auto-executor service');
// Clear all scheduled executions
for (const [requestId, data] of this.queuedRequests) {
if (data.timeoutId) {
clearTimeout(data.timeoutId);
}
}
this.queuedRequests.clear();
this.treasury.removeAllListeners();
}
}
// Run the service
async function main() {
const provider = new ethers.JsonRpcProvider(RPC_URL);
const operatorWallet = new ethers.Wallet(OPERATOR_PRIVATE_KEY, provider);
const treasury = new ethers.Contract(TREASURY_ADDRESS, TREASURY_ABI, provider);
const service = new AutoExecutorService(treasury, operatorWallet);
await service.start();
// Keep running
console.log('Service running... Press Ctrl+C to stop');
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n⚠️ Received SIGINT signal');
service.stop();
process.exit(0);
});
}
main().catch(console.error);
// dashboard-api.js
// Express API for admin dashboard
const express = require('express');
const app = express();
app.use(express.json());
const withdrawalManager = new WithdrawalManager(treasury, provider);
// Get all pending manual approvals
app.get('/api/withdrawals/pending', async (req, res) => {
const pending = Object.values(withdrawalManager.db)
.filter(w => w.status === 'PENDING_APPROVAL')
.map(w => ({
requestId: w.requestId,
recipient: w.recipient,
amount: ethers.formatEther(w.amount),
token: w.tokenAddress === ethers.ZeroAddress ? 'ETH' : w.tokenAddress,
createdAt: new Date(w.createdAt),
type: 'Manual Approval Required'
}));
res.json({ pending, count: pending.length });
});
// Approve withdrawal
app.post('/api/withdrawals/:requestId/approve', async (req, res) => {
const { requestId } = req.params;
const { adminAddress } = req.body;
try {
const result = await withdrawalManager.approveManualWithdrawal(requestId, adminAddress);
res.json({ success: true, ...result });
} catch (error) {
res.status(400).json({ success: false, error: error.message });
}
});
// Reject withdrawal
app.post('/api/withdrawals/:requestId/reject', async (req, res) => {
const { requestId } = req.params;
const { adminAddress, reason } = req.body;
try {
const result = await withdrawalManager.rejectManualWithdrawal(requestId, adminAddress, reason);
res.json({ success: true, ...result });
} catch (error) {
res.status(400).json({ success: false, error: error.message });
}
});
// Get withdrawal status
app.get('/api/withdrawals/:requestId', async (req, res) => {
const { requestId } = req.params;
const withdrawal = withdrawalManager.db[requestId];
if (!withdrawal) {
return res.status(404).json({ error: 'Withdrawal not found' });
}
res.json(withdrawal);
});
app.listen(4000, () => {
console.log('📊 Dashboard API running on port 4000');
});
// QUICK REFERENCE
// 1. FULLY AUTOMATIC (no manual intervention)
await manager.submitWithdrawal({
recipient: "0x...",
tokenAddress: ethers.ZeroAddress,
amount: ethers.parseEther("100"),
signerWallets: [signer1, signer2],
autoExecute: true // ✅ Goes straight through
});
// 2. MANUAL APPROVAL REQUIRED (stops for admin review)
await manager.submitWithdrawal({
recipient: "0x...",
tokenAddress: ethers.ZeroAddress,
amount: ethers.parseEther("100"),
signerWallets: [signer1, signer2],
autoExecute: false // ❌ Waits for admin approval
});
// 3. AUTOMATIC but QUEUED (for large amounts)
await manager.submitWithdrawal({
recipient: "0x...",
tokenAddress: ethers.ZeroAddress,
amount: ethers.parseEther("5000"), // Above threshold
signerWallets: [signer1, signer2],
autoExecute: true // ✅ Auto-executes after 24h timelock
});
// 4. SET REQUIRED SIGNERS TO 1 (effectively disable multi-sig)
await treasury.updateTreasurySettings(
ethers.parseEther("1000"),
ethers.parseEther("5000"),
ethers.parseEther("50000"),
86400,
1 // ⬅️ Only 1 signature needed!
);
// production-treasury-api.js
// Full production-ready implementation
const express = require('express');
const ethers = require('ethers');
const winston = require('winston');
// Logger setup
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
new winston.transports.Console({ format: winston.format.simple() })
]
});
// Configuration
const config = {
rpcUrl: process.env.RPC_URL,
treasuryAddress: process.env.TREASURY_ADDRESS,
adminPrivateKey: process.env.ADMIN_PRIVATE_KEY,
signer1PrivateKey: process.env.SIGNER1_PRIVATE_KEY,
signer2PrivateKey: process.env.SIGNER2_PRIVATE_KEY,
operatorPrivateKey: process.env.OPERATOR_PRIVATE_KEY
};
// Initialize
const provider = new ethers.JsonRpcProvider(config.rpcUrl);
const adminWallet = new ethers.Wallet(config.adminPrivateKey, provider);
const signer1 = new ethers.Wallet(config.signer1PrivateKey, provider);
const signer2 = new ethers.Wallet(config.signer2PrivateKey, provider);
const operatorWallet = new ethers.Wallet(config.operatorPrivateKey, provider);
const treasury = new ethers.Contract(config.treasuryAddress, TREASURY_ABI, adminWallet);
const app = express();
app.use(express.json());
// Middleware: Authentication
app.use((req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (apiKey !== process.env.API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});
// POST /api/withdraw - Submit withdrawal
app.post('/api/withdraw', async (req, res) => {
try {
const { recipient, tokenAddress, amount, autoExecute = true } = req.body;
// Validation
if (!ethers.isAddress(recipient)) {
return res.status(400).json({ error: 'Invalid recipient address' });
}
if (tokenAddress !== 'ETH' && !ethers.isAddress(tokenAddress)) {
return res.status(400).json({ error: 'Invalid token address' });
}
const amountWei = ethers.parseEther(amount.toString());
const tokenAddr = tokenAddress === 'ETH' ? ethers.ZeroAddress : tokenAddress;
logger.info('Withdrawal request received', { recipient, amount, tokenAddress, autoExecute });
// Pre-flight checks
const validation = await validateWithdrawal(recipient, tokenAddr, amountWei);
if (!validation.valid) {
return res.status(400).json({ error: 'Validation failed', details: validation.errors });
}
// Generate request
const expiry = Math.floor(Date.now() / 1000) + 3600;
const requestId = generateRequestId(recipient, tokenAddr, amountWei, expiry);
// Get signatures
const typedData = await getTypedData(requestId, recipient, tokenAddr, amountWei, expiry);
const signatures = [
await signer1.signTypedData(typedData.domain, typedData.types, typedData.value),
await signer2.signTypedData(typedData.domain, typedData.types, typedData.value)
];
// Submit
const tx = await treasury.submitWithdrawal(
requestId,
recipient,
tokenAddr,
amountWei,
expiry,
signatures
);
const receipt = await tx.wait();
// Parse result
const events = receipt.logs
.map(log => {
try { return treasury.interface.parseLog(log); } catch { return null; }
})
.filter(e => e !== null);
let status = 'unknown';
for (const event of events) {
if (event.name === 'WithdrawalExecuted') {
status = 'executed';
} else if (event.name === 'WithdrawalQueued') {
status = 'queued';
}
}
logger.info('Withdrawal submitted', { requestId, status, txHash: receipt.hash });
res.json({
success: true,
requestId,
status,
txHash: receipt.hash,
gasUsed: receipt.gasUsed.toString()
});
} catch (error) {
logger.error('Withdrawal failed', { error: error.message, stack: error.stack });
res.status(500).json({ error: error.message });
}
});
// GET /api/balance - Get treasury balances
app.get('/api/balance', async (req, res) => {
try {
const ethBalance = await treasury.getBalance(ethers.ZeroAddress);
const tokens = {
'USDT': '0x55d398326f99059fF775485246999027B3197955',
'USDC': '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d'
};
const balances = {
ETH: ethers.formatEther(ethBalance)
};
for (const [symbol, address] of Object.entries(tokens)) {
try {
const balance = await treasury.getBalance(address);
balances[symbol] = ethers.formatUnits(balance, 18);
} catch {
balances[symbol] = '0';
}
}
res.json({ balances });
} catch (error) {
logger.error('Failed to get balances', { error: error.message });
res.status(500).json({ error: error.message });
}
});
// GET /api/status - Get treasury status
app.get('/api/status', async (req, res) => {
try {
const [
settings,
isPaused,
[globalUsed, globalLastReset],
ethBalance
] = await Promise.all([
treasury.getTreasurySettings(),
treasury.paused(),
treasury.getGlobalDailyUsage(),
treasury.getBalance(ethers.ZeroAddress)
]);
res.json({
paused: isPaused,
balance: {
eth: ethers.formatEther(ethBalance)
},
settings: {
instantThreshold: ethers.formatEther(settings._instantWithdrawalThreshold),
perUserDailyLimit: ethers.formatEther(settings._perUserDailyLimit),
globalDailyLimit: ethers.formatEther(settings._globalDailyLimit),
timelockHours: Number(settings._timelockDuration) / 3600,
requiredSigners: Number(settings._requiredSigners)
},
dailyUsage: {
used: ethers.formatEther(globalUsed),
limit: ethers.formatEther(settings._globalDailyLimit),
remaining: ethers.formatEther(settings._globalDailyLimit - globalUsed),
percentUsed: (Number(globalUsed) * 100) / Number(settings._globalDailyLimit)
}
});
} catch (error) {
logger.error('Failed to get status', { error: error.message });
res.status(500).json({ error: error.message });
}
});
// POST /api/admin/pause - Emergency pause
app.post('/api/admin/pause', async (req, res) => {
try {
const tx = await treasury.pause();
await tx.wait();
logger.warn('Treasury paused', { by: adminWallet.address });
res.json({ success: true, message: 'Treasury paused' });
} catch (error) {
logger.error('Failed to pause', { error: error.message });
res.status(500).json({ error: error.message });
}
});
// POST /api/admin/unpause - Resume operations
app.post('/api/admin/unpause', async (req, res) => {
try {
const tx = await treasury.unpause();
await tx.wait();
logger.info('Treasury unpaused', { by: adminWallet.address });
res.json({ success: true, message: 'Treasury unpaused' });
} catch (error) {
logger.error('Failed to unpause', { error: error.message });
res.status(500).json({ error: error.message });
}
});
// Helper functions
function generateRequestId(recipient, tokenAddress, amount, expiry) {
return ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
['address', 'address', 'uint256', 'uint256', 'uint256'],
[recipient, tokenAddress, amount, expiry, Date.now()]
)
);
}
async function getTypedData(requestId, recipient, tokenAddress, amount, expiry) {
const chainId = (await provider.getNetwork()).chainId;
return {
domain: {
name: 'QubiSafeGuardian',
version: '2.0',
chainId: Number(chainId),
verifyingContract: config.treasuryAddress
},
types: {
WithdrawalRequest: [
{ name: 'requestId', type: 'bytes32' },
{ name: 'recipient', type: 'address' },
{ name: 'tokenAddress', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'expiry', type: 'uint256' }
]
},
value: { requestId, recipient, tokenAddress, amount, expiry }
};
}
async function validateWithdrawal(recipient, tokenAddress, amount) {
const errors = [];
const isPaused = await treasury.paused();
if (isPaused) errors.push('Treasury is paused');
if (tokenAddress !== ethers.ZeroAddress) {
const isAllowed = await treasury.tokenAllowlist(tokenAddress);
if (!isAllowed) errors.push('Token not allowed');
}
const balance = await treasury.getBalance(tokenAddress);
if (balance < amount) errors.push('Insufficient treasury balance');
return { valid: errors.length === 0, errors };
}
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
logger.info(`Treasury API running on port ${PORT}`);
});