409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
/**
|
|
* Operator-earnings attribution (audit) — tsx smoke runner.
|
|
*
|
|
* cp408 — the operator's 90% is now paid DIRECTLY at payment time by the fee
|
|
* split (see feeTransfersFor / sumFeeTransfers). This module no longer queues a
|
|
* relay payout; it only records attribution + earnings for the dashboard. This
|
|
* smoke covers:
|
|
*
|
|
* - Pure share-computation (computeOperatorShareBlurt), which now delegates to
|
|
* the same splitListingFeeBlurt the payment uses, so recorded earnings match
|
|
* what the operator was actually paid.
|
|
* - Pure tag-field validation (validateOperatorTagField).
|
|
* - End-to-end attributeBlurtFeeToOperator against a mock PoolClient covering
|
|
* every AttributionResult branch AND verifying the audit-only side effects:
|
|
* • Successful attribution → 1 operator_attribution_events INSERT +
|
|
* 1 operator_earnings UPSERT. NO relay queue, NO operator_payouts.
|
|
* • Replay (trx_id UNIQUE 23505) → duplicate_attribution, NO earnings
|
|
* upsert on the second attempt.
|
|
*
|
|
* Black-hat scenarios under test:
|
|
* - Tag forging → tag_unknown, no DB writes
|
|
* - Inactive operator → tag_unknown via WHERE is_active=TRUE
|
|
* - Replay (UNIQUE 23505) → duplicate_attribution, NO downstream writes
|
|
* - Malformed / missing tags → tag_malformed / no_tag before any SQL
|
|
* - Sub-BLURT-precision rounding → 3-decimal output, no float drift
|
|
*
|
|
* Usage:
|
|
* tsx apps/indexer/scripts/operator-earnings-smoke.ts
|
|
*/
|
|
|
|
import {
|
|
computeOperatorShareBlurt,
|
|
validateOperatorTagField,
|
|
attributeBlurtFeeToOperator,
|
|
OPERATOR_BLURT_SPLIT_PERCENT
|
|
} from '../src/indexer/operatorEarnings.ts';
|
|
import { makeMockClient, type QueryExpectation } from '../test/testutils/mockClient.ts';
|
|
|
|
let failures = 0;
|
|
let scenarios = 0;
|
|
|
|
function scenario(name: string, fn: () => void | Promise<void>): Promise<void> {
|
|
scenarios++;
|
|
return Promise.resolve()
|
|
.then(fn)
|
|
.then(
|
|
() => {
|
|
console.log(` ✓ ${name}`);
|
|
},
|
|
(err) => {
|
|
failures++;
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
);
|
|
}
|
|
|
|
function assertEqual(actual: unknown, expected: unknown, label: string): void {
|
|
const a = JSON.stringify(actual);
|
|
const e = JSON.stringify(expected);
|
|
if (a !== e) {
|
|
throw new Error(`${label}: expected ${e}, got ${a}`);
|
|
}
|
|
}
|
|
|
|
const TRX_ID = '0000000000000000000000000000000000000abc';
|
|
const BLOCK_NUM = 12_345;
|
|
const BLOCK_TIME = new Date('2026-05-02T12:00:00Z');
|
|
|
|
async function run(): Promise<void> {
|
|
console.log('operator-earnings attribution (audit-only) smoke');
|
|
console.log(
|
|
` (split policy: ${OPERATOR_BLURT_SPLIT_PERCENT}% to operator, ${
|
|
100 - OPERATOR_BLURT_SPLIT_PERCENT
|
|
}% to treasury — both delivered at payment time by the fee split)`
|
|
);
|
|
|
|
// ─── computeOperatorShareBlurt ────────────────────────────────
|
|
// Delegates to splitListingFeeBlurt: treasury = round(10% of total) in
|
|
// integer milliBLURT, operator = remainder — so shares always sum to total.
|
|
|
|
await scenario('share: 100 BLURT → 90 / 10', () => {
|
|
const r = computeOperatorShareBlurt(100);
|
|
assertEqual(r, { operatorShareBlurt: '90.000', treasuryShareBlurt: '10.000' }, 'shares');
|
|
});
|
|
|
|
await scenario('share: 60 BLURT → 54 / 6', () => {
|
|
const r = computeOperatorShareBlurt(60);
|
|
assertEqual(r, { operatorShareBlurt: '54.000', treasuryShareBlurt: '6.000' }, 'shares');
|
|
});
|
|
|
|
await scenario('share: 0.125 BLURT → 0.112 / 0.013 (round to milliBLURT)', () => {
|
|
const r = computeOperatorShareBlurt(0.125);
|
|
assertEqual(r, { operatorShareBlurt: '0.112', treasuryShareBlurt: '0.013' }, 'shares');
|
|
});
|
|
|
|
await scenario('share: shares always sum exactly to the fee total', () => {
|
|
for (const fee of [60, 75, 100, 0.125, 123.456, 42.001]) {
|
|
const r = computeOperatorShareBlurt(fee);
|
|
const sum = Number(r.operatorShareBlurt) + Number(r.treasuryShareBlurt);
|
|
// The fee itself is milliBLURT-rounded in the split; compare at 3dp.
|
|
if (Math.round(sum * 1000) !== Math.round(fee * 1000)) {
|
|
throw new Error(`fee ${fee}: shares ${r.operatorShareBlurt}+${r.treasuryShareBlurt} != total`);
|
|
}
|
|
}
|
|
});
|
|
|
|
await scenario('share: rejects negative fee', () => {
|
|
try {
|
|
computeOperatorShareBlurt(-1);
|
|
throw new Error('expected throw');
|
|
} catch (err) {
|
|
if (!(err instanceof Error) || !err.message.includes('invalid')) {
|
|
throw new Error(`wrong error: ${err}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
await scenario('share: rejects zero fee', () => {
|
|
try {
|
|
computeOperatorShareBlurt(0);
|
|
throw new Error('expected throw');
|
|
} catch (err) {
|
|
if (!(err instanceof Error) || !err.message.includes('invalid')) {
|
|
throw new Error(`wrong error: ${err}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
await scenario('share: rejects NaN', () => {
|
|
try {
|
|
computeOperatorShareBlurt(NaN);
|
|
throw new Error('expected throw');
|
|
} catch (err) {
|
|
if (!(err instanceof Error) || !err.message.includes('invalid')) {
|
|
throw new Error(`wrong error: ${err}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── validateOperatorTagField ─────────────────────────────────
|
|
|
|
await scenario('tag-validate: missing → reason=missing', () => {
|
|
assertEqual(validateOperatorTagField(undefined), { reason: 'missing' }, 'r');
|
|
assertEqual(validateOperatorTagField(null), { reason: 'missing' }, 'r');
|
|
assertEqual(validateOperatorTagField(''), { reason: 'missing' }, 'r');
|
|
});
|
|
|
|
await scenario('tag-validate: non-string → reason=malformed', () => {
|
|
assertEqual(validateOperatorTagField(123), { reason: 'malformed' }, 'r');
|
|
assertEqual(validateOperatorTagField({}), { reason: 'malformed' }, 'r');
|
|
assertEqual(validateOperatorTagField([]), { reason: 'malformed' }, 'r');
|
|
});
|
|
|
|
await scenario('tag-validate: too long → malformed', () => {
|
|
const longTag = 'a'.repeat(65);
|
|
assertEqual(validateOperatorTagField(longTag), { reason: 'malformed' }, 'r');
|
|
});
|
|
|
|
await scenario('tag-validate: bad charset → malformed', () => {
|
|
assertEqual(validateOperatorTagField('Alice'), { reason: 'malformed' }, 'r');
|
|
assertEqual(validateOperatorTagField('alice@bob'), { reason: 'malformed' }, 'r');
|
|
assertEqual(validateOperatorTagField('alice bob'), { reason: 'malformed' }, 'r');
|
|
assertEqual(validateOperatorTagField("alice'); DROP TABLE"), { reason: 'malformed' }, 'r');
|
|
});
|
|
|
|
await scenario('tag-validate: well-formed → tag returned', () => {
|
|
assertEqual(validateOperatorTagField('alice'), { tag: 'alice' }, 'r');
|
|
assertEqual(validateOperatorTagField('morphit-berlin'), { tag: 'morphit-berlin' }, 'r');
|
|
assertEqual(validateOperatorTagField('a.b_c-d.0'), { tag: 'a.b_c-d.0' }, 'r');
|
|
});
|
|
|
|
// ─── attributeBlurtFeeToOperator: audit-only side effects ─────
|
|
|
|
const baseArgs = {
|
|
orderAccount: 'bob',
|
|
orderPermlink: 'order-2026-05-02-aaa',
|
|
feeBlurt: 60,
|
|
trxId: TRX_ID,
|
|
blockNum: BLOCK_NUM,
|
|
blockTime: BLOCK_TIME
|
|
};
|
|
|
|
function expectLookup(rows: { account: string }[]): QueryExpectation {
|
|
return {
|
|
match: 'FROM operators',
|
|
rows,
|
|
rowCount: rows.length
|
|
};
|
|
}
|
|
|
|
function expectInsertAttribution(): QueryExpectation {
|
|
return {
|
|
match: 'INSERT INTO operator_attribution_events',
|
|
rows: [],
|
|
rowCount: 1
|
|
};
|
|
}
|
|
|
|
function expectUpsertEarnings(): QueryExpectation {
|
|
return {
|
|
match: 'INSERT INTO operator_earnings',
|
|
rows: [],
|
|
rowCount: 1
|
|
};
|
|
}
|
|
|
|
await scenario('attribute: missing tag → no_tag, NO DB writes', async () => {
|
|
const mock = makeMockClient([]);
|
|
const r = await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: undefined,
|
|
...baseArgs,
|
|
instanceOperatorTag: 'alice'
|
|
});
|
|
assertEqual(r, { kind: 'no_tag' }, 'result');
|
|
assertEqual(mock.queries.length, 0, 'no queries');
|
|
});
|
|
|
|
await scenario('attribute: malformed tag → tag_malformed, NO DB writes', async () => {
|
|
const mock = makeMockClient([]);
|
|
const r = await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: 'BAD CHARS',
|
|
...baseArgs,
|
|
instanceOperatorTag: 'BAD CHARS'
|
|
});
|
|
assertEqual(r, { kind: 'tag_malformed' }, 'result');
|
|
assertEqual(mock.queries.length, 0, 'no queries');
|
|
});
|
|
|
|
await scenario('attribute: tag-too-long → tag_malformed, NO DB writes', async () => {
|
|
const mock = makeMockClient([]);
|
|
const r = await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: 'a'.repeat(65),
|
|
...baseArgs,
|
|
instanceOperatorTag: 'alice'
|
|
});
|
|
assertEqual(r, { kind: 'tag_malformed' }, 'result');
|
|
assertEqual(mock.queries.length, 0, 'no queries');
|
|
});
|
|
|
|
await scenario('attribute: unknown tag → tag_unknown, lookup happened, no writes', async () => {
|
|
const mock = makeMockClient([expectLookup([])]);
|
|
const r = await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: 'ghost',
|
|
...baseArgs,
|
|
instanceOperatorTag: 'ghost'
|
|
});
|
|
assertEqual(r, { kind: 'tag_unknown' }, 'result');
|
|
assertEqual(mock.queries.length, 1, 'lookup only');
|
|
});
|
|
|
|
await scenario(
|
|
'attribute: known active operator, fee 60 → attributed (audit + earnings, NO relay queue)',
|
|
async () => {
|
|
const mock = makeMockClient([
|
|
expectLookup([{ account: 'alice' }]),
|
|
expectInsertAttribution(),
|
|
expectUpsertEarnings()
|
|
]);
|
|
const r = await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: 'alice',
|
|
...baseArgs,
|
|
instanceOperatorTag: 'alice'
|
|
});
|
|
assertEqual(
|
|
r,
|
|
{
|
|
kind: 'attributed',
|
|
operatorAccount: 'alice',
|
|
operatorShareBlurt: 54
|
|
},
|
|
'result'
|
|
);
|
|
assertEqual(
|
|
mock.queries.length,
|
|
3,
|
|
'lookup + attribution insert + earnings upsert (NO relay queue, NO payout audit)'
|
|
);
|
|
// Positively assert the retired writes are absent.
|
|
for (const q of mock.queries) {
|
|
if (q.text.includes('relay_pending_transfers') || q.text.includes('operator_payouts')) {
|
|
throw new Error(`retired payout write present: ${q.text.slice(0, 60)}`);
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
await scenario(
|
|
'attribute: replay (trx_id UNIQUE violation) → duplicate_attribution, NO earnings upsert',
|
|
async () => {
|
|
// CRITICAL: if the attribution insert fails with a unique violation, we
|
|
// must NOT proceed to the operator_earnings UPSERT — otherwise replays
|
|
// would double-count earnings.
|
|
const mock = makeMockClient([
|
|
expectLookup([{ account: 'alice' }]),
|
|
{
|
|
match: 'INSERT INTO operator_attribution_events',
|
|
throwError: Object.assign(new Error('duplicate'), {
|
|
code: '23505'
|
|
})
|
|
}
|
|
// NO further expectations: handler must abort.
|
|
]);
|
|
const r = await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: 'alice',
|
|
...baseArgs,
|
|
instanceOperatorTag: 'alice'
|
|
});
|
|
assertEqual(r, { kind: 'duplicate_attribution' }, 'result');
|
|
assertEqual(mock.queries.length, 2, 'lookup + failed attribution (NO earnings upsert)');
|
|
}
|
|
);
|
|
|
|
await scenario('attribute: non-unique-violation throw bubbles up', async () => {
|
|
const mock = makeMockClient([
|
|
expectLookup([{ account: 'alice' }]),
|
|
{
|
|
match: 'INSERT INTO operator_attribution_events',
|
|
throwError: new Error('connection lost')
|
|
}
|
|
]);
|
|
try {
|
|
await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: 'alice',
|
|
...baseArgs,
|
|
instanceOperatorTag: 'alice'
|
|
});
|
|
throw new Error('expected handler to rethrow');
|
|
} catch (err) {
|
|
if (!(err instanceof Error) || !err.message.includes('connection lost')) {
|
|
throw new Error(`wrong error: ${err}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
await scenario(
|
|
'attribute: lookup uses parameterized query (SQLi-proof structural test)',
|
|
async () => {
|
|
const mock = makeMockClient([
|
|
expectLookup([{ account: 'alice' }]),
|
|
expectInsertAttribution(),
|
|
expectUpsertEarnings()
|
|
]);
|
|
await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: 'alice',
|
|
...baseArgs,
|
|
instanceOperatorTag: 'alice'
|
|
});
|
|
const lookup = mock.queries[0]!;
|
|
assertEqual(lookup.params, ['alice'], 'tag is a $1 parameter');
|
|
}
|
|
);
|
|
|
|
await scenario('attribute: inactive operator (filter excluded) → tag_unknown', async () => {
|
|
// Lookup includes WHERE is_active = TRUE. An inactive operator's row is
|
|
// filtered out, so the lookup returns 0 rows — same outward behavior as a
|
|
// non-existent tag. Deactivated operators simply stop earning new
|
|
// attribution; nothing is stranded (they were paid at source).
|
|
const mock = makeMockClient([expectLookup([])]);
|
|
const r = await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: 'alice',
|
|
...baseArgs,
|
|
instanceOperatorTag: 'alice'
|
|
});
|
|
assertEqual(r, { kind: 'tag_unknown' }, 'result');
|
|
const lookup = mock.queries[0]!;
|
|
if (!lookup.text.includes('is_active')) {
|
|
throw new Error('lookup must filter is_active = TRUE');
|
|
}
|
|
});
|
|
|
|
await scenario(
|
|
'attribute: Part-111 gate — op for another instance → attributed_other_instance, NO writes',
|
|
async () => {
|
|
const mock = makeMockClient([]);
|
|
const r = await attributeBlurtFeeToOperator({
|
|
client: mock.client,
|
|
operatorTagRaw: 'other-community',
|
|
...baseArgs,
|
|
instanceOperatorTag: 'alice'
|
|
});
|
|
assertEqual(
|
|
r,
|
|
{ kind: 'attributed_other_instance', opTag: 'other-community', instanceTag: 'alice' },
|
|
'result'
|
|
);
|
|
assertEqual(mock.queries.length, 0, 'no DB writes for another instance');
|
|
}
|
|
);
|
|
|
|
console.log('');
|
|
if (failures > 0) {
|
|
console.log(`✗ ${failures}/${scenarios} scenarios failed`);
|
|
process.exit(1);
|
|
} else {
|
|
console.log(`✓ all ${scenarios} scenarios passed`);
|
|
}
|
|
}
|
|
|
|
await run();
|