Skip to content

Commit 2ce7cf4

Browse files
Merge pull request #1148 from presidojay1/fix/issues-778-851-779-775
Fix enum discriminant collision, fuzz multisig, trace outbox boundary, reproducible builds, SLOs
2 parents 2493c75 + f2b1c56 commit 2ce7cf4

12 files changed

Lines changed: 675 additions & 138 deletions

File tree

.github/workflows/contract-fuzzing.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,31 @@ jobs:
8080
8181
echo "Fuzzing completed for ${{ matrix.contract }} contract"
8282
83+
- name: Install nightly toolchain + cargo-fuzz for libFuzzer targets (rewards only)
84+
if: matrix.contract == 'rewards'
85+
continue-on-error: true
86+
run: |
87+
rustup toolchain install nightly
88+
cargo install cargo-fuzz --locked
89+
90+
- name: Run libFuzzer targets (issue #851: fuzz_multisig, plus fuzz_balance)
91+
if: matrix.contract == 'rewards'
92+
continue-on-error: true
93+
working-directory: contracts/rewards
94+
run: |
95+
echo "Running cargo-fuzz libFuzzer targets for ${FUZZ_DURATION} seconds each..."
96+
cargo +nightly fuzz run fuzz_balance -- -max_total_time=${FUZZ_DURATION} || echo "fuzz_balance completed/found an issue"
97+
cargo +nightly fuzz run fuzz_multisig -- -max_total_time=${FUZZ_DURATION} || echo "fuzz_multisig completed/found an issue"
98+
99+
- name: Upload libFuzzer crash artifacts (rewards only)
100+
if: matrix.contract == 'rewards' && always()
101+
uses: actions/upload-artifact@v4
102+
with:
103+
name: libfuzzer-artifacts-rewards-${{ github.run_number }}
104+
path: contracts/rewards/fuzz/artifacts/
105+
if-no-files-found: ignore
106+
retention-days: 30
107+
83108
- name: Check for regression files
84109
run: |
85110
if [ -d "contracts/${{ matrix.contract }}/proptest-regressions" ] && [ "$(ls -A contracts/${{ matrix.contract }}/proptest-regressions)" ]; then

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@opentelemetry/instrumentation-http": "^0.55.0",
3030
"@opentelemetry/resources": "^1.28.0",
3131
"@opentelemetry/sdk-node": "^0.55.0",
32+
"@opentelemetry/sdk-trace-base": "^1.28.0",
3233
"@opentelemetry/semantic-conventions": "^1.28.0",
3334
"@stellar/stellar-sdk": "^14.0.0",
3435
"better-sqlite3": "^11.10.0",

backend/src/services/outboxService.js

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@
1010
* const relay = new OutboxRelay(db, handlers, { logger });
1111
* relay.start(); // begin polling
1212
* relay.stop(); // graceful shutdown
13+
*
14+
* Tracing (issue #778): `writeOutbox` captures the enqueuing request's trace
15+
* context and stores it alongside the payload; `_deliver` re-establishes it
16+
* as the parent of a new `outbox.deliver` span, so a single trace shows the
17+
* full request → job path even though delivery happens on a later,
18+
* unrelated poll tick.
1319
*/
1420

21+
import { captureTraceparent, linkedSpan } from '../tracing.js';
22+
1523
const DEFAULT_POLL_INTERVAL_MS = 5_000;
1624
const DEFAULT_LOCK_DURATION_MS = 60_000;
1725
const DEFAULT_MAX_ATTEMPTS = 5;
@@ -34,11 +42,18 @@ export function writeOutbox(db, eventType, payload, opts = {}) {
3442
const deliverAfter =
3543
delayMs > 0 ? new Date(Date.now() + delayMs).toISOString() : new Date().toISOString();
3644

45+
// Stash the enqueuing request's trace context inside the stored envelope
46+
// (issue #778) rather than adding a DB column for it — `_traceparent` is a
47+
// reserved key on the envelope, never on the caller's own payload, so it
48+
// round-trips through the existing `payload TEXT` column with no schema
49+
// migration.
50+
const envelope = { payload, _traceparent: captureTraceparent() };
51+
3752
const stmt = db.prepare(`
3853
INSERT INTO outbox (event_type, payload, partition_key, deliver_after)
3954
VALUES (?, ?, ?, ?)
4055
`);
41-
const result = stmt.run(eventType, JSON.stringify(payload), partitionKey, deliverAfter);
56+
const result = stmt.run(eventType, JSON.stringify(envelope), partitionKey, deliverAfter);
4257
return result.lastInsertRowid;
4358
}
4459

@@ -122,19 +137,37 @@ export class OutboxRelay {
122137
async _deliver(row) {
123138
const handler = this.handlers[row.event_type];
124139
let payload;
140+
let traceparent = null;
125141
try {
126-
payload = JSON.parse(row.payload);
142+
const parsed = JSON.parse(row.payload);
143+
// Rows written before issue #778 store the raw payload directly;
144+
// rows written since wrap it as `{ payload, _traceparent }`. Detect by
145+
// the reserved `_traceparent` key rather than assuming every row is
146+
// the new shape, so already-queued (pre-deploy) rows still deliver.
147+
if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, '_traceparent')) {
148+
payload = parsed.payload;
149+
traceparent = parsed._traceparent;
150+
} else {
151+
payload = parsed;
152+
}
127153
} catch {
128154
this._markFailed(row.id, 'invalid JSON payload');
129155
return;
130156
}
131157

132158
try {
133-
if (handler) {
134-
await handler(payload);
135-
} else {
136-
this.logger.warn({ eventType: row.event_type }, 'no outbox handler registered');
137-
}
159+
await linkedSpan(
160+
traceparent,
161+
'outbox.deliver',
162+
{ 'outbox.event_type': row.event_type, 'outbox.id': row.id, 'outbox.attempts': row.attempts },
163+
async () => {
164+
if (handler) {
165+
await handler(payload);
166+
} else {
167+
this.logger.warn({ eventType: row.event_type }, 'no outbox handler registered');
168+
}
169+
},
170+
);
138171
this.db
139172
.prepare(`UPDATE outbox SET status = 'delivered', locked_until = NULL WHERE id = ?`)
140173
.run(row.id);

backend/src/tracing.js

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@
2828
* auto-instrumentation patches miss the loaded modules.
2929
*/
3030

31-
import { trace, SpanStatusCode, context as otelContext } from '@opentelemetry/api';
31+
import {
32+
trace,
33+
SpanStatusCode,
34+
SpanKind,
35+
context as otelContext,
36+
propagation,
37+
} from '@opentelemetry/api';
3238

3339
let sdkInstance = null;
3440

@@ -275,6 +281,64 @@ export function traceparentMiddleware() {
275281
/** Headers to expose so a browser fetch can read the traceparent. */
276282
export const TRACING_EXPOSED_HEADERS = ['traceparent'];
277283

284+
/**
285+
* Capture the currently active span's context as a W3C `traceparent`
286+
* string, or `null` if there is no active span (issue #778).
287+
*
288+
* The transactional outbox pattern (`outboxService.js`) breaks OTel's
289+
* automatic in-process context propagation: a row is written now, inside
290+
* the HTTP request's trace, but delivered later by a completely separate
291+
* poll loop tick with no ambient span. Persisting the traceparent string
292+
* alongside the outbox row is what lets `linkedSpan()` below re-establish
293+
* that parent/child relationship once the relay picks the row up.
294+
*/
295+
export function captureTraceparent() {
296+
const span = trace.getSpan(otelContext.active());
297+
if (!span) return null;
298+
const ctx = span.spanContext();
299+
const flags = ctx.traceFlags.toString(16).padStart(2, '0');
300+
return `00-${ctx.traceId}-${ctx.spanId}-${flags}`;
301+
}
302+
303+
/**
304+
* Run `fn` inside a new span that is a *child* of the trace identified by
305+
* `traceparent` (as produced by `captureTraceparent()`), even though this
306+
* call is happening on a later event-loop tick / different logical
307+
* "request" than the one that created it — the async-boundary case issue
308+
* #778 asks for (outbox relay, background jobs).
309+
*
310+
* Falls back to a plain, unlinked `withSpan()` when `traceparent` is
311+
* missing or malformed, so a job enqueued before this feature existed (no
312+
* stored traceparent) still gets traced, just without a parent link.
313+
*/
314+
export async function linkedSpan(traceparent, name, attributes, fn) {
315+
if (!traceparent) {
316+
return withSpan(name, attributes, fn);
317+
}
318+
319+
const parentContext = propagation.extract(otelContext.active(), { traceparent });
320+
return otelContext.with(parentContext, () => {
321+
const tracer = trace.getTracer('trivela-backend');
322+
return tracer.startActiveSpan(
323+
name,
324+
{ attributes, kind: SpanKind.CONSUMER },
325+
async (span) => {
326+
try {
327+
const result = await fn(span);
328+
span.setStatus({ code: SpanStatusCode.OK });
329+
return result;
330+
} catch (err) {
331+
span.recordException(err);
332+
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
333+
throw err;
334+
} finally {
335+
span.end();
336+
}
337+
},
338+
);
339+
});
340+
}
341+
278342
/** Graceful shutdown hook — flush exporter on SIGTERM. */
279343
export async function shutdownTracing() {
280344
if (sdkInstance && typeof sdkInstance.shutdown === 'function') {

backend/src/tracing.test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,94 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { trace, context as otelContext } from '@opentelemetry/api';
4+
import {
5+
BasicTracerProvider,
6+
InMemorySpanExporter,
7+
SimpleSpanProcessor,
8+
} from '@opentelemetry/sdk-trace-base';
9+
import { captureTraceparent, linkedSpan } from './tracing.js';
10+
11+
/**
12+
* Trace assertion test for issue #778: confirms `captureTraceparent()` +
13+
* `linkedSpan()` correctly re-link a span across an async boundary — the
14+
* exact shape of the outbox relay's write-now/deliver-later gap, where
15+
* OTel's automatic context propagation can't reach on its own since the
16+
* delivery happens on a completely separate event-loop tick with no
17+
* ambient span.
18+
*/
19+
20+
function setupInMemoryTracing() {
21+
const exporter = new InMemorySpanExporter();
22+
const provider = new BasicTracerProvider({
23+
spanProcessors: [new SimpleSpanProcessor(exporter)],
24+
});
25+
trace.setGlobalTracerProvider(provider);
26+
return { provider, exporter };
27+
}
28+
29+
test('linkedSpan re-establishes parent/child across an async boundary', async () => {
30+
const { provider, exporter } = setupInMemoryTracing();
31+
try {
32+
const tracer = trace.getTracer('test');
33+
34+
// Simulate the HTTP request that enqueues the outbox row: start a span,
35+
// capture its traceparent (what writeOutbox stores alongside the row),
36+
// then end the span — mirroring the request finishing well before the
37+
// relay ever picks the row up.
38+
let traceparent;
39+
let parentSpanId;
40+
let parentTraceId;
41+
await tracer.startActiveSpan('http.request', async (parentSpan) => {
42+
const ctx = parentSpan.spanContext();
43+
parentSpanId = ctx.spanId;
44+
parentTraceId = ctx.traceId;
45+
traceparent = captureTraceparent();
46+
parentSpan.end();
47+
});
48+
49+
assert.ok(traceparent, 'expected a traceparent to be captured from the active span');
50+
51+
// Simulate the relay's later, unrelated poll tick: no ambient span here
52+
// at all — linkedSpan must reconstruct the parent purely from the
53+
// stored traceparent string.
54+
await otelContext.with(otelContext.active(), async () => {
55+
await linkedSpan(traceparent, 'outbox.deliver', { 'outbox.event_type': 'test.event' }, async () => {
56+
// handler body — nothing to do for this assertion.
57+
});
58+
});
59+
60+
await provider.forceFlush();
61+
const spans = exporter.getFinishedSpans();
62+
const deliverSpan = spans.find((s) => s.name === 'outbox.deliver');
63+
64+
assert.ok(deliverSpan, 'expected an outbox.deliver span to have been recorded');
65+
assert.equal(
66+
deliverSpan.parentSpanId,
67+
parentSpanId,
68+
'outbox.deliver span must be a child of the request span that enqueued it',
69+
);
70+
assert.equal(
71+
deliverSpan.spanContext().traceId,
72+
parentTraceId,
73+
'outbox.deliver span must share the same trace id as the request that enqueued it',
74+
);
75+
} finally {
76+
await provider.shutdown();
77+
}
78+
});
79+
80+
test('linkedSpan still traces (unlinked) when no traceparent is available', async () => {
81+
const { provider, exporter } = setupInMemoryTracing();
82+
try {
83+
await linkedSpan(null, 'outbox.deliver', {}, async () => {});
84+
await provider.forceFlush();
85+
const spans = exporter.getFinishedSpans();
86+
const deliverSpan = spans.find((s) => s.name === 'outbox.deliver');
87+
assert.ok(deliverSpan, 'expected a span even without a traceparent to link to');
88+
assert.equal(deliverSpan.parentSpanId, undefined, 'span should have no parent when unlinked');
89+
} finally {
90+
await provider.shutdown();
91+
}
192
/**
293
* Tests for distributed tracing across async boundaries
394
*

contracts/rewards/fuzz/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

contracts/rewards/fuzz/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,16 @@ cargo-fuzz = true
1515
libfuzzer-sys = "0.4"
1616
trivela-rewards-contract = { path = "..", features = ["testutils"] }
1717
soroban-sdk = { version = "25.1", features = ["testutils"] }
18+
ed25519-dalek = "2"
1819

1920
[[bin]]
2021
name = "fuzz_balance"
2122
path = "fuzz_targets/fuzz_balance.rs"
2223
test = false
2324
doc = false
25+
26+
[[bin]]
27+
name = "fuzz_multisig"
28+
path = "fuzz_targets/fuzz_multisig.rs"
29+
test = false
30+
doc = false

0 commit comments

Comments
 (0)