Skip to content

Commit d8602a1

Browse files
authored
Merge pull request #453 from EmberAGI/next
chore: merge next into main
2 parents b1a384f + 5b2e750 commit d8602a1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2767
-518
lines changed

typescript/clients/web-ag-ui/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ All scripts use Turborepo to run tasks across the monorepo:
5151
- `pnpm build` - Builds all apps for production
5252
- `pnpm lint` - Runs linting across all apps
5353

54+
## Traefik Auth Toggle (Production)
55+
56+
The default `compose.yaml` deploys `demo.emberai.xyz` without Traefik auth middleware.
57+
58+
To deploy with auth disabled (default):
59+
60+
```bash
61+
docker compose --env-file traefik.env -f compose.yaml up -d --build
62+
```
63+
64+
To deploy with auth enabled, include the override file:
65+
66+
```bash
67+
docker compose --env-file traefik.env -f compose.yaml -f compose.auth.yaml up -d --build
68+
```
69+
70+
When auth is enabled, make sure:
71+
- `TRAUTH_COOKIE_KEY` is set in `traefik.env`
72+
- `auth/users.htpasswd` exists and contains valid credentials
73+
5474
### Running Scripts for Individual Apps
5575

5676
You can also run scripts for individual apps using pnpm's filter flag:

typescript/clients/web-ag-ui/apps/web/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ NEXT_PUBLIC_DELEGATIONS_BYPASS=false
1010

1111
# Optional: which agent is selected by default.
1212
# NEXT_PUBLIC_DEFAULT_AGENT_ID=agent-clmm
13+
# Optional: enable extra mock Hire Agents rows for pagination QA.
14+
NEXT_PUBLIC_ENABLE_HIRE_AGENTS_PAGINATION_MOCKS=false
1315

