From 1b96d98288c6f1f6937d62d066e2dec69d226790 Mon Sep 17 00:00:00 2001 From: fytgian Date: Mon, 31 Aug 2026 09:25:13 +0000 Subject: [PATCH] feat(ops): indexer + treasury ops bundle for issues 283-286 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #283: document a stable, replay-idempotent event ID scheme (network:contract:ledger:tx:index) in DASHBOARD_SYNC.md / EVENT_INDEXING.md, with a reference consumer test (tests/event_replay.rs, `cargo test event`) and a language-neutral replay fixture. - #284: add docs/subgraph/ — a Graph-Protocol schema.graphql for Registered/Verified/Removed plus a derived Contributor aggregate, the event -> entity mapping, a handler sketch, an example query, and local-run notes. Field names mirror src/events.rs; no payload fields invented. - #285: add scripts/payout_allowlist.{py,sh} — verified-only payout CSV generated from the unauthenticated get_public_paginated read, bots and unverified rows excluded by default, with pagination-completeness checks. - #286: rework scripts/bulk_verify.sh to call batch_verify in pages of MAX_WRITE_BATCH (25) with automatic per-username fallback, pacing, dry-run, partial-success (BatchSummary) handling, and per-batch audit logging. - document the two scripts in ADMIN_RUNBOOK.md; ignore __pycache__. Closes: #283 Closes: #284 Closes: #285 Closes: #286 --- .gitignore | 6 +- docs/ADMIN_RUNBOOK.md | 45 ++++- docs/DASHBOARD_SYNC.md | 42 +++++ docs/EVENT_INDEXING.md | 26 +++ docs/subgraph/README.md | 121 +++++++++++++ docs/subgraph/schema.graphql | 104 +++++++++++ scripts/bulk_verify.sh | 212 ++++++++++++++++++----- scripts/payout_allowlist.py | 97 +++++++++++ scripts/payout_allowlist.sh | 28 +++ scripts/trustbridge_client.py | 40 ++++- tests/event_replay.rs | 147 ++++++++++++++++ tests/testdata/event_replay_fixture.json | 92 ++++++++++ 12 files changed, 910 insertions(+), 50 deletions(-) create mode 100644 docs/subgraph/README.md create mode 100644 docs/subgraph/schema.graphql create mode 100644 scripts/payout_allowlist.py create mode 100644 scripts/payout_allowlist.sh create mode 100644 tests/event_replay.rs create mode 100644 tests/testdata/event_replay_fixture.json diff --git a/.gitignore b/.gitignore index 43f2829..4a1d935 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,8 @@ pr.md # MiMoCode .mimocode/ -.mimocode \ No newline at end of file +.mimocode + +# Python +__pycache__/ +*.pyc \ No newline at end of file diff --git a/docs/ADMIN_RUNBOOK.md b/docs/ADMIN_RUNBOOK.md index 3c99a45..fe3e74d 100644 --- a/docs/ADMIN_RUNBOOK.md +++ b/docs/ADMIN_RUNBOOK.md @@ -231,7 +231,31 @@ stellar contract invoke --id "$CONTRACT_ID" --source-account admin --network "$N - **Partial success:** unknown / already-verified entries are counted as `failed` and skipped; the batch does **not** abort. Inspect the returned `BatchSummary` — a `success_rate < 100` is informational, not an error. -- For large lists use `scripts/bulk_verify.sh` (handles paging + RPC pacing). +- For large lists use `scripts/bulk_verify.sh` (see below). + +#### `scripts/bulk_verify.sh` — paged `batch_verify` adapter (Issue #286) + +```bash +CONTRACT_ID="$CONTRACT_ID" SOURCE=admin NETWORK="$NETWORK" \ + ./scripts/bulk_verify.sh --file usernames.txt \ + [--dry-run] [--batch-size 25] [--pace-ms 500] \ + [--audit-log verify-audit.log] [--continue-on-error] [--no-batch] +``` + +- Reads usernames one per line (blank lines and `#` comments ignored) and + calls `batch_verify` in pages of `--batch-size` (default and ceiling 25 = + `MAX_WRITE_BATCH`). +- **Fallback:** if the deployed contract has no `batch_verify`, the script + detects the CLI error on the first call, prints a note, and finishes the + run with per-username `verify`. Force this with `--no-batch`. +- **Idempotency:** a per-batch `successful < total` is reported as `PARTIAL` + (already-verified / unknown usernames), not a hard failure — it matches the + contract's `BatchSummary` semantics. In fallback mode an `AlreadyVerified` + error is likewise treated as success. +- `--dry-run` submits no transaction and logs the intended batches. +- `--audit-log` appends one JSON line per batch/username: + `{"timestamp","scope","target","network","result","detail"}`. +- Exit code is non-zero if any username ended unverified. #### `revoke_verification` — withdraw verification @@ -440,6 +464,25 @@ CONTRACT_ID="$CONTRACT_ID" SOURCE=admin NETWORK="$NETWORK" ./scripts/export_regi `registry-export-.json`. `SOURCE` must sign as the admin. Take an export **before** any bulk verify/remove or dashboard migration. +#### `scripts/payout_allowlist.sh` — verified-only payout CSV (Issue #285) + +```bash +CONTRACT_ID="$CONTRACT_ID" NETWORK="$NETWORK" \ + ./scripts/payout_allowlist.sh [--output allowlist.csv] \ + [--include-unverified] [--include-bots] +``` + +- Pages the **unauthenticated** `get_public_paginated` read — no admin key; + `SOURCE` can be any funded identity (defaults to `default`). +- Writes `payout-allowlist-.csv` with columns + `github_username,payout_address,stellar_address,verified,registered_at` + (`payout_address` falls back to `stellar_address` when unset). +- **Verified-only by default:** unverified registrations and records flagged + `is_bot` are excluded so a squatter or CI account is never paid. Opt back in + with `--include-unverified` / `--include-bots`. +- Read-only; pagination completeness is enforced (a stalled cursor aborts). + Payment submission is out of scope. + --- ## Storage TTL Maintenance (Keeper) diff --git a/docs/DASHBOARD_SYNC.md b/docs/DASHBOARD_SYNC.md index 041c7df..cd38d45 100644 --- a/docs/DASHBOARD_SYNC.md +++ b/docs/DASHBOARD_SYNC.md @@ -152,6 +152,48 @@ asserts the local `verified` flag is `true` exactly once — i.e. the second apply is detected as a duplicate by `(ledger_sequence, tx_hash)` and produces no state change, no duplicate row, and no double count in any aggregate. +### Stable event ID (Issue #283) + +The composite key above is fine for a relational store but awkward to pass +around as one value. Consumers that want a single opaque id per event should +derive it deterministically from the delivery envelope: + +``` +event_id = "{network_id}:{contract_id}:{ledger_sequence}:{tx_hash}:{event_index}" +``` + +- `network_id` — lower-hex SHA-256 of the network passphrase (same value as + `domain.network_id`, see [EVENT_INDEXING.md](EVENT_INDEXING.md#event-domain-separation-issue-226)). +- `contract_id` — the emitting contract's `C...` address (`domain.contract_id`). +- `ledger_sequence` — ledger the event was emitted in. +- `tx_hash` — hex transaction hash that emitted it. +- `event_index` — zero-based position of this event within that transaction's + event list. Required because one `batch_verify` / `batch_remove` transaction + emits many events with the same topic in the same ledger. + +**Algorithm for a consumer:** + +1. On each delivery, compute `event_id`. +2. If `event_id` is already in the applied-set, drop the delivery — it is a + reconnect replay, a catch-up re-read, or a worker retry. Do nothing else. +3. Otherwise apply the event (a last-write-wins field/record update, never an + increment — see the table above), then record `event_id` in the applied-set. +4. A full re-sync of the entire stream is therefore a no-op once every id has + been seen. + +**Uniqueness scope: one contract instance on one network.** `network_id` and +`contract_id` are baked into the id, so it never collides across a redeploy or +another network — an upgrade re-emitting history, or a testnet stream leaking +into a public-network table, both produce ids that do not match anything in the +target store. Consumers that prefer a fixed-width id may hash the string +(e.g. SHA-256) — the hash inherits the same uniqueness scope. + +A language-neutral fixture of replayed deliveries and the expected end state +lives at +[`tests/testdata/event_replay_fixture.json`](../tests/testdata/event_replay_fixture.json); +`tests/event_replay.rs` (`cargo test event`) applies it through a reference +consumer and asserts the stream is replay-safe. + See [ABI.md — Events](ABI.md#events) for the full topic/payload reference per event type. diff --git a/docs/EVENT_INDEXING.md b/docs/EVENT_INDEXING.md index b71e523..d985b92 100644 --- a/docs/EVENT_INDEXING.md +++ b/docs/EVENT_INDEXING.md @@ -151,3 +151,29 @@ topic assignments, and no event was removed or renamed. Consumers that ignore unknown fields need no change to keep working — they simply do not get the deduplication benefit. Consumers that parse events positionally must be updated, because `domain` is appended to each payload. + +--- + +## Stable event ID (Issue #283) + +For a single opaque id per event, derive it from the delivery envelope: + +``` +event_id = "{network_id}:{contract_id}:{ledger_sequence}:{tx_hash}:{event_index}" +``` + +`event_index` is the zero-based position of the event within its transaction — +required because one `batch_verify` / `batch_remove` transaction emits many +events sharing a topic in one ledger. Uniqueness scope is one contract instance +on one network. Full algorithm, the replay fixture +(`tests/testdata/event_replay_fixture.json`), and the reference consumer test +(`tests/event_replay.rs`, `cargo test event`) are in +[DASHBOARD_SYNC.md](DASHBOARD_SYNC.md#stable-event-id-issue-283). + +## GraphQL subgraph schema (Issue #284) + +[`docs/subgraph/`](subgraph/) has a Graph-Protocol `schema.graphql` for +`RegisteredEvent`, `VerifiedEvent`, and `RemovedEvent` plus a derived +`Contributor` aggregate, the event → entity mapping, a handler sketch, an +example query, and how to run a schema check and a local event stream without a +hosted indexer. diff --git a/docs/subgraph/README.md b/docs/subgraph/README.md new file mode 100644 index 0000000..b3b38db --- /dev/null +++ b/docs/subgraph/README.md @@ -0,0 +1,121 @@ +# TrustBridge Registry Subgraph (Issue #284) + +The dashboard currently polls RPC directly. This directory is a **schema + +mapping spec** so Wave UIs can query contributor history from a subgraph instead +of writing an indexer from scratch. Operating a hosted indexer is out of scope +for this repo — [`scripts/event_indexer.sh`](../../scripts/event_indexer.sh) +remains the runnable local reference. + +- [`schema.graphql`](schema.graphql) — entities for `RegisteredEvent`, + `VerifiedEvent`, `RemovedEvent`, plus a derived `Contributor` aggregate. + +## Event → entity mapping + +Field names and types below are copied from the on-chain `#[contractevent]` +definitions in [`src/events.rs`](../../src/events.rs). Do not add payload fields +the contract does not emit. + +| Contract event | Topic symbol | Payload fields (from `src/events.rs`) | Entity | +|---|---|---|---| +| `RegisteredEvent` | `registered_event` | `github_username` (topic), `stellar_address`, `timestamp`, `sponsor: Option
` | `RegisteredEvent` | +| `VerifiedEvent` | `verified_event` | `github_username` (topic), `stellar_address`, `timestamp`, `domain` | `VerifiedEvent` | +| `RemovedEvent` | `removed_event` | `github_username` (topic), `stellar_address`, `timestamp`, `domain` | `RemovedEvent` | + +Notes and gotchas: + +- **`RegisteredEvent` has no `domain` field.** Only `VerifiedEvent` and + `RemovedEvent` carry `EventDomain` (Issue #226). The schema reflects this — + `RegisteredEvent.domain` does not exist. Take `contractId` / `networkId` for a + registration from the deployment the subgraph is pointed at. +- **`EventDomain`** = `{ contract_id: Address, network_id: BytesN<32>, + contract_version: (u32,u32,u32), domain_version: u32 }`. Mapped as an embedded + type, not a queryable entity. +- **`reason_code`** belongs to `VerificationRevokedEvent` and `PausedEvent`, not + to any of the three events modelled here. It is intentionally absent. +- **Batch removes**: `batch_remove` emits one `RemovedEvent` per removed + contributor, all in one transaction. The entity `id` includes the per-tx + `eventIndex` so they do not collide. +- **Entity `id`** is the stable event id from + [`DASHBOARD_SYNC.md`](../DASHBOARD_SYNC.md#stable-event-id-issue-283): + `{networkId}:{contractId}:{ledgerSequence}:{txHash}:{eventIndex}`. Using it as + the primary key makes ingestion replay-idempotent for free. +- **`Contributor` aggregate** is last-write-wins keyed on `ledgerSequence`: + a `RegisteredEvent` sets `stellarAddress` and clears `verified`; a + `VerifiedEvent` sets `verified = true`; a `RemovedEvent` sets `removed = true` + and `stellarAddress = null`. A later `RegisteredEvent` clears `removed`. +- Treat the subgraph as a change-notification cache. After any gap, reconcile + against `get_public_paginated` on-chain — see `DASHBOARD_SYNC.md`. + +## Mapping handler sketch + +```ts +export function handleRegistered(ev: RegisteredEvent): void { + let e = new RegisteredEventEntity(eventId(ev)); // networkId:contractId:ledger:tx:index + e.githubUsername = ev.params.github_username; + e.stellarAddress = ev.params.stellar_address; + e.timestamp = ev.params.timestamp; + e.sponsor = ev.params.sponsor; // may be null + e.ledgerSequence = ev.ledger; + e.txHash = ev.transaction.hash; + e.contributor = ev.params.github_username; + e.save(); + touchContributor(ev.params.github_username, ev.ledger, ev.params.timestamp, { + stellarAddress: ev.params.stellar_address, verified: false, removed: false, + }); +} +``` + +`handleVerified` / `handleRemoved` are the same shape, reading `domain.*` from +the payload and updating the `Contributor` `verified` / `removed` flags. + +## Running locally + +No hosted service is required to develop against this schema: + +```bash +# 1. schema check — the schema is plain GraphQL SDL +npx graphql-schema-linter docs/subgraph/schema.graphql +# or, with the Graph tooling: +npx --yes @graphprotocol/graph-cli@latest codegen --skip-migrations \ + --output-dir /tmp/tb-subgraph docs/subgraph/schema.graphql + +# 2. produce a local event stream to map against +CONTRACT_ID=C... ONESHOT=1 ./scripts/event_indexer.sh +# -> ./.indexer/events-.jsonl (one raw event per line) + +# 3. a full local Graph Node stack (Postgres + IPFS + graph-node) via +# docker-compose is the standard path once a manifest exists; that manifest +# is deployment-specific (contract address, start block) and is not checked +# in here. +``` + +## Example query + +```graphql +{ + # every currently-verified, not-removed contributor + contributors(where: { verified: true, removed: false }, orderBy: lastEventAt, orderDirection: desc) { + githubUsername + stellarAddress + firstRegisteredAt + lastEventAt + } + + # full history for one username + registeredEvents(where: { githubUsername: "octocat" }, orderBy: ledgerSequence) { + id + stellarAddress + sponsor + timestamp + } + verifiedEvents(where: { githubUsername: "octocat" }, orderBy: ledgerSequence) { + id + timestamp + domain { contractId networkId contractVersion } + } + removedEvents(where: { githubUsername: "octocat" }, orderBy: ledgerSequence) { + id + timestamp + } +} +``` diff --git a/docs/subgraph/schema.graphql b/docs/subgraph/schema.graphql new file mode 100644 index 0000000..f37e8c9 --- /dev/null +++ b/docs/subgraph/schema.graphql @@ -0,0 +1,104 @@ +""" +TrustBridge registry subgraph schema (Issue #284). + +Covers the three events a dashboard needs to reconstruct contributor history +without a bespoke indexer: RegisteredEvent, VerifiedEvent, RemovedEvent. +Field names and types mirror the on-chain `#[contractevent]` definitions in +`src/events.rs` exactly — see docs/subgraph/README.md for the event -> entity +mapping. No payload field here is invented; anything the contract does not emit +is not modelled. + +This schema is written in the Graph Protocol subgraph dialect (entity +directives, `Bytes`, `BigInt`). It is a schema + mapping spec only; operating a +hosted indexer is out of scope for this repo. +""" + +""" +Deployment provenance carried by VerifiedEvent and RemovedEvent (`EventDomain`, +Issue #226). RegisteredEvent does NOT carry this field — see README. +Embedded, not a top-level entity: it has no identity of its own. +""" +type EventDomain { + "Emitting contract instance (C... address)." + contractId: Bytes! + "SHA-256 of the network passphrase." + networkId: Bytes! + "Contract version at emit time, as [major, minor, patch]." + contractVersion: [Int!]! + "Schema version of the domain envelope itself. Currently 1." + domainVersion: Int! +} + +""" +One `RegisteredEvent`: a GitHub username registered or re-registered to a +Stellar address. Topic: `registered_event`. `github_username` is the event +topic. `sponsor` is present only when a third party paid for the registration. +""" +type RegisteredEvent @entity(immutable: true) { + "Stable event id: {networkId}:{contractId}:{ledgerSequence}:{txHash}:{eventIndex} (Issue #283)." + id: ID! + githubUsername: String! + stellarAddress: Bytes! + timestamp: BigInt! + sponsor: Bytes + ledgerSequence: BigInt! + txHash: Bytes! + contributor: Contributor! +} + +""" +One `VerifiedEvent`: an admin or Verifier marked a contributor verified. +Topic: `verified_event`. `github_username` is the event topic. +""" +type VerifiedEvent @entity(immutable: true) { + "Stable event id (Issue #283)." + id: ID! + githubUsername: String! + stellarAddress: Bytes! + timestamp: BigInt! + domain: EventDomain! + ledgerSequence: BigInt! + txHash: Bytes! + contributor: Contributor! +} + +""" +One `RemovedEvent`: a registration removed by the registrant or an admin. +Topic: `removed_event`. `github_username` is the event topic. A large +`batch_remove` emits one `RemovedEvent` per successfully removed contributor, +all in the same transaction — `eventIndex` in the `id` keeps them distinct. +""" +type RemovedEvent @entity(immutable: true) { + "Stable event id (Issue #283)." + id: ID! + githubUsername: String! + stellarAddress: Bytes! + timestamp: BigInt! + domain: EventDomain! + ledgerSequence: BigInt! + txHash: Bytes! + contributor: Contributor! +} + +""" +Derived, mutable aggregate: current best-known state of one GitHub username on +one contract instance. Rebuilt by the mappings from the event stream; the +contract's own storage remains the source of truth for reconciliation. +""" +type Contributor @entity { + "The GitHub username (lower-cased, as stored on-chain)." + id: ID! + githubUsername: String! + "Address from the most recent RegisteredEvent, or null once removed." + stellarAddress: Bytes + "True after a VerifiedEvent, false after a RemovedEvent or before first verify." + verified: Boolean! + "False until a RemovedEvent with no later RegisteredEvent." + removed: Boolean! + firstRegisteredAt: BigInt + lastEventAt: BigInt! + lastLedgerSequence: BigInt! + registrations: [RegisteredEvent!]! @derivedFrom(field: "contributor") + verifications: [VerifiedEvent!]! @derivedFrom(field: "contributor") + removals: [RemovedEvent!]! @derivedFrom(field: "contributor") +} diff --git a/scripts/bulk_verify.sh b/scripts/bulk_verify.sh index e448ad9..92d479d 100644 --- a/scripts/bulk_verify.sh +++ b/scripts/bulk_verify.sh @@ -1,14 +1,17 @@ #!/usr/bin/env bash -# bulk_verify.sh — Maintainer bulk verify CLI +# bulk_verify.sh — Maintainer bulk verify CLI (Issue #286) # # Reads GitHub usernames (one per line) from a file and marks each one as verified. -# Continues on partial failure and summarises successes/failures. +# Calls the on-chain `batch_verify` in pages of up to MAX_WRITE_BATCH (25) when +# the deployed contract exposes it, and falls back to per-username `verify` on +# older deployments. Continues on partial failure and summarises the outcome. # Includes pacing (configurable delay between calls) to avoid RPC throttling. # # Usage: # ./scripts/bulk_verify.sh --file usernames.txt \ # --contract C... --source admin-identity --network testnet \ -# [--dry-run] [--pace-ms 500] [--audit-log audit.log] [--continue-on-error] +# [--dry-run] [--pace-ms 500] [--audit-log audit.log] [--continue-on-error] \ +# [--batch-size 25] [--no-batch] # # Required env (or flags): # CONTRACT_ID — deployed contract C-address @@ -19,13 +22,23 @@ # SOURCE must be initialized as admin or hold Role::Verifier on the contract. # See docs/DEPLOYMENT.md for role setup instructions. # +# Batching: +# --batch-size N usernames per batch_verify call (default 25, the contract's +# MAX_WRITE_BATCH; clamped to that ceiling). +# --no-batch skip batch_verify entirely and call verify once per username. +# batch_verify is idempotent at the contract: an already-verified or unknown +# username is counted in the returned BatchSummary as failed and skipped, and +# the batch does not abort. A batch whose success_rate < 100 is treated as a +# partial success here, not a hard error, unless --continue-on-error is unset +# and the whole call failed. +# # Pacing: # RPC nodes apply per-IP rate limits. Use --pace-ms (default 500 ms) to insert -# a sleep between calls. Increase to 1000–2000 ms for large batches (>50 usernames) -# or when hitting HTTP 429 responses. +# a sleep between calls (per batch, or per username in fallback mode). Increase +# to 1000–2000 ms for large runs or when hitting HTTP 429 responses. # -# Audit log format (one JSON-like line per username): -# {"timestamp":"","username":"","network":"","result":"ok|error|dry-run","detail":""} +# Audit log format (one JSON-like line per batch or username): +# {"timestamp":"","scope":"batch|username","target":"","network":"","result":"ok|partial|error|dry-run","detail":""} set -euo pipefail @@ -39,6 +52,9 @@ CONTINUE_ON_ERROR=false PACE_MS="${PACE_MS:-500}" AUDIT_LOG="" STELLAR="${STELLAR:-stellar}" +BATCH_SIZE="${BATCH_SIZE:-25}" +MAX_WRITE_BATCH=25 +NO_BATCH=false usage() { grep '^#' "$0" | sed 's/^# \?//' | grep -v '^!' @@ -55,6 +71,8 @@ while [[ $# -gt 0 ]]; do --dry-run) DRY_RUN=true; shift ;; --continue-on-error) CONTINUE_ON_ERROR=true; shift ;; --pace-ms) PACE_MS="$2"; shift 2 ;; + --batch-size) BATCH_SIZE="$2"; shift 2 ;; + --no-batch) NO_BATCH=true; shift ;; --audit-log) AUDIT_LOG="$2"; shift 2 ;; -h|--help) usage ;; *) echo "Unknown flag: $1"; usage ;; @@ -66,77 +84,177 @@ done [[ -z "$CONTRACT_ID" ]] && echo "ERROR: --contract (or CONTRACT_ID env) is required." >&2 && exit 1 [[ -z "$NETWORK" ]] && echo "ERROR: --network is required (testnet | futurenet | mainnet). Never defaults to mainnet." >&2 && exit 1 [[ ! -f "$FILE" ]] && echo "ERROR: file not found: $FILE" >&2 && exit 1 +[[ ! "$BATCH_SIZE" =~ ^[0-9]+$ ]] && echo "ERROR: --batch-size must be a positive integer." >&2 && exit 1 +(( BATCH_SIZE < 1 )) && BATCH_SIZE=1 +(( BATCH_SIZE > MAX_WRITE_BATCH )) && BATCH_SIZE=$MAX_WRITE_BATCH # ---------- helpers ---------- log_audit() { - local username="$1" result="$2" detail="$3" + local scope="$1" target="$2" result="$3" detail="$4" local ts; ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) - local line="{\"timestamp\":\"$ts\",\"username\":\"$username\",\"network\":\"$NETWORK\",\"result\":\"$result\",\"detail\":\"$detail\"}" + local esc_detail=${detail//\\/\\\\}; esc_detail=${esc_detail//\"/\\\"} + local line="{\"timestamp\":\"$ts\",\"scope\":\"$scope\",\"target\":\"$target\",\"network\":\"$NETWORK\",\"result\":\"$result\",\"detail\":\"$esc_detail\"}" echo "$line" [[ -n "$AUDIT_LOG" ]] && echo "$line" >> "$AUDIT_LOG" } -# ---------- main loop ---------- -TOTAL=0; SUCCESS=0; FAILED=0; SKIPPED=0 +invoke() { + # Run `stellar contract invoke -- `, capturing combined output in + # the global `output` and the return code in `rc`. + set +e + output=$("$STELLAR" contract invoke \ + --id "$CONTRACT_ID" \ + --source-account "$SOURCE" \ + --network "$NETWORK" \ + --send=yes \ + -- "$@" 2>&1) + rc=$? + set -e +} + +# `true` once the deployed contract is shown not to expose `batch_verify`. +BATCH_UNSUPPORTED=false +looks_like_missing_fn() { + grep -qiE 'unrecognized subcommand|unexpected argument|no such|not found|MissingValue.*batch_verify|unknown (function|method)' <<<"$1" +} + +pace() { [[ ${PACE_MS:-0} -gt 0 ]] && sleep "$PACE_S"; } + +# ---------- collect usernames ---------- +USERNAMES=() +while IFS= read -r username || [[ -n "$username" ]]; do + username="${username//$'\r'/}" + [[ -z "$username" || "$username" =~ ^[[:space:]]*# ]] && continue + USERNAMES+=("$username") +done < "$FILE" + +TOTAL=${#USERNAMES[@]} +SUCCESS=0; FAILED=0; SKIPPED=0; BATCHES=0 PACE_S=$(echo "scale=3; $PACE_MS/1000" | bc 2>/dev/null || echo "0.5") +MODE="batch (size $BATCH_SIZE)" +[[ "$NO_BATCH" = true ]] && MODE="per-username (--no-batch)" + echo "=== bulk_verify.sh ===" echo " File: $FILE" echo " Contract: $CONTRACT_ID" echo " Network: $NETWORK" echo " Source: $SOURCE" +echo " Usernames: $TOTAL" +echo " Mode: $MODE" echo " Dry-run: $DRY_RUN" echo " Pace: ${PACE_MS} ms between calls" [[ -n "$AUDIT_LOG" ]] && echo " Audit log: $AUDIT_LOG" echo "" -while IFS= read -r username || [[ -n "$username" ]]; do - [[ -z "$username" || "$username" =~ ^# ]] && continue - TOTAL=$((TOTAL + 1)) - +verify_single() { + local u="$1" if [[ "$DRY_RUN" = true ]]; then - echo "[DRY-RUN] would verify: $username" - log_audit "$username" "dry-run" "no transaction submitted" + echo "[DRY-RUN] would verify: $u" + log_audit "username" "$u" "dry-run" "no transaction submitted" SKIPPED=$((SKIPPED + 1)) - continue + return 0 fi + echo "Verifying: $u ..." + invoke verify --caller "$SOURCE" --github-username "$u" + if [[ $rc -eq 0 ]] || grep -qi 'AlreadyVerified' <<<"$output"; then + echo " OK: $u" + log_audit "username" "$u" "ok" "verified" + SUCCESS=$((SUCCESS + 1)) + return 0 + fi + echo " ERROR: $u — $output" >&2 + log_audit "username" "$u" "error" "$output" + FAILED=$((FAILED + 1)) + return 1 +} - echo "Verifying: $username ..." - set +e - output=$("$STELLAR" contract invoke \ - --id "$CONTRACT_ID" \ - --source-account "$SOURCE" \ - --network "$NETWORK" \ - --send=yes \ - -- verify \ - --github-username "$username" 2>&1) - rc=$? - set -e +# Verify a batch. Returns non-zero only on a hard failure (whole call errored +# and it was not a missing-function fallback). +verify_batch() { + local -a batch=("$@") + local joined; joined=$(IFS=,; echo "${batch[*]}") + local json="["; local u + for u in "${batch[@]}"; do json+="\"$u\","; done + json="${json%,}]" - if [[ $rc -eq 0 ]]; then - echo " OK: $username" - log_audit "$username" "ok" "verified" - SUCCESS=$((SUCCESS + 1)) + if [[ "$DRY_RUN" = true ]]; then + echo "[DRY-RUN] would batch_verify ${#batch[@]}: $joined" + log_audit "batch" "$joined" "dry-run" "no transaction submitted" + SKIPPED=$((SKIPPED + ${#batch[@]})) + return 0 + fi + + echo "batch_verify ${#batch[@]}: $joined ..." + invoke batch_verify --caller "$SOURCE" --usernames "$json" + + if [[ $rc -ne 0 ]] && looks_like_missing_fn "$output"; then + echo " note: contract has no batch_verify — falling back to per-username verify" >&2 + BATCH_UNSUPPORTED=true + local ok=0 + for u in "${batch[@]}"; do + verify_single "$u" || { [[ "$CONTINUE_ON_ERROR" = false ]] && return 1; } + pace + done + return 0 + fi + + BATCHES=$((BATCHES + 1)) + if [[ $rc -ne 0 ]]; then + echo " ERROR: batch failed — $output" >&2 + log_audit "batch" "$joined" "error" "$output" + FAILED=$((FAILED + ${#batch[@]})) + return 1 + fi + + local ok + ok=$(grep -o '"successful"[^0-9]*[0-9]\+' <<<"$output" | grep -o '[0-9]\+$' | head -1 || true) + [[ -z "$ok" ]] && ok=${#batch[@]} # older CLI printing: assume full success on rc 0 + local miss=$(( ${#batch[@]} - ok )) + SUCCESS=$((SUCCESS + ok)) + FAILED=$((FAILED + miss)) + if (( miss > 0 )); then + echo " PARTIAL: $ok/${#batch[@]} verified (already-verified / unknown usernames skipped)" + log_audit "batch" "$joined" "partial" "successful=$ok of ${#batch[@]}" else - echo " ERROR: $username — $output" >&2 - log_audit "$username" "error" "$output" - FAILED=$((FAILED + 1)) - if [[ "$CONTINUE_ON_ERROR" = false ]]; then - echo "Stopping batch on first error. Use --continue-on-error to process remaining usernames." >&2 - break - fi + echo " OK: $ok/${#batch[@]} verified" + log_audit "batch" "$joined" "ok" "successful=$ok" fi + return 0 +} - # Pace to avoid RPC throttling - [[ $PACE_MS -gt 0 ]] && sleep "$PACE_S" -done < "$FILE" +# ---------- main loop ---------- +i=0 +while (( i < TOTAL )); do + if [[ "$NO_BATCH" = true || "$BATCH_UNSUPPORTED" = true ]]; then + verify_single "${USERNAMES[$i]}" || { + if [[ "$CONTINUE_ON_ERROR" = false ]]; then + echo "Stopping on first error. Use --continue-on-error to process the rest." >&2 + break + fi + } + i=$((i + 1)) + else + chunk=("${USERNAMES[@]:i:BATCH_SIZE}") + verify_batch "${chunk[@]}" || { + if [[ "$CONTINUE_ON_ERROR" = false ]]; then + echo "Stopping on first error. Use --continue-on-error to process the rest." >&2 + break + fi + } + i=$((i + ${#chunk[@]})) + fi + pace +done echo "" echo "=== Summary ===" -echo " Total: $TOTAL" -echo " Success: $SUCCESS" -echo " Failed: $FAILED" -echo " Dry-run: $SKIPPED" +echo " Usernames: $TOTAL" +echo " Verified: $SUCCESS" +echo " Failed: $FAILED" +echo " Dry-run: $SKIPPED" +echo " Batches: $BATCHES" +[[ "$BATCH_UNSUPPORTED" = true ]] && echo " Note: fell back to per-username verify (no batch_verify on contract)" [[ -n "$AUDIT_LOG" ]] && echo " Audit log written to: $AUDIT_LOG" [[ $FAILED -gt 0 ]] && exit 1 diff --git a/scripts/payout_allowlist.py b/scripts/payout_allowlist.py new file mode 100644 index 0000000..1e06b88 --- /dev/null +++ b/scripts/payout_allowlist.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Generate a payout allowlist CSV from on-chain registry state (Issue #285). + +Treasury teams need a CSV of payout recipients sourced directly from the +contract rather than a possibly-stale dashboard cache. This reads the +unauthenticated ``get_public_paginated`` endpoint (no admin key required), +keeps verified records only by default, and writes one row per contributor. + +Read-only: no mutating call is ever made. Payment submission is out of scope. +""" + +from __future__ import annotations + +import argparse +import csv +import os +import sys +from pathlib import Path + +from trustbridge_client import StellarCLIError, TrustBridgeClient + +# Column order is aligned with the JSON export in export_registry.py, with the +# payout destination added as the leading operational field. +COLUMNS = ["github_username", "payout_address", "stellar_address", "verified", "registered_at"] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--contract", default=os.environ.get("CONTRACT_ID"), help="deployed contract ID") + parser.add_argument( + "--source", + default=os.environ.get("SOURCE", "default"), + help="Stellar CLI identity used to sign the read (any funded identity; no admin role needed)", + ) + parser.add_argument("--network", default=os.environ.get("NETWORK", "testnet")) + parser.add_argument("--output", default=os.environ.get("OUTPUT_FILE"), help="output CSV path") + parser.add_argument("--page-limit", type=int, default=int(os.environ.get("PAGE_LIMIT", "100"))) + parser.add_argument( + "--include-unverified", + action="store_true", + help="opt in to unverified rows (default: verified-only, so squatter registrations are never paid)", + ) + parser.add_argument( + "--include-bots", + action="store_true", + help="keep records flagged as CI bots (default: excluded from payout allowlists)", + ) + args = parser.parse_args() + + if not args.contract: + parser.error("--contract or CONTRACT_ID is required") + if args.page_limit < 1: + parser.error("--page-limit must be positive") + + output = Path(args.output or f"payout-allowlist-{args.network}.csv") + client = TrustBridgeClient(args.contract, args.source, args.network) + + rows = [] + total = 0 + skipped_unverified = 0 + skipped_bots = 0 + for record in client.iter_public_records(args.page_limit): + total += 1 + if not args.include_unverified and not record.verified: + skipped_unverified += 1 + continue + if not args.include_bots and record.is_bot: + skipped_bots += 1 + continue + rows.append( + { + "github_username": record.github_username, + "payout_address": record.payout_address, + "stellar_address": record.stellar_address, + "verified": str(record.verified).lower(), + "registered_at": record.registered_at, + } + ) + + with output.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=COLUMNS) + writer.writeheader() + writer.writerows(rows) + + print( + f"Wrote {len(rows)} row(s) to {output} " + f"(scanned {total}, skipped {skipped_unverified} unverified, {skipped_bots} bot)" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (StellarCLIError, OSError, RuntimeError, ValueError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/scripts/payout_allowlist.sh b/scripts/payout_allowlist.sh new file mode 100644 index 0000000..6bb8859 --- /dev/null +++ b/scripts/payout_allowlist.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Generate a verified-only payout allowlist CSV from on-chain registry state +# (Issue #285). Reads the unauthenticated get_public_paginated endpoint — no +# admin credentials required — filters to verified records, and writes one CSV +# row per contributor. Read-only; payment submission is out of scope. +# +# Usage: +# CONTRACT_ID=C... NETWORK=testnet ./scripts/payout_allowlist.sh +# CONTRACT_ID=C... NETWORK=testnet OUTPUT_FILE=allowlist.csv ./scripts/payout_allowlist.sh +# +# Environment variables: +# CONTRACT_ID — deployed contract ID (required) +# SOURCE — Stellar CLI identity to sign the read (default: "default"; +# any funded identity works, no admin role needed) +# NETWORK — testnet | mainnet | futurenet (default: testnet) +# OUTPUT_FILE — CSV path (default: payout-allowlist-.csv) +# PAGE_LIMIT — records per page (default: 100, the contract's MAX_PAGE_LIMIT) +# +# Extra flags are forwarded to payout_allowlist.py, e.g. --include-unverified. +# +# Output columns: github_username,payout_address,stellar_address,verified,registered_at + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +exec python3 "$ROOT/scripts/payout_allowlist.py" "$@" diff --git a/scripts/trustbridge_client.py b/scripts/trustbridge_client.py index ea03a22..1c957af 100644 --- a/scripts/trustbridge_client.py +++ b/scripts/trustbridge_client.py @@ -31,12 +31,14 @@ class RegistryRecord: stellar_address: str verified: bool registered_at: int + payout_address: str = "" + is_bot: bool = False @dataclass(frozen=True) class RegistryPage: records: list[RegistryRecord] - next_cursor: int | None + next_cursor: object | None total: int has_more: bool @@ -90,6 +92,8 @@ def _record(username: str, value: dict[str, Any]) -> RegistryRecord: stellar_address=value["stellar_address"], verified=bool(value["verified"]), registered_at=int(value["registered_at"]), + payout_address=value.get("payout_address") or value["stellar_address"], + is_bot=bool(value.get("is_bot", False)), ) def get_address(self, username: str) -> RegistryRecord | None: @@ -119,6 +123,40 @@ def get_registered_page(self, cursor: int = 0, limit: int = 100) -> RegistryPage has_more=bool(value["has_more"]), ) + def get_public_page(self, cursor: object = 0, limit: int = 100) -> RegistryPage: + """One page of the unauthenticated ``get_public_paginated`` read. + + ``next_cursor`` is an opaque token — it is passed straight back to the + next call and never interpreted here. + """ + value = self._invoke("get_public_paginated", ("--cursor", str(cursor), "--limit", str(limit))) + if not isinstance(value, dict): + raise ValueError(f"get_public_paginated returned unexpected value: {value!r}") + records = [self._record(item[0], item[1]) for item in value["records"]] + next_cursor = value.get("next_cursor") + return RegistryPage( + records=records, + next_cursor=None if next_cursor is None else next_cursor, + total=int(value["total"]), + has_more=bool(value["has_more"]), + ) + + def iter_public_records(self, page_limit: int = 100): + """Yield every registry record via ``get_public_paginated``, guarding + against a stalled cursor echoed by an unreliable RPC node.""" + cursor: object = 0 + seen: set[str] = set() + while True: + page = self.get_public_page(cursor, page_limit) + yield from page.records + if not page.has_more or page.next_cursor is None: + return + token = str(page.next_cursor) + if token in seen: + raise RuntimeError(f"pagination stalled at cursor {token}") + seen.add(token) + cursor = page.next_cursor + def batch_verify(self, usernames: Sequence[str]) -> int: value = self._invoke("batch_verify", ("--caller", self.source, "--usernames", json.dumps(list(usernames))), send=True) return int(value["successful"] if isinstance(value, dict) else value) diff --git a/tests/event_replay.rs b/tests/event_replay.rs new file mode 100644 index 0000000..4163ce6 --- /dev/null +++ b/tests/event_replay.rs @@ -0,0 +1,147 @@ +//! Replay-idempotency fixture for Issue #283. +//! +//! None of the contract's `#[contractevent]` payloads carry a sequence number, +//! so a consumer that keys only on `(github_username, event_type, timestamp)` +//! double-applies `RegisteredEvent` / `VerifiedEvent` on every indexer +//! reconnect. This test pins the **stable event ID** scheme documented in +//! `docs/DASHBOARD_SYNC.md` ("Stable event ID (Issue #283)") and proves a +//! consumer that keys on it is replay-safe. +//! +//! The scheme is derived entirely from the Horizon/RPC delivery envelope, not +//! from the event payload: +//! +//! ```text +//! event_id = "{network_id}:{contract_id}:{ledger_sequence}:{tx_hash}:{event_index}" +//! ``` +//! +//! Uniqueness scope is global: `network_id` and `contract_id` are embedded, so +//! an id is stable for the life of one contract instance on one network and +//! never collides with a redeploy or another network (Issue #226 domain +//! separation, applied to the id itself). +//! +//! `tests/testdata/event_replay_fixture.json` is the language-neutral copy of +//! the same deliveries for consumers in other stacks; this test asserts the two +//! stay in sync. + +#![cfg(test)] + +/// One raw event delivery, exactly as an indexer receives it. +struct Delivery { + event_type: &'static str, + github_username: &'static str, + stellar_address: &'static str, + ledger_sequence: u64, + tx_hash: &'static str, + event_index: u32, +} + +const NETWORK_ID: &str = "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472"; +const CONTRACT_ID: &str = "CDTRUSTBRIDGEEXAMPLECONTRACTID000000000000000000000000AAAA"; + +/// The documented derivation. Pure function of the delivery envelope. +fn event_id(d: &Delivery) -> String { + format!( + "{NETWORK_ID}:{CONTRACT_ID}:{}:{}:{}", + d.ledger_sequence, d.tx_hash, d.event_index + ) +} + +#[rustfmt::skip] +fn deliveries() -> Vec { + let addr_a = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; + let addr_b = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; + let tx1 = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901"; + let tx2 = "b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2"; + let tx3 = "c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3"; + let tx4 = "d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4"; + vec![ + // 1. register + Delivery { event_type: "registered_event", github_username: "octocat", stellar_address: addr_a, ledger_sequence: 1_000_042, tx_hash: tx1, event_index: 0 }, + // 2. verify + Delivery { event_type: "verified_event", github_username: "octocat", stellar_address: addr_a, ledger_sequence: 1_000_050, tx_hash: tx2, event_index: 0 }, + // 3. revoke + Delivery { event_type: "verification_revoked_event", github_username: "octocat", stellar_address: addr_a, ledger_sequence: 1_000_061, tx_hash: tx3, event_index: 1 }, + // 4. exact replay of #2 on reconnect + Delivery { event_type: "verified_event", github_username: "octocat", stellar_address: addr_a, ledger_sequence: 1_000_050, tx_hash: tx2, event_index: 0 }, + // 5. exact replay of #1 on catch-up + Delivery { event_type: "registered_event", github_username: "octocat", stellar_address: addr_a, ledger_sequence: 1_000_042, tx_hash: tx1, event_index: 0 }, + // 6. genuine re-registration in a later ledger + Delivery { event_type: "registered_event", github_username: "octocat", stellar_address: addr_b, ledger_sequence: 1_002_000, tx_hash: tx4, event_index: 0 }, + ] +} + +/// Minimal consumer model: last-write-wins per username, deduped by `event_id`. +#[derive(Default)] +struct Consumer { + seen: std::collections::HashSet, + address: Option, + verified: bool, + applied: u32, + ignored: u32, +} + +impl Consumer { + fn apply(&mut self, d: &Delivery) { + let id = event_id(d); + if !self.seen.insert(id) { + self.ignored += 1; + return; + } + self.applied += 1; + match d.event_type { + "registered_event" => { + self.address = Some(d.stellar_address.to_string()); + self.verified = false; + } + "verified_event" => self.verified = true, + "verification_revoked_event" => self.verified = false, + "removed_event" => self.address = None, + other => panic!("unhandled event_type in fixture: {other}"), + } + } +} + +#[test] +fn event_replay_is_idempotent() { + let deliveries = deliveries(); + assert!( + deliveries.iter().all(|d| d.github_username == "octocat"), + "fixture is a single-username replay scenario" + ); + + let mut once = Consumer::default(); + for d in &deliveries { + once.apply(d); + } + + // Replaying the whole stream again — a full re-sync — must not move state. + let mut twice = Consumer::default(); + for d in deliveries.iter().chain(deliveries.iter()) { + twice.apply(d); + } + + assert_eq!(once.applied, 4, "four distinct events in the fixture"); + assert_eq!(once.ignored, 2, "two deliveries are exact replays"); + assert_eq!( + once.address.as_deref(), + Some("GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"), + "final address is the re-registration target" + ); + assert!(!once.verified, "revoke is the last verification-affecting event"); + + assert_eq!(twice.applied, once.applied, "second full pass applies nothing"); + assert_eq!(twice.address, once.address); + assert_eq!(twice.verified, once.verified); +} + +#[test] +fn event_ids_match_the_language_neutral_fixture() { + let fixture = include_str!("testdata/event_replay_fixture.json"); + for d in &deliveries() { + let id = event_id(d); + assert!( + fixture.contains(&id), + "fixture JSON is missing event_id {id}; regenerate testdata/event_replay_fixture.json" + ); + } +} diff --git a/tests/testdata/event_replay_fixture.json b/tests/testdata/event_replay_fixture.json new file mode 100644 index 0000000..d32631d --- /dev/null +++ b/tests/testdata/event_replay_fixture.json @@ -0,0 +1,92 @@ +{ + "_comment": [ + "Consumer-oriented replay fixture for Issue #283.", + "Six raw event deliveries as an indexer would receive them from Horizon/RPC.", + "Deliveries 4 and 5 are exact replays of deliveries 2 and 1 (reconnect / catch-up).", + "Delivery 6 is a genuine re-registration of the same username in a later ledger.", + "A consumer that keys on `event_id` must apply four distinct events and treat", + "the two replays as no-ops, ending with `octocat` registered and NOT verified." + ], + "network_id": "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472", + "contract_id": "CDTRUSTBRIDGEEXAMPLECONTRACTID000000000000000000000000AAAA", + "id_scheme": "{network_id}:{contract_id}:{ledger_sequence}:{tx_hash}:{event_index}", + "deliveries": [ + { + "delivery": 1, + "event_type": "registered_event", + "github_username": "octocat", + "stellar_address": "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + "timestamp": 1732800000, + "ledger_sequence": 1000042, + "tx_hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901", + "event_index": 0, + "event_id": "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472:CDTRUSTBRIDGEEXAMPLECONTRACTID000000000000000000000000AAAA:1000042:a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901:0" + }, + { + "delivery": 2, + "event_type": "verified_event", + "github_username": "octocat", + "stellar_address": "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + "timestamp": 1732800300, + "ledger_sequence": 1000050, + "tx_hash": "b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2", + "event_index": 0, + "event_id": "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472:CDTRUSTBRIDGEEXAMPLECONTRACTID000000000000000000000000AAAA:1000050:b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2:0" + }, + { + "delivery": 3, + "event_type": "verification_revoked_event", + "github_username": "octocat", + "stellar_address": "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + "timestamp": 1732800600, + "ledger_sequence": 1000061, + "tx_hash": "c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3", + "event_index": 1, + "event_id": "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472:CDTRUSTBRIDGEEXAMPLECONTRACTID000000000000000000000000AAAA:1000061:c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3:1" + }, + { + "delivery": 4, + "_note": "exact replay of delivery 2 on reconnect", + "event_type": "verified_event", + "github_username": "octocat", + "stellar_address": "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + "timestamp": 1732800300, + "ledger_sequence": 1000050, + "tx_hash": "b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2", + "event_index": 0, + "event_id": "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472:CDTRUSTBRIDGEEXAMPLECONTRACTID000000000000000000000000AAAA:1000050:b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2:0" + }, + { + "delivery": 5, + "_note": "exact replay of delivery 1 on catch-up", + "event_type": "registered_event", + "github_username": "octocat", + "stellar_address": "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + "timestamp": 1732800000, + "ledger_sequence": 1000042, + "tx_hash": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901", + "event_index": 0, + "event_id": "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472:CDTRUSTBRIDGEEXAMPLECONTRACTID000000000000000000000000AAAA:1000042:a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901:0" + }, + { + "delivery": 6, + "_note": "genuine re-registration in a later ledger — distinct event_id", + "event_type": "registered_event", + "github_username": "octocat", + "stellar_address": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + "timestamp": 1732900000, + "ledger_sequence": 1002000, + "tx_hash": "d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4", + "event_index": 0, + "event_id": "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472:CDTRUSTBRIDGEEXAMPLECONTRACTID000000000000000000000000AAAA:1002000:d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f901a2b3c4:0" + } + ], + "expected_final_state": { + "octocat": { + "stellar_address": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + "verified": false, + "applied_event_ids": 4, + "ignored_replays": 2 + } + } +}