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
2 changes: 2 additions & 0 deletions .github/workflows/deploy_develop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
PROFILE_REGISTRY_CONTRACT_ADDRESS: ${{ secrets.DEV_PROFILE_REGISTRY_CONTRACT_ADDRESS }}
PROFILE_ATTESTATION_SIGNER_ADDRESS: ${{ secrets.DEV_PROFILE_ATTESTATION_SIGNER_ADDRESS }}
PROFILE_ATTESTATION_PRIVATE_KEY: ${{ secrets.DEV_PROFILE_ATTESTATION_PRIVATE_KEY }}
GIPHY_API_KEY: ${{ secrets.DEV_GIPHY_API_KEY }}
deploy_testnet:
name: api-testnet
uses: ./.github/workflows/ssh_deploy.yaml
Expand All @@ -63,3 +64,4 @@ jobs:
PROFILE_REGISTRY_CONTRACT_ADDRESS: ${{ secrets.TESTNET_PROFILE_REGISTRY_CONTRACT_ADDRESS }}
PROFILE_ATTESTATION_SIGNER_ADDRESS: ${{ secrets.TESTNET_PROFILE_ATTESTATION_SIGNER_ADDRESS }}
PROFILE_ATTESTATION_PRIVATE_KEY: ${{ secrets.TESTNET_PROFILE_ATTESTATION_PRIVATE_KEY }}
GIPHY_API_KEY: ${{ secrets.DEV_GIPHY_API_KEY }}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Testnet deployment uses wrong secret prefix for Giphy key

Medium Severity

The deploy_testnet job references secrets.DEV_GIPHY_API_KEY instead of secrets.TESTNET_GIPHY_API_KEY. Every other environment-specific secret in this job uses the TESTNET_ prefix (e.g., TESTNET_TRENDING_TAGS_API_KEY, TESTNET_X_CLIENT_ID), so this looks like a copy-paste error from the deploy_mainnet job above. This causes the testnet deployment to use the dev Giphy API key instead of its own.

Fix in Cursor Fix in Web

1 change: 1 addition & 0 deletions .github/workflows/deploy_main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ jobs:
PROFILE_REGISTRY_CONTRACT_ADDRESS: ${{ secrets.PROD_PROFILE_REGISTRY_CONTRACT_ADDRESS }}
PROFILE_ATTESTATION_SIGNER_ADDRESS: ${{ secrets.PROD_PROFILE_ATTESTATION_SIGNER_ADDRESS }}
PROFILE_ATTESTATION_PRIVATE_KEY: ${{ secrets.PROD_PROFILE_ATTESTATION_PRIVATE_KEY }}
GIPHY_API_KEY: ${{ secrets.PROD_GIPHY_API_KEY }}

1 change: 1 addition & 0 deletions .github/workflows/deploy_staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ jobs:
PROFILE_REGISTRY_CONTRACT_ADDRESS: ${{ secrets.STAG_PROFILE_REGISTRY_CONTRACT_ADDRESS }}
PROFILE_ATTESTATION_SIGNER_ADDRESS: ${{ secrets.STAG_PROFILE_ATTESTATION_SIGNER_ADDRESS }}
PROFILE_ATTESTATION_PRIVATE_KEY: ${{ secrets.STAG_PROFILE_ATTESTATION_PRIVATE_KEY }}
GIPHY_API_KEY: ${{ secrets.STAG_GIPHY_API_KEY }}
6 changes: 6 additions & 0 deletions .github/workflows/ssh_deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ on:
TRENDING_TAGS_API_KEY:
description: "Trending tags API key"
required: true
GIPHY_API_KEY:
description: "Giphy API key"
required: true
X_CLIENT_ID:
description: "X-Client-Id for API requests"
required: false
Expand Down Expand Up @@ -87,6 +90,7 @@ jobs:
PROFILE_REGISTRY_CONTRACT_ADDRESS: "${{ secrets.PROFILE_REGISTRY_CONTRACT_ADDRESS }}"
PROFILE_ATTESTATION_SIGNER_ADDRESS: "${{ secrets.PROFILE_ATTESTATION_SIGNER_ADDRESS }}"
PROFILE_ATTESTATION_PRIVATE_KEY: "${{ secrets.PROFILE_ATTESTATION_PRIVATE_KEY }}"
GIPHY_API_KEY: "${{ secrets.GIPHY_API_KEY }}"
with:
host: "${{ secrets.DEPLOY_HOST }}"
username: "${{ secrets.DEPLOY_USERNAME }}"
Expand All @@ -104,6 +108,7 @@ jobs:
PROFILE_REGISTRY_CONTRACT_ADDRESS,
PROFILE_ATTESTATION_SIGNER_ADDRESS,
PROFILE_ATTESTATION_PRIVATE_KEY,
GIPHY_API_KEY,
SHA
script: |
echo $SHA > $HOST_DATA_DIR/REVISION || true
Expand All @@ -128,6 +133,7 @@ jobs:
-e PROFILE_REGISTRY_CONTRACT_ADDRESS \
-e PROFILE_ATTESTATION_SIGNER_ADDRESS \
-e PROFILE_ATTESTATION_PRIVATE_KEY \
-e GIPHY_API_KEY \
-e NODE_ENV=production \
-e REDIS_HOST=${{ inputs.CONTAINER_NAME }}-redis \
-e REDIS_PORT=6379 \
Expand Down
2 changes: 2 additions & 0 deletions src/account/account.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import { LeaderboardController } from './controllers/leaderboard.controller';
import { AccountLeaderboardSnapshot } from './entities/account-leaderboard-snapshot.entity';
import { LeaderboardSnapshotService } from './services/leaderboard-snapshot.service';
import { ProfileModule } from '@/profile/profile.module';
import { AePricingModule } from '@/ae-pricing/ae-pricing.module';

@Module({
imports: [
AeModule, // Includes CoinGeckoService
AePricingModule, // Provides CoinHistoricalPriceService for DB-cached prices
ProfileModule,
TransactionsModule,
TokensModule,
Expand Down
113 changes: 105 additions & 8 deletions src/account/services/bcl-pnl.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('BclPnlService', () => {
jest.clearAllMocks();
});

it('uses joined latest-price CTEs instead of correlated subqueries', async () => {
it('uses LATERAL + LIMIT 1 for price lookups instead of DISTINCT ON full scans', async () => {
const { service, transactionRepository } = createService();
transactionRepository.query.mockResolvedValue([]);

Expand All @@ -28,13 +28,12 @@ describe('BclPnlService', () => {
const [sql, params] = transactionRepository.query.mock.calls[0];

expect(sql).toContain('WITH aggregated_holdings AS');
expect(sql).toContain('latest_price_ae AS');
expect(sql).toContain('latest_price_usd AS');
expect(sql).toContain('DISTINCT ON (tx.sale_address)');
expect(sql).toContain('INNER JOIN aggregated_holdings agg');
expect(sql).toContain('LEFT JOIN latest_price_ae');
expect(sql).toContain('LEFT JOIN latest_price_usd');
expect(sql).not.toContain('tx2.sale_address = tx.sale_address');
expect(sql).toContain('LEFT JOIN LATERAL');
expect(sql).toContain('LIMIT 1');
expect(sql).toContain('ae_price ON true');
expect(sql).toContain('usd_price ON true');
expect(sql).not.toContain('DISTINCT ON (tx.sale_address)');
expect(sql).not.toContain('INNER JOIN aggregated_holdings agg');
expect(params).toEqual(['ak_test', 100, 50]);
});

Expand Down Expand Up @@ -95,6 +94,104 @@ describe('BclPnlService', () => {
expect(result.totalGainUsd).toBe(36);
});

it('calculateTokenPnlsBatch runs a single query for all heights and groups results', async () => {
const { service, transactionRepository } = createService();

// Two heights, one token each
transactionRepository.query.mockResolvedValue([
{
snapshot_height: 200,
sale_address: 'ct_alpha',
current_holdings: '3',
total_volume_bought: '3',
total_amount_spent_ae: '9',
total_amount_spent_usd: '18',
total_amount_received_ae: '0',
total_amount_received_usd: '0',
total_volume_sold: '0',
current_unit_price_ae: '4',
current_unit_price_usd: '8',
},
{
snapshot_height: 300,
sale_address: 'ct_alpha',
current_holdings: '5',
total_volume_bought: '5',
total_amount_spent_ae: '15',
total_amount_spent_usd: '30',
total_amount_received_ae: '0',
total_amount_received_usd: '0',
total_volume_sold: '0',
current_unit_price_ae: '6',
current_unit_price_usd: '12',
},
]);

const result = await service.calculateTokenPnlsBatch(
'ak_test',
[200, 300],
);

// Single DB round-trip regardless of number of heights
expect(transactionRepository.query).toHaveBeenCalledTimes(1);
const [sql, params] = transactionRepository.query.mock.calls[0];

// Batch query uses UNNEST, a MATERIALIZED CTE (single tx scan), and LATERAL price lookups
expect(sql).toContain('unnest($2::int[])');
expect(sql).toContain('snapshot_height');
expect(sql).toContain('AS MATERIALIZED');
expect(sql).toContain('address_txs');
expect(sql).not.toContain('JOIN transactions tx'); // no repeated scan of transactions
expect(sql).toContain('LEFT JOIN LATERAL');
expect(sql).toContain('LIMIT 1');
expect(sql).not.toContain('DISTINCT ON (agg.snapshot_height, agg.sale_address)');
expect(params).toEqual(['ak_test', [200, 300]]);

expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);

const at200 = result.get(200)!;
expect(at200.pnls.ct_alpha.current_value.ae).toBe(12); // 3 * 4
expect(at200.totalCurrentValueAe).toBe(12);

const at300 = result.get(300)!;
expect(at300.pnls.ct_alpha.current_value.ae).toBe(30); // 5 * 6
expect(at300.totalCurrentValueAe).toBe(30);
});

it('calculateTokenPnlsBatch returns empty map for empty heights array', async () => {
const { service, transactionRepository } = createService();

const result = await service.calculateTokenPnlsBatch('ak_test', []);

expect(transactionRepository.query).not.toHaveBeenCalled();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});

it('calculateTokenPnlsBatch deduplicates heights before querying', async () => {
const { service, transactionRepository } = createService();
transactionRepository.query.mockResolvedValue([]);

await service.calculateTokenPnlsBatch('ak_test', [100, 100, 200, 100]);

expect(transactionRepository.query).toHaveBeenCalledTimes(1);
const [, params] = transactionRepository.query.mock.calls[0];
// Duplicates removed; order of unique values is preserved
expect(params[1]).toEqual([100, 200]);
});

it('calculateTokenPnlsBatch passes fromBlockHeight as $3 when provided', async () => {
const { service, transactionRepository } = createService();
transactionRepository.query.mockResolvedValue([]);

await service.calculateTokenPnlsBatch('ak_test', [500], 50);

const [sql, params] = transactionRepository.query.mock.calls[0];
expect(sql).toContain('block_height >= $3');
expect(params).toEqual(['ak_test', [500], 50]);
});

it('preserves range-based pnl result semantics', async () => {
const { service, transactionRepository } = createService();
transactionRepository.query.mockResolvedValue([
Expand Down
Loading
Loading