mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 23:01:26 +00:00
* 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>
295 lines
10 KiB
Ruby
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
|