import { Hono } from 'hono' import { tempoTestnet } from 'viem/tempo/chains' import * as TestAdmin from '../../../test/Admin.js' import * as TestApp from '../../../test/App.js' import * as TestEarn from '../../../test/Earn.js' import * as Runtime from '../../../test/runtime.js' import * as Earn from '../../apps/data/routes/earn.js' import * as core_EarnVaults from '../../db/tables/earnVaults.js' import * as RewardAccounts from '../../db/tables/rewardAccounts.js' import * as RewardCampaigns from '../../db/tables/rewardCampaigns.js' import type * as Campaigns from '../../internal/rewards/Campaigns.js' import * as App from './earn-vaults.js' const asset = TestEarn.assetTokenAddress const vaultAddress = TestEarn.vaultAddress const rewardConfig = { boostRewards: { endTimestamp: 2_000_001_300, excludedAddresses: [], funding: { wallet: '0x5000000000000000000000000000000000000005' }, intervalSeconds: 300, perUserPrincipalCapAssets: '25000000000', startTimestamp: 2_000_000_100, targetAnnualRateBps: 700, totalPrincipalCapAssets: '10000000000000', treasury: '0x6000000000000000000000000000000000000006', }, } satisfies Campaigns.Config const input = { chainId: tempoTestnet.id, description: 'Bridge test PATHUSD deposited into the canonical Earn v1 stack.', label: 'btPATHUSD Earn', privateInputTokens: [], privateOutputTokens: [], slug: 'btpathusd-earn', vaultAddress, zones: [], } as const const zoneInput = { ...input, label: 'Moderato Earn', slug: 'moderato-earn', vaultAddress: TestEarn.zoneVaultAddress, zones: [TestEarn.zoneRoute], } as const describe('authentication', () => { test('denied identity returns 401', async () => { const { app } = TestAdmin.setup({ identify: () => null }) const [detail, list, put, remove] = await Promise.all([ app.request(`/earn/verified-vaults/${vaultAddress}?chainId=${tempoTestnet.id}`), app.request(`/earn/verified-vaults?chainId=${tempoTestnet.id}`), app.request('/earn/verified-vaults', { body: JSON.stringify(input), headers: { 'content-type': 'application/json' }, method: 'PUT', }), app.request(`/earn/verified-vaults/${vaultAddress}?chainId=${tempoTestnet.id}`, { method: 'DELETE', }), ]) expect([detail.status, list.status, put.status, remove.status]).toEqual([401, 401, 401, 401]) }) }) describe('getVerifiedEarnVault', () => { test('rejects malformed vault addresses', async () => { const { app } = TestAdmin.setup() const response = await app.request(`/earn/verified-vaults/invalid?chainId=${tempoTestnet.id}`) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'vault_id_invalid' } }) }) // This surface cannot measure rates or value assets, so it rejects both // fields instead of answering with a null that reads as unmeasurable. test('rejects rate and valuation includes', async () => { const { app } = TestAdmin.setup() const response = await app.request( `/earn/verified-vaults/${vaultAddress}?chainId=${tempoTestnet.id}&include=apy,tvl`, ) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'query_invalid' } }) }) test.runIf(Runtime.get().mode === 'testnet')( 'resolves an unverified vault for registry entry', async () => { const db = TestApp.database() const { app } = TestAdmin.setup({ db, zones: [TestEarn.zone] }) const publicApp = TestApp.create({ auth: false, db, zones: [TestEarn.zone] }) const query = `?chainId=${tempoTestnet.id}&include=access,capabilities,zone` const [response, publicResponse] = await Promise.all([ app.request(`/earn/verified-vaults/${vaultAddress}${query}`), publicApp.request(`/v1/earn/vaults/${vaultAddress}${query}`), ]) expect([response.status, publicResponse.status]).toEqual([200, 200]) const [vault, publicVault] = await Promise.all([ TestApp.json(response, App.schema.getVerifiedEarnVault.Response), TestApp.json(publicResponse, Earn.schema.getEarnVault.Response), ]) // Every field the admin include set can select is resolved identically to // the public surface. expect(publicVault.meta).toBeUndefined() expect(vault).toStrictEqual(publicVault) expect({ description: vault.description, slug: vault.slug, vaultAddress: vault.vaultAddress, verified: vault.verified, }).toMatchInlineSnapshot(` { "description": null, "slug": null, "vaultAddress": "0xf4ae63687d6753a78e7f551d2eda1d0d31a5ea3a", "verified": false, } `) }, 90_000, ) test.runIf(Runtime.get().mode === 'testnet')( 'returns token logo URLs under the app mount', async () => { const { app } = TestAdmin.setup({ assets: { get: async (key) => key === `${tempoTestnet.id}/icons/${asset}` ? { body: new ArrayBuffer(0), contentType: 'image/svg+xml' } : undefined, }, zones: [TestEarn.zone], }) const mounted = new Hono().route('/admin', app) const response = await mounted.request( `http://host/admin/earn/verified-vaults/${vaultAddress}?chainId=${tempoTestnet.id}&include=token.logoUri`, ) const vault = await TestApp.json(response, App.schema.getVerifiedEarnVault.Response) expect(vault.assetToken.logoUri).toBe( `http://host/admin/verified-tokens/${asset}/logo?chainId=${tempoTestnet.id}`, ) expect((await mounted.request(vault.assetToken.logoUri!)).status).toBe(200) }, 90_000, ) }) describe('listVerifiedEarnVaults', () => { test('rejects rate and valuation includes', async () => { const { app } = TestAdmin.setup() const response = await app.request( `/earn/verified-vaults?chainId=${tempoTestnet.id}&include=apy,tvl`, ) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'query_invalid' } }) }) test.runIf(Runtime.get().mode === 'testnet')( 'matches the public verified collection', async () => { const db = TestApp.database() await core_EarnVaults.upsert(db, TestEarn.verifiedVault) const { app } = TestAdmin.setup({ db, zones: [TestEarn.zone] }) const publicApp = TestApp.create({ auth: false, db, zones: [TestEarn.zone] }) const query = `?chainId=${tempoTestnet.id}&include=access,capabilities,zone&limit=5` const [response, publicResponse] = await Promise.all([ app.request(`/earn/verified-vaults${query}`), publicApp.request(`/v1/earn/vaults/verified${query}`), ]) expect([response.status, publicResponse.status]).toEqual([200, 200]) const [body, publicBody] = await Promise.all([ TestApp.json(response, App.schema.listVerifiedEarnVaults.Response), TestApp.json(publicResponse, App.schema.listVerifiedEarnVaults.Response), ]) expect(body).toStrictEqual(publicBody) }, 90_000, ) }) describe('putVerifiedEarnVault', () => { test('rejects invalid slugs', async () => { const { app } = TestAdmin.setup() const response = await app.request('/earn/verified-vaults', { body: JSON.stringify({ ...input, slug: 'Not a slug' }), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'body_invalid' } }) }) test('rejects derived fields', async () => { const { app } = TestAdmin.setup() const response = await app.request('/earn/verified-vaults', { body: JSON.stringify({ ...input, engine: { type: 'erc4626' } }), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'body_invalid' } }) }) test.runIf(Runtime.get().mode === 'testnet')( 'rejects duplicate Zone routes', async () => { const { app } = TestAdmin.setup({ zones: [TestEarn.zone] }) const response = await app.request('/earn/verified-vaults', { body: JSON.stringify({ ...zoneInput, zones: [TestEarn.zoneRoute, TestEarn.zoneRoute], }), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'body_invalid', details: [{ message: 'Zone 1424310003 is configured more than once.' }], }, }) }, 60_000, ) test.runIf(Runtime.get().mode === 'testnet')( 'rejects a router bound to a different vault', async () => { const { app } = TestAdmin.setup({ zones: [TestEarn.zone] }) const response = await app.request('/earn/verified-vaults', { body: JSON.stringify({ ...zoneInput, vaultAddress }), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(response.status).toBe(409) expect(await response.json()).toEqual( expect.objectContaining({ error: expect.objectContaining({ code: 'earn_vault_verification_failed', details: expect.arrayContaining([ { message: 'Earn router is configured for a different Earn vault.' }, ]), }), }), ) }, 60_000, ) test.runIf(Runtime.get().mode === 'testnet')( 'verifies, stores, and preserves a Zone route', async () => { const { app, db } = TestAdmin.setup({ zones: [TestEarn.zone] }) const response = await app.request('/earn/verified-vaults', { body: JSON.stringify(zoneInput), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(response.status).toBe(200) const vault = await TestApp.json(response, App.schema.putVerifiedEarnVault.Response) expect(vault.capabilities).toMatchObject({ privateRouting: true, routerSwaps: true }) expect(vault.zones).toHaveLength(1) expect(vault.zones?.[0]).toMatchObject(TestEarn.zoneRoute) expect(vault.zone).toStrictEqual( vault.zones?.[0] ? { chainId: vault.zones[0].chainId, inputTokens: vault.zones[0].inputTokens, name: vault.zones[0].name, outputTokens: vault.zones[0].outputTokens, } : null, ) expect(vault.zones?.[0]?.inputTokens).toHaveLength(1) expect(vault.zones?.[0]?.outputTokens).toStrictEqual(vault.zones?.[0]?.inputTokens) const updatedResponse = await app.request('/earn/verified-vaults', { body: JSON.stringify({ chainId: zoneInput.chainId, label: 'Updated Moderato Earn', vaultAddress: zoneInput.vaultAddress, }), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(updatedResponse.status).toBe(200) expect( (await TestApp.json(updatedResponse, App.schema.putVerifiedEarnVault.Response)).zones, ).toStrictEqual(vault.zones) expect( await core_EarnVaults.get(db, { chainId: zoneInput.chainId, vaultAddress: zoneInput.vaultAddress, }), ).toMatchObject({ zones: [TestEarn.zoneRoute] }) }, 90_000, ) test.runIf(Runtime.get().mode === 'testnet')( 'migrates preserved private tokens to a verified Zone route', async () => { const { app, db } = TestAdmin.setup({ zones: [TestEarn.zone] }) await core_EarnVaults.upsert(db, { ...TestEarn.zoneVerifiedVault, privateInputTokens: [asset], privateOutputTokens: [asset], zones: [], }) const response = await app.request('/earn/verified-vaults', { body: JSON.stringify({ chainId: zoneInput.chainId, vaultAddress: zoneInput.vaultAddress, zones: zoneInput.zones, }), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(response.status).toBe(200) const vault = await TestApp.json(response, App.schema.putVerifiedEarnVault.Response) expect(vault.zones).toHaveLength(1) expect( await core_EarnVaults.get(db, { chainId: zoneInput.chainId, vaultAddress: zoneInput.vaultAddress, }), ).toMatchObject({ privateInputTokens: [asset], privateOutputTokens: [asset], zones: [TestEarn.zoneRoute], }) }, 90_000, ) test.runIf(Runtime.get().mode === 'testnet')( 'rejects private tokens without a configured router', async () => { const { app } = TestAdmin.setup({ zones: [TestEarn.zone] }) const response = await app.request('/earn/verified-vaults', { body: JSON.stringify({ ...input, privateInputTokens: [asset], privateOutputTokens: [asset], }), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(response.status).toBe(409) expect(await response.json()).toMatchObject({ error: { code: 'earn_vault_verification_failed' }, }) }, 90_000, ) test.runIf(Runtime.get().mode === 'testnet')( 'verifies, stores, and updates curated fields', async () => { const { app, db } = TestAdmin.setup({ zones: [TestEarn.zone] }) const response = await app.request('/earn/verified-vaults', { body: JSON.stringify(input), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(response.status).toBe(200) const body = await TestApp.json(response, App.schema.putVerifiedEarnVault.Response) expect({ description: body.description, engineType: body.engine.type, gatewayExposed: 'gateway' in body, id: body.id, label: body.label, slug: body.slug, vaultAddress: body.vaultAddress, verified: body.verified, zone: body.zone, }).toMatchInlineSnapshot(` { "description": "Bridge test PATHUSD deposited into the canonical Earn v1 stack.", "engineType": "erc4626", "gatewayExposed": false, "id": "0xf4ae63687d6753a78e7f551d2eda1d0d31a5ea3a", "label": "btPATHUSD Earn", "slug": "btpathusd-earn", "vaultAddress": "0xf4ae63687d6753a78e7f551d2eda1d0d31a5ea3a", "verified": true, "zone": null, } `) const created = await core_EarnVaults.get(db, { chainId: tempoTestnet.id, vaultAddress, }) const updatedResponse = await app.request('/earn/verified-vaults', { body: JSON.stringify({ chainId: tempoTestnet.id, description: null, label: 'Updated btPATHUSD Earn', slug: 'updated-btpathusd-earn', vaultAddress, }), headers: { 'content-type': 'application/json' }, method: 'PUT', }) expect(updatedResponse.status).toBe(200) const updated = await TestApp.json(updatedResponse, App.schema.putVerifiedEarnVault.Response) expect({ description: updated.description, label: updated.label, slug: updated.slug, zone: updated.zone, }).toMatchInlineSnapshot(` { "description": null, "label": "Updated btPATHUSD Earn", "slug": "updated-btpathusd-earn", "zone": null, } `) const stored = await core_EarnVaults.get(db, { chainId: tempoTestnet.id, vaultAddress, }) expect(stored).toMatchObject({ privateInputTokens: [], privateOutputTokens: [], slug: 'updated-btpathusd-earn', }) expect(stored?.createdAt).toBe(created?.createdAt) }, 90_000, ) }) describe('reward campaigns', () => { test('rejects reward configuration outside Tempo mainnet', async () => { const { app, db } = TestAdmin.setup({ rpc: { url: () => 'http://127.0.0.1:1' } }) await core_EarnVaults.upsert(db, input) const response = await app.request( `/earn/verified-vaults/${vaultAddress}/rewards?chainId=${tempoTestnet.id}`, { body: JSON.stringify(rewardConfig), headers: { 'content-type': 'application/json' }, method: 'POST', }, ) expect(response.status).toBe(409) await expect(response.json()).resolves.toMatchObject({ error: { code: 'earn_reward_campaign_invalid' }, }) }) test('reads and controls one vault-keyed campaign without contract access', async () => { const { app, db } = TestAdmin.setup({ rpc: { url: () => 'http://127.0.0.1:1' } }) await core_EarnVaults.upsert(db, input) await RewardCampaigns.upsert(db, { assetAddress: asset, assetDecimals: 6, chainId: tempoTestnet.id, config: rewardConfig, earnShareAddress: '0x2000000000000000000000000000000000000002', earnShareDecimals: 18, now: 1_999_999_000, vaultAddress, }) const path = `/earn/verified-vaults/${vaultAddress}/rewards?chainId=${tempoTestnet.id}` const configured = await app.request(path) expect(configured.status).toBe(200) await expect(configured.json()).resolves.toMatchObject({ config: rewardConfig, paused: false, peripheryReady: false, unpaidEarnShares: '0', }) const paused = await app.request( `/earn/verified-vaults/${vaultAddress}/rewards/pause?chainId=testnet`, { method: 'POST' }, ) expect(paused.status).toBe(200) await expect(paused.json()).resolves.toMatchObject({ paused: true }) const resumed = await app.request( `/earn/verified-vaults/${vaultAddress}/rewards/resume?chainId=testnet`, { method: 'POST' }, ) expect(resumed.status).toBe(200) await expect(resumed.json()).resolves.toMatchObject({ paused: false }) }) test('commits pause changes when live claim reconciliation is unavailable', async () => { const { app, db } = TestAdmin.setup({ rpc: { url: () => 'http://127.0.0.1:1' } }) await core_EarnVaults.upsert(db, input) await RewardCampaigns.upsert(db, { assetAddress: asset, assetDecimals: 6, chainId: tempoTestnet.id, config: rewardConfig, earnShareAddress: '0x2000000000000000000000000000000000000002', earnShareDecimals: 18, now: 1_999_999_000, vaultAddress, }) await RewardCampaigns.setBindings(db, { chainId: tempoTestnet.id, distributorAddress: '0x7000000000000000000000000000000000000007', vaultAddress, }) await RewardAccounts.replace(db, { accounts: [ { accrualRemainder: '0', allocatedPrincipalAssets: '100', cumulativeEntitlement: '15', cumulativePaid: '10', deferral: null, eligibilityRegisteredAt: null, lots: [], pendingRewardAssets: '0', publicEarnShares: '100', qualifiedEarnShares: '100', recipient: '0x4000000000000000000000000000000000000004', registrationOrder: '1', updatedAt: new Date().toISOString(), }, ], chainId: tempoTestnet.id, vaultAddress, }) const response = await app.request( `/earn/verified-vaults/${vaultAddress}/rewards/pause?chainId=testnet`, { method: 'POST' }, ) expect(response.status).toBe(200) await expect(response.json()).resolves.toMatchObject({ paused: true, unpaidEarnShares: '5', unpaidRecipients: 1, }) await expect( RewardCampaigns.get(db, { chainId: tempoTestnet.id, vaultAddress }), ).resolves.toMatchObject({ paused: true }) }) }) describe('deleteVerifiedEarnVault', () => { test('returns a conflict while the vault owns reward campaign state', async () => { const { app, db } = TestAdmin.setup({ rpc: { url: () => 'http://127.0.0.1:1' } }) await core_EarnVaults.upsert(db, input) await RewardCampaigns.upsert(db, { assetAddress: asset, assetDecimals: 6, chainId: tempoTestnet.id, config: rewardConfig, earnShareAddress: '0x2000000000000000000000000000000000000002', earnShareDecimals: 18, now: 1_999_999_000, vaultAddress, }) const response = await app.request( `/earn/verified-vaults/${vaultAddress}?chainId=${tempoTestnet.id}`, { method: 'DELETE' }, ) expect(response.status).toBe(409) await expect(response.json()).resolves.toMatchObject({ error: { code: 'earn_vault_rewards_exist', message: 'A vault with reward campaign history cannot be removed because recovery claims remain available.', }, }) }) test('physically removes the row without reading contracts', async () => { const { app, db } = TestAdmin.setup({ rpc: { url: () => 'http://127.0.0.1:1' } }) await core_EarnVaults.upsert(db, input) const response = await app.request( `/earn/verified-vaults/${vaultAddress}?chainId=${tempoTestnet.id}`, { method: 'DELETE' }, ) expect(response.status).toBe(204) expect( await core_EarnVaults.get(db, { chainId: tempoTestnet.id, vaultAddress }), ).toBeUndefined() const missing = await app.request( `/earn/verified-vaults/${vaultAddress}?chainId=${tempoTestnet.id}`, { method: 'DELETE' }, ) expect(missing.status).toBe(404) }) test.runIf(Runtime.get().mode === 'testnet')( 'leaves the public vault available as unverified', async () => { const db = TestApp.database() await core_EarnVaults.upsert(db, TestEarn.verifiedVault) const { app } = TestAdmin.setup({ db, zones: [TestEarn.zone] }) const publicApp = TestApp.create({ auth: false, db, zones: [TestEarn.zone] }) const response = await app.request( `/earn/verified-vaults/${vaultAddress}?chainId=${tempoTestnet.id}`, { method: 'DELETE' }, ) expect(response.status).toBe(204) const publicResponse = await publicApp.request( `/v1/earn/vaults/${vaultAddress}?chainId=${tempoTestnet.id}&include=zone`, ) expect(publicResponse.status).toBe(200) const vault = await TestApp.json(publicResponse, App.schema.getVerifiedEarnVault.Response) expect({ description: vault.description, slug: vault.slug, verified: vault.verified, zone: vault.zone, }).toStrictEqual({ description: null, slug: null, verified: false, zone: null, }) }, 90_000, ) })