Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions packages/worker/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/src/queues/generateTestTx/config/
8 changes: 7 additions & 1 deletion packages/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
10 changes: 6 additions & 4 deletions packages/worker/src/clients/psqlClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface ConnectionConfig {
database?: string
host?: string
port?: number
ssl: {
ssl?: {
rejectUnauthorized: boolean
};
}
Expand All @@ -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
Expand Down
114 changes: 114 additions & 0 deletions packages/worker/src/config/logger.ts
Original file line number Diff line number Diff line change
@@ -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);
8 changes: 7 additions & 1 deletion packages/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`)
Expand Down
194 changes: 194 additions & 0 deletions packages/worker/src/queues/generateTestTx/README.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/worker/src/queues/generateTestTx/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading