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

[WIP] Add b-sdk for trade queries #333

Merged
merged 91 commits into from
Dec 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
d90b321
Basic addition of b-sdk.
johngrantuk May 3, 2023
8f62a6c
Uses pools from Prisma db.
johngrantuk May 8, 2023
298eba2
Add TradeResults Prisma for recording results.
johngrantuk May 8, 2023
56b4a8d
Update SOR service to have placeholders for compare, etc.
johngrantuk May 8, 2023
1de2adc
Add placeholder for SORV1 service call to make more obvious.
johngrantuk May 8, 2023
3101589
Move SOR_QUERIES in to temp file as only used there and can potential…
johngrantuk May 9, 2023
3884a90
Update Phantom/Composable pool comments.
johngrantuk May 9, 2023
4e539c0
Add pool ids to ignore in to network config.
johngrantuk May 9, 2023
8b2ae68
WIP SORv1 API query.
johngrantuk May 10, 2023
92d269c
Use cache for pools.
johngrantuk May 10, 2023
1f9bcff
Query API (Currently Balancers) for SORV1.
johngrantuk May 19, 2023
1725a25
Add basic compare, WIP.
johngrantuk May 19, 2023
5c3b6aa
Remove Prisma trade result in favour of cloudwatch implementation.
johngrantuk May 19, 2023
243d855
Find best and log result via cloudwatch.
johngrantuk May 19, 2023
5cc1667
Change response to be in API format. Use generated types.
johngrantuk May 19, 2023
be6bcfa
Remove temp.
johngrantuk May 19, 2023
a01a9fb
Implement mapResultToCowSwap.
johngrantuk May 19, 2023
554a26e
Compare correctly for ExactOut.
johngrantuk May 19, 2023
388772e
Use cloneDeep for b-sdk as it mutates pools.
johngrantuk May 19, 2023
3578f01
Remove TODO on swapOptions as I believe we dont need it.
johngrantuk May 19, 2023
abdee5e
Merge branch 'v3-canary' into sor-v2
johngrantuk May 19, 2023
a0ec5c3
Remove old gql.
johngrantuk May 19, 2023
65f84ad
Refactor to common Swap result interface.
johngrantuk May 25, 2023
946a6ed
Update SDK package.
johngrantuk May 25, 2023
f14a203
Implement onchain query method for V2 service.
johngrantuk May 25, 2023
311b82d
Implement onchain query method for V1 service.
johngrantuk May 25, 2023
5d04f38
Handle empty responses.
johngrantuk May 30, 2023
633c674
Return correct empty response.
johngrantuk May 30, 2023
ac5cda0
Merge branch 'v3-canary' into sor-v2
johngrantuk May 30, 2023
fbc8553
Use prisma pool version.
johngrantuk May 30, 2023
67fce9a
Handle invalid swap case correctly.
johngrantuk May 30, 2023
a91db31
Filtering out Linear pools with 0 price rate which causes issues on b…
johngrantuk May 31, 2023
969e2ae
Merge branch 'v3-canary' into sor-v2
franzns Jun 2, 2023
c844d9b
Merge branch 'sor-v2' of github.com:beethovenxfi/beethovenx-backend i…
franzns Jun 2, 2023
b0b1409
Update logs with chainId. Fix prettier.
johngrantuk Jun 7, 2023
c5f1ed5
Initial restructure for Balancer/Beets separation.
johngrantuk Jun 7, 2023
87a6b70
Split resolvers.
johngrantuk Jun 7, 2023
1dafb8e
Add getBeetsSwapResponse type.
johngrantuk Jun 8, 2023
aa06361
Add reusable formatResponse function.
johngrantuk Jun 8, 2023
bdf7591
Add mapResultToBeetsSwap - still need to add routes.
johngrantuk Jun 8, 2023
78e46ad
Add sorV1Beets service and hook up to resolver.
johngrantuk Jun 8, 2023
c9f75cd
Best swap for beets endpoint. Zero response.
johngrantuk Jun 9, 2023
585bda3
Map Routes for SingleSwap.
johngrantuk Jun 9, 2023
bd036a2
Route mapping.
johngrantuk Jun 9, 2023
4e6a2ad
Route mapping for batchSwap with paths > 1.
johngrantuk Jun 9, 2023
be0516a
Wider catch in V2 service. (Catches b-sdk/Fantom issue so response is…
johngrantuk Jun 14, 2023
ad90592
Remove balancerQueryTest query.
johngrantuk Jun 14, 2023
2b7821a
Remove union type in sorGetSwaps query.
johngrantuk Jun 14, 2023
fefc348
fix schema exclusion
franzns Jun 14, 2023
16fdead
Return marketSp of 0 for beets.
johngrantuk Jun 20, 2023
0c2cf05
Merge branch 'sor-v2' of https://github.com/beethovenxfi/beethovenx-b…
johngrantuk Jun 20, 2023
915ae7b
Merge canary.
johngrantuk Aug 28, 2023
1221d7d
Revert commented out section in codegen.yml.
johngrantuk Aug 29, 2023
ae35e83
exclude all in protocol specific folder
franzns Aug 29, 2023
de6ea10
Revert "exclude all in protocol specific folder"
johngrantuk Sep 1, 2023
1688a41
Change to use 2 different query types for Beets/Cow swaps. Add missin…
johngrantuk Sep 1, 2023
fd52d50
fix: Filter out Linear pools. Filter out pools from vulnerability/mit…
johngrantuk Sep 4, 2023
061e524
chore: Remove unneccesary Migration folders.
johngrantuk Sep 4, 2023
2ea2442
chore: Update sdk to 0.1.1 and add missing RawPool fields.
johngrantuk Sep 4, 2023
aae9bd8
feat: Add poolGetGyroPools query.
johngrantuk Sep 5, 2023
5d6ade7
chore: Add new Gyro fields for SOR calcs.
johngrantuk Sep 5, 2023
048699d
Merge branch 'v3-canary' into sor-v2
johngrantuk Sep 7, 2023
6390fe4
chore: More Gyro fields for SOR.
johngrantuk Sep 7, 2023
abbc61e
feat: Add Gyro pools for sorV2 service.
johngrantuk Sep 7, 2023
08767d1
Merge branch 'v3-canary' into sor-v2
johngrantuk Sep 7, 2023
a3e4a10
bump typescript version
franzns Sep 15, 2023
a22b7dc
Merge branch 'v3-canary' into sor-v2
franzns Sep 18, 2023
9aef5d0
Merge branch 'v3-canary' into sor-v2
franzns Sep 18, 2023
c7d44b7
formatting and output
franzns Sep 18, 2023
ca4865d
adding cloudwatch metrics
franzns Sep 18, 2023
0aca520
logging
franzns Sep 18, 2023
caca5e3
chore: Bump b-sdk.
johngrantuk Oct 12, 2023
026bf85
chore: Add Gyro3 to mapRawPoolType.
johngrantuk Oct 13, 2023
33e39d5
fix: Handle amount scaling correctly across various services by using…
johngrantuk Oct 13, 2023
d5f7af5
chore: Remove debug statements.
johngrantuk Oct 13, 2023
54d90d2
fix: For Balancer resolver use raw scale for swapAmount.
johngrantuk Oct 17, 2023
b7e3bff
Merge branch 'v3-canary' into sor-v2
gmbronco Nov 13, 2023
d99f22f
chain aware SOR queries
gmbronco Nov 13, 2023
23aad1b
gyro pool data updates
gmbronco Nov 13, 2023
056c178
excluding unsupported pools
gmbronco Nov 14, 2023
14f2393
sor metrics
gmbronco Nov 14, 2023
920600c
sor bench logging
gmbronco Nov 15, 2023
d12e892
last review and styling
gmbronco Nov 21, 2023
f256592
updating b-sdk
gmbronco Nov 21, 2023
f7ac2dd
Merge branch 'v3-canary' into sor-v2
gmbronco Dec 6, 2023
3792147
prettier
gmbronco Dec 7, 2023
c62793e
V2 options
gmbronco Dec 7, 2023
22abdd7
Merge branch 'v3-canary' into sor-v2
gmbronco Dec 7, 2023
61cec2b
node18 in github action
gmbronco Dec 7, 2023
b50d476
sor args cleaup
gmbronco Dec 7, 2023
0aa8c08
less profiling
gmbronco Dec 7, 2023
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
4 changes: 2 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
node-version: '18.x'
- name: Install deps
run: yarn
run: yarn
- name: Generate Schema
run: yarn generate
- name: Prisma Generate
Expand Down
38 changes: 35 additions & 3 deletions modules/balancer/balancer.gql
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
extend type Mutation {
balancerMutationTest: String!
}

extend type Query {
balancerQueryTest: String!
sorGetCowSwaps(
chain: GqlChain!
tokenIn: String!
tokenOut: String!
swapType: GqlSorSwapType!
swapAmount: BigDecimal! #expected in raw amount
): GqlCowSwapApiResponse!
}

extend type Mutation {
balancerMutationTest: String!
enum GqlSorSwapType {
EXACT_IN
EXACT_OUT
}

type GqlCowSwapApiResponse {
tokenAddresses: [String!]!
swaps: [GqlSwap!]!
swapAmount: String!
swapAmountForSwaps: String!
returnAmount: String!
returnAmountFromSwaps: String!
returnAmountConsideringFees: String!
tokenIn: String!
tokenOut: String!
marketSp: String!
}

type GqlSwap {
poolId: String!
assetInIndex: Int!
assetOutIndex: Int!
amount: String!
userData: String!
}
12 changes: 9 additions & 3 deletions modules/balancer/balancer.resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import moment from 'moment';
import { Resolvers } from '../../schema';
import { sorService } from '../sor/sor.service';
import { getTokenAmountRaw } from '../sor/utils';

const balancerResolvers: Resolvers = {
Query: {
balancerQueryTest: async (parent, {}, context) => {
return `${moment().utc().valueOf()}`;
sorGetCowSwaps: async (parent, args, context) => {
const amountToken = args.swapType === "EXACT_IN" ? args.tokenIn : args.tokenOut;
// Use TokenAmount to help follow scaling requirements in later logic
// args.swapAmount is RawScale, e.g. 1USDC should be passed as 1000000
const amount = await getTokenAmountRaw(amountToken, args.swapAmount, args.chain);
const swaps = await sorService.getCowSwaps({ ...args, swapAmount: amount, swapOptions: {} });
return { ...swaps, __typename: 'GqlCowSwapApiResponse' };
},
},
Mutation: {
Expand Down
31 changes: 31 additions & 0 deletions modules/beethoven/balancer-sdk.gql
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
extend type Query {
sorGetSwaps(
chain: GqlChain!
tokenIn: String!
tokenOut: String!
swapType: GqlSorSwapType!
swapAmount: BigDecimal! #expected in human readable form
swapOptions: GqlSorSwapOptionsInput!
graphTraversalConfig: GqlGraphTraversalConfigInput
): GqlSorGetSwapsResponse!
sorGetBatchSwapForTokensIn(
tokensIn: [GqlTokenAmountHumanReadable!]!
Expand All @@ -24,6 +26,14 @@ input GqlSorSwapOptionsInput {
forceRefresh: Boolean #don't use any cached responses
}

input GqlGraphTraversalConfigInput {
maxDepth: Int # default 6
maxNonBoostedPathDepth: Int # default 3
maxNonBoostedHopTokensInBoostedPath: Int # default 2
approxPathsToReturn: Int # default 5
poolIdsToInclude: [String]
}

type GqlSorGetSwapsResponse {
tokenIn: String!
tokenOut: String!
Expand Down Expand Up @@ -77,3 +87,24 @@ type GqlSorGetBatchSwapForTokensInResponse {
swaps: [GqlSorSwap!]!
assets: [String!]!
}

type GqlCowSwapApiResponse {
tokenAddresses: [String!]!
swaps: [GqlSwap!]!
swapAmount: String!
swapAmountForSwaps: String!
returnAmount: String!
returnAmountFromSwaps: String!
returnAmountConsideringFees: String!
tokenIn: String!
tokenOut: String!
marketSp: String!
}

type GqlSwap {
poolId: String!
assetInIndex: Int!
assetOutIndex: Int!
amount: String!
userData: String!
}
21 changes: 19 additions & 2 deletions modules/beethoven/balancer-sdk.resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import { Resolvers } from '../../schema';
import { balancerSorService } from './balancer-sor.service';
import { tokenService } from '../token/token.service';
import { sorService } from '../sor/sor.service';
import { getTokenAmountHuman } from '../sor/utils';
import { GraphTraversalConfig } from '../sor/types';

const balancerSdkResolvers: Resolvers = {
Query: {
sorGetSwaps: async (parent, args, context) => {
const tokens = await tokenService.getTokens();
const tokenIn = args.tokenIn.toLowerCase();
const tokenOut = args.tokenOut.toLowerCase();
const amountToken = args.swapType === 'EXACT_IN' ? tokenIn : tokenOut;
// Use TokenAmount to help follow scaling requirements in later logic
// args.swapAmount is HumanScale
const amount = await getTokenAmountHuman(amountToken, args.swapAmount, args.chain);
const graphTraversalConfig = args.graphTraversalConfig as GraphTraversalConfig;

const swaps = await sorService.getBeetsSwaps({
...args,
tokenIn,
tokenOut,
graphTraversalConfig,
swapAmount: amount,
});

return balancerSorService.getSwaps({ ...args, tokens });
return { ...swaps, __typename: 'GqlSorGetSwapsResponse' };
},
sorGetBatchSwapForTokensIn: async (parent, args, context) => {
const tokens = await tokenService.getTokens();
Expand Down
169 changes: 107 additions & 62 deletions modules/beethoven/balancer-sor.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GqlSorGetSwapsResponse, GqlSorSwapOptionsInput, GqlSorSwapType } from '../../schema';
import { GqlSorGetSwapsResponse, GqlSorSwapOptionsInput, GqlSorSwapType, GqlPoolMinimal } from '../../schema';
import { formatFixed, parseFixed } from '@ethersproject/bignumber';
import { PrismaToken } from '@prisma/client';
import { poolService } from '../pool/pool.service';
Expand All @@ -17,6 +17,7 @@ import { DeploymentEnv } from '../network/network-config-types';
import * as Sentry from '@sentry/node';
import _ from 'lodash';
import { Logger } from '@ethersproject/logger';
import { SwapInfoRoute } from '@balancer-labs/sor';

interface GetSwapsInput {
tokenIn: string;
Expand Down Expand Up @@ -55,35 +56,7 @@ export class BalancerSorService {
let swapInfo = await this.querySor(swapType, tokenIn, tokenOut, swapAmountScaled, swapOptions);
// no swaps found, return 0
if (swapInfo.swaps.length === 0) {
return {
...swapInfo,
tokenIn: replaceZeroAddressWithEth(swapInfo.tokenIn),
tokenOut: replaceZeroAddressWithEth(swapInfo.tokenOut),
swapType,
tokenInAmount: swapType === 'EXACT_IN' ? swapAmount : BigNumber.from('0').toString(),
tokenOutAmount: swapType === 'EXACT_IN' ? BigNumber.from('0').toString() : swapAmount,
swapAmount: swapType === 'EXACT_IN' ? BigNumber.from('0').toString() : swapAmount,
swapAmountScaled: BigNumber.from('0').toString(),
swapAmountForSwaps: swapInfo.swapAmountForSwaps
? BigNumber.from(swapInfo.swapAmountForSwaps).toString()
: undefined,
returnAmount: BigNumber.from('0').toString(),
returnAmountScaled: BigNumber.from('0').toString(),
returnAmountConsideringFees: BigNumber.from(swapInfo.returnAmountConsideringFees).toString(),
returnAmountFromSwaps: swapInfo.returnAmountFromSwaps
? BigNumber.from(swapInfo.returnAmountFromSwaps).toString()
: undefined,
routes: swapInfo.routes.map((route) => ({
...route,
hops: route.hops.map((hop) => ({
...hop,
pool: pools.find((pool) => pool.id === hop.poolId)!,
})),
})),
effectivePrice: BigNumber.from('0').toString(),
effectivePriceReversed: BigNumber.from('0').toString(),
priceImpact: BigNumber.from('0').toString(),
};
return this.zeroResponse(swapType, tokenIn, tokenOut, swapAmount);
}

let deltas: string[] = [];
Expand Down Expand Up @@ -142,27 +115,70 @@ export class BalancerSorService {
const tokenInAmount = BigNumber.from(deltas[swapInfo.tokenAddresses.indexOf(tokenIn)]);
const tokenOutAmount = BigNumber.from(deltas[swapInfo.tokenAddresses.indexOf(tokenOut)]).abs();

const swapAmountQuery = swapType === 'EXACT_OUT' ? tokenOutAmount : tokenInAmount;
const returnAmount = swapType === 'EXACT_IN' ? tokenOutAmount : tokenInAmount;

const returnAmountFixed = formatFixed(
returnAmount,
this.getTokenDecimals(swapType === 'EXACT_IN' ? tokenOut : tokenIn, tokens),
);

const swapAmountQueryFixed = formatFixed(
swapAmountQuery,
this.getTokenDecimals(swapType === 'EXACT_OUT' ? tokenOut : tokenIn, tokens),
);
return this.formatResponse({
tokenIn: swapInfo.tokenIn,
tokenOut: swapInfo.tokenOut,
tokens,
tokenInAmtEvm: tokenInAmount.toString(),
tokenOutAmtEvm: tokenOutAmount.toString(),
swapAmountForSwaps: BigNumber.from(swapInfo.swapAmountForSwaps).toString(),
returnAmountConsideringFees: BigNumber.from(swapInfo.returnAmountConsideringFees).toString(),
returnAmountFromSwaps: BigNumber.from(swapInfo.returnAmountFromSwaps).toString(),
routes: swapInfo.routes,
pools,
marketSp: swapInfo.marketSp,
swaps: swapInfo.swaps,
tokenAddresses: swapInfo.tokenAddresses,
swapType,
});
}

const tokenInAmountFixed = formatFixed(tokenInAmount, this.getTokenDecimals(tokenIn, tokens));
const tokenOutAmountFixed = formatFixed(tokenOutAmount, this.getTokenDecimals(tokenOut, tokens));
formatResponse(swapData: {
johngrantuk marked this conversation as resolved.
Show resolved Hide resolved
tokenIn: string;
tokenOut: string;
swapType: GqlSorSwapType;
tokens: PrismaToken[];
tokenInAmtEvm: string;
tokenOutAmtEvm: string;
swapAmountForSwaps: string;
returnAmountConsideringFees: string;
returnAmountFromSwaps: string;
routes: SwapInfoRoute[];
pools: GqlPoolMinimal[];
marketSp: string;
swaps: SwapV2[];
tokenAddresses: string[];
}): GqlSorGetSwapsResponse {
const {
tokenIn,
tokenOut,
swapType,
tokens,
tokenInAmtEvm,
tokenOutAmtEvm,
swapAmountForSwaps,
returnAmountConsideringFees,
returnAmountFromSwaps,
routes,
pools,
marketSp,
swaps,
tokenAddresses,
} = swapData;

const tokenInAmountFixed = formatFixed(tokenInAmtEvm, this.getTokenDecimals(tokenIn, tokens));
const tokenOutAmountFixed = formatFixed(tokenOutAmtEvm, this.getTokenDecimals(tokenOut, tokens));

const swapAmountQuery = swapType === 'EXACT_OUT' ? tokenOutAmtEvm : tokenInAmtEvm;
const returnAmount = swapType === 'EXACT_IN' ? tokenOutAmtEvm : tokenInAmtEvm;
const swapAmountQueryFixed = swapType === 'EXACT_OUT' ? tokenOutAmountFixed : tokenInAmountFixed;
const returnAmountFixed = swapType === 'EXACT_IN' ? tokenOutAmountFixed : tokenInAmountFixed;

const effectivePrice = oldBnum(tokenInAmountFixed).div(tokenOutAmountFixed);
const effectivePriceReversed = oldBnum(tokenOutAmountFixed).div(tokenInAmountFixed);
const priceImpact = effectivePrice.div(swapInfo.marketSp).minus(1);
const priceImpact = effectivePrice.div(marketSp).minus(1);

for (const route of swapInfo.routes) {
for (const route of routes) {
route.tokenInAmount = oldBnum(tokenInAmountFixed)
.multipliedBy(route.share)
.dp(this.getTokenDecimals(tokenIn, tokens))
Expand All @@ -174,24 +190,22 @@ export class BalancerSorService {
}

return {
...swapInfo,
tokenIn: replaceZeroAddressWithEth(swapInfo.tokenIn),
tokenOut: replaceZeroAddressWithEth(swapInfo.tokenOut),
swaps,
marketSp,
tokenAddresses,
tokenIn: replaceZeroAddressWithEth(tokenIn),
tokenOut: replaceZeroAddressWithEth(tokenOut),
swapType,
tokenInAmount: tokenInAmountFixed,
tokenOutAmount: tokenOutAmountFixed,
swapAmount: swapAmountQueryFixed,
swapAmountScaled: swapAmountQuery.toString(),
swapAmountForSwaps: swapInfo.swapAmountForSwaps
? BigNumber.from(swapInfo.swapAmountForSwaps).toString()
: undefined,
swapAmountScaled: swapAmountQuery,
swapAmountForSwaps: swapAmountForSwaps ? BigNumber.from(swapAmountForSwaps).toString() : undefined,
returnAmount: returnAmountFixed,
returnAmountScaled: returnAmount.toString(),
returnAmountConsideringFees: BigNumber.from(swapInfo.returnAmountConsideringFees).toString(),
returnAmountFromSwaps: swapInfo.returnAmountFromSwaps
? BigNumber.from(swapInfo.returnAmountFromSwaps).toString()
: undefined,
routes: swapInfo.routes.map((route) => ({
returnAmountScaled: returnAmount,
returnAmountConsideringFees: BigNumber.from(returnAmountConsideringFees).toString(),
returnAmountFromSwaps: returnAmountFromSwaps ? BigNumber.from(returnAmountFromSwaps).toString() : undefined,
routes: routes.map((route) => ({
...route,
hops: route.hops.map((hop) => ({
...hop,
Expand All @@ -204,6 +218,35 @@ export class BalancerSorService {
};
}

zeroResponse(
swapType: GqlSorSwapType,
tokenIn: string,
tokenOut: string,
swapAmount: string,
): GqlSorGetSwapsResponse {
return {
marketSp: '0',
tokenAddresses: [],
swaps: [],
tokenIn: replaceZeroAddressWithEth(tokenIn),
tokenOut: replaceZeroAddressWithEth(tokenOut),
swapType,
tokenInAmount: swapType === 'EXACT_IN' ? swapAmount : '0',
tokenOutAmount: swapType === 'EXACT_IN' ? '0' : swapAmount,
swapAmount: swapType === 'EXACT_IN' ? '0' : swapAmount,
swapAmountScaled: '0',
swapAmountForSwaps: '0',
returnAmount: '0',
returnAmountScaled: '0',
returnAmountConsideringFees: '0',
returnAmountFromSwaps: '0',
routes: [],
effectivePrice: '0',
effectivePriceReversed: '0',
priceImpact: '0',
};
}

private async querySor(
swapType: string,
tokenIn: string,
Expand Down Expand Up @@ -293,11 +336,13 @@ export class BalancerSorService {
tokenAddress = tokenAddress.toLowerCase();
const match = tokens.find((token) => token.address === tokenAddress);

if (!match) {
throw new Error('Unknown token: ' + tokenAddress);
let decimals = match?.decimals;
if (!decimals) {
console.error(`Unknown token: ${tokenAddress}`);
decimals = 18;
}

return match.decimals;
return decimals;
}

private batchSwaps(assetArray: string[][], swaps: SwapV2[][]): { swaps: SwapV2[]; assets: string[] } {
Expand Down
Loading