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
76 changes: 76 additions & 0 deletions apps/solana-relay/src/redis.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

describe('claimable token account recreation rate limit', () => {
afterEach(() => {
vi.useRealTimers()
vi.doUnmock('redis')
vi.doUnmock('./logger')
vi.resetModules()
})

it('allows 10 recreations per UTC day and rejects the 11th', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-24T23:30:00.000Z'))

let count = 0
const keys: string[] = []
const redisClient = {
on: vi.fn((_event: string, callback: () => void) => callback()),
connect: vi.fn(),
multi: vi.fn(() => {
const results: unknown[] = []
type MockRedisTransaction = {
incr: (key: string) => MockRedisTransaction
expire: () => MockRedisTransaction
exec: () => Promise<unknown[]>
}
const transaction: MockRedisTransaction = {
incr: vi.fn((key: string) => {
keys.push(key)
count += 1
results.push(count)
return transaction
}),
expire: vi.fn(() => {
results.push(true)
return transaction
}),
exec: vi.fn(async () => results)
}
return transaction
})
}

vi.doMock('redis', () => ({
createClient: vi.fn(() => redisClient)
}))
vi.doMock('./logger', () => ({
logger: { error: vi.fn(), info: vi.fn() }
}))

const {
CLAIMABLE_TOKEN_ACCOUNT_RECREATION_SYSTEM_LIMIT,
rateLimitClaimableTokenAccountRecreation
} = await import('./redis')

for (
let i = 0;
i < CLAIMABLE_TOKEN_ACCOUNT_RECREATION_SYSTEM_LIMIT;
i += 1
) {
await expect(
rateLimitClaimableTokenAccountRecreation(`account-${i}`)
).resolves.toBeUndefined()
}

await expect(
rateLimitClaimableTokenAccountRecreation('account-over-limit')
).rejects.toThrow(
'System has recreated too many claimable token accounts today'
)
expect(keys).toHaveLength(11)
expect(new Set(keys)).toEqual(
new Set(['claimable-token-account-recreation-count:global:2026-09-24'])
)
})
})
37 changes: 37 additions & 0 deletions apps/solana-relay/src/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const getCachedContentNodes = async () => {
const TOKEN_ACCOUNT_CREATION_USER_LIMIT = 2
const TOKEN_ACCOUNT_CREATION_VERIFIED_USER_LIMIT = 5
const TOKEN_ACCOUNT_CREATION_SYSTEM_LIMIT = 10
export const CLAIMABLE_TOKEN_ACCOUNT_RECREATION_SYSTEM_LIMIT = 10

export const rateLimitTokenAccountCreation = async (
wallet: string,
Expand Down Expand Up @@ -135,3 +136,39 @@ export const rateLimitTokenAccountCreation = async (
throw new Error('System has created too many token accounts')
}
}

/**
* Limits relay-funded recreations of previously created claimable token
* accounts across the entire system. First-time account creation uses a
* separate path and does not consume this bucket.
*/
export const rateLimitClaimableTokenAccountRecreation = async (
account: string
) => {
const redis = await getRedisConnection()
const currentDate = new Date().toISOString().split('T')[0]
const key = `claimable-token-account-recreation-count:global:${currentDate}`

const [systemCount] = await redis
.multi()
.incr(key)
.expire(key, TWO_DAYS_IN_SECONDS)
.exec()

if (
typeof systemCount !== 'number' ||
systemCount > CLAIMABLE_TOKEN_ACCOUNT_RECREATION_SYSTEM_LIMIT
) {
logger.error(
{
account,
systemCount,
systemLimit: CLAIMABLE_TOKEN_ACCOUNT_RECREATION_SYSTEM_LIMIT
},
'System exceeded claimable token account recreation limit'
)
throw new Error(
'System has recreated too many claimable token accounts today'
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { vi, beforeEach, afterEach, describe, it, expect } from 'vitest'

import { config } from '../../config'
import { rateLimitClaimableTokenAccountRecreation } from '../../redis'

import { InvalidRelayInstructionError } from './InvalidRelayInstructionError'
import {
Expand All @@ -34,6 +35,18 @@ import {
JUPITER_ROUTE_DISCRIMINANT,
JUPITER_SHARED_ACCOUNTS_ROUTE_DISCRIMINANT
} from './assertRelayAllowedInstructions'
import { wasClaimableTokenAccountPreviouslyCreated } from './claimableTokenAccountHistory'

vi.mock('../../redis', () => ({
rateLimitClaimableTokenAccountRecreation: vi.fn(),
rateLimitTokenAccountCreation: vi.fn(async () => {
throw new Error('Token account creation rate limit exceeded')
})
}))

vi.mock('./claimableTokenAccountHistory', () => ({
wasClaimableTokenAccountPreviouslyCreated: vi.fn()
}))

vi.mock('../../utils/connections', () => ({
getConnection: () => ({
Expand Down Expand Up @@ -167,6 +180,15 @@ describe('Solana Relay', function () {
})
})
beforeEach(() => {
vi.mocked(wasClaimableTokenAccountPreviouslyCreated).mockReset()
vi.mocked(wasClaimableTokenAccountPreviouslyCreated).mockResolvedValue(
false
)
vi.mocked(rateLimitClaimableTokenAccountRecreation).mockReset()
vi.mocked(rateLimitClaimableTokenAccountRecreation).mockResolvedValue(
undefined
)

// Mock initializeDiscoveryDb to avoid real DB connection
vi.mock('@pedalboard/basekit', () => ({
initializeDiscoveryDb: vi.fn(() => ({
Expand Down Expand Up @@ -693,6 +715,84 @@ describe('Solana Relay', function () {
})

describe('Claimable Tokens Program', function () {
it('should not consume the recreation limit for first-time account creation', async function () {
const wallet = '0xe42b199d864489387bf64262874fc6472bcbc151'
const payer = getRandomPublicKey()
const mint = getRandomPublicKey()
const userBank = getRandomPublicKey()

await assertRelayAllowedInstructions([
ClaimableTokensProgram.createAccountInstruction({
ethAddress: wallet,
payer,
mint,
authority: audioClaimableTokenAuthority,
userBank,
programId: CLAIMABLE_TOKEN_PROGRAM_ID
})
])

expect(wasClaimableTokenAccountPreviouslyCreated).toHaveBeenCalledWith(
userBank.toBase58()
)
expect(rateLimitClaimableTokenAccountRecreation).not.toHaveBeenCalled()
})

it('should consume the recreation limit for previously created accounts', async function () {
vi.mocked(wasClaimableTokenAccountPreviouslyCreated).mockResolvedValue(
true
)
const wallet = '0xe42b199d864489387bf64262874fc6472bcbc151'
const payer = getRandomPublicKey()
const mint = getRandomPublicKey()
const userBank = getRandomPublicKey()

await assertRelayAllowedInstructions([
ClaimableTokensProgram.createAccountInstruction({
ethAddress: wallet,
payer,
mint,
authority: audioClaimableTokenAuthority,
userBank,
programId: CLAIMABLE_TOKEN_PROGRAM_ID
})
])

expect(rateLimitClaimableTokenAccountRecreation).toHaveBeenCalledWith(
userBank.toBase58()
)
})

it('should reject a recreation when the system limit is exhausted', async function () {
vi.mocked(wasClaimableTokenAccountPreviouslyCreated).mockResolvedValue(
true
)
vi.mocked(rateLimitClaimableTokenAccountRecreation).mockRejectedValue(
new Error(
'System has recreated too many claimable token accounts today'
)
)
const wallet = '0xe42b199d864489387bf64262874fc6472bcbc151'
const payer = getRandomPublicKey()
const mint = getRandomPublicKey()
const userBank = getRandomPublicKey()

await expect(
assertRelayAllowedInstructions([
ClaimableTokensProgram.createAccountInstruction({
ethAddress: wallet,
payer,
mint,
authority: audioClaimableTokenAuthority,
userBank,
programId: CLAIMABLE_TOKEN_PROGRAM_ID
})
])
).rejects.toThrow(
'System has recreated too many claimable token accounts today'
)
})

it('should allow claimable token program instructions with valid authority', async function () {
// Dummy eth address to make the encoder happy
const wallet = '0xe42b199d864489387bf64262874fc6472bcbc151'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ import {
} from '@solana/web3.js'

import { config } from '../../config'
import { rateLimitTokenAccountCreation } from '../../redis'
import {
rateLimitClaimableTokenAccountRecreation,
rateLimitTokenAccountCreation
} from '../../redis'
import { getConnection } from '../../utils/connections'

import { InvalidRelayInstructionError } from './InvalidRelayInstructionError'
import { isUserAbusive } from './antiAbuse'
import { wasClaimableTokenAccountPreviouslyCreated } from './claimableTokenAccountHistory'
import { getAllowedMints } from './getAllowedMints'

const MEMO_PROGRAM_ID = 'Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo'
Expand Down Expand Up @@ -405,6 +409,23 @@ const assertAllowedClaimableTokenProgramInstruction = async (
`Invalid Claimable Token Authority: ${authority}`
)
}

if (ClaimableTokensProgram.isCreateAccountInstruction(decodedInstruction)) {
const userBank = decodedInstruction.keys.userBank.pubkey.toBase58()
const wasPreviouslyCreated =
await wasClaimableTokenAccountPreviouslyCreated(userBank)

if (wasPreviouslyCreated) {
try {
await rateLimitClaimableTokenAccountRecreation(userBank)
} catch (e) {
throw new InvalidRelayInstructionError(
instructionIndex,
(e as Error).message
)
}
}
}
}

/**
Expand Down
20 changes: 20 additions & 0 deletions apps/solana-relay/src/routes/relay/claimableTokenAccountHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { SolClaimableAccounts, Table } from '@pedalboard/storage'

import { db } from '../../db'

/**
* The index retains claimable account creation rows after an account is
* closed, so an existing row identifies a create as a recreation.
*/
export const wasClaimableTokenAccountPreviouslyCreated = async (
account: string
) => {
const existingAccount = await db<SolClaimableAccounts>(
Table.SolClaimableAccounts
)
.select('account')
.where('account', '=', account)
.first()

return existingAccount !== undefined
}
Loading