Skip to content

Commit

Permalink
web: LQT integration (#2041)
Browse files Browse the repository at this point in the history
* view service: LQT scaffolding  (#2035)

* view service: lqt voting notes scaffolding

* view service: tournament votes rpc wip

* wasm: extending planner with lqt voting action (#2037)

* indexedDB: LQT historical votes table (#2038)

* indexedDB: lqt historical votes table

* workaround to normalize action cases

* temporary measure for compilation purposes

* linting

* continue flushing out LQT integration

* wasm: direct all available voting power to a single asset

* wasm: informative expect messages

* changeset

* wasm: wasm bindgen tests

* view server: expand vitest suite for new view service methods

* wasm: clippy

* protobuf: revert temporary workaround and consume fixed protos

* action view: liquidity tournament visible and opaque views and translators

* address feedback

* linting

* nit naming

* refactor: account for LQT votes per delegation token

* action views: fix opaque action view

* more feedback

* attempt to pass ci

* satisfy linter

* changeset

* delete outdated changeset

* stale ref; satisfy linter
  • Loading branch information
TalDerei authored Feb 19, 2025
1 parent 07aa2fe commit 49ae3ab
Show file tree
Hide file tree
Showing 36 changed files with 1,399 additions and 310 deletions.
12 changes: 12 additions & 0 deletions .changeset/calm-shirts-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@penumbra-zone/protobuf': major
'@penumbra-zone/services': major
'@penumbra-zone/storage': major
'@penumbra-zone/types': major
'@penumbra-zone/ui-deprecated': minor
'@penumbra-zone/perspective': minor
'@penumbra-zone/wasm': minor
'@penumbra-zone/ui': minor
---

LQT integration in web packages
9 changes: 9 additions & 0 deletions packages/perspective/src/plan/view-action-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,15 @@ export const viewActionPlan =
});
}

case 'actionLiquidityTournamentVote': {
return new ActionView({
actionView: {
case: 'actionLiquidityTournamentVote',
value: {},
},
});
}

case undefined:
return new ActionView({
actionView: actionPlan.action,
Expand Down
3 changes: 2 additions & 1 deletion packages/perspective/src/transaction/classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ export type TransactionClassification =
| 'positionRewardClaim'
| 'communityPoolSpend'
| 'communityPoolOutput'
| 'communityPoolDeposit';
| 'communityPoolDeposit'
| 'liquidityTournamentVote';
4 changes: 4 additions & 0 deletions packages/perspective/src/transaction/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ export const classifyTransaction = (txv?: TransactionView): TransactionClassific
if (allActionCases.has('communityPoolOutput')) {
return 'communityPoolOutput';
}
if (allActionCases.has('actionLiquidityTournamentVote')) {
return 'liquidityTournamentVote';
}

const hasOpaqueSpend = txv.bodyView?.actionViews.some(
a => a.actionView.case === 'spend' && a.actionView.value.spendView.case === 'opaque',
Expand Down Expand Up @@ -167,6 +170,7 @@ export const TRANSACTION_LABEL_BY_CLASSIFICATION: Record<TransactionClassificati
proposalSubmit: 'Proposal Submit',
proposalWithdraw: 'Proposal Withdraw',
validatorDefinition: 'Validator Definition',
liquidityTournamentVote: 'Liquidity Tournament Vote',
};

export const getTransactionClassificationLabel = (txv?: TransactionView): string =>
Expand Down
9 changes: 9 additions & 0 deletions packages/perspective/src/translators/action-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Address } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb';
import { asOpaqueSwapView } from './swap-view.js';
import { asOpaqueSwapClaimView } from './swap-claim-view.js';
import { asOpaqueDelegatorVoteView } from './delegator-vote-view.js';
import { asOpaqueLiquidityTournamentVoteView } from './liquidity-tournament-vote-view.js';

export const asPublicActionView: Translator<ActionView> = actionView => {
switch (actionView?.actionView.case) {
Expand Down Expand Up @@ -49,6 +50,14 @@ export const asPublicActionView: Translator<ActionView> = actionView => {
},
});

case 'actionLiquidityTournamentVote':
return new ActionView({
actionView: {
case: 'actionLiquidityTournamentVote',
value: asOpaqueLiquidityTournamentVoteView(actionView.actionView.value),
},
});

// Currently defaulting to displaying that all data is public as it's better
// to err on communicating private data as public than the other way around
// TODO: Do proper audit of what data for each action is public
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
ActionLiquidityTournamentVoteView,
ActionLiquidityTournamentVoteView_Opaque,
} from '@penumbra-zone/protobuf/penumbra/core/component/funding/v1/funding_pb';
import { Translator } from './types.js';

export const asOpaqueLiquidityTournamentVoteView: Translator<
ActionLiquidityTournamentVoteView
> = liquidityTournamentVoteView => {
return new ActionLiquidityTournamentVoteView({
liquidityTournamentVote: {
case: 'opaque',
value: new ActionLiquidityTournamentVoteView_Opaque({
vote: liquidityTournamentVoteView?.liquidityTournamentVote.value?.vote,
}),
},
});
};
2 changes: 1 addition & 1 deletion packages/protobuf/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"gen:ibc": "buf generate buf.build/cosmos/ibc:7ab44ae956a0488ea04e04511efa5f70",
"gen:ics23": "buf generate buf.build/cosmos/ics23:55085f7c710a45f58fa09947208eb70b",
"gen:noble": "buf generate buf.build/noble-assets/forwarding:5a8609a6772d417584a9c60cd8b80881",
"gen:penumbra": "buf generate buf.build/penumbra-zone/penumbra:adb116eefae84c1abd53a1594b895360",
"gen:penumbra": "buf generate buf.build/penumbra-zone/penumbra:ae2300bce202a7d429727f1340e7412d3b9f810c",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"lint:strict": "tsc --noEmit && eslint src --max-warnings 0",
Expand Down
1 change: 1 addition & 0 deletions packages/protobuf/src/services/penumbra-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export { QueryService as GovernanceService } from '../../gen/penumbra/core/compo
export { QueryService as SctService } from '../../gen/penumbra/core/component/sct/v1/sct_connect.js';
export { QueryService as ShieldedPoolService } from '../../gen/penumbra/core/component/shielded_pool/v1/shielded_pool_connect.js';
export { QueryService as StakeService } from '../../gen/penumbra/core/component/stake/v1/stake_connect.js';
export { FundingService } from '../../gen/penumbra/core/component/funding/v1/funding_connect.js';
4 changes: 3 additions & 1 deletion packages/protobuf/src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import type { CustodyService } from './services/penumbra-custody.js';
import type { ViewService } from './services/penumbra-view.js';
import type {
FundingService,
AppService,
AuctionService,
CommunityPoolService,
Expand Down Expand Up @@ -43,4 +44,5 @@ export type PenumbraService =
| typeof SimulationService
| typeof StakeService
| typeof TendermintProxyService
| typeof ViewService;
| typeof ViewService
| typeof FundingService;
8 changes: 8 additions & 0 deletions packages/services/src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export interface IndexedDbMock {
saveGasPrices?: Mock;
saveTransactionInfo?: Mock;
getTransactionInfo?: Mock;
getBlockHeightByEpoch?: Mock;
saveLQTHistoricalVote?: Mock;
getLQTHistoricalVotes?: Mock;
}

export interface AuctionMock {
Expand All @@ -62,6 +65,11 @@ export interface MockQuerier {
sct?: SctMock;
shieldedPool?: ShieldedPoolMock;
stake?: StakeMock;
funding?: FundingMock;
}

export interface FundingMock {
lqtCheckNullifier?: Mock;
}

export interface SctMock {
Expand Down
4 changes: 4 additions & 0 deletions packages/services/src/view-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { witness } from './witness.js';
import { witnessAndBuild } from './witness-and-build.js';
import { transparentAddress } from './transparent-address.js';
import { latestSwaps } from './latest-swaps.js';
import { lqtVotingNotes } from './lqt-voting-notes.js';
import { tournamentVotes } from './tournament-votes.js';

export type Impl = ServiceImpl<typeof ViewService>;

Expand Down Expand Up @@ -66,4 +68,6 @@ export const viewImpl: Impl = {
witnessAndBuild,
transparentAddress,
latestSwaps,
lqtVotingNotes,
tournamentVotes,
};
145 changes: 145 additions & 0 deletions packages/services/src/view-service/lqt-voting-notes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
LqtVotingNotesRequest,
LqtVotingNotesResponse,
NotesForVotingResponse,
} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
import { createContextValues, createHandlerContext, HandlerContext } from '@connectrpc/connect';
import { ViewService } from '@penumbra-zone/protobuf';
import { servicesCtx } from '../ctx/prax.js';
import { IndexedDbMock, MockQuerier, MockServices } from '../test-utils.js';
import type { ServicesInterface } from '@penumbra-zone/types/services';
import { lqtVotingNotes } from './lqt-voting-notes.js';
import { Epoch } from '@penumbra-zone/protobuf/penumbra/core/component/sct/v1/sct_pb';
import { LqtCheckNullifierResponse } from '@penumbra-zone/protobuf/penumbra/core/component/funding/v1/funding_pb';
import { TransactionId } from '@penumbra-zone/protobuf/penumbra/core/txhash/v1/txhash_pb';

describe('lqtVotingNotes request handler', () => {
let mockServices: MockServices;
let mockIndexedDb: IndexedDbMock;
let mockQuerier: MockQuerier;
let mockCtx: HandlerContext;

beforeEach(() => {
vi.resetAllMocks();

mockIndexedDb = {
getLQTHistoricalVotes: vi.fn(),
getBlockHeightByEpoch: vi.fn(),
getNotesForVoting: vi.fn(),
};

mockServices = {
getWalletServices: vi.fn(() =>
Promise.resolve({ indexedDb: mockIndexedDb }),
) as MockServices['getWalletServices'],
};

mockCtx = createHandlerContext({
service: ViewService,
method: ViewService.methods.lqtVotingNotes,
protocolName: 'mock',
requestMethod: 'MOCK',
url: '/mock',
contextValues: createContextValues().set(servicesCtx, () =>
Promise.resolve(mockServices as unknown as ServicesInterface),
),
});
});

test('returns no voting notes if the nullifier has already been used for voting in the current epoch', async () => {
// voting notes mocked with static data, and the mock bypasses the logic in the real implementation,
// but that's fine.
mockIndexedDb.getNotesForVoting?.mockResolvedValueOnce(testData);
mockIndexedDb.getBlockHeightByEpoch?.mockResolvedValueOnce(epoch);

mockQuerier = {
funding: {
lqtCheckNullifier: vi.fn().mockResolvedValue(
new LqtCheckNullifierResponse({
transaction: new TransactionId({
inner: new Uint8Array([]),
}),
alreadyVoted: true,
epochIndex: 100n,
}),
),
},
};

mockServices = {
getWalletServices: vi.fn(() =>
Promise.resolve({ indexedDb: mockIndexedDb, querier: mockQuerier }),
) as MockServices['getWalletServices'],
};

const responses: LqtVotingNotesResponse[] = [];
const req = new LqtVotingNotesRequest({});
for await (const res of lqtVotingNotes(req, mockCtx)) {
responses.push(new LqtVotingNotesResponse(res));
}

expect(responses.length).toBe(0);
});

test('returns voting notes when the nullifier has not been used for voting in the current epoch', async () => {
mockIndexedDb.getNotesForVoting?.mockResolvedValueOnce(testData);
mockIndexedDb.getBlockHeightByEpoch?.mockResolvedValueOnce(epoch);

mockQuerier = {
funding: {
lqtCheckNullifier: vi.fn().mockResolvedValue(
new LqtCheckNullifierResponse({
transaction: new TransactionId({
inner: new Uint8Array([]),
}),
alreadyVoted: false,
epochIndex: 100n,
}),
),
},
};

mockServices = {
getWalletServices: vi.fn(() =>
Promise.resolve({ indexedDb: mockIndexedDb, querier: mockQuerier }),
) as MockServices['getWalletServices'],
};

const responses: LqtVotingNotesResponse[] = [];
const req = new LqtVotingNotesRequest({});
for await (const res of lqtVotingNotes(req, mockCtx)) {
responses.push(new LqtVotingNotesResponse(res));
}

expect(responses.length).toBe(2);
});
});

const testData: NotesForVotingResponse[] = [
NotesForVotingResponse.fromJson({
noteRecord: {
noteCommitment: {
inner: 'pXS1k2kvlph+vuk9uhqeoP1mZRc+f526a06/bg3EBwQ=',
},
},
identityKey: {
ik: 'VAv+z5ieJk7AcAIJoVIqB6boOj0AhZB2FKWsEidfvAE=',
},
}),
NotesForVotingResponse.fromJson({
noteRecord: {
noteCommitment: {
inner: '2XS1k2kvlph+vuk9uhqeoP1mZRc+f526a06/bg3EBwQ=',
},
},
identityKey: {
ik: 'pkxdxOn9EMqdjoCJdEGBKA8XY9P9RK9XmurIly/9yBA=',
},
}),
];

const epoch = new Epoch({
index: 100n,
startHeight: 5000n,
});
46 changes: 46 additions & 0 deletions packages/services/src/view-service/lqt-voting-notes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Impl } from './index.js';
import { servicesCtx } from '../ctx/prax.js';
import { notesForVoting } from './notes-for-voting.js';
import {
LqtVotingNotesResponse,
NotesForVotingRequest,
SpendableNoteRecord,
} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
import { Nullifier } from '@penumbra-zone/protobuf/penumbra/core/component/sct/v1/sct_pb';

export const lqtVotingNotes: Impl['lqtVotingNotes'] = async function* (req, ctx) {
const services = await ctx.values.get(servicesCtx)();
const { indexedDb, querier } = await services.getWalletServices();

// Get the starting block height for the corresponding epoch index.
const epoch = await indexedDb.getBlockHeightByEpoch(req.epochIndex);

// Retrieve SNRs from storage ('ASSETS' in IndexedDB) for the specified subaccount that are eligible for voting
// at the start height of the current epoch. Alternatively, a wasm helper `get_voting_notes` can be used to
// perform the same function.
const notesForVotingRequest = new NotesForVotingRequest({
addressIndex: req.accountFilter,
votableAtHeight: epoch?.startHeight,
});
const votingNotes = notesForVoting(notesForVotingRequest, ctx);

// Iterate through each voting note and check if it has already been used for voting
// by performing a nullifier point query against the rpc provided by the funding service.
for await (const votingNote of votingNotes) {
if (!votingNote.noteRecord || !epoch?.index) {
continue;
}
const lqtCheckNullifierResponse = await querier.funding.lqtCheckNullifier(
epoch.index,
votingNote.noteRecord.nullifier as Nullifier,
);
if (lqtCheckNullifierResponse.alreadyVoted) {
continue;
}

const noteRecord = votingNote.noteRecord as SpendableNoteRecord;

// Yield the SNRs that haven't been used for voting yet.
yield new LqtVotingNotesResponse({ noteRecord });
}
};
Loading

0 comments on commit 49ae3ab

Please sign in to comment.