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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"typeorm:generate": "npm run typeorm -- migration:generate",
"typeorm:run": "npm run typeorm -- migration:run",
"typeorm:revert": "npm run typeorm -- migration:revert",
"typeorm:show": "npm run typeorm -- migration:show"
"typeorm:show": "npm run typeorm -- migration:show",
"generate:contract-errors": "node scripts/generate-contract-errors.js",
"check:contract-errors": "node scripts/generate-contract-errors.js --check"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
Expand Down
209 changes: 209 additions & 0 deletions packages/backend/scripts/generate-contract-errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* Generates src/indexer/contract-errors.generated.ts from every
* public `#[contracterror]` enum under packages/contracts/<crate>/src/errors.rs
* (falling back to src/lib.rs for crates without an errors.rs).
*
* The Rust enums are the single source of truth for contract error codes; the
* backend's DiagnosticParserService uses the generated registry to translate
* `Error(Contract, #N)` host errors into human-readable messages.
*
* Usage:
* node scripts/generate-contract-errors.js # write the file
* node scripts/generate-contract-errors.js --check # exit 1 if out of date
*/
'use strict';

const fs = require('fs');
const path = require('path');

const BACKEND_ROOT = path.resolve(__dirname, '..');
const CONTRACTS_ROOT = path.resolve(BACKEND_ROOT, '..', 'contracts');
const OUTPUT = path.join(
BACKEND_ROOT,
'src',
'indexer',
'contract-errors.generated.ts',
);

/** "StakeBelowMinimum" -> "Stake below minimum". */
function humanize(name) {
const words = name
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.toLowerCase();
return words.charAt(0).toUpperCase() + words.slice(1);
}

/** Turn a Rust doc comment into a single user-facing sentence. */
function cleanDoc(lines) {
const text = lines
.join(' ')
.replace(/`/g, '')
.replace(/\[([^\]]+)\]/g, '$1')
.replace(/^#\d+:\s*/, '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return '';
// Don't capitalise a leading identifier such as `stake_amount`.
const firstWord = text.split(' ')[0];
const sentence = /[_\d]/.test(firstWord)
? text
: text.charAt(0).toUpperCase() + text.slice(1);
return /[.!?]$/.test(sentence) ? sentence : `${sentence}.`;
}

/** Parse every #[contracterror] enum in one errors.rs source. */
function parseErrorEnums(source) {
const enums = [];
const enumRe = /#\[contracterror\][\s\S]*?pub enum (\w+)\s*\{([\s\S]*?)\n\}/g;
let match;
while ((match = enumRe.exec(source)) !== null) {
const [, enumName, body] = match;
const variants = [];
let doc = [];
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim();
const docMatch = /^\/\/\/\s?(.*)$/.exec(line);
if (docMatch) {
doc.push(docMatch[1]);
continue;
}
const variantMatch = /^(\w+)\s*=\s*(\d+)\s*,?/.exec(line);
if (variantMatch) {
const [, name, code] = variantMatch;
variants.push({
code: Number(code),
name,
message: cleanDoc(doc) || `${humanize(name)}.`,
});
}
if (line && !line.startsWith('//')) doc = [];
}
variants.sort((a, b) => a.code - b.code);
enums.push({ enumName, variants });
}
return enums;
}

function collectRegistry() {
const crates = fs
.readdirSync(CONTRACTS_ROOT, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();

const registry = [];
for (const crate of crates) {
// errors.rs is the convention; a few single-file crates (e.g.
// prediction_market_futures) declare their enum in lib.rs instead.
// Private mirror enums used for cross-contract calls are not `pub` and
// are skipped by parseErrorEnums.
const file = ['errors.rs', 'lib.rs']
.map((f) => path.join(CONTRACTS_ROOT, crate, 'src', f))
.find((f) => fs.existsSync(f));
if (!file) continue;
const enums = parseErrorEnums(fs.readFileSync(file, 'utf8'));
if (enums.length === 0) continue;
// Each crate exposes exactly one contract error enum; the first wins if
// a crate ever declares more (a second would collide on codes anyway).
registry.push({ crate, ...enums[0] });
}
return registry;
}

function render(registry) {
const q = (s) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
const out = [];
out.push('/* eslint-disable */');
out.push(
'// AUTO-GENERATED by scripts/generate-contract-errors.js — DO NOT EDIT.',
);
out.push('// Source of truth: packages/contracts/*/src/errors.rs');
out.push(
'// Regenerate with: pnpm --filter backend generate:contract-errors',
);
out.push('');
out.push('export interface ContractErrorDefinition {');
out.push(' code: number;');
out.push(' name: string;');
out.push(' message: string;');
out.push('}');
out.push('');
out.push('export interface ContractErrorEnum {');
out.push(' /** Rust crate directory under packages/contracts. */');
out.push(' contract: string;');
out.push(' /** Rust `#[contracterror]` enum name. */');
out.push(' enumName: string;');
out.push(' errors: Readonly<Record<number, ContractErrorDefinition>>;');
out.push('}');
out.push('');
out.push(
'export const CONTRACT_ERROR_REGISTRY: Readonly<Record<string, ContractErrorEnum>> = {',
);
for (const { crate, enumName, variants } of registry) {
out.push(` ${crate}: {`);
out.push(` contract: ${q(crate)},`);
out.push(` enumName: ${q(enumName)},`);
out.push(' errors: {');
for (const v of variants) {
out.push(
` ${v.code}: { code: ${v.code}, name: ${q(v.name)}, message: ${q(v.message)} },`,
);
}
out.push(' },');
out.push(' },');
}
out.push('};');
out.push('');
return out.join('\n');
}

/**
* Format with the repo's prettier config so the pre-commit lint-staged
* `prettier --write` hook leaves the generated file untouched.
*/
async function format(content) {
try {
const prettier = require('prettier');
const options = (await prettier.resolveConfig(OUTPUT)) ?? {};
return await prettier.format(content, { ...options, filepath: OUTPUT });
} catch (err) {
console.warn(`prettier unavailable, writing unformatted: ${err.message}`);
return content;
}
}

async function main() {
if (!fs.existsSync(CONTRACTS_ROOT)) {
console.error(`Contracts directory not found: ${CONTRACTS_ROOT}`);
process.exit(1);
}
const content = await format(render(collectRegistry()));

if (process.argv.includes('--check')) {
const current = fs.existsSync(OUTPUT)
? fs.readFileSync(OUTPUT, 'utf8')
: '';
if (current.replace(/\r\n/g, '\n') !== content) {
console.error(
'contract-errors.generated.ts is out of date — run generate:contract-errors',
);
process.exit(1);
}
console.log('contract-errors.generated.ts is up to date');
return;
}

fs.writeFileSync(OUTPUT, content, 'utf8');
console.log(`Wrote ${path.relative(BACKEND_ROOT, OUTPUT)}`);
}

if (require.main === module) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

module.exports = { parseErrorEnums, collectRegistry, render, humanize };
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Failed Soroban transaction index (BE-004).
*
* One row per failed transaction hash with the parsed diagnostic report
* (contract error variant, auth / footprint flags, host messages) so users
* can be notified with a human-readable reason and operators can debug
* failures after the RPC's short retention window has expired.
*/
export class CreateFailedTransactionsTable1760000060000 implements MigrationInterface {
name = 'CreateFailedTransactionsTable1760000060000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "failed_transactions" (
"id" SERIAL NOT NULL,
"txHash" character varying(64) NOT NULL,
"contractId" character varying(64),
"userAddress" character varying(64),
"ledger" bigint,
"resultCode" character varying(64),
"operationResultCode" character varying(64),
"errorCategory" character varying(32),
"errorName" character varying(128),
"errorCode" integer,
"message" text NOT NULL,
"diagnostics" jsonb NOT NULL,
"notified" boolean NOT NULL DEFAULT false,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_failed_transactions" PRIMARY KEY ("id"),
CONSTRAINT "UQ_failed_transactions_txHash" UNIQUE ("txHash")
)
`);

await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_failed_transactions_contractId"
ON "failed_transactions" ("contractId")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_failed_transactions_userAddress"
ON "failed_transactions" ("userAddress")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_failed_transactions_errorCategory"
ON "failed_transactions" ("errorCategory")
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_failed_transactions_errorCategory"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_failed_transactions_userAddress"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_failed_transactions_contractId"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "failed_transactions"`);
}
}
Loading
Loading