1416
# Optional: polling intervals (ms) for sync refresh.
1517
# NEXT_PUBLIC_AGENT_LIST_SYNC_POLL_MS=15000
180 KB
Loading
180 KB
Loading
86 KB
Loading
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { NextResponse } from 'next/server';
2+
import { z } from 'zod';
3+
4+
const WalletAddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/);
5+
6+
const PaginationSchema = z.object({
7+
cursor: z.string().nullable().optional(),
8+
currentPage: z.number().int().optional(),
9+
totalPages: z.number().int().optional(),
10+
totalItems: z.number().int().optional(),
11+
});
12+
13+
const TokenIdentifierSchema = z.object({
14+
chainId: z.string(),
15+
address: z.string(),
16+
});
17+
18+
const WalletBalanceSchema = z.object({
19+
tokenUid: TokenIdentifierSchema,
20+
amount: z.string(),
21+
symbol: z.string().optional(),
22+
valueUsd: z.number().optional(),
23+
decimals: z.number().int().optional(),
24+
});
25+
26+
const PerpetualPositionSchema = z.object({
27+
key: z.string(),
28+
marketAddress: z.string(),
29+
positionSide: z.enum(['long', 'short']),
30+
sizeInUsd: z.string(),
31+
});
32+
33+
const TokenSchema = z.object({
34+
tokenUid: TokenIdentifierSchema,
35+
name: z.string(),
36+
symbol: z.string(),
37+
isNative: z.boolean(),
38+
decimals: z.number().int(),
39+
iconUri: z.string().nullish().optional(),
40+
isVetted: z.boolean(),
41+
});
42+
43+
const TokenizedYieldPositionSchema = z.object({
44+
marketIdentifier: TokenIdentifierSchema,
45+
pt: z.object({
46+
token: TokenSchema,
47+
exactAmount: z.string(),
48+
}),
49+
yt: z.object({
50+
token: TokenSchema,
51+
exactAmount: z.string(),
52+
claimableRewards: z.array(
53+
z.object({
54+
token: TokenSchema,
55+
exactAmount: z.string(),
56+
}),
57+
),
58+
}),
59+
});
60+
61+
const LiquidityPositionSchema = z.object({
62+
positionId: z.string().optional(),
63+
poolName: z.string().optional(),
64+
positionValueUsd: z.string().optional(),
65+
providerId: z.string(),
66+
pooledTokens: z.array(z.unknown()),
67+
feesOwedTokens: z.array(z.unknown()),
68+
rewardsOwedTokens: z.array(z.unknown()),
69+
});
70+
71+
function resolveOnchainActionsBaseUrl(): string {
72+
return (
73+
process.env.ONCHAIN_ACTIONS_API_URL ??
74+
process.env.NEXT_PUBLIC_ONCHAIN_ACTIONS_API_URL ??
75+
'https://api.emberai.xyz'
76+
);
77+
}
78+
79+
type CollectionKey = 'balances' | 'positions';
80+
81+
async function fetchPaginatedCollection<T>(params: {
82+
endpoint: string;
83+
key: CollectionKey;
84+
schema: z.ZodType<T>;
85+
}): Promise<T[]> {
86+
const baseUrl = resolveOnchainActionsBaseUrl().replace(/\/$/, '');
87+
let page = 1;
88+
let totalPages = 1;
89+
let cursor: string | undefined;
90+
const results: T[] = [];
91+
92+
while (page <= totalPages) {
93+
const pageUrl = new URL(`${baseUrl}${params.endpoint}`);
94+
if (page > 1) {
95+
pageUrl.searchParams.set('page', String(page));
96+
if (cursor) {
97+
pageUrl.searchParams.set('cursor', cursor);
98+
}
99+
}
100+
101+
const response = await fetch(pageUrl.toString());
102+
const payloadText = await response.text();
103+
if (!response.ok) {
104+
throw new Error(
105+
`onchain-actions request failed (${response.status}) for ${params.endpoint}: ${payloadText}`,
106+
);
107+
}
108+
109+
const payload = payloadText.trim().length > 0 ? JSON.parse(payloadText) : {};
110+
const pageSchema = PaginationSchema.extend({
111+
[params.key]: z.array(params.schema),
112+
});
113+
const parsed = pageSchema.parse(payload) as {
114+
cursor?: string | null;
115+
totalPages?: number;
116+
} & Record<string, unknown>;
117+
118+
const items = parsed[params.key];
119+
if (!Array.isArray(items)) {
120+
throw new Error(`Invalid ${params.key} payload`);
121+
}
122+
results.push(...(items as T[]));
123+
124+
totalPages = parsed.totalPages ?? 1;
125+
cursor = parsed.cursor ?? undefined;
126+
page += 1;
127+
}
128+
129+
return results;
130+
}
131+
132+
export async function GET(
133+
_request: Request,
134+
{ params }: { params: Promise<{ walletAddress: string }> },
135+
): Promise<NextResponse> {
136+
const { walletAddress } = await params;
137+
const parsedWalletAddress = WalletAddressSchema.safeParse(walletAddress);
138+
if (!parsedWalletAddress.success) {
139+
return NextResponse.json({ error: 'Invalid wallet address' }, { status: 400 });
140+
}
141+
142+
try {
143+
const [balances, perpetuals, pendle, liquidity] = await Promise.all([
144+
fetchPaginatedCollection({
145+
endpoint: `/wallet/balances/${walletAddress}`,
146+
key: 'balances',
147+
schema: WalletBalanceSchema,
148+
}),
149+
fetchPaginatedCollection({
150+
endpoint: `/perpetuals/positions/${walletAddress}`,
151+
key: 'positions',
152+
schema: PerpetualPositionSchema,
153+
}),
154+
fetchPaginatedCollection({
155+
endpoint: `/tokenizedYield/positions/${walletAddress}`,
156+
key: 'positions',
157+
schema: TokenizedYieldPositionSchema,
158+
}),
159+
fetchPaginatedCollection({
160+
endpoint: `/liquidity/positions/${walletAddress}`,
161+
key: 'positions',
162+
schema: LiquidityPositionSchema,
163+
}),
164+
]);
165+
166+
return NextResponse.json(
167+
{
168+
walletAddress,
169+
balances,
170+
positions: {
171+
perpetuals,
172+
pendle,
173+
liquidity,
174+
},
175+
},
176+
{
177+
headers: {
178+
'Cache-Control': 'no-store',
179+
},
180+
},
181+
);
182+
} catch (error) {
183+
const message = error instanceof Error ? error.message : 'Unknown error';
184+
return NextResponse.json({ error: 'Failed to load wallet portfolio', details: message }, { status: 502 });
185+
}
186+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2+
3+
import { GET } from './route';
4+
5+
describe('/api/onchain-actions/wallet/[walletAddress]/portfolio', () => {
6+
const originalFetch = global.fetch;
7+
8+
beforeEach(() => {
9+
vi.restoreAllMocks();
10+
process.env.ONCHAIN_ACTIONS_API_URL = 'https://api.example.test';
11+
});
12+
13+
afterEach(() => {
14+
global.fetch = originalFetch;
15+
});
16+
17+
it('returns balances and grouped positions for a wallet', async () => {
18+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
19+
const url = typeof input === 'string' ? input : input.toString();
20+
21+
if (url.includes('/wallet/balances/0x1111111111111111111111111111111111111111')) {
22+
return new Response(
23+
JSON.stringify({
24+
balances: [
25+
{
26+
tokenUid: { chainId: '42161', address: '0xaf88d065e77c8cc2239327c5edb3a432268e5831' },
27+
amount: '1000000',
28+
symbol: 'USDC',
29+
decimals: 6,
30+
valueUsd: 1,
31+
},
32+
],
33+
cursor: null,
34+
currentPage: 1,
35+
totalPages: 1,
36+
totalItems: 1,
37+
}),
38+
{ status: 200 },
39+
);
40+
}
41+
42+
if (url.includes('/perpetuals/positions/0x1111111111111111111111111111111111111111')) {
43+
return new Response(
44+
JSON.stringify({
45+
positions: [
46+
{
47+
key: 'perp-1',
48+
marketAddress: '0x47c031236e19d024b42f8AE6780E44A573170703',
49+
positionSide: 'long',
50+
sizeInUsd: '1000000000000000000',
51+
},
52+
],
53+
cursor: null,
54+
currentPage: 1,
55+
totalPages: 1,
56+
totalItems: 1,
57+
}),
58+
{ status: 200 },
59+
);
60+
}
61+
62+
if (url.includes('/tokenizedYield/positions/0x1111111111111111111111111111111111111111')) {
63+
return new Response(
64+
JSON.stringify({
65+
positions: [
66+
{
67+
marketIdentifier: {
68+
chainId: '42161',
69+
address: '0x6f9d8ef8fbcf2f3928c1f0f7f53295d85f4cb8d9',
70+
},
71+
pt: {
72+
token: {
73+
tokenUid: {
74+
chainId: '42161',
75+
address: '0x6f9d8ef8fbcf2f3928c1f0f7f53295d85f4cb8d9',
76+
},
77+
name: 'Pendle PT',
78+
symbol: 'PT',
79+
isNative: false,
80+
decimals: 18,
81+
isVetted: true,
82+
},
83+
exactAmount: '1',
84+
},
85+
yt: {
86+
token: {
87+
tokenUid: {
88+
chainId: '42161',
89+
address: '0x6f9d8ef8fbcf2f3928c1f0f7f53295d85f4cb8d9',
90+
},
91+
name: 'Pendle YT',
92+
symbol: 'YT',
93+
isNative: false,
94+
decimals: 18,
95+
isVetted: true,
96+
},
97+
exactAmount: '1',
98+
claimableRewards: [],
99+
},
100+
},
101+
],
102+
cursor: null,
103+
currentPage: 1,
104+
totalPages: 1,
105+
totalItems: 1,
106+
}),
107+
{ status: 200 },
108+
);
109+
}
110+
111+
if (url.includes('/liquidity/positions/0x1111111111111111111111111111111111111111')) {
112+
return new Response(
113+
JSON.stringify({
114+
positions: [
115+
{
116+
positionId: 'clmm-1',
117+
poolName: 'USDC/WETH',
118+
positionValueUsd: '500',
119+
providerId: 'Algebra_0x1a3c9B1d2F0529D97f2afC5136Cc23e58f1FD35B_42161',
120+
pooledTokens: [],
121+
feesOwedTokens: [],
122+
rewardsOwedTokens: [],
123+
},
124+
],
125+
cursor: null,
126+
currentPage: 1,
127+
totalPages: 1,
128+
totalItems: 1,
129+
}),
130+
{ status: 200 },
131+
);
132+
}
133+
134+
return new Response('not found', { status: 404 });
135+
});
136+
137+
vi.stubGlobal('fetch', fetchMock);
138+
139+
const request = new Request(
140+
'http://localhost/api/onchain-actions/wallet/0x1111111111111111111111111111111111111111/portfolio',
141+
);
142+
143+
const response = await GET(request, {
144+
params: Promise.resolve({ walletAddress: '0x1111111111111111111111111111111111111111' }),
145+
});
146+
147+
expect(response.status).toBe(200);
148+
const payload = (await response.json()) as {
149+
walletAddress: string;
150+
balances: unknown[];
151+
positions: {
152+
perpetuals: unknown[];
153+
pendle: unknown[];
154+
liquidity: unknown[];
155+
};
156+
};
157+
158+
expect(payload.walletAddress).toBe('0x1111111111111111111111111111111111111111');
159+
expect(payload.balances).toHaveLength(1);
160+
expect(payload.positions.perpetuals).toHaveLength(1);
161+
expect(payload.positions.pendle).toHaveLength(1);
162+
expect(payload.positions.liquidity).toHaveLength(1);
163+
164+
expect(fetchMock).toHaveBeenCalledTimes(4);
165+
});
166+
});

0 commit comments

Comments
 (0)