From 86549e6364661538345d1f9f7709b93fa481f7f2 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Sat, 30 Sep 2023 13:16:56 +0900 Subject: [PATCH 1/3] fix: restrict NFT minting only to voter --- src/helpers/snapshot.ts | 22 +++++++++++++++++++ src/lib/nftClaimer/mint.ts | 9 ++++++-- src/lib/nftClaimer/utils.ts | 7 +++++- test/integration/lib/nftClaimer/utils.test.ts | 22 +++++++++++++++++++ test/unit/lib/nftClaimer/mint.test.ts | 14 +++++++++++- 5 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 test/integration/lib/nftClaimer/utils.test.ts diff --git a/src/helpers/snapshot.ts b/src/helpers/snapshot.ts index 98fb13da..8fdced63 100644 --- a/src/helpers/snapshot.ts +++ b/src/helpers/snapshot.ts @@ -100,6 +100,14 @@ const VOTES_QUERY = gql` } `; +const VOTE_QUERY = gql` + query Votes($voter: String!, $proposalId: String!) { + votes(first: 1, where: { voter: $voter, proposal: $proposalId }) { + id + } + } +`; + const SPACE_QUERY = gql` query Space($id: String) { space(id: $id) { @@ -143,6 +151,20 @@ export async function fetchVotes( return votes; } +export async function fetchVote(voter: string, proposalId: string) { + const { + data: { votes } + }: { data: { votes: Vote[] } } = await client.query({ + query: VOTE_QUERY, + variables: { + voter, + proposalId + } + }); + + return votes[0]; +} + export async function fetchSpace(id: string) { const { data: { space } diff --git a/src/lib/nftClaimer/mint.ts b/src/lib/nftClaimer/mint.ts index b071d601..73a51d64 100644 --- a/src/lib/nftClaimer/mint.ts +++ b/src/lib/nftClaimer/mint.ts @@ -1,12 +1,13 @@ import { splitSignature } from '@ethersproject/bytes'; -import { fetchProposal, Space } from '../../helpers/snapshot'; +import { fetchProposal, Space, Proposal } from '../../helpers/snapshot'; import { validateProposal, getProposalContract, signer, numberizeProposalId, validateMintInput, - mintingAllowed + mintingAllowed, + hasVoted } from './utils'; import abi from './spaceCollectionImplementationAbi.json'; import { FormatTypes, Interface } from '@ethersproject/abi'; @@ -39,6 +40,10 @@ export default async function payload(input: { throw new Error('Space has closed minting'); } + if (!hasVoted(params.recipient, proposal as Proposal)) { + throw new Error('Minting is open only for voters'); + } + const message = { proposer: params.proposalAuthor, recipient: params.recipient, diff --git a/src/lib/nftClaimer/utils.ts b/src/lib/nftClaimer/utils.ts index 61a7efd7..c400d8c3 100644 --- a/src/lib/nftClaimer/utils.ts +++ b/src/lib/nftClaimer/utils.ts @@ -6,7 +6,7 @@ import { Contract } from '@ethersproject/contracts'; import { getAddress, isAddress } from '@ethersproject/address'; import { BigNumber } from '@ethersproject/bignumber'; import { capture } from '@snapshot-labs/snapshot-sentry'; -import type { Proposal, Space } from '../../helpers/snapshot'; +import { fetchVote, type Proposal, type Space } from '../../helpers/snapshot'; import { fetchWithKeepAlive } from '../../helpers/utils'; const requiredEnvKeys = [ @@ -43,6 +43,11 @@ export async function mintingAllowed(space: Space) { return (await getSpaceCollection(space.id)).enabled; } +export async function hasVoted(address: string, proposal: Proposal) { + const vote = await fetchVote(address, proposal.id); + return vote !== undefined; +} + export async function validateSpace(address: string, space: Space | null) { if (!space) { throw new Error('RECORD_NOT_FOUND'); diff --git a/test/integration/lib/nftClaimer/utils.test.ts b/test/integration/lib/nftClaimer/utils.test.ts new file mode 100644 index 00000000..eb5f8773 --- /dev/null +++ b/test/integration/lib/nftClaimer/utils.test.ts @@ -0,0 +1,22 @@ +import { Proposal } from '../../../../src/helpers/snapshot'; +import { hasVoted } from '../../../../src/lib/nftClaimer/utils'; + +describe('nftClaimer/utils', () => { + describe('hasVoted()', () => { + it('returns true when the address has voted on the given proposal', () => { + expect( + hasVoted('0x96176C25803Ce4cF046aa74895646D8514Ea1611', { + id: 'QmPvbwguLfcVryzBRrbY4Pb9bCtxURagdv1XjhtFLf3wHj' + } as Proposal) + ).resolves.toBe(true); + }); + + it('returns false when the address has not voted on the given proposal', () => { + expect( + hasVoted('0x96176C25803Ce4cF046aa74895646D8514Ea1611', { + id: '0xcf201ad7a32dcd399654c476093f079554dae429a13063f50d839e5621cd2e6e' + } as Proposal) + ).resolves.toBe(false); + }); + }); +}); diff --git a/test/unit/lib/nftClaimer/mint.test.ts b/test/unit/lib/nftClaimer/mint.test.ts index 01dd12e0..90338dd6 100644 --- a/test/unit/lib/nftClaimer/mint.test.ts +++ b/test/unit/lib/nftClaimer/mint.test.ts @@ -32,6 +32,10 @@ const mockValidateProposal = jest.fn((proposal: any): void => { const mockMintingAllowed = jest.fn((space: any): boolean => { return true; }); +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const mockHasVoted = jest.fn((address: string, proposal: string): boolean => { + return true; +}); jest.mock('../../../../src/lib/nftClaimer/utils', () => { // Require the original module to not be mocked... const originalModule = jest.requireActual('../../../../src/lib/nftClaimer/utils'); @@ -41,7 +45,8 @@ jest.mock('../../../../src/lib/nftClaimer/utils', () => { ...originalModule, getProposalContract: (id: string) => mockGetProposalContract(id), validateProposal: (id: any) => mockValidateProposal(id), - mintingAllowed: (space: any) => mockMintingAllowed(space) + mintingAllowed: (space: any) => mockMintingAllowed(space), + hasVoted: (address: string, proposal: string) => mockHasVoted(address, proposal) }; }); @@ -110,6 +115,13 @@ describe('nftClaimer', () => { }); }); + describe('when address has not voted on the proposal', () => { + it('throws an error', () => { + mockHasVoted.mockReturnValueOnce(false); + return expect(async () => await payload(input)).rejects.toThrow(); + }); + }); + describe('when maxSupply has been reached', () => { it.todo('throws an error'); }); From 2c04330753056a6e35bcdccfbdd49df1d97a523f Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Sat, 30 Sep 2023 13:46:13 +0900 Subject: [PATCH 2/3] fix: prevent double minting --- src/lib/nftClaimer/mint.ts | 9 ++++++-- src/lib/nftClaimer/utils.ts | 31 +++++++++++++++++++++++++++ test/unit/lib/nftClaimer/mint.test.ts | 14 +++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/lib/nftClaimer/mint.ts b/src/lib/nftClaimer/mint.ts index 73a51d64..39b8956d 100644 --- a/src/lib/nftClaimer/mint.ts +++ b/src/lib/nftClaimer/mint.ts @@ -7,7 +7,8 @@ import { numberizeProposalId, validateMintInput, mintingAllowed, - hasVoted + hasVoted, + hasMinted } from './utils'; import abi from './spaceCollectionImplementationAbi.json'; import { FormatTypes, Interface } from '@ethersproject/abi'; @@ -40,10 +41,14 @@ export default async function payload(input: { throw new Error('Space has closed minting'); } - if (!hasVoted(params.recipient, proposal as Proposal)) { + if (!(await hasVoted(params.recipient, proposal as Proposal))) { throw new Error('Minting is open only for voters'); } + if (await hasMinted(params.recipient, proposal as Proposal)) { + throw new Error('You can only mint once per vote'); + } + const message = { proposer: params.proposalAuthor, recipient: params.recipient, diff --git a/src/lib/nftClaimer/utils.ts b/src/lib/nftClaimer/utils.ts index c400d8c3..3bbd2e22 100644 --- a/src/lib/nftClaimer/utils.ts +++ b/src/lib/nftClaimer/utils.ts @@ -48,6 +48,11 @@ export async function hasVoted(address: string, proposal: Proposal) { return vote !== undefined; } +export async function hasMinted(address: string, proposal: Proposal) { + const mint = await getMint(address, proposal.id); + return mint !== undefined; +} + export async function validateSpace(address: string, space: Space | null) { if (!space) { throw new Error('RECORD_NOT_FOUND'); @@ -133,6 +138,32 @@ export async function getSpaceCollection(spaceId: string) { return spaceCollections[0]; } +const MINT_COLLECTION_QUERY = gql` + query Mints($voter: String, $proposalId: String) { + mints(where: { minterAddress: $voter, proposal: $proposalId }, first: 1) { + id + } + } +`; + +type Mint = { + id: string; +}; + +export async function getMint(voter: string, proposalId: string) { + const { + data: { mints } + }: { data: { mints: Mint[] } } = await client.query({ + query: MINT_COLLECTION_QUERY, + variables: { + voter, + proposalId + } + }); + + return mints[0]; +} + export function numberizeProposalId(id: string) { return BigNumber.from(id.startsWith('0x') ? id : CID.parse(id).bytes).toString(); } diff --git a/test/unit/lib/nftClaimer/mint.test.ts b/test/unit/lib/nftClaimer/mint.test.ts index 90338dd6..5bc5ab72 100644 --- a/test/unit/lib/nftClaimer/mint.test.ts +++ b/test/unit/lib/nftClaimer/mint.test.ts @@ -36,6 +36,10 @@ const mockMintingAllowed = jest.fn((space: any): boolean => { const mockHasVoted = jest.fn((address: string, proposal: string): boolean => { return true; }); +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const mockHasMinted = jest.fn((address: string, proposal: string): boolean => { + return false; +}); jest.mock('../../../../src/lib/nftClaimer/utils', () => { // Require the original module to not be mocked... const originalModule = jest.requireActual('../../../../src/lib/nftClaimer/utils'); @@ -46,7 +50,8 @@ jest.mock('../../../../src/lib/nftClaimer/utils', () => { getProposalContract: (id: string) => mockGetProposalContract(id), validateProposal: (id: any) => mockValidateProposal(id), mintingAllowed: (space: any) => mockMintingAllowed(space), - hasVoted: (address: string, proposal: string) => mockHasVoted(address, proposal) + hasVoted: (address: string, proposal: string) => mockHasVoted(address, proposal), + hasMinted: (address: string, proposal: string) => mockHasMinted(address, proposal) }; }); @@ -122,6 +127,13 @@ describe('nftClaimer', () => { }); }); + describe('when address has already minted', () => { + it('throws an error', () => { + mockHasMinted.mockReturnValueOnce(true); + return expect(async () => await payload(input)).rejects.toThrow(); + }); + }); + describe('when maxSupply has been reached', () => { it.todo('throws an error'); }); From 08095ea77f2383427211ae4365cb13287d68c618 Mon Sep 17 00:00:00 2001 From: Wan Qi Chen <495709+wa0x6e@users.noreply.github.com> Date: Sat, 30 Sep 2023 13:46:59 +0900 Subject: [PATCH 3/3] fix: add missing `await` --- src/lib/nftClaimer/mint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/nftClaimer/mint.ts b/src/lib/nftClaimer/mint.ts index 73a51d64..4e8f17d9 100644 --- a/src/lib/nftClaimer/mint.ts +++ b/src/lib/nftClaimer/mint.ts @@ -40,7 +40,7 @@ export default async function payload(input: { throw new Error('Space has closed minting'); } - if (!hasVoted(params.recipient, proposal as Proposal)) { + if (!(await hasVoted(params.recipient, proposal as Proposal))) { throw new Error('Minting is open only for voters'); }