fix(onchain-wallets): detect a token-only Solana wallet, and pin asset_kind

Two findings from the review of the previous commits.

Detection read only the wallet's lamports, so a wallet emptied of SOL but
still holding SPL tokens answered "nothing here". Each token account carries
its own rent, so an empty wallet address is not an empty wallet. The token
accounts are asked only once the balance comes back zero, so the ordinary
case still costs the one request this probe is meant to be, and accounts
left behind empty do not count as activity.

The narrow blast radius is worth stating: has_activity? only runs when an
address matches more than one chain, and when no candidate answers the user
is asked to choose rather than turned away. So this was a worse screen, not
a rejected wallet.

Separately, the check constraint accepted any asset_kind that carried a
contract address. Each partial unique index names its kind, so a row with
any other one is keyed by nothing and duplicates freely. Adding a token kind
already means adding its index here, so pinning the three in the table adds
no coupling that the indexes did not already have.

Both regression tests fail on the previous code. The third test - emptied
token accounts are not activity - passes either way by design: it guards the
new branch rather than testing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
buzzromain
2026-08-22 12:21:09 +00:00
co-authored by Claude Opus 5
parent db1ff07926
commit b3dae571bd
4 changed files with 36 additions and 1 deletions
+8 -1
View File
@@ -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
@@ -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 ],
Generated
+1
View File
@@ -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|
@@ -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")