Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add /v1/chains/:chainId/safes/:safeAddress/portfolio route #2261

Draft
wants to merge 15 commits into
base: portfolio-domain
Choose a base branch
from
3 changes: 3 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { SafesModule } from '@/routes/safes/safes.module';
import { NotificationsModule } from '@/routes/notifications/v1/notifications.module';
import { EstimationsModule } from '@/routes/estimations/estimations.module';
import { MessagesModule } from '@/routes/messages/messages.module';
import { PortfolioModule } from '@/routes/portfolio/portfolio.module';
import { RequestScopedLoggingModule } from '@/logging/logging.module';
import { RouteLoggerInterceptor } from '@/routes/common/interceptors/route-logger.interceptor';
import { NotFoundLoggerMiddleware } from '@/middleware/not-found-logger.middleware';
Expand Down Expand Up @@ -71,6 +72,7 @@ export class AppModule implements NestModule {
accounts: isAccountsFeatureEnabled,
email: isEmailFeatureEnabled,
delegatesV2: isDelegatesV2Enabled,
portfolio: isPortfolioFeatureEnabled,
pushNotifications: isPushNotificationsEnabled,
} = configFactory()['features'];

Expand Down Expand Up @@ -103,6 +105,7 @@ export class AppModule implements NestModule {
MessagesModule,
NotificationsModule,
OwnersModule,
...(isPortfolioFeatureEnabled ? [PortfolioModule] : []),
RelayControllerModule,
RootModule,
SafeAppsModule,
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export default (): ReturnType<typeof configuration> => ({
delegatesV2: false,
counterfactualBalances: false,
accounts: false,
portfolio: false,
pushNotifications: false,
hookHttpPostEvent: false,
improvedAddressPoisoning: false,
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export default () => ({
counterfactualBalances:
process.env.FF_COUNTERFACTUAL_BALANCES?.toLowerCase() === 'true',
accounts: process.env.FF_ACCOUNTS?.toLowerCase() === 'true',
portfolio: process.env.FF_PORTFOLIO?.toLowerCase() === 'true',
// TODO: When enabled, we must add `db` as a requirement alongside `redis`
pushNotifications:
process.env.FF_PUSH_NOTIFICATIONS?.toLowerCase() === 'true',
Expand Down
12 changes: 7 additions & 5 deletions src/domain/portfolio/entities/__tests__/portfolio.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export function portfolioBuilder(): IBuilder<Portfolio> {
);
}

function assetByProtocolsBuilder(): IBuilder<Portfolio['assetByProtocols']> {
export function assetByProtocolsBuilder(): IBuilder<
Portfolio['assetByProtocols']
> {
const builder = new Builder<Portfolio['assetByProtocols']>();
const protocols = faker.helpers.multiple(() => faker.string.sample(), {
count: {
Expand All @@ -38,13 +40,13 @@ function assetByProtocolsBuilder(): IBuilder<Portfolio['assetByProtocols']> {

export function assetByProtocolBuilder(): IBuilder<AssetByProtocol> {
return new Builder<AssetByProtocol>()
.with('chains', assetByProtocolChainBuilder().build())
.with('chains', assetByProtocolChainsBuilder().build())
.with('name', faker.string.sample())
.with('imgLarge', faker.internet.url())
.with('value', faker.string.numeric());
}

export function assetByProtocolChainBuilder(): IBuilder<
export function assetByProtocolChainsBuilder(): IBuilder<
AssetByProtocol['chains']
> {
const builder = new Builder<AssetByProtocol['chains']>();
Expand Down Expand Up @@ -87,7 +89,7 @@ export function protocolPositionBuilder(): IBuilder<ProtocolPosition> {
: complexProtocolPositionBuilder();
}

function regularProtocolPositionBuilder(): IBuilder<ProtocolPosition> {
export function regularProtocolPositionBuilder(): IBuilder<ProtocolPosition> {
return (
new Builder<ProtocolPosition>()
.with('name', faker.string.sample())
Expand All @@ -98,7 +100,7 @@ function regularProtocolPositionBuilder(): IBuilder<ProtocolPosition> {
);
}

function complexProtocolPositionBuilder(): IBuilder<ProtocolPosition> {
export function complexProtocolPositionBuilder(): IBuilder<ProtocolPosition> {
return (
new Builder<ProtocolPosition>()
.with('name', faker.string.sample())
Expand Down
60 changes: 13 additions & 47 deletions src/domain/portfolio/entities/portfolio.entity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker';
import { getAddress } from 'viem';
import {
assetByProtocolBuilder,
assetByProtocolChainBuilder,
assetByProtocolChainsBuilder,
nestedProtocolPositionBuilder,
portfolioAssetBuilder,
portfolioBuilder,
Expand Down Expand Up @@ -349,54 +349,20 @@ describe('Portfolio', () => {
expect(result.success).toBe(true);
});

it('should not allow an unknown position type', () => {
const type = faker.word.verb() as (typeof ProtocolPositionType)[number];
const protocolPositions = protocolPositionsBuilder()
it('should set unknown position types as UNKNOWN', () => {
const type = faker.word.noun() as (typeof ProtocolPositionType)[number];
const allProtocolPositions = protocolPositionsBuilder()
.with(type, protocolPositionBuilder().build())
.build();
const { [type]: unknownProtocolPosition, ...protocolPositions } =
allProtocolPositions;

const result = ProtocolPositionsSchema.safeParse(protocolPositions);
const result = ProtocolPositionsSchema.safeParse(allProtocolPositions);

expect(!result.success && result.error.issues).toStrictEqual([
{
code: 'invalid_enum_value',
message: `Invalid enum value. Expected 'DEPOSIT' | 'FARMING' | 'GOVERNANCE' | 'INSURANCEBUYER' | 'INSURANCESELLER' | 'INVESTMENT' | 'LENDING' | 'LEVERAGE' | 'LEVERAGED FARMING' | 'LIQUIDITYPOOL' | 'LOCKED' | 'MARGIN' | 'MARGINPS' | 'NFTBORROWER' | 'NFTFRACTION' | 'NFTLENDER' | 'NFTLENDING' | 'NFTLIQUIDITYPOOL' | 'NFTSTAKED' | 'OPTIONSBUYER' | 'OPTIONSSELLER' | 'PERPETUALS' | 'REWARDS' | 'SPOT' | 'STAKED' | 'VAULT' | 'VAULTPS' | 'VESTING' | 'WALLET' | 'YIELD', received '${type}'`,
options: [
'DEPOSIT',
'FARMING',
'GOVERNANCE',
'INSURANCEBUYER',
'INSURANCESELLER',
'INVESTMENT',
'LENDING',
'LEVERAGE',
'LEVERAGED FARMING',
'LIQUIDITYPOOL',
'LOCKED',
'MARGIN',
'MARGINPS',
'NFTBORROWER',
'NFTFRACTION',
'NFTLENDER',
'NFTLENDING',
'NFTLIQUIDITYPOOL',
'NFTSTAKED',
'OPTIONSBUYER',
'OPTIONSSELLER',
'PERPETUALS',
'REWARDS',
'SPOT',
'STAKED',
'VAULT',
'VAULTPS',
'VESTING',
'WALLET',
'YIELD',
],
path: [type],
received: type,
},
]);
expect(result.success && result.data).toEqual({
...protocolPositions,
UNKNOWN: unknownProtocolPosition,
});
});

it('should not validate an invalid ProtocolPosition', () => {
Expand Down Expand Up @@ -432,7 +398,7 @@ describe('Portfolio', () => {

describe('AssetByProtocolChainSchema', () => {
it('should validate an AssetByProtocolChain', () => {
const assetByProtocolChain = assetByProtocolChainBuilder().build();
const assetByProtocolChain = assetByProtocolChainsBuilder().build();

const result = AssetByProtocolChainSchema.safeParse(assetByProtocolChain);

Expand All @@ -441,7 +407,7 @@ describe('Portfolio', () => {

it('should not allow an unknown position chain key', () => {
const key = faker.word.noun() as (typeof ProtocolChainKeys)[number];
const assetByProtocolChain = assetByProtocolChainBuilder()
const assetByProtocolChain = assetByProtocolChainsBuilder()
.with(key, {
protocolPositions: protocolPositionsBuilder().build(),
})
Expand Down
2 changes: 1 addition & 1 deletion src/domain/portfolio/entities/portfolio.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const ProtocolPositionType = [
] as const;

export const ProtocolPositionsSchema = z.record(
z.enum(ProtocolPositionType),
z.enum([...ProtocolPositionType, 'UNKNOWN']).catch('UNKNOWN'),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add logging here but I'm not sure on the best approach as we'd need to inject the logging service into the entity. To keep the footprint focused, I will address this in a follow up PR.

ProtocolPositionSchema,
);

Expand Down
51 changes: 51 additions & 0 deletions src/routes/portfolio/entities/portfolio-asset.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ApiProperty } from '@nestjs/swagger';
import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity';

export enum PortfolioAssetType {
General = 'GENERAL',
Borrow = 'BORROW',
Dex = 'DEX',
Rewards = 'REWARDS',
Supply = 'SUPPLY',
}

export class PortfolioAsset extends TokenInfo {
@ApiProperty({ enum: PortfolioAssetType })
type: PortfolioAssetType;

@ApiProperty()
balance: string;

@ApiProperty()
price: string;

@ApiProperty({ description: 'USD' })
fiatBalance: string;

constructor(args: {
type: PortfolioAssetType;
address: `0x${string}`;
decimals: number;
logoUri: string;
name: string;
symbol: string;
balance: string;
price: string;
fiatBalance: string;
}) {
super({
address: args.address,
decimals: args.decimals,
logoUri: args.logoUri,
name: args.name,
symbol: args.symbol,
// Don't trust external API
trusted: false,
});

this.type = args.type;
this.balance = args.balance;
this.price = args.price;
this.fiatBalance = args.fiatBalance;
}
}
25 changes: 25 additions & 0 deletions src/routes/portfolio/entities/portfolio-item-page.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ApiExtraModels, ApiProperty } from '@nestjs/swagger';
import { Page } from '@/routes/common/entities/page.entity';
import { PositionItem } from '@/routes/portfolio/entities/position-item.entity';

@ApiExtraModels(PositionItem)
export class PortfolioItemPage extends Page<PositionItem> {
@ApiProperty({
type: [PositionItem],
isArray: true,
})
results!: Array<PositionItem>;

constructor(args: {
results: Array<PositionItem>;
count: number;
next: string | null;
previous: string | null;
}) {
super();
this.results = args.results;
this.count = args.count;
this.next = args.next;
this.previous = args.previous;
}
}
129 changes: 129 additions & 0 deletions src/routes/portfolio/entities/position-item.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {
ApiProperty,
ApiPropertyOptional,
getSchemaPath,
} from '@nestjs/swagger';
import { PortfolioAsset } from '@/routes/portfolio/entities/portfolio-asset.entity';

type BasePosition = {
name: string;
fiatBalance: string;
};

type Position = BasePosition & {
assets: Array<PortfolioAsset>;
};

enum PositionType {
Regular = 'REGULAR',
Complex = 'COMPLEX',
}

export class RegularProtocolPosition implements Position {
@ApiProperty({ enum: [PositionType.Regular] })
type = PositionType.Regular;

@ApiProperty()
name: string;

@ApiProperty({ type: PortfolioAsset, isArray: true })
assets: Array<PortfolioAsset>;

@ApiProperty()
fiatBalance: string;

constructor(args: {
name: string;
assets: Array<PortfolioAsset>;
fiatBalance: string;
}) {
this.name = args.name;
this.assets = args.assets;
this.fiatBalance = args.fiatBalance;
}
}

export class NestedProtocolPosition implements Position {
@ApiProperty()
name: string;

@ApiProperty({ type: PortfolioAsset, isArray: true })
assets: Array<PortfolioAsset>;

@ApiProperty()
fiatBalance: string;

@ApiPropertyOptional({
type: String,
nullable: true,
})
healthRate?: string;

constructor(args: {
name: string;
fiatBalance: string;
healthRate?: string;
assets: Array<PortfolioAsset>;
}) {
this.name = args.name;
this.assets = args.assets;
this.fiatBalance = args.fiatBalance;
this.healthRate = args.healthRate;
}
}

export class ComplexProtocolPosition implements BasePosition {
@ApiProperty({ enum: [PositionType.Complex] })
type = PositionType.Complex;

@ApiProperty()
name: string;

@ApiProperty({ type: NestedProtocolPosition, isArray: true })
positions: Array<NestedProtocolPosition>;

@ApiProperty()
fiatBalance: string;

constructor(args: {
name: string;
positions: Array<NestedProtocolPosition>;
fiatBalance: string;
}) {
this.name = args.name;
this.positions = args.positions;
this.fiatBalance = args.fiatBalance;
}
}

export class PositionItem implements BasePosition {
@ApiProperty()
fiatBalance: string;

@ApiProperty()
name: string;

@ApiProperty()
logoUri: string;

@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(RegularProtocolPosition) },
{ $ref: getSchemaPath(ComplexProtocolPosition) },
],
isArray: true,
})
protocolPositions: Array<RegularProtocolPosition | ComplexProtocolPosition>;

constructor(args: {
fiatBalance: string;
name: string;
logoUri: string;
protocolPositions: Array<RegularProtocolPosition | ComplexProtocolPosition>;
}) {
this.fiatBalance = args.fiatBalance;
this.name = args.name;
this.logoUri = args.logoUri;
this.protocolPositions = args.protocolPositions;
}
}
Loading
Loading