diff --git a/packages/worker/.env.example b/packages/worker/.env.example index 7889d54f7..0878e85b2 100644 --- a/packages/worker/.env.example +++ b/packages/worker/.env.example @@ -20,3 +20,6 @@ WORKER_REDIS_PORT=6379 WORKER_ENVIRONMENT=production WORKER_NAME=bako-worker WORKER_PORT=3063 + +# Worker transaction +TRANSACTION_CRON_INTERVAL_MS=1200000 \ No newline at end of file diff --git a/packages/worker/.gitignore b/packages/worker/.gitignore new file mode 100644 index 000000000..7f0ad7edd --- /dev/null +++ b/packages/worker/.gitignore @@ -0,0 +1 @@ +/src/queues/generateTestTx/config/ diff --git a/packages/worker/package.json b/packages/worker/package.json index c96a69480..fbd89414c 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -18,6 +18,8 @@ "@types/node-cron": "3.0.11", "bull": "^4.16.5", "express": "4.21.2", + "bakosafe": "0.6.0", + "pino": "9.6.0", "fuels": "0.101.3", "ioredis": "^5.7.0", "mongodb": "^6.18.0", @@ -48,6 +50,10 @@ "prettier": "2.2.1", "pretty-quick": "3.1.0", "ts-node-dev": "2.0.0", - "tscpaths": "0.0.9" + "tscpaths": "0.0.9", + "pino-pretty": "11.2.2", + "viem": "^2.30.6", + "ethers": "^6.14.3", + "zod": "^4.3.6" } } diff --git a/packages/worker/src/clients/psqlClient.ts b/packages/worker/src/clients/psqlClient.ts index 73d6b0d16..93b05a830 100644 --- a/packages/worker/src/clients/psqlClient.ts +++ b/packages/worker/src/clients/psqlClient.ts @@ -14,7 +14,7 @@ interface ConnectionConfig { database?: string host?: string port?: number - ssl: { + ssl?: { rejectUnauthorized: boolean }; } @@ -28,10 +28,12 @@ export const defaultConnection: ConnectionConfig = { database: WORKER_DATABASE_NAME, host: WORKER_DATABASE_HOST, port: Number(WORKER_DATABASE_PORT), - ssl: { + ssl: isLocal + ? undefined + : { rejectUnauthorized: false, - } -} + }, +}; export class PsqlClient { private readonly client: Client diff --git a/packages/worker/src/config/logger.ts b/packages/worker/src/config/logger.ts new file mode 100644 index 000000000..bb9d2389e --- /dev/null +++ b/packages/worker/src/config/logger.ts @@ -0,0 +1,114 @@ +import pino from 'pino'; + +const { NODE_ENV, LOG_LEVEL } = process.env; + +const isDevelopment = NODE_ENV === 'development'; + +const pinoConfig: pino.LoggerOptions = { + level: LOG_LEVEL || (isDevelopment ? 'debug' : 'info'), + timestamp: pino.stdTimeFunctions.isoTime, + + // Sensitive data redaction for LGPD, GDPR, PCI DSS, SOC 2 Type II compliance + redact: { + paths: [ + // ===== Authentication & Authorization ===== + 'password', + '*.password', + 'token', + '*.token', + 'authorization', + '*.authorization', + 'headers.authorization', + 'apiKey', + '*.apiKey', + 'api_key', + '*.api_key', + 'accessToken', + '*.accessToken', + 'refreshToken', + '*.refreshToken', + + // ===== Cryptography & Keys (12 terms) ===== + 'privateKey', + '*.privateKey', + 'private_key', + '*.private_key', + 'seed', + '*.seed', + 'mnemonic', + '*.mnemonic', + 'signature', + '*.signature', + 'signedMessage', + '*.signedMessage', + + // ===== Blockchain & Fuel (10 terms) ===== + 'wallet', + '*.wallet', + 'walletAddress', + '*.walletAddress', + 'predicateAddress', + '*.predicateAddress', + 'vault.configurable', + 'signer', + '*.signer', + 'predicate_address', + + // ===== WebAuthn & Hardware Security (8 terms) ===== + 'webauthn', + '*.webauthn', + 'credentialId', + '*.credentialId', + 'credential_id', + '*.credential_id', + 'credentialPublicKey', + '*.credentialPublicKey', + + // ===== Infrastructure & Endpoints (10 terms) ===== + 'DATABASE_URL', + 'REDIS_URL', + 'connectionString', + '*.connectionString', + 'connection_string', + + // ===== User Data & Recovery (14 terms) ===== + 'code', + '*.code', + 'recovery_code', + '*.recovery_code', + 'pin', + '*.pin', + 'email', + '*.email', + 'phone', + '*.phone', + + // ===== Transaction & Operation Data (11 terms) ===== + 'operationData', + '*.operationData', + 'operation_data', + '*.operation_data', + + // ===== Network & Connectivity (3 terms) ===== + 'ipAddress', + 'ip_address', + ], + remove: true, + }, + + // Development: human-readable output with pino-pretty + // Production: JSON structured logging for centralized systems + transport: isDevelopment + ? { + target: 'pino-pretty', + options: { + colorize: true, + singleLine: false, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + } + : undefined, +}; + +export const logger = pino(pinoConfig); diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index f04e0d111..c6a1417f0 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -8,7 +8,12 @@ import AssetCron from "./queues/assetsValue/scheduler"; import assetQueue from "./queues/assetsValue/queue"; import { MongoDatabase } from "./clients/mongoClient"; import { PsqlClient } from "./clients"; -import { userBlockSyncQueue, userLogoutSyncQueue, UserBlockSyncCron } from "./queues/userBlockSync"; +import { + userBlockSyncQueue, + userLogoutSyncQueue, + UserBlockSyncCron, +} from "./queues/userBlockSync"; +import TransactionCron from "./queues/generateTestTx/scheduler"; const { WORKER_PORT, @@ -73,6 +78,7 @@ PsqlClient.connect(); BalanceCron.create(); AssetCron.create(); UserBlockSyncCron.create(); +TransactionCron.create(); app.listen(WORKER_PORT ?? 3063, () => console.log(`Server running on ${WORKER_PORT}`) diff --git a/packages/worker/src/queues/generateTestTx/README.md b/packages/worker/src/queues/generateTestTx/README.md new file mode 100644 index 000000000..92d93423a --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/README.md @@ -0,0 +1,194 @@ +# Transaction Cron + +A cron job responsible for automatically executing transactions on the Fuel network from configured vaults. On each execution, a vault is randomly selected from the available ones and a transaction is sent using the locally configured signers. + +--- + +## File structure + +``` +src/queues/generateTestTx/ +├── config/ # Folder containing vault configuration JSON files +│ ├── vault_1.json +│ ├── vault_2.json +│ └── ... +├── utils/ +│ ├── loader.ts # Reads and validates the selected JSON +│ ├── vault.ts # Instantiates the Vault from the config +│ └── signer.ts # Signs the transaction by network type (Fuel/EVM) +├── types.ts # Shared interfaces and enums +├── constants.ts # Queue constants and execution interval +├── queue.ts # Bull job processor +└── scheduler.ts # Configures and schedules jobs +``` + +--- + +## How it works + +1. When the worker starts, `TransactionCron` clears old jobs from Redis and schedules two jobs: + - **Immediate** — runs as soon as the worker starts + - **Recurring** — runs at every interval defined in `REPEAT_INTERVAL_MS`, with an initial delay to avoid colliding with the immediate job + +2. On each execution, `loader.ts` randomly picks a JSON file from the `config/` folder, avoiding repeating the last used vault. The JSON is validated with **Zod** on every read — invalid configs throw a descriptive error. + +3. With the config loaded, `vault.ts` instantiates the `Vault` from the `bakosafe` SDK with the configured signers, padded to 10 positions as required by the predicate. + +4. `signer.ts` iterates over the signers and loads each private key from the environment variable defined in `envKey`. Signers whose environment variable is not set are silently skipped. + +5. The transaction is sent with the collected witnesses. If an error occurs, the logger records it along with gas estimation details and the job is marked as failed in Redis (no retries). + +--- + +## Configuring a vault + +Create a `.json` file inside `src/queues/generateTestTx/config/`. The filename can be anything, as long as it ends in `.json`. + +```json +{ + "vault": { + "signaturesCount": 2, + "hashPredicate": "0xc5baa01086a27ebe7fdd676485887b380a0595f2becbd1d60e6c16bba58dd888", + "version": "0x967aaa71b3db34acd8104ed1d7ff3900e67cff3d153a0ffa86d85957f579aa6a", + "signers": [ + { + "address": "0x2bba3b154de16722ddbdbf40843c8464f773638c5383c4aa46d69e611fb3e199", + "type": "fuel", + "envKey": "VAULTCONFIG_SIGNER_1_KEY" + }, + { + "address": "0xAbCdEf1234567890abcdef1234567890abcdef12", + "type": "evm", + "envKey": "VAULTCONFIG_SIGNER_2_KEY" + } + ] + }, + "network": "mainnet", + "defaultAmount": "0.000000001" +} +``` + +Then add the corresponding private keys to your `.env` file: + +```env +VAULTCONFIG_SIGNER_1_KEY=0xYOUR_FUEL_PRIVATE_KEY +VAULTCONFIG_SIGNER_2_KEY=0xYOUR_EVM_PRIVATE_KEY +``` + +### Fields + +| Field | Description | +|---|---| +| `signaturesCount` | Minimum number of signatures required for the transaction to be valid | +| `hashPredicate` | Hash of the vault predicate (obtained at deploy time) | +| `version` | bakosafe predicate version | +| `signers` | List of up to 10 signers | +| `network` | Fuel network (`mainnet`, `devnet`) | +| `defaultAmount` | Amount to send in the transaction (in ETH) | + +### Signer fields + +| Field | Description | +|---|---| +| `address` | B256 address for Fuel, `0x` address for EVM | +| `type` | Network type: `fuel` or `evm` | +| `envKey` | Name of the environment variable that holds the private key for this signer | + +> **Important:** the `address` must correspond to the private key stored in the environment variable defined by `envKey`. If they don't match, the predicate will reject the signature. + +--- + +## Signer types + +### Fuel + +Signs using `WalletUnlocked` from the `fuels` SDK directly with the `hashTxId`. + +```json +{ + "address": "0x2bba3b...", + "type": "fuel", + "envKey": "VAULT_1_SIGNER_1_KEY" +} +``` + +### EVM + +Signs using `ethers.Wallet`. The message format is automatically detected based on the predicate version: recent versions use `encodedTxId` directly; legacy versions use `arrayify(stringToHex(hashTxId))`. + +```json +{ + "address": "0xAbCdEf...", + "type": "evm", + "envKey": "VAULT_1_SIGNER_2_KEY" +} +``` + +### External signer (no env key set) + +If the environment variable defined in `envKey` is not set, the signer is skipped by the cron. Use this pattern to register real user addresses in the vault — the address must be in the signers array for the predicate to recognize it, but the signature is not collected automatically. + +```json +{ + "address": "0xUserAddress...", + "type": "fuel", + "envKey": "VAULT_1_USER_KEY" +} +``` + +--- + +## Adding a new vault + +1. Create a new JSON file in the `config/` folder following the model above +2. Set `envKey` for each signer and add the corresponding private keys to `.env` +3. Make sure the vault has enough balance to cover the gas fee +4. No worker restart needed — the loader reads the files on every execution + +--- + +## Interval configuration + +The execution interval can be set via environment variable: + +```env +TRANSACTION_CRON_INTERVAL_MS=1200000 +``` + +If not set, it defaults to 20 minutes. The interval is always relative to the last execution, not the system clock. + +--- + +## Error logs + +Every failed job logs a structured `gas` field alongside the error to help diagnose the failure: + +```json +{ + "error": { "message": "...", "name": "FuelError" }, + "gas": { + "maxFee": "1500", + "gasPrice": "1", + "balance": "800", + "isInsufficient": true + } +} +``` + +| Field | Description | +|---|---| +| `maxFee` | Estimated maximum fee for the transaction | +| `gasPrice` | Current network gas price | +| `balance` | Current vault balance | +| `isInsufficient` | `true` if balance is lower than the estimated fee | + +All fields show `"unavailable"` when the error occurs before the vault balance can be fetched. + +--- + +## Important notes + +- **Balance:** Each vault must have enough balance to cover gas. If the balance is insufficient, the job fails and the error is recorded by the logger with gas details. +- **Private keys:** Never put private keys directly in JSON config files. Always use environment variables via `envKey`. Never commit `.env` files with real values — use `.env.example` as a reference. +- **Redis:** Failed jobs are stored in Redis for inspection via Bull Dashboard. Successful jobs are automatically removed. +- **SDK version:** The worker uses `bakosafe` + `fuels`. If the network's `fuel-core` version differs from what the SDK supports, a compatibility warning may appear in the logs — it does not block execution, but keeping the SDK up to date is recommended. diff --git a/packages/worker/src/queues/generateTestTx/constants.ts b/packages/worker/src/queues/generateTestTx/constants.ts new file mode 100644 index 000000000..c05a18852 --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/constants.ts @@ -0,0 +1,6 @@ +export const QUEUE_TRANSACTION = "QUEUE_TRANSACTION"; + +// Interval can be configured per environment using an environment variable. +// Default: 20 minutes. +export const REPEAT_INTERVAL_MS = + Number(process.env.TRANSACTION_CRON_INTERVAL_MS) || 20 * 60 * 1000; diff --git a/packages/worker/src/queues/generateTestTx/index.ts b/packages/worker/src/queues/generateTestTx/index.ts new file mode 100644 index 000000000..d1803b360 --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/index.ts @@ -0,0 +1,6 @@ +import "./queue"; +import "./scheduler"; +import "./constants"; +import "./types"; +import "./utils"; +import "./config"; diff --git a/packages/worker/src/queues/generateTestTx/queue.ts b/packages/worker/src/queues/generateTestTx/queue.ts new file mode 100644 index 000000000..c2e2ebbc6 --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/queue.ts @@ -0,0 +1,114 @@ +import Queue from "bull"; +import { Provider, bn } from "fuels"; +import type { BN } from "fuels"; +import { redisConfig } from "@/clients"; +import { networks } from "@/mocks/networks"; +import { QUEUE_TRANSACTION } from "./constants"; +import { loadVaultConfig } from "@/queues/generateTestTx/utils/loader"; +import { createVault } from "@/queues/generateTestTx/utils/vault"; +import { collectWitnesses } from "@/queues/generateTestTx/utils/signer"; +import { estimateFeeWithBalance } from "@/queues/generateTestTx/utils/estimateFee"; +import { logger } from "@/config/logger"; + +const transactionQueue = new Queue(QUEUE_TRANSACTION, { + redis: redisConfig, +}); + +transactionQueue.process(1, async (job) => { + logger.info(`[${QUEUE_TRANSACTION}] Job started`); + + let maxFee: BN | undefined; + let gasPrice: BN | undefined; + let balance: BN | undefined; + + const config = loadVaultConfig(); + logger.info(`[${QUEUE_TRANSACTION}] Network: ${config.network}`); + + const provider = new Provider(networks[config.network]); + const vault = createVault(provider, config); + + try { + ({ maxFee, gasPrice, balance } = await estimateFeeWithBalance( + vault, + config.defaultAmount + )); + } catch { + try { + const baseAssetId = await provider.getBaseAssetId(); + const { coins } = await vault.getCoins(baseAssetId); + balance = coins.reduce((acc, c) => acc.add(c.amount), bn(0)); + gasPrice = await provider.getLatestGasPrice(); + } catch {} + } + + try { + const baseAsset = await provider.getBaseAssetId(); + const { tx, hashTxId, encodedTxId } = await vault.transaction({ + name: "Transaction Cron", + assets: [ + { + to: vault.address.toB256(), + amount: config.defaultAmount, + assetId: baseAsset, + }, + ], + }); + logger.info(`[${QUEUE_TRANSACTION}] Transaction created: ${hashTxId}`); + + const witnesses = await collectWitnesses( + vault, + hashTxId, + encodedTxId, + config.vault.signers, + provider + ); + logger.info( + `[${QUEUE_TRANSACTION}] Witnesses collected: ${witnesses.length}` + ); + + if (witnesses.length === 0) { + throw new Error("[Queue] No local signers available."); + } + + tx.witnesses = witnesses; + + const result = await vault.send(tx); + logger.info(`[${QUEUE_TRANSACTION}] TX sent, waiting for result...`); + + const response = await result.waitForResult(); + logger.info(`[${QUEUE_TRANSACTION}] TX SUCCESS`, { + status: response.status, + fee: response.fee?.toString(), + }); + } catch (error) { + const amountInUnits = bn.parseUnits(config.defaultAmount); + + logger.error( + { + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : String(error), + gas: { + maxFee: maxFee?.toString() ?? "unavailable", + gasPrice: gasPrice?.toString() ?? "unavailable", + balance: balance?.toString() ?? "unavailable", + totalRequired: maxFee + ? maxFee.add(amountInUnits).toString() + : "unavailable", + isInsufficient: + maxFee && balance + ? balance.lt(maxFee.add(amountInUnits)) + : "unavailable", + }, + }, + `[${QUEUE_TRANSACTION}] Error` + ); + } +}); + +export default transactionQueue; diff --git a/packages/worker/src/queues/generateTestTx/scheduler.ts b/packages/worker/src/queues/generateTestTx/scheduler.ts new file mode 100644 index 000000000..9b320706f --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/scheduler.ts @@ -0,0 +1,59 @@ +import transactionQueue from "./queue"; +import { QUEUE_TRANSACTION, REPEAT_INTERVAL_MS } from "./constants"; +import { logger } from "@/config/logger"; + +class TransactionCron { + private static instance: TransactionCron; + private isRunning: boolean = false; + + private constructor() {} + + public static create(): TransactionCron { + if (!this.instance) { + this.instance = new TransactionCron(); + } + if (!this.instance.isRunning) { + this.instance.setup(); + } + return this.instance; + } + + private async setup(): Promise { + try { + this.isRunning = true; + + await transactionQueue.obliterate({ force: true }); + + await transactionQueue.add( + {}, + { + jobId: `startup-${QUEUE_TRANSACTION}`, + attempts: 1, + removeOnComplete: true, + removeOnFail: false, + } + ); + + await transactionQueue.add( + {}, + { + repeat: { every: REPEAT_INTERVAL_MS }, + delay: REPEAT_INTERVAL_MS, + jobId: "transaction-cron-recurrent", + attempts: 1, + removeOnComplete: true, + removeOnFail: false, + } + ); + + logger.info( + `[${QUEUE_TRANSACTION}] Setup complete: Immediate job added & Cron scheduled.` + ); + } catch (e) { + this.isRunning = false; + logger.error({ error: e }, `[${QUEUE_TRANSACTION}] Error in setup`); + } + } +} + +export default TransactionCron; diff --git a/packages/worker/src/queues/generateTestTx/types.ts b/packages/worker/src/queues/generateTestTx/types.ts new file mode 100644 index 000000000..f40b8bba0 --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/types.ts @@ -0,0 +1,26 @@ +export enum SignerType { + FUEL = "fuel", + EVM = "evm", +} + +export interface SignerConfig { + address: string; + type: SignerType; + envKey: string; +} + +export interface VaultConfigFile { + vault: { + signaturesCount: number; + signers: SignerConfig[]; + hashPredicate?: string; + version?: string; + }; + network: string; + defaultAmount: string; +} + +export interface SignResult { + address: string; + witness: string; +} diff --git a/packages/worker/src/queues/generateTestTx/utils/estimateFee.ts b/packages/worker/src/queues/generateTestTx/utils/estimateFee.ts new file mode 100644 index 000000000..7cf3cdfc0 --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/utils/estimateFee.ts @@ -0,0 +1,51 @@ +import { ScriptTransactionRequest, bn, calculateGasFee } from "fuels"; +import type { BN } from "fuels"; +import { Vault } from "bakosafe"; + +export interface FeeEstimate { + maxFee: BN; + gasPrice: BN; + balance: BN; +} + +export async function estimateFeeWithBalance( + vault: Vault, + amount: string +): Promise { + const provider = vault.provider; + const baseAssetId = await provider.getBaseAssetId(); + const predicateGasUsed = await vault.maxGasUsed(); + + const { coins } = await vault.getCoins(baseAssetId); + const balance = coins.reduce((acc, c) => acc.add(c.amount), bn(0)); + + const transactionRequest = new ScriptTransactionRequest(); + const amountBN = bn.parseUnits(amount); + + const fakeResources = vault.generateFakeResources([ + { assetId: baseAssetId, amount: amountBN.mul(10) }, + ]); + + transactionRequest.addCoinOutput(vault.address, amountBN, baseAssetId); + transactionRequest.addResources(fakeResources); + + const { gasPriceFactor } = await provider.getGasConfig(); + const { maxFee, gasPrice } = await provider.estimateTxGasAndFee({ + transactionRequest, + }); + + const serializedTxCount = bn(transactionRequest.toTransactionBytes().length); + const totalGasWithBytes = predicateGasUsed.add(serializedTxCount.mul(64)); + + const predicateSuccessFeeDiff = calculateGasFee({ + gas: totalGasWithBytes, + priceFactor: gasPriceFactor, + gasPrice, + }); + + return { + maxFee: maxFee.add(predicateSuccessFeeDiff).mul(20).div(10), + gasPrice, + balance, + }; +} diff --git a/packages/worker/src/queues/generateTestTx/utils/loader.ts b/packages/worker/src/queues/generateTestTx/utils/loader.ts new file mode 100644 index 000000000..8e2cf9aad --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/utils/loader.ts @@ -0,0 +1,37 @@ +import fs from "fs"; +import path from "path"; +import { VaultConfigFile } from "@/queues/generateTestTx/types"; +import { QUEUE_TRANSACTION } from "@/queues/generateTestTx/constants"; + +const CONFIGS_DIR = path.resolve(__dirname, "../config"); + +export function loadVaultConfig(): VaultConfigFile { + if (!fs.existsSync(CONFIGS_DIR)) { + throw new Error( + `[${QUEUE_TRANSACTION}] configs/ directory not found at: ${CONFIGS_DIR}` + ); + } + + const files = fs.readdirSync(CONFIGS_DIR).filter((f) => f.endsWith(".json")); + + if (files.length === 0) { + throw new Error( + `[${QUEUE_TRANSACTION}] No JSON files found in: ${CONFIGS_DIR}` + ); + } + + const selected = files[Math.floor(Math.random() * files.length)]; + const fullPath = path.join(CONFIGS_DIR, selected); + + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(fullPath, "utf-8")); + } catch { + throw new Error( + `[${QUEUE_TRANSACTION}] Failed to parse ${selected} — invalid JSON.` + ); + } + + console.log(`[${QUEUE_TRANSACTION}] Selected vault config: ${selected}`); + return raw as VaultConfigFile; +} diff --git a/packages/worker/src/queues/generateTestTx/utils/signer.ts b/packages/worker/src/queues/generateTestTx/utils/signer.ts new file mode 100644 index 000000000..1faf3b28b --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/utils/signer.ts @@ -0,0 +1,79 @@ +import { Provider, WalletUnlocked, arrayify } from "fuels"; +import { ethers } from "ethers"; +import { QUEUE_TRANSACTION } from "@/queues/generateTestTx/constants"; +import { Vault } from "bakosafe"; +import { stringToHex } from "viem"; +import { + SignerConfig, + SignerType, + SignResult, +} from "@/queues/generateTestTx/types"; + +export async function signWithSigner( + vault: Vault, + hashTxId: string, + encodedTxId: string, + signer: SignerConfig, + provider: Provider +): Promise { + const privateKey = process.env[signer.envKey]; + + if (!privateKey) { + console.warn( + `[${QUEUE_TRANSACTION}] No private key found for env "${signer.envKey}" — skipping signer ${signer.address} [${signer.type}]` + ); + return null; + } + + switch (signer.type) { + case SignerType.FUEL: { + const wallet = new WalletUnlocked(privateKey, provider); + const signature = await wallet.signMessage(hashTxId); + return { + address: signer.address, + witness: vault.encodeSignature(signer.address, signature), + }; + } + + case SignerType.EVM: { + const evmWallet = new ethers.Wallet(privateKey); + + const messageToSign = encodedTxId.startsWith("0x") + ? arrayify(stringToHex(hashTxId)) + : encodedTxId; + + const signature = await evmWallet.signMessage(messageToSign); + return { + address: signer.address, + witness: vault.encodeSignature(signer.address, signature), + }; + } + + default: { + console.error( + `[${QUEUE_TRANSACTION}] Unknown signer type: "${ + (signer as SignerConfig).type + }"` + ); + return null; + } + } +} + +export async function collectWitnesses( + vault: Vault, + hashTxId: string, + encodedTxId: string, + signers: SignerConfig[], + provider: Provider +): Promise { + const results = await Promise.all( + signers.map((signer) => + signWithSigner(vault, hashTxId, encodedTxId, signer, provider) + ) + ); + + return results + .filter((r): r is SignResult => r !== null) + .map((r) => r.witness); +} diff --git a/packages/worker/src/queues/generateTestTx/utils/vault.ts b/packages/worker/src/queues/generateTestTx/utils/vault.ts new file mode 100644 index 000000000..75ef89f7b --- /dev/null +++ b/packages/worker/src/queues/generateTestTx/utils/vault.ts @@ -0,0 +1,27 @@ +import { Provider } from "fuels"; +import { Vault } from "bakosafe"; +import { VaultConfigFile } from "@/queues/generateTestTx/types"; + +type VaultConfigurable = ConstructorParameters[1]; + +// The Fuel predicate requires exactly 10 positions in the signers array. +// Unused positions are filled with zeros. +const MAX_SIGNERS = 10; +const ZERO = "0x0000000000000000000000000000000000000000000000000000000000000000"; + +export function createVault(provider: Provider, config: VaultConfigFile): Vault { + const { signaturesCount, signers, hashPredicate, version } = config.vault; + + const paddedSigners = [ + ...signers.map((s) => s.address), + ...Array(MAX_SIGNERS - signers.length).fill(ZERO), + ]; + + const configurable = { + SIGNATURES_COUNT: signaturesCount, + SIGNERS: paddedSigners, + ...(hashPredicate ? { HASH_PREDICATE: hashPredicate } : {}), + } satisfies VaultConfigurable; + + return new Vault(provider, configurable, version); +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc996d92..00b849b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,7 +76,7 @@ importers: version: 1.13.5 bakosafe: specifier: 0.6.0 - version: 0.6.0(fuels@0.101.3(vitest@3.0.9(@types/node@20.6.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.4.5) + version: 0.6.0(fuels@0.101.3(vitest@3.0.9(@types/node@20.6.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.4.5)(zod@4.3.6) body-parser: specifier: 1.20.4 version: 1.20.4 @@ -295,7 +295,7 @@ importers: version: 1.13.5 bakosafe: specifier: 0.6.0 - version: 0.6.0(fuels@0.101.3(vitest@3.0.9(@types/node@20.6.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.4.5) + version: 0.6.0(fuels@0.101.3(vitest@3.0.9(@types/node@20.6.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.4.5)(zod@4.3.6) date-fns: specifier: 2.30.0 version: 2.30.0 @@ -426,6 +426,9 @@ importers: '@types/node-cron': specifier: 3.0.11 version: 3.0.11 + bakosafe: + specifier: 0.6.0 + version: 0.6.0(fuels@0.101.3(vitest@3.0.9(@types/node@20.6.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.4.5)(zod@4.3.6) bull: specifier: ^4.16.5 version: 4.16.5 @@ -447,6 +450,9 @@ importers: pg: specifier: 8.5.1 version: 8.5.1 + pino: + specifier: 9.6.0 + version: 9.6.0 redis: specifier: 4.7.0 version: 4.7.0 @@ -502,12 +508,18 @@ importers: eslint-plugin-prettier: specifier: 3.3.1 version: 3.3.1(eslint-config-prettier@8.1.0(eslint@7.22.0))(eslint@7.22.0)(prettier@2.2.1) + ethers: + specifier: ^6.14.3 + version: 6.16.0 husky: specifier: 5.2.0 version: 5.2.0 lint-staged: specifier: 10.5.4 version: 10.5.4 + pino-pretty: + specifier: 11.2.2 + version: 11.2.2 prettier: specifier: 2.2.1 version: 2.2.1 @@ -520,9 +532,18 @@ importers: tscpaths: specifier: 0.0.9 version: 0.0.9 + viem: + specifier: ^2.30.6 + version: 2.45.1(typescript@5.4.5)(zod@4.3.6) + zod: + specifier: ^4.3.6 + version: 4.3.6 packages: + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} @@ -1599,6 +1620,9 @@ packages: resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + '@noble/curves@1.3.0': resolution: {integrity: sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==} @@ -1617,6 +1641,10 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/hashes@1.3.3': resolution: {integrity: sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==} engines: {node: '>= 16'} @@ -2466,6 +2494,9 @@ packages: '@types/node@22.19.9': resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -2689,6 +2720,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} @@ -3761,6 +3795,10 @@ packages: ethereum-cryptography@2.2.1: resolution: {integrity: sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==} + ethers@6.16.0: + resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + engines: {node: '>=14.0.0'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -6319,6 +6357,9 @@ packages: resolution: {integrity: sha512-tz4qimSJTCjYtHVsoY7pvxLcxhmhgmwzm7fyMEiL3/kPFFVyUuZOwuwcWwjkAsIrSUKJK22A7fNuJUwxzQ+H+w==} hasBin: true + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -6488,6 +6529,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -6831,8 +6875,13 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: + '@adraffy/ens-normalize@1.10.1': {} + '@adraffy/ens-normalize@1.11.1': {} '@babel/code-frame@7.12.11': @@ -7946,6 +7995,10 @@ snapshots: '@noble/ciphers@1.3.0': {} + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + '@noble/curves@1.3.0': dependencies: '@noble/hashes': 1.3.3 @@ -7966,6 +8019,8 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/hashes@1.3.2': {} + '@noble/hashes@1.3.3': {} '@noble/hashes@1.4.0': {} @@ -8967,6 +9022,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/normalize-package-data@2.4.4': {} '@types/parse-json@4.0.2': {} @@ -9190,9 +9249,10 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 - abitype@1.2.3(typescript@5.4.5): + abitype@1.2.3(typescript@5.4.5)(zod@4.3.6): optionalDependencies: typescript: 5.4.5 + zod: 4.3.6 abort-controller@3.0.0: dependencies: @@ -9224,6 +9284,8 @@ snapshots: acorn@8.15.0: {} + aes-js@4.0.0-beta.5: {} + aggregate-error@3.1.0: dependencies: clean-stack: 2.2.0 @@ -9422,7 +9484,7 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) - bakosafe@0.6.0(fuels@0.101.3(vitest@3.0.9(@types/node@20.6.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.4.5): + bakosafe@0.6.0(fuels@0.101.3(vitest@3.0.9(@types/node@20.6.0)(tsx@4.21.0)(yaml@2.8.2)))(typescript@5.4.5)(zod@4.3.6): dependencies: '@ethereumjs/util': 9.0.3 '@ethersproject/bytes': 5.7.0 @@ -9434,7 +9496,7 @@ snapshots: lodash.partition: 4.6.0 pnpm: 10.28.2 uuid: 9.0.1 - viem: 2.45.1(typescript@5.4.5) + viem: 2.45.1(typescript@5.4.5)(zod@4.3.6) transitivePeerDependencies: - bufferutil - debug @@ -10466,6 +10528,19 @@ snapshots: '@scure/bip32': 1.4.0 '@scure/bip39': 1.3.0 + ethers@6.16.0: + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + event-target-shim@5.0.1: {} eventemitter3@5.0.1: {} @@ -12185,7 +12260,7 @@ snapshots: os-tmpdir@1.0.2: {} - ox@0.11.3(typescript@5.4.5): + ox@0.11.3(typescript@5.4.5)(zod@4.3.6): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -12193,7 +12268,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.4.5) + abitype: 1.2.3(typescript@5.4.5)(zod@4.3.6) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.4.5 @@ -13439,6 +13514,8 @@ snapshots: transitivePeerDependencies: - supports-color + tslib@2.7.0: {} + tslib@2.8.1: {} tsx@4.21.0: @@ -13551,6 +13628,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.19.8: {} + undici-types@6.21.0: {} undici@7.20.0: {} @@ -13625,15 +13704,15 @@ snapshots: vary@1.1.2: {} - viem@2.45.1(typescript@5.4.5): + viem@2.45.1(typescript@5.4.5)(zod@4.3.6): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.4.5) + abitype: 1.2.3(typescript@5.4.5)(zod@4.3.6) isows: 1.0.7(ws@8.18.3) - ox: 0.11.3(typescript@5.4.5) + ox: 0.11.3(typescript@5.4.5)(zod@4.3.6) ws: 8.18.3 optionalDependencies: typescript: 5.4.5 @@ -13875,3 +13954,5 @@ snapshots: archiver-utils: 5.0.2 compress-commons: 6.0.2 readable-stream: 4.7.0 + + zod@4.3.6: {}