Skip to content

Commit 5ba854d

Browse files
larryl3uclaude
andcommitted
fix: rustfmt lint and narrow testnet config to stables
- Apply cargo +nightly fmt to address_reputation files + smoke test. - Disable LayerZero WETH.e and SendOftDemo in the testnet config (non-stables, out of P0 scope); both stay easy to re-enable. - Add processor-level README documenting tables, mechanics, P0 limitations, and the testnet run command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a4cb5e3 commit 5ba854d

6 files changed

Lines changed: 122 additions & 17 deletions

File tree

processor/example-configs/address_reputation.testnet.yaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,16 @@ server_config:
3636
# One row per OFT module. The module address is the FA creator (and
3737
# publisher of the `oft_core` Move module).
3838

39+
# Non-stable, disabled to keep the stables-focused graph clean. Flip
40+
# `enabled: true` to include WETH.e in trace results.
3941
- name: layerzero_weth_e
4042
module_address: "0x2fa1f2914aa17d239410cb81ab46dd8fa9230272c58bc84e9e8b971eded79ca5"
4143
event_type: "0x2fa1f2914aa17d239410cb81ab46dd8fa9230272c58bc84e9e8b971eded79ca5::oft_core::OftReceived"
4244
recipient_field_path: to_address
4345
amount_field_path: amount_received_ld
4446
chain_id_field_path: src_eid
4547
evm_source_field_path: null # OftReceived only carries `guid`, not the EVM sender
46-
enabled: true
48+
enabled: false
4749

4850
- name: layerzero_usdt_e
4951
module_address: "0x9cda672762a6f88e4b608428dd063e03aaf6712f0a427923dd0f1416afa1c075"
@@ -75,16 +77,15 @@ server_config:
7577
evm_source_field_path: null
7678
enabled: false
7779

78-
# LayerZero SendOftDemo (SOD). Demo / test OFT, kept for completeness;
79-
# safe to leave on since it's a real OFT-style bridge.
80+
# SendOftDemo (SOD) is a LayerZero test/demo OFT, not a stable. Disabled.
8081
- name: layerzero_send_oft_demo
8182
module_address: "0xe024846967ef05baed04aa2396802b360dcd975fc704bfd261089cf5355669ad"
8283
event_type: "0xe024846967ef05baed04aa2396802b360dcd975fc704bfd261089cf5355669ad::oft_core::OftReceived"
8384
recipient_field_path: to_address
8485
amount_field_path: amount_received_ld
8586
chain_id_field_path: src_eid
8687
evm_source_field_path: null
87-
enabled: true
88+
enabled: false
8889

8990
transaction_stream_config:
9091
indexer_grpc_data_service_address: "https://grpc.testnet.movementnetwork.xyz:443"
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# address_reputation processor
2+
3+
Builds an owner-keyed transfer graph on Movement and annotates bridge deposits
4+
as terminal "head" nodes, so any address's funds can be backtraced to the bridge
5+
they came from.
6+
7+
**Scope: stables + FA v2 only.** Tracks USDCx (Circle) and USDC.e / USDT.e
8+
(LayerZero). All in-scope tokens are FA v2 — coin v1 and non-stable assets are
9+
intentionally out of scope for P0.
10+
11+
## Tables
12+
13+
- `address_transfer_edges` — one row per FA transfer; `from`/`to` are owners
14+
- `bridge_inflows` — head nodes (bridge module → recipient, with `src_chain_id`)
15+
- `evm_address_risk_scores` — external input, NULL `evm_source` allowed
16+
- `address_reputation` — materialized score, `max(current, decay * sender)` upsert
17+
18+
See `db/queries/trace.sql` for the recursive CTE that walks the graph backward,
19+
short-circuiting on `is_bridge_inflow`.
20+
21+
## Bridge config (YAML, not SQL)
22+
23+
Bridges live in `AddressReputationConfig.bridges` — mainnet/testnet differ by
24+
config file, not migration. Restart picks up changes.
25+
26+
Testnet seed (`example-configs/address_reputation.testnet.yaml`), each verified
27+
against captured on-chain events:
28+
29+
| name | module | event |
30+
|---|---|---|
31+
| `circle_usdcx` | `0x989577...` | `::usdcx::Mint` |
32+
| `layerzero_weth_e` | `0x2fa1f2...` | `::oft_core::OftReceived` |
33+
| `layerzero_usdt_e` | `0x9cda67...` | `::oft_core::OftReceived` |
34+
| `layerzero_usdc_e` | `0x339873...` | `::oft_core::OftReceived` |
35+
| `layerzero_usdc_e_alt` | `0xdbfc7b...` | dormant, disabled |
36+
| `layerzero_send_oft_demo` | `0xe02484...` | `::oft_core::OftReceived` |
37+
38+
## Correctness mechanics
39+
40+
- **Owner resolution**: per-txn scan of `WriteResource` for `0x1::object::ObjectCore`
41+
builds `storage_id → owner`. Mirrors `fungible_asset_processor_helpers::parse_v2_coin`
42+
Loop 1. Without this, bridge mints (owner-keyed) wouldn't connect to subsequent
43+
user transfers (storage_id-keyed).
44+
- **Asset resolution**: per-txn scan of `0x1::fungible_asset::FungibleStore` builds
45+
`storage_id → metadata` so every edge carries `asset_type`.
46+
- **Synthetic bridge edge**: bridge mints emit only a `Deposit` (no paired
47+
`Withdraw`); extractor synthesizes a `bridge_module → recipient` edge so the
48+
head always lands in the graph.
49+
50+
## P0 limitations
51+
52+
- Coin v1 events skipped. Every in-scope token is FA v2 — USDCx, the LayerZero
53+
`.e` family, and native MOVE all live as fungible assets, not legacy `Coin<T>`.
54+
Coin v1 on Movement is dominated by gas fees and legacy test tokens, none of
55+
which we want in the graph. Revisit if a future bridge starts minting into
56+
`Coin<T>`.
57+
- `evm_source` is always NULL for now — neither USDCx `Mint` nor LayerZero
58+
`OftReceived` carries it in the event; it's in entry-function args / LZ packet.
59+
P1.
60+
- Reputation propagation is naive `max(current, decay * sender_score)` — cycle-
61+
safe and idempotent, but not amount-weighted.
62+
63+
## Test
64+
65+
```
66+
cargo test -p processor --test address_reputation_smoke -- --nocapture
67+
```
68+
69+
Builds minimal protos from real testnet txns 158025629 (USDCx mint) and
70+
163802127 (user FA transfer); asserts bridge head edges, owner-keyed user
71+
edges, and `asset_type` resolution.
72+
73+
## Run on testnet
74+
75+
```
76+
diesel migration run --database-url postgres://...
77+
cargo run -p processor --release -- \
78+
--config processor/example-configs/address_reputation.testnet.yaml
79+
```

processor/src/processors/address_reputation/address_reputation_config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ impl BridgeConfig {
4747
fn default_amount_path() -> String {
4848
"amount".to_string()
4949
}
50+
5051
const fn default_enabled() -> bool {
5152
true
5253
}
@@ -56,6 +57,7 @@ impl AddressReputationConfig {
5657
pub const fn default_channel_size() -> usize {
5758
10
5859
}
60+
5961
pub const fn default_decay() -> f64 {
6062
0.8
6163
}

processor/src/processors/address_reputation/address_reputation_extractor.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,10 @@ impl Processable for AddressReputationExtractor {
119119
// Bridge check (registry-driven, exact event-type match). We do this BEFORE
120120
// generic FA classification so a bridge's emitted FA Deposit (which fires
121121
// alongside the Mint) doesn't get double-counted as a user transfer.
122-
if let Some(entry) =
123-
self.bridge_registry.iter().find(|e| e.enabled && e.event_type == type_str)
122+
if let Some(entry) = self
123+
.bridge_registry
124+
.iter()
125+
.find(|e| e.enabled && e.event_type == type_str)
124126
{
125127
if let Some(inflow) = parse_bridge_event(
126128
event.data.as_str(),
@@ -161,7 +163,11 @@ impl Processable for AddressReputationExtractor {
161163
.get(&storage_id)
162164
.cloned()
163165
.unwrap_or_else(|| storage_id.clone());
164-
let amount = match v.get("amount").and_then(|x| x.as_str()).and_then(|s| BigDecimal::from_str(s).ok()) {
166+
let amount = match v
167+
.get("amount")
168+
.and_then(|x| x.as_str())
169+
.and_then(|s| BigDecimal::from_str(s).ok())
170+
{
165171
Some(a) => a,
166172
None => continue,
167173
};

processor/src/processors/address_reputation/address_reputation_storer.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,9 @@ async fn upsert_address_reputation(
218218
.on_conflict(ar::address)
219219
.do_update()
220220
.set((
221-
ar::score.eq(sql::<Numeric>("GREATEST(address_reputation.score, EXCLUDED.score)")),
221+
ar::score.eq(sql::<Numeric>(
222+
"GREATEST(address_reputation.score, EXCLUDED.score)",
223+
)),
222224
ar::highest_seed.eq(sql::<Numeric>(
223225
"GREATEST(address_reputation.highest_seed, EXCLUDED.highest_seed)",
224226
)),

processor/tests/address_reputation_smoke.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ fn make_mint_txn() -> Transaction {
116116
r#"{{"amount":"{MINT_AMOUNT}","relayer":"0xdb8069db67708d47796f837e9862ca9aae6ed5a522bc0b9b7bc25584e77577bb","recipient":"{MINT_RECIPIENT_OWNER}","fee_amount":"0","remote_token":"0x63f169ba69623ba6ccf34620857644feb46d0f87e1d7bbcf8c071d30c3d94bd6","remote_domain":10005}}"#
117117
);
118118
Transaction {
119-
timestamp: Some(Timestamp { seconds: 1_700_000_000, nanos: 0 }),
119+
timestamp: Some(Timestamp {
120+
seconds: 1_700_000_000,
121+
nanos: 0,
122+
}),
120123
version: 158_025_629,
121124
info: Some(TransactionInfo {
122125
hash: vec![],
@@ -147,12 +150,13 @@ fn make_mint_txn() -> Transaction {
147150
}
148151

149152
fn make_transfer_txn() -> Transaction {
150-
let withdraw_data =
151-
format!(r#"{{"store":"{SENDER_STORE}","amount":"{TRANSFER_AMOUNT}"}}"#);
152-
let deposit_data =
153-
format!(r#"{{"store":"{RECIPIENT_STORE}","amount":"{TRANSFER_AMOUNT}"}}"#);
153+
let withdraw_data = format!(r#"{{"store":"{SENDER_STORE}","amount":"{TRANSFER_AMOUNT}"}}"#);
154+
let deposit_data = format!(r#"{{"store":"{RECIPIENT_STORE}","amount":"{TRANSFER_AMOUNT}"}}"#);
154155
Transaction {
155-
timestamp: Some(Timestamp { seconds: 1_700_000_500, nanos: 0 }),
156+
timestamp: Some(Timestamp {
157+
seconds: 1_700_000_500,
158+
nanos: 0,
159+
}),
156160
version: 163_802_127,
157161
info: Some(TransactionInfo {
158162
hash: vec![],
@@ -241,14 +245,25 @@ async fn extractor_emits_bridge_head_and_owner_keyed_transfer() {
241245
}
242246

243247
// --- assertions ---
244-
assert_eq!(inflows.len(), 1, "expected one bridge inflow for the mint txn");
248+
assert_eq!(
249+
inflows.len(),
250+
1,
251+
"expected one bridge inflow for the mint txn"
252+
);
245253
let bi = &inflows[0];
246254
assert_eq!(bi.aptos_recipient, MINT_RECIPIENT_OWNER);
247255
assert_eq!(bi.bridge_name, "circle_usdcx");
248256
assert_eq!(bi.src_chain_id, Some(10005));
249-
assert!(bi.evm_source.is_none(), "Mint event carries no EVM source field");
257+
assert!(
258+
bi.evm_source.is_none(),
259+
"Mint event carries no EVM source field"
260+
);
250261

251-
assert_eq!(edges.len(), 2, "expected one synthetic bridge edge + one user transfer edge");
262+
assert_eq!(
263+
edges.len(),
264+
2,
265+
"expected one synthetic bridge edge + one user transfer edge"
266+
);
252267

253268
let bridge_edge = edges
254269
.iter()

0 commit comments

Comments
 (0)