From 2fafa7f86204c112402b56fba8c2d0d5a5a811ad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 6 Sep 2026 17:38:06 +0000 Subject: [PATCH 1/2] Fix token claim/cancel fallback property-version collision Key TokenV1Claimed/Canceled by (token_data_id, property_version) so two PendingClaims deletes for the same named token resolve the correct offerer when the parent resource is not rewritten. Co-authored-by: Young Yang Liauw --- .../token_v2/token_models/token_claims.rs | 216 +++++++++++++++++- .../token_v2_models/v2_token_activities.rs | 32 ++- 2 files changed, 238 insertions(+), 10 deletions(-) diff --git a/processor/src/processors/token_v2/token_models/token_claims.rs b/processor/src/processors/token_v2/token_models/token_claims.rs index 779126e1..c0637d3c 100644 --- a/processor/src/processors/token_v2/token_models/token_claims.rs +++ b/processor/src/processors/token_v2/token_models/token_claims.rs @@ -24,11 +24,15 @@ use field_count::FieldCount; use parquet_derive::ParquetRecordWriter; use serde::{Deserialize, Serialize}; -// Map to keep track of the metadata of token offers that were claimed. The key is the token data id of the offer. -pub type TokenV1Claimed = AHashMap; +/// (token_data_id, property_version). Token V1 offers of the same named token +/// with different property versions are distinct `current_token_pending_claims` rows. +pub type TokenV1OfferEventKey = (String, BigDecimal); -// Map to keep track of the metadata of token offers that were canceled. The key is the token data id of the offer. -pub type TokenV1Canceled = AHashMap; +// Map to keep track of the metadata of token offers that were claimed. +pub type TokenV1Claimed = AHashMap; + +// Map to keep track of the metadata of token offers that were canceled. +pub type TokenV1Canceled = AHashMap; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CurrentTokenPendingClaim { @@ -166,6 +170,10 @@ impl CurrentTokenPendingClaim { if let Some(offer) = &maybe_offer { let table_handle = standardize_address(&table_item.handle.to_string()); let token_data_id = offer.token_id.token_data_id.to_id(); + let offer_key = ( + token_data_id.clone(), + offer.token_id.property_version.clone(), + ); // Try to find owner from write resources let mut maybe_owner_address = table_handle_to_owner @@ -174,10 +182,10 @@ impl CurrentTokenPendingClaim { // If table handle isn't in TableHandleToOwner, try to find owner from token v1 claim events if maybe_owner_address.is_none() { - if let Some(token_claimed) = tokens_claimed.get(&token_data_id) { + if let Some(token_claimed) = tokens_claimed.get(&offer_key) { maybe_owner_address = token_claimed.from_address.clone(); } - if let Some(token_canceled) = tokens_canceled.get(&token_data_id) { + if let Some(token_canceled) = tokens_canceled.get(&offer_key) { maybe_owner_address = token_canceled.from_address.clone(); } } @@ -337,3 +345,199 @@ impl From for PostgresCurrentTokenPendingClaim { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::processors::token_v2::token_v2_models::v2_token_activities::TokenActivityV2; + use ahash::AHashMap; + use aptos_indexer_processor_sdk::aptos_protos::transaction::v1::{ + DeleteTableData, DeleteTableItem, Event, EventKey, + }; + use bigdecimal::BigDecimal; + + const ALICE: &str = "0x00000000000000000000000000000000000000000000000000000000000000aa"; + const BOB: &str = "0x00000000000000000000000000000000000000000000000000000000000000bb"; + const CAROL: &str = "0x00000000000000000000000000000000000000000000000000000000000000cc"; + const ALICE_CLAIMS: &str = "0x0000000000000000000000000000000000000000000000000000000000000ca1"; + const BOB_CLAIMS: &str = "0x0000000000000000000000000000000000000000000000000000000000000cb2"; + + fn ts() -> chrono::NaiveDateTime { + chrono::DateTime::from_timestamp(0, 0).unwrap().naive_utc() + } + + fn token_id_json(property_version: u32) -> String { + format!( + r#"{{"token_data_id":{{"creator":"0x1","collection":"col","name":"tok"}},"property_version":"{property_version}"}}"# + ) + } + + fn claim_event(offerer: &str, to: &str, property_version: u32) -> Event { + Event { + key: Some(EventKey { + creation_number: 0, + account_address: offerer.to_string(), + }), + sequence_number: 0, + r#type: None, + type_str: "0x3::token_transfers::Claim".to_string(), + data: format!( + r#"{{"amount":"1","account":"{offerer}","to_address":"{to}","token_id":{}}}"#, + token_id_json(property_version) + ), + } + } + + fn cancel_event(offerer: &str, to: &str, property_version: u32) -> Event { + Event { + key: Some(EventKey { + creation_number: 0, + account_address: offerer.to_string(), + }), + sequence_number: 0, + r#type: None, + type_str: "0x3::token_transfers::CancelOffer".to_string(), + data: format!( + r#"{{"amount":"1","account":"{offerer}","to_address":"{to}","token_id":{}}}"#, + token_id_json(property_version) + ), + } + } + + fn delete_offer(handle: &str, to: &str, property_version: u32) -> DeleteTableItem { + let key = format!( + r#"{{"to_addr":"{to}","token_id":{}}}"#, + token_id_json(property_version) + ); + DeleteTableItem { + state_key_hash: vec![], + handle: handle.to_string(), + key: key.clone(), + data: Some(DeleteTableData { + key, + key_type: "0x3::token_transfers::TokenOfferId".to_string(), + }), + } + } + + fn parse_claim_events(events: &[Event]) -> (TokenV1Claimed, TokenV1Canceled) { + let mut tokens_claimed = AHashMap::new(); + let mut tokens_canceled = AHashMap::new(); + let mut withdrawn = AHashMap::new(); + let mut deposited = AHashMap::new(); + for (i, event) in events.iter().enumerate() { + TokenActivityV2::get_v1_from_parsed_event( + event, + 1, + ts(), + i as i64, + &None, + &mut tokens_claimed, + &mut tokens_canceled, + &mut withdrawn, + &mut deposited, + ) + .unwrap(); + } + (tokens_claimed, tokens_canceled) + } + + /// Two holders of the same named token (pv0 vs a mutated pv1) offer to the + /// same recipient. The recipient claims both in one txn. PendingClaims is + /// not rewritten, so from_address comes from the claim-event map. + /// + /// Keying that map only by token_data_id keeps the last event and writes + /// Alice's delete against Bob's PK (wrong-row update / silent skip of + /// Alice's pending-claim row). + #[test] + fn claim_fallback_does_not_reuse_offerer_across_property_versions() { + let (tokens_claimed, tokens_canceled) = + parse_claim_events(&[claim_event(ALICE, CAROL, 0), claim_event(BOB, CAROL, 1)]); + + assert_eq!( + tokens_claimed.len(), + 2, + "both property versions must be kept" + ); + + let alice_row = CurrentTokenPendingClaim::from_delete_table_item( + &delete_offer(ALICE_CLAIMS, CAROL, 0), + 1, + ts(), + &AHashMap::new(), + &tokens_claimed, + &tokens_canceled, + ) + .unwrap() + .expect("alice pv0 claim must resolve"); + let bob_row = CurrentTokenPendingClaim::from_delete_table_item( + &delete_offer(BOB_CLAIMS, CAROL, 1), + 1, + ts(), + &AHashMap::new(), + &tokens_claimed, + &tokens_canceled, + ) + .unwrap() + .expect("bob pv1 claim must resolve"); + + assert_eq!(alice_row.from_address, ALICE); + assert_eq!(alice_row.to_address, CAROL); + assert_eq!(alice_row.property_version, BigDecimal::from(0)); + assert_eq!(alice_row.amount, BigDecimal::zero()); + + assert_eq!(bob_row.from_address, BOB); + assert_eq!(bob_row.to_address, CAROL); + assert_eq!(bob_row.property_version, BigDecimal::from(1)); + assert_eq!(bob_row.amount, BigDecimal::zero()); + + assert_ne!( + ( + &alice_row.token_data_id_hash, + &alice_row.property_version, + &alice_row.from_address, + &alice_row.to_address + ), + ( + &bob_row.token_data_id_hash, + &bob_row.property_version, + &bob_row.from_address, + &bob_row.to_address + ), + "pending-claim PKs must stay distinct" + ); + } + + /// Cancel of pv1 must not steal the offerer of a same-token_data_id pv0 claim. + #[test] + fn cancel_fallback_does_not_overwrite_other_property_version_claim() { + let (tokens_claimed, tokens_canceled) = + parse_claim_events(&[claim_event(ALICE, CAROL, 0), cancel_event(BOB, CAROL, 1)]); + + let alice_row = CurrentTokenPendingClaim::from_delete_table_item( + &delete_offer(ALICE_CLAIMS, CAROL, 0), + 1, + ts(), + &AHashMap::new(), + &tokens_claimed, + &tokens_canceled, + ) + .unwrap() + .expect("alice pv0 claim must resolve"); + let bob_row = CurrentTokenPendingClaim::from_delete_table_item( + &delete_offer(BOB_CLAIMS, CAROL, 1), + 1, + ts(), + &AHashMap::new(), + &tokens_claimed, + &tokens_canceled, + ) + .unwrap() + .expect("bob pv1 cancel must resolve"); + + assert_eq!(alice_row.from_address, ALICE); + assert_eq!(alice_row.property_version, BigDecimal::from(0)); + assert_eq!(bob_row.from_address, BOB); + assert_eq!(bob_row.property_version, BigDecimal::from(1)); + } +} diff --git a/processor/src/processors/token_v2/token_v2_models/v2_token_activities.rs b/processor/src/processors/token_v2/token_v2_models/v2_token_activities.rs index 5ea0d503..977559df 100644 --- a/processor/src/processors/token_v2/token_v2_models/v2_token_activities.rs +++ b/processor/src/processors/token_v2/token_v2_models/v2_token_activities.rs @@ -317,7 +317,13 @@ impl TokenActivityV2 { to_address: Some(inner.get_to_address()), token_amount: inner.amount.clone(), }; - tokens_canceled.insert(token_data_id_struct.to_id(), helper.clone()); + tokens_canceled.insert( + ( + token_data_id_struct.to_id(), + helper.property_version.clone(), + ), + helper.clone(), + ); helper }, TokenEvent::ClaimTokenEvent(inner) => { @@ -329,7 +335,13 @@ impl TokenActivityV2 { to_address: Some(inner.get_to_address()), token_amount: inner.amount.clone(), }; - tokens_claimed.insert(token_data_id_struct.to_id(), helper.clone()); + tokens_claimed.insert( + ( + token_data_id_struct.to_id(), + helper.property_version.clone(), + ), + helper.clone(), + ); helper }, TokenEvent::Offer(inner) => TokenActivityHelperV1 { @@ -348,7 +360,13 @@ impl TokenActivityV2 { to_address: Some(inner.get_to_address()), token_amount: inner.amount.clone(), }; - tokens_canceled.insert(token_data_id_struct.to_id(), helper.clone()); + tokens_canceled.insert( + ( + token_data_id_struct.to_id(), + helper.property_version.clone(), + ), + helper.clone(), + ); helper }, TokenEvent::Claim(inner) => { @@ -360,7 +378,13 @@ impl TokenActivityV2 { to_address: Some(inner.get_to_address()), token_amount: inner.amount.clone(), }; - tokens_claimed.insert(token_data_id_struct.to_id(), helper.clone()); + tokens_claimed.insert( + ( + token_data_id_struct.to_id(), + helper.property_version.clone(), + ), + helper.clone(), + ); helper }, }; From 6907f7b7f7bdf215671ccc60fcc21ae000b0edf4 Mon Sep 17 00:00:00 2001 From: Young Yang Liauw <7528420+sausagee@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:34:47 -0700 Subject: [PATCH 2/2] ci: apply #70 unblocker files (stable xclippy + bookworm docker) Stack the CI unblocker files from PR #70 (stable cargo xclippy, bookworm Dockerfiles with make) onto this bugfix branch so CI can go green while #70 awaits review. Merge target remains main; this does not merge #70. --- Dockerfile | 22 +++++++++++++++++----- Dockerfile.address-reputation-api | 22 +++++++++++++++++----- scripts/rust_lint.sh | 5 ++++- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index a7e266e6..57df1080 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,13 +2,25 @@ # Stage 1: Build the binary -FROM rust:slim-bullseye as builder +FROM rust:slim-bookworm AS builder WORKDIR /app COPY --link . /app -RUN for i in 1 2 3; do apt-get update && apt-get install --fix-missing -y cmake curl clang git pkg-config libssl-dev libdw-dev libpq-dev lld && break || sleep 10; done +RUN apt-get update \ + && apt-get install --no-install-recommends --fix-missing -y \ + cmake \ + curl \ + clang \ + make \ + git \ + pkg-config \ + libssl-dev \ + libdw-dev \ + libpq-dev \ + lld \ + && rm -rf /var/lib/apt/lists/* ENV CARGO_NET_GIT_FETCH_WITH_CLI true # TODO: Fix this with real processors. RUN cargo build --locked --release -p processor && ls -lah target/release/ @@ -24,19 +36,19 @@ ENV GIT_SHA ${GIT_SHA} # Stage 2: Create the final image -FROM debian:bullseye-slim +FROM debian:bookworm-slim COPY --from=builder /usr/local/bin/processor /usr/local/bin RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install --no-install-recommends --fix-missing -y \ - libssl1.1 \ + libssl3 \ ca-certificates \ net-tools \ tcpdump \ iproute2 \ - netcat \ + netcat-openbsd \ libdw-dev \ libpq-dev \ curl diff --git a/Dockerfile.address-reputation-api b/Dockerfile.address-reputation-api index 5be4c111..488d8ed5 100644 --- a/Dockerfile.address-reputation-api +++ b/Dockerfile.address-reputation-api @@ -2,13 +2,25 @@ # Stage 1: Build the binary -FROM rust:slim-bullseye as builder +FROM rust:slim-bookworm AS builder WORKDIR /app COPY --link . /app -RUN for i in 1 2 3; do apt-get update && apt-get install --fix-missing -y cmake curl clang git pkg-config libssl-dev libdw-dev libpq-dev lld && break || sleep 10; done +RUN apt-get update \ + && apt-get install --no-install-recommends --fix-missing -y \ + cmake \ + curl \ + clang \ + make \ + git \ + pkg-config \ + libssl-dev \ + libdw-dev \ + libpq-dev \ + lld \ + && rm -rf /var/lib/apt/lists/* ENV CARGO_NET_GIT_FETCH_WITH_CLI true RUN cargo build --locked --release -p address-reputation-api && ls -lah target/release/ RUN cp target/release/address-reputation-api /usr/local/bin @@ -23,19 +35,19 @@ ENV GIT_SHA ${GIT_SHA} # Stage 2: Create the final image -FROM debian:bullseye-slim +FROM debian:bookworm-slim COPY --from=builder /usr/local/bin/address-reputation-api /usr/local/bin RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install --no-install-recommends --fix-missing -y \ - libssl1.1 \ + libssl3 \ ca-certificates \ net-tools \ tcpdump \ iproute2 \ - netcat \ + netcat-openbsd \ libdw-dev \ libpq-dev \ curl diff --git a/scripts/rust_lint.sh b/scripts/rust_lint.sh index 261c1aa9..32a48f2b 100755 --- a/scripts/rust_lint.sh +++ b/scripts/rust_lint.sh @@ -25,7 +25,10 @@ fi set -e set -x -cargo +nightly xclippy +# Run clippy on the pinned STABLE toolchain (from rust-toolchain.toml), NOT +# nightly. Latest nightly makes Infallible an alias of !, which breaks +# allocative (impl Allocative for both). Stable clippy matches the build toolchain. +cargo xclippy # We require the nightly build of cargo fmt # to provide stricter rust formatting.