Files
sure/app/controllers/onchain_wallet_items_controller.rb
T
buzzromainandClaude Opus 5 d7bf401cc7 fix(onchain-wallets): call transfers transfers, and drop the address swap (#3153)
* fix(onchain-wallets): call transfers transfers, and drop the address swap

Three things reported from real use.

**A transfer was announced as a purchase.** A movement was imported with
`activity_label: "Buy"/"Sell"` and named "Buy 1.5 FAKE" — but coins arriving at
an address were not bought there, and nothing here knows whether they were ever
bought at all. The trade shape stays, because it is what carries quantity and
cost basis in this ledger, but the label is now "Transfer" and the name is the
one the movement already had while it was unpriced. The old wording also made
the same event rename itself the day a price turned up for it.

Worth pairing with the change to trades/_header.html.erb, which until now read
the amount's sign and would still say "Buy" whatever the label.

**Changing a tracked address is gone.** It repointed the rows at a new address
while keeping their accounts, holdings and history — so trades reconstructed
from address A stayed under an account presented as address B. Its own help
text said so out loud: "The accounts, holdings and history stay as they are."
Removing the address and adding the new one is not just simpler, it is the only
one of the two that is honest, because it takes the old history with the old
address.

**The buttons follow the app's conventions now.** Actions that repeat per row
belong in a menu here — accounts/_account.html.erb renders its own that way —
not in a row of labelled buttons, which is what a provider panel does when it
has a single connection to act on. So the per-address actions are a menu, the
per-asset disconnect is icon-only, and the accounts-page card gains the actions
menu every other provider card already had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): give a tracked asset its icon on the accounts page

Account#logo_url asks its provider adapter for one, and ours answered nothing:
there is no institution behind a self-custody wallet, and nothing attaches a
file, so every tracked asset showed a blank where every other account shows an
icon. Fixing Security#crypto_base_asset covered the holdings list, which reads
the security directly — this is the other path, and it went through the adapter.

Built from the symbol rather than looked up. The accounts page renders one of
these per account, so resolving a Security each would be a query per row, and
the symbol is all Brandfetch's crypto endpoint needs. It answers nil without a
client id, which is the same nothing the page shows today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(onchain-wallets): follow the moved banner and the menu in the browser

The system suite still expected the settings panel to carry the read-only
reassurance, and to find "Review tokens" as a visible button. Both moved in the
previous commit: the banner into the linking modal, where the question it
answers is actually asked, and the per-address actions into a menu, as repeated
row actions are rendered everywhere else in this app.

Caught by CI rather than by me — the per-branch checks I ran covered
`bin/rails test` and not `test:system`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(onchain-wallets): pin the reassurance to the frame it moved into

The assertion proved the banner was somewhere on the page, which is exactly
what the change does not claim: the point is where it lives. Now it asserts the
text is absent from the settings panel and present inside the modal, so the
test fails if the banner drifts back or never arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(onchain-wallets): rename movements imported before transfers had a name

Review on #3153.

Wallets synced before this change keep their `Buy`/`Sell` labels and their
"Buy 1.5 shares of CRYPTO:BTC" wording, and nothing was rewriting them:
`perform_sync` returns early when no address changed on chain, and the
repair pass only ever looked at display-only `Transaction` rows. A cold
address would have shown the old wording indefinitely — which for a wallet
nobody touches is most of them.

The repair now relabels this processor's own trades too, scoped to its
`external_id` prefix and to `source: SOURCE` so a trade the user entered by
hand is never renamed. It runs from `perform_post_sync`, which is the pass
that already runs for every linked asset rather than only the changed ones.
Idempotent, so a nightly sync does not rewrite the same rows forever.

Separately: `Security.brandfetch_crypto_url` interpolated the symbol
straight into a URL path, and `Onchain::AssetSymbol.canonical` only upcases
and trims. An on-chain token can be called whatever its deployer chose, so
a slash pointed the path elsewhere on the CDN and a hash pushed the client
id into a fragment Brandfetch never sees. Guarded in the helper rather than
at the call site — six callers reach it from four providers.

Also from review: the icon test saves and restores
`Setting.brand_fetch_client_id` instead of hard-coding nil in its `ensure`,
which was erasing whatever the suite had configured.

The "one query" claim in a repair test's name was never asserted, and this
change adds a second query. Renamed to what it actually checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* test(onchain): drop the last change_address test with its feature

#3182 added a `change_address` test while this branch was removing the
feature it exercises. The rebase kept both, leaving a test calling a route
this branch deletes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:07:58 +02:00

295 lines
10 KiB
Ruby

# frozen_string_literal: true
# Self-custody wallet connections. Chain-agnostic throughout: a chain is a
# registry key that arrives as a parameter, gets validated against the registry,
# and is never branched on.
class OnchainWalletItemsController < ApplicationController
before_action :require_admin!
before_action :set_onchain_wallet_item,
only: %i[update destroy sync manage review_tokens update_tokens
disconnect_wallet disconnect_asset]
before_action :set_wallet, only: %i[review_tokens update_tokens disconnect_wallet]
def update
if @onchain_wallet_item.update(onchain_wallet_item_params)
render_panel_success(t(".success"))
else
render_panel_error(@onchain_wallet_item.errors.full_messages.join(", "))
end
end
def destroy
@onchain_wallet_item.destroy_later
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
def sync
@onchain_wallet_item.sync_later unless @onchain_wallet_item.syncing?
respond_to do |format|
format.html { redirect_back_or_to settings_providers_path }
format.json { head :ok }
end
end
# Turns the "no crypto prices configured" warning into one click. Without a
# crypto-capable market data provider every on-chain holding is valued at zero,
# which reads as a broken sync; making the user go and find the setting is how
# that becomes a support ticket instead of a fix.
def enable_crypto_prices
unless self_hosted?
return redirect_back_or_to settings_providers_path, alert: t(".not_self_hosted")
end
Onchain::SecurityResolver.enable_price_provider!
redirect_back_or_to settings_providers_path, notice: t(".success")
end
# Step 1: paste an address.
def new_wallet
@address = nil
end
# Step 2: work out the chain if it wasn't given, then show what is there.
def preview_wallet
@address = normalized_address
return render_new_wallet(t(".errors.address_required")) if @address.blank?
@chain = requested_chain
if @chain.blank?
detection = OnchainWalletItem::ChainDetector.new(preview_item, @address).detect
return render_new_wallet(t(".errors.unrecognized_address")) if detection.unrecognized?
if detection.ambiguous?
@detection = detection
return render :choose_chain
end
@chain = detection.chain
end
# Only now can the address be put in its canonical form: what counts as the
# same address is a per-chain question, and the duplicate check has to run on
# the canonical form or two spellings become two wallets.
@address = Onchain::Chains.canonical_address(@chain, @address)
return render_new_wallet(t(".errors.already_linked")) if Current.family.onchain_address_linked?(@chain, @address)
snapshot = fetch_snapshot(@chain, @address)
return render_new_wallet(@provider_error) if snapshot.nil?
@assets_truncated = snapshot.assets_truncated?
@review = OnchainWalletItem::TokenReview.new(snapshot: snapshot)
render :token_review
end
# Step 3: track the assets that were ticked.
def link_wallet
@address = normalized_address
@chain = requested_chain
return render_new_wallet(t(".errors.unrecognized_address")) if @chain.blank? || @address.blank?
@address = Onchain::Chains.canonical_address(@chain, @address)
return render_new_wallet(t(".errors.already_linked")) if Current.family.onchain_address_linked?(@chain, @address)
snapshot = fetch_snapshot(@chain, @address)
return render_new_wallet(@provider_error) if snapshot.nil?
# The connection is created only now. Reading the address needs none, so a
# failed read must not leave an empty one behind — the same reason previewing
# uses an unsaved one.
result = OnchainWalletItem::WalletLinker
.new(Current.family.onchain_wallet_item!, chain: @chain, address: @address)
.link(snapshot: snapshot, selected_keys: params[:assets])
unless result.success?
@assets_truncated = snapshot.assets_truncated?
@review = OnchainWalletItem::TokenReview.new(snapshot: snapshot)
# Telling someone who ticked three assets to "pick at least one" sends them
# back to tick the same three.
flash.now[:alert] = if result.errors.any?
t(".errors.none_tracked", assets: result.errors.to_sentence)
else
t(".errors.nothing_selected")
end
return render :token_review, status: :unprocessable_entity
end
flash[:alert] = t(".errors.some_failed", assets: result.errors.to_sentence) if result.errors.any?
redirect_to accounts_path, notice: t(".success", count: result.created), status: :see_other
end
# Everything a linked wallet can have done to it, in one place.
def manage
end
# The token screen again, with the address left alone. Without this the only
# way to stop tracking one asset would be to change the address.
def review_tokens
snapshot = fetch_snapshot(@chain, @address)
return redirect_to_manage(alert: @provider_error) if snapshot.nil?
@assets_truncated = snapshot.assets_truncated?
@review = review_for(snapshot)
end
def update_tokens
snapshot = fetch_snapshot(@chain, @address)
return redirect_to_manage(alert: @provider_error) if snapshot.nil?
result = wallet_linker.revise(snapshot: snapshot, selected_keys: params[:assets])
unless result.changed?
@assets_truncated = snapshot.assets_truncated?
@review = review_for(snapshot)
flash.now[:alert] = if result.errors.any?
t(".errors.none_tracked", assets: result.errors.to_sentence)
else
t(".errors.no_changes")
end
return render :review_tokens, status: :unprocessable_entity
end
flash[:alert] = t(".errors.some_failed", assets: result.errors.to_sentence) if result.errors.any?
redirect_to_manage(notice: t(".success", created: result.created, removed: result.removed))
end
def disconnect_wallet
removed = @onchain_wallet_item.disconnect_wallet!(chain: @chain, address: @address)
redirect_to_manage(notice: t(".success", count: removed))
end
def disconnect_asset
onchain_account = @onchain_wallet_item.onchain_wallet_accounts.find(params[:onchain_wallet_account_id])
onchain_account.destroy!
redirect_to_manage(notice: t(".success", symbol: onchain_account.symbol))
end
private
def set_onchain_wallet_item
@onchain_wallet_item = Current.family.onchain_wallet_items.find(params[:id])
end
# A wallet is a (chain, address) couple, and only one this connection already
# tracks: every management action is scoped through the family's own item.
def set_wallet
@chain = requested_chain
@address = normalized_address
return if @chain.present? && @onchain_wallet_item.tracks_address?(@chain, @address)
redirect_to_manage(alert: t("onchain_wallet_items.errors.wallet_not_found"))
end
def wallet_linker
OnchainWalletItem::WalletLinker.new(@onchain_wallet_item, chain: @chain, address: @address)
end
def review_for(snapshot)
OnchainWalletItem::TokenReview.new(
snapshot: snapshot,
tracked_accounts: @onchain_wallet_item.accounts_for_wallet(@chain, @address).to_a
)
end
def redirect_to_manage(flash_message)
redirect_to manage_onchain_wallet_item_path(@onchain_wallet_item), **flash_message, status: :see_other
end
def onchain_wallet_item_params
permitted = params.require(:onchain_wallet_item).permit(:sync_start_date, :etherscan_api_key)
# Blank means "leave the stored key alone", not "clear it".
permitted.delete(:etherscan_api_key) if permitted[:etherscan_api_key].blank?
permitted
end
# Reading an address needs no saved connection, so previewing one never
# leaves an empty item behind for an abandoned flow.
def preview_item
@preview_item ||= Current.family.onchain_wallet_item ||
Current.family.onchain_wallet_items.new(name: I18n.t("onchain.institution_name"))
end
def normalized_address
params[:address].to_s.strip
end
# Only a chain the registry knows can get through.
def requested_chain
chain = params[:chain].to_s.strip
Onchain::Chains.exists?(chain) ? chain : nil
end
# Expected data-source failures become a localized message. Anything else is
# a bug: the user gets a generic message and the details go to the debug log,
# never into the response.
def fetch_snapshot(chain, address)
OnchainWalletItem::Importer.new(preview_item).fetch_snapshot(chain: chain, address: address)
rescue Onchain::Chains::RateLimitedError
@provider_error = t("onchain_wallet_items.errors.rate_limited")
nil
rescue Onchain::Chains::UnreachableError
@provider_error = t("onchain_wallet_items.errors.chain_unreachable")
nil
rescue Onchain::Chains::Error
@provider_error = t("onchain_wallet_items.errors.unrecognized_address")
nil
rescue StandardError => e
capture_unexpected(e, chain: chain)
@provider_error = t("onchain_wallet_items.errors.unexpected")
nil
end
def capture_unexpected(error, chain:)
DebugLogEntry.capture(
category: "provider_sync_error",
level: "error",
message: "Unexpected on-chain wallet failure: #{error.class}",
source: self.class.name,
provider_key: "onchain_wallet",
family: Current.family,
metadata: { chain: chain, error: error.message }
)
end
def render_new_wallet(error_message, status: :unprocessable_entity)
@error_message = error_message
render :new_wallet, status: status
end
def render_panel_success(message)
if turbo_frame_request?
flash.now[:notice] = message
@onchain_wallet_items = Current.family.onchain_wallet_items.active.ordered
render turbo_stream: [
turbo_stream.update(
"onchain_wallet-providers-panel",
partial: "settings/providers/onchain_wallet_panel",
locals: { onchain_wallet_items: @onchain_wallet_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: message, status: :see_other
end
end
def render_panel_error(message)
if turbo_frame_request?
render turbo_stream: turbo_stream.update(
"onchain_wallet-providers-panel",
partial: "settings/providers/onchain_wallet_panel",
locals: { error_message: message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: message, status: :see_other
end
end
end