Add DeFi via Coinstats (#1417)

* feat: handle defi account with coinstats provider

* chore: refactor to follow project conventions

* fix: fixing codex/coderabbit findings

* fix: fixing coderabbit findings

* fix: fixing coderabbit findings

* fix: fixing coderabbit findings

* fix: fixing coderabbit findings

* fix: fixing coderabbit findings
This commit is contained in:
Romain Brucker
2026-04-11 21:37:07 +02:00
committed by GitHub
parent 7427b753e5
commit 16a0fa08f8
6 changed files with 502 additions and 0 deletions

View File

@@ -10,6 +10,8 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
)
@mock_provider = mock("Provider::Coinstats")
# Stub DeFi endpoint globally — individual tests override if needed
@mock_provider.stubs(:get_wallet_defi).returns(success_response({ protocols: [] }))
end
# Helper to wrap data in Provider::Response
@@ -592,4 +594,218 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
assert_equal 5000.0, coinstats_account1.current_balance.to_f # 2.0 * 2500
assert_equal 4500.0, coinstats_account2.current_balance.to_f # 0.1 * 45000
end
# DeFi / staking tests
test "creates DeFi account with balance equal to total position value, not quantity * price" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Ethereum Wallet",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum (0x12...abc)",
currency: "USD",
account_id: "ethereum",
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
)
AccountProvider.create!(account: account, provider: coinstats_account)
# DeFi response: 32 ETH staked, total position value = $70,272 (= 32 * $2196)
# The `price` field is TotalValueDto (total position value), NOT price per token.
defi_response = {
protocols: [
{
id: "lido",
name: "Lido",
logo: "https://example.com/lido.png",
investments: [
{
name: "Staking",
assets: [
{
title: "Deposit",
coinId: "ethereum",
symbol: "ETH",
amount: 32.0,
price: { USD: 70272.0 } # total value, not per-token
}
]
}
]
}
]
}
@mock_provider.expects(:get_wallet_defi)
.with(address: "0x123abc", connection_id: "ethereum")
.returns(success_response(defi_response))
@mock_provider.stubs(:get_wallet_balances).returns(success_response([]))
@mock_provider.stubs(:extract_wallet_balance).returns([])
@mock_provider.stubs(:get_wallet_transactions).returns(success_response([]))
@mock_provider.stubs(:extract_wallet_transactions).returns([])
assert_difference "CoinstatsAccount.count", 1 do
assert_difference "Account.count", 1 do
CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import
end
end
defi_account = @coinstats_item.coinstats_accounts.find_by(account_id: "defi:ethereum:lido:staking:ethereum:deposit")
assert_not_nil defi_account
assert_equal "defi", defi_account.raw_payload["source"]
# Balance must be the total position value ($70,272), NOT 32 * $70,272
assert_equal 70272.0, defi_account.current_balance.to_f
assert_equal "ETH (Lido Staking)", defi_account.name
end
test "zeros out DeFi account when staking position is no longer active" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Ethereum Wallet",
balance: 1000,
currency: "USD"
)
wallet_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum (0x12...abc)",
currency: "USD",
account_id: "ethereum",
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
)
AccountProvider.create!(account: account, provider: wallet_account)
# Existing DeFi account from a previous sync
defi_crypto = Crypto.create!
defi_linked_account = @family.accounts.create!(
accountable: defi_crypto,
name: "ETH (Lido Staking)",
balance: 70272,
currency: "USD"
)
defi_account = @coinstats_item.coinstats_accounts.create!(
name: "ETH (Lido Staking)",
currency: "USD",
account_id: "defi:ethereum:lido:staking:ethereum:deposit",
wallet_address: "0x123abc",
current_balance: 70272,
raw_payload: {
source: "defi",
address: "0x123abc",
blockchain: "ethereum",
protocol_id: "lido",
amount: 32.0,
balance: 70272.0
}
)
AccountProvider.create!(account: defi_linked_account, provider: defi_account)
# DeFi response returns empty — position has been fully unstaked
@mock_provider.expects(:get_wallet_defi)
.with(address: "0x123abc", connection_id: "ethereum")
.returns(success_response({ protocols: [] }))
@mock_provider.stubs(:get_wallet_balances).returns(success_response([]))
@mock_provider.stubs(:extract_wallet_balance).returns([])
@mock_provider.stubs(:get_wallet_transactions).returns(success_response([]))
@mock_provider.stubs(:extract_wallet_transactions).returns([])
CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import
defi_account.reload
assert_equal 0.0, defi_account.current_balance.to_f
end
test "defi accounts are skipped in wallet update loop" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Ethereum Wallet",
balance: 1000,
currency: "USD"
)
wallet_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum",
currency: "USD",
account_id: "ethereum",
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
)
AccountProvider.create!(account: account, provider: wallet_account)
defi_crypto = Crypto.create!
defi_linked_account = @family.accounts.create!(
accountable: defi_crypto,
name: "ETH (Lido Staking)",
balance: 1000,
currency: "USD"
)
defi_account = @coinstats_item.coinstats_accounts.create!(
name: "ETH (Lido Staking)",
currency: "USD",
account_id: "defi:ethereum:lido:staking:ethereum:deposit",
wallet_address: "0x123abc",
current_balance: 1000,
raw_payload: {
source: "defi",
address: "0x123abc",
blockchain: "ethereum",
amount: 0.5,
balance: 1000.0
}
)
AccountProvider.create!(account: defi_linked_account, provider: defi_account)
# get_wallet_defi called once (for the one wallet), get_wallet_balances/transactions only
# called once despite two linked accounts (DeFi account excluded from wallet fetch)
@mock_provider.expects(:get_wallet_defi)
.with(address: "0x123abc", connection_id: "ethereum")
.once
.returns(success_response({ protocols: [] }))
@mock_provider.expects(:get_wallet_balances).with("ethereum:0x123abc").once
.returns(success_response([]))
@mock_provider.stubs(:extract_wallet_balance).returns([])
@mock_provider.expects(:get_wallet_transactions).with("ethereum:0x123abc").once
.returns(success_response([]))
@mock_provider.stubs(:extract_wallet_transactions).returns([])
result = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import
assert result[:success]
end
test "propagates DeFi sync failure into accounts_failed count" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Ethereum Wallet",
balance: 1000,
currency: "USD"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Ethereum",
currency: "USD",
account_id: "ethereum",
raw_payload: { address: "0x123abc", blockchain: "ethereum" }
)
AccountProvider.create!(account: account, provider: coinstats_account)
@mock_provider.expects(:get_wallet_defi)
.with(address: "0x123abc", connection_id: "ethereum")
.raises(Provider::Coinstats::Error.new("DeFi endpoint unavailable"))
@mock_provider.stubs(:get_wallet_balances).returns(success_response([]))
@mock_provider.stubs(:extract_wallet_balance).returns([])
@mock_provider.stubs(:get_wallet_transactions).returns(success_response([]))
@mock_provider.stubs(:extract_wallet_transactions).returns([])
result = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider).import
# Wallet account still updated, but DeFi failure is counted
assert_equal 1, result[:accounts_failed]
refute result[:success]
end
end