Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion components/entities/activity/ActivityEventRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ const changes = computed(() => {
valueTitle: entry.value,
summary: entry.summary,
addresses: entry.addresses,
addressDetails: entry.addressDetails,
avatarAssets: entry.field === 'asset_pair'
|| (event.type === 'set_resolved_vault' && entry.field === 'asset')
? entry.addresses?.map(address => ({
Expand Down Expand Up @@ -468,7 +469,7 @@ const vaultDisplay = computed(() => {
<div
v-for="(address, addressIndex) in detail.addresses"
:key="`${address.address}:${addressIndex}`"
class="flex w-full min-w-0 items-center gap-8"
class="flex w-full min-w-0 flex-wrap items-center gap-x-8 gap-y-2"
>
<AssetAvatar
v-if="'avatarAssets' in detail && detail.avatarAssets?.[addressIndex]"
Expand All @@ -484,6 +485,10 @@ const vaultDisplay = computed(() => {
:vault-type="address.vaultType"
compact-vault
/>
<span
v-if="'addressDetails' in detail && detail.addressDetails?.[addressIndex]"
class="text-p4 text-content-secondary"
>{{ detail.addressDetails[addressIndex] }}</span>
</div>
</div>
</template>
Expand Down
47 changes: 47 additions & 0 deletions tests/utils/activity-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getActivityChangeEntries,
getActivityEventIcon,
getActivityLiquidationDisplayDetails,
getActivityResolvableVaultAddresses,
getPortfolioActivityPositionParticipant,
getActivityTransferDirection,
getActivityTransactionGroupLabel,
Expand Down Expand Up @@ -201,6 +202,7 @@ describe('activity display helpers', () => {
expect(formatActivityEventLabel({ type: 'set_interest_rate_model' })).toBe('Interest rate model updated')
expect(formatActivityEventLabel({ type: 'set_liquidation_cool_off_time' })).toBe('Liquidation cool-off time updated')
expect(formatActivityEventLabel({ type: 'set_is_allocator' })).toBe('Allocator status updated')
expect(formatActivityEventLabel({ label: 'Flow caps updated', type: 'set_flow_caps' })).toBe('Public allocator limits updated')
expect(formatActivityEventLabel({ type: 'set_oracle_config' })).toBe('Oracle route updated')
expect(formatActivityEventLabel({ type: 'set_fallback_oracle' })).toBe('Fallback oracle updated')
expect(formatActivityEventLabel({ type: 'set_resolved_vault' })).toBe('Resolved vault updated')
Expand Down Expand Up @@ -714,6 +716,51 @@ describe('activity display helpers', () => {
{ field: 'new_supply_cap', label: 'New supply cap', value: '155M USDC' },
])

const flowCapsConfig = JSON.stringify([
{ id: OTHER_VAULT, caps: { maxIn: '10000000000', maxOut: '2500000000' } },
{ id: SHARES, caps: { maxIn: '0', maxOut: '10000000000' } },
])
expect(getActivityChangeEntries({
type: 'set_flow_caps',
vault: VAULT,
vaultType: 'earn',
change: { fields: { config: flowCapsConfig } },
}, getVaultMetadata)).toEqual([{
field: 'config',
label: 'Strategies',
summary: '2 strategies',
addresses: [
{
address: OTHER_VAULT,
label: 'Collateral vault',
linkKind: 'vault',
vaultType: 'evk',
},
{ address: SHARES, linkKind: 'explorer' },
],
addressDetails: [
'Max in 10K USDC · Max out 2.5K USDC',
'Max in 0 USDC · Max out 10K USDC',
],
}])
expect(getActivityResolvableVaultAddresses({
type: 'set_flow_caps',
vault: VAULT,
change: { fields: { config: flowCapsConfig } },
})).toEqual([VAULT, OTHER_VAULT, SHARES])

const malformedFlowCapsConfig = '[{"id":"not-an-address"}]'
expect(getActivityChangeEntries({
type: 'set_flow_caps',
vault: VAULT,
vaultType: 'earn',
change: { fields: { config: malformedFlowCapsConfig } },
}, getVaultMetadata)).toEqual([{
field: 'config',
label: 'Config',
value: malformedFlowCapsConfig,
}])

expect(getActivityChangeEntries({
type: 'set_oracle_config',
vault: VAULT,
Expand Down
105 changes: 102 additions & 3 deletions utils/activity-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
ActivityVaultType,
LiquidationRecord,
} from '@eulerxyz/euler-v2-sdk'
import { formatUnits, isAddress, maxUint256, zeroAddress, type Address } from 'viem'
import { formatUnits, getAddress, isAddress, maxUint256, zeroAddress, type Address } from 'viem'
import { compactNumber, formatCompactUsdValue, formatSmartAmount, shortenAddress } from '~/utils/string-utils'
import { CFG_DONT_SOCIALIZE_DEBT } from '~/entities/constants'
import { decodeHookedOperationsMask, getHookedOperationMetas } from '~/utils/vault-hooks'
Expand Down Expand Up @@ -76,6 +76,7 @@ const CATEGORY_LABELS: Record<ActivityCategory, string> = {
governance: 'Governance',
}

const UINT128_MAX = 2n ** 128n - 1n
const UINT136_MAX = 2n ** 136n - 1n

const ACCOUNT_ACTIVITY_EVENT_TYPES = [
Expand Down Expand Up @@ -447,6 +448,7 @@ export const getActivityTransferDirection = (
export const formatActivityEventLabel = (
event: ActivityEventLabelSource,
): string => {
if (event.type === 'set_flow_caps') return 'Public allocator limits updated'
const sourceLabel = event.label?.trim()
if (sourceLabel) return sourceLabel
const normalizedLabel = {
Expand Down Expand Up @@ -832,6 +834,7 @@ export interface ActivityChangeEntry {
value?: string
summary?: string
addresses?: ActivityChangeAddress[]
addressDetails?: string[]
}

type ActivityChangeEventSource = Pick<ActivityEvent, 'change' | 'type' | 'vault' | 'vaultType'>
Expand Down Expand Up @@ -869,6 +872,56 @@ const parseActivityInteger = (value: ActivityChangeValue): bigint | null => {
}
}

interface ActivityFlowCapsConfig {
id: Address
maxIn: string
maxOut: string
}

const isActivityRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)

const isActivityUint128 = (value: unknown): value is string =>
typeof value === 'string'
&& value.length <= UINT128_MAX.toString().length
&& /^\d+$/.test(value)
&& BigInt(value) <= UINT128_MAX

/**
* The activity API serializes FlowCapsConfig[] into a string because the SDK
* change-value contract intentionally accepts only scalars and string arrays.
* Keep this parser event-specific and fail closed to the generic raw display.
*/
const parseActivityFlowCapsConfig = (
value: ActivityChangeValue | undefined,
): ActivityFlowCapsConfig[] | null => {
if (typeof value !== 'string') return null

try {
const parsed: unknown = JSON.parse(value)
if (!Array.isArray(parsed)) return null

const configs: ActivityFlowCapsConfig[] = []
for (const item of parsed) {
if (
!isActivityRecord(item)
|| typeof item.id !== 'string'
|| !isAddress(item.id)
|| !isActivityRecord(item.caps)
) {
return null
}
const { maxIn, maxOut } = item.caps
if (!isActivityUint128(maxIn) || !isActivityUint128(maxOut)) return null
configs.push({ id: getAddress(item.id), maxIn, maxOut })
}
return configs
}
catch {
return null
}
}

/** Decodes the EVK AmountCap uint16 encoding into underlying token units. */
export const decodeEvkAmountCap = (value: ActivityChangeValue): bigint | null => {
const raw = parseActivityInteger(value)
Expand Down Expand Up @@ -1094,14 +1147,51 @@ const resolveOracleAssetPair = (
}
}

const resolveFlowCapsConfig = (
event: ActivityChangeEventSource,
getVaultMetadata: ActivityVaultMetadataLookup | undefined,
getTokenSymbol: ActivityAddressLabelLookup | undefined,
): ActivityChangeEntry | null => {
if (event.type !== 'set_flow_caps') return null
const configs = parseActivityFlowCapsConfig(event.change?.fields.config)
if (configs === null) return null
if (configs.length === 0) {
return { field: 'config', label: 'Strategies', value: 'None' }
}

const addresses = resolveChangeAddresses(
event,
'strategy',
configs.map(config => config.id),
getVaultMetadata,
getTokenSymbol,
)
if (!addresses) return null

const asset = event.vault ? getVaultMetadata?.(event.vault)?.asset : undefined
const formatCap = (value: string) =>
formatActivityTokenAmount(value, asset, true) ?? value
return {
field: 'config',
label: configs.length === 1 ? 'Strategy' : 'Strategies',
...(configs.length > 1 ? { summary: `${configs.length} strategies` } : {}),
addresses,
addressDetails: configs.map(config =>
`Max in ${formatCap(config.maxIn)} · Max out ${formatCap(config.maxOut)}`),
}
}

export const getActivityChangeEntries = (
event: ActivityChangeEventSource,
getVaultMetadata?: ActivityVaultMetadataLookup,
getTokenSymbol?: ActivityAddressLabelLookup,
): ActivityChangeEntry[] => {
const assetPair = resolveOracleAssetPair(event, getTokenSymbol)
const flowCapsConfig = resolveFlowCapsConfig(event, getVaultMetadata, getTokenSymbol)
const fields = orderedActivityChangeFields(event)
.filter(([field]) => !assetPair || (field !== 'asset0' && field !== 'asset1'))
.filter(([field]) =>
(!assetPair || (field !== 'asset0' && field !== 'asset1'))
&& (!flowCapsConfig || field !== 'config'))

const entries = fields.map(([field, value]): ActivityChangeEntry => {
// The zero address reads better as an explicit "None" than as a linked,
Expand Down Expand Up @@ -1179,7 +1269,11 @@ export const getActivityChangeEntries = (
value: formatted ?? formatActivityChangeValue(value),
}
})
return assetPair ? [assetPair, ...entries] : entries
return [
...(assetPair ? [assetPair] : []),
...(flowCapsConfig ? [flowCapsConfig] : []),
...entries,
]
}

interface ActivityParticipantSource {
Expand Down Expand Up @@ -1235,6 +1329,11 @@ export const getActivityResolvableVaultAddresses = (
if (typeof item === 'string' && isAddress(item)) addresses.add(item as Address)
}
}
if (event.type === 'set_flow_caps') {
for (const config of parseActivityFlowCapsConfig(event.change?.fields.config) ?? []) {
addresses.add(config.id)
}
}
return [...addresses]
}

Expand Down
Loading