diff --git a/app/models/onchain/solana_adapter.rb b/app/models/onchain/solana_adapter.rb index a290a86aa..317affb42 100644 --- a/app/models/onchain/solana_adapter.rb +++ b/app/models/onchain/solana_adapter.rb @@ -60,7 +60,14 @@ class Onchain::SolanaAdapter def has_activity?(address) return false unless valid_address?(address) - detection_provider.get_balance(address).positive? + return true if detection_provider.get_balance(address).positive? + + # A wallet can hold SPL tokens with no SOL of its own: each token account + # carries its own rent, so an empty wallet address is not an empty wallet. + # Only asked once the balance comes back zero, so the ordinary case still + # costs the single request this probe is meant to be. Emptied token accounts + # are left behind on Solana by design and are not activity. + held_token_accounts(detection_provider.get_token_accounts(address)).any? rescue StandardError => e Rails.logger.warn("Onchain::SolanaAdapter - activity probe failed: #{e.class}") false diff --git a/db/migrate/20260818120000_create_onchain_wallet_items_and_accounts.rb b/db/migrate/20260818120000_create_onchain_wallet_items_and_accounts.rb index fd2db6e2c..ff3a4a4d1 100644 --- a/db/migrate/20260818120000_create_onchain_wallet_items_and_accounts.rb +++ b/db/migrate/20260818120000_create_onchain_wallet_items_and_accounts.rb @@ -63,6 +63,13 @@ class CreateOnchainWalletItemsAndAccounts < ActiveRecord::Migration[7.2] # The model enforces this as well; a direct write does not go through it. t.check_constraint "asset_kind = 'native' OR contract_address IS NOT NULL", name: "chk_onchain_wallet_accounts_token_has_contract" + + # The partial unique indexes below name their kind, so a row carrying any + # other one is keyed by nothing and duplicates freely. Adding a token kind + # already means adding its index here; this keeps the table from silently + # accepting rows for one that has none. + t.check_constraint "asset_kind IN ('native', 'erc20', 'spl')", + name: "chk_onchain_wallet_accounts_known_asset_kind" end add_index :onchain_wallet_accounts, [ :onchain_wallet_item_id, :chain, :wallet_address ], diff --git a/db/schema.rb b/db/schema.rb index d69e05b7f..7e63ad32b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1526,6 +1526,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_120000) do t.index ["onchain_wallet_item_id", "chain", "wallet_address"], name: "index_onchain_wallet_accounts_unique_native", unique: true, where: "((asset_kind)::text = 'native'::text)" t.index ["onchain_wallet_item_id"], name: "index_onchain_wallet_accounts_on_onchain_wallet_item_id" t.check_constraint "asset_kind::text = 'native'::text OR contract_address IS NOT NULL", name: "chk_onchain_wallet_accounts_token_has_contract" + t.check_constraint "asset_kind::text = ANY (ARRAY['native'::character varying, 'erc20'::character varying, 'spl'::character varying]::text[])", name: "chk_onchain_wallet_accounts_known_asset_kind" end create_table "onchain_wallet_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/models/onchain/solana_adapter_test.rb b/test/models/onchain/solana_adapter_test.rb index 1538a9c89..0261efe77 100644 --- a/test/models/onchain/solana_adapter_test.rb +++ b/test/models/onchain/solana_adapter_test.rb @@ -317,6 +317,26 @@ class Onchain::SolanaAdapterTest < ActiveSupport::TestCase assert snapshot.history_truncated? end + test "a wallet holding SPL tokens but no SOL is still detected" do + stub_snapshot( + lamports: 0, + token_accounts: [ token_account(mint: USDC_MINT, amount: "1000000", decimals: 6) ] + ) + + # Each token account carries its own rent, so a wallet address emptied of SOL + # is not an emptied wallet. + assert @adapter.has_activity?(ADDRESS) + end + + test "token accounts left behind empty are not activity" do + stub_snapshot( + lamports: 0, + token_accounts: [ token_account(mint: USDC_MINT, amount: "0", decimals: 6) ] + ) + + assert_not @adapter.has_activity?(ADDRESS) + end + test "detection asks once and does not retry a rate-limited node" do probe = stub_request(:post, Provider::SolanaRpc.url).to_return(status: 429, body: "rate limited")