-
Notifications
You must be signed in to change notification settings - Fork 11
Add genesis claim-creator-rewards command #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
0015f8a
Add genesis claim-creator-rewards command
MarkSackerberg 4d1e6c6
Address claim-creator-rewards PR feedback
MarkSackerberg 5fde6bd
Make preview fetch non-blocking in claim-creator-rewards
MarkSackerberg 74aa037
Add devnet smoke tests for claim-creator-rewards
MarkSackerberg c9d57a7
Reject RPC/network mismatches in claim-creator-rewards
MarkSackerberg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,272 @@ | ||
| import { | ||
| BondingCurveBucketV2, | ||
| claimCreatorRewards, | ||
| GenesisApiConfig, | ||
| getBondingCurveBucketV2GpaBuilder, | ||
| getRaydiumCpmmBucketV2GpaBuilder, | ||
| isGenesisApiError, | ||
| Key, | ||
| RaydiumCpmmBucketV2, | ||
| SvmNetwork, | ||
| } from '@metaplex-foundation/genesis' | ||
| import { isPublicKey, PublicKey, publicKey, Umi, unwrapOptionRecursively } from '@metaplex-foundation/umi' | ||
| import { base58 } from '@metaplex-foundation/umi/serializers' | ||
| import { Flags } from '@oclif/core' | ||
| import ora from 'ora' | ||
|
|
||
| import { TransactionCommand } from '../../TransactionCommand.js' | ||
| import { generateExplorerUrl } from '../../explorers.js' | ||
| import { getDefaultApiUrl } from '../../lib/genesis/launchApi.js' | ||
| import { detectSvmNetwork, RpcChain } from '../../lib/util.js' | ||
|
|
||
| const NO_REWARDS_MESSAGE = 'No rewards available to claim' | ||
|
|
||
| // extensions @ 488 + reserved(8) + backendSigner(32) + firstBuy(40) + firstBuyDone(1) + paddingAlign(7) = +88. | ||
| const BC_CREATOR_FEE_WALLET_OFFSET = 488 + 88 | ||
| // extensions @ 576 + reserved(8) + backendSigner(32) + lpLockSchedule option (272) = +312. | ||
| const RAYDIUM_CREATOR_FEE_WALLET_OFFSET = 576 + 312 | ||
|
|
||
| const WSOL_MINT = 'So11111111111111111111111111111111111111112' | ||
|
|
||
| interface ClaimablePreview { | ||
| address: string | ||
| pending: bigint | ||
| quoteMint: string | ||
| type: 'Bonding Curve' | 'Raydium CPMM' | ||
| } | ||
|
|
||
| export default class GenesisClaimCreatorRewards extends TransactionCommand<typeof GenesisClaimCreatorRewards> { | ||
| static override description = `Claim accrued creator rewards across all Genesis bonding-curve and Raydium buckets. | ||
|
|
||
| Calls the Metaplex Genesis API to discover every bucket where the wallet is the | ||
| creator fee recipient — across bonding-curve launches and the Raydium CPMM pools | ||
| that bonding-curves and launchpools graduate to — and submits one transaction | ||
| per claimable bucket. The configured signer always pays transaction fees; the | ||
| creator fee wallet receives the claimed rewards. | ||
|
|
||
| Returns a friendly message when there is nothing to claim.` | ||
|
|
||
| static override examples = [ | ||
| '$ mplx genesis claim-creator-rewards', | ||
| '$ mplx genesis claim-creator-rewards --wallet <CREATOR_FEE_WALLET>', | ||
| '$ mplx genesis claim-creator-rewards --network solana-devnet', | ||
| '$ mplx genesis claim-creator-rewards --apiUrl https://api.metaplex.dev', | ||
| ] | ||
|
|
||
| static override flags = { | ||
| apiUrl: Flags.string({ | ||
| description: 'Genesis API base URL (defaults to https://api.metaplex.com for mainnet, https://api.metaplex.dev for devnet)', | ||
| required: false, | ||
| }), | ||
| network: Flags.option({ | ||
| description: 'Network override (auto-detected from RPC if not set)', | ||
| options: ['solana-mainnet', 'solana-devnet'] as const, | ||
| required: false, | ||
| })(), | ||
| wallet: Flags.string({ | ||
| char: 'w', | ||
| description: 'Creator fee wallet to claim for. The signer always pays fees; rewards go to this wallet. Defaults to the configured signer.', | ||
| required: false, | ||
| }), | ||
| } | ||
|
|
||
| static override usage = 'genesis claim-creator-rewards [FLAGS]' | ||
|
|
||
| public async run(): Promise<unknown> { | ||
| const { flags } = await this.parse(GenesisClaimCreatorRewards) | ||
|
|
||
| if (flags.wallet && !isPublicKey(flags.wallet)) { | ||
| this.error('--wallet must be a valid public key') | ||
| } | ||
|
|
||
| const wallet: PublicKey = flags.wallet ? publicKey(flags.wallet) : this.context.signer.publicKey | ||
|
|
||
| if (this.context.chain === RpcChain.Localnet) { | ||
| this.error('genesis claim-creator-rewards requires a mainnet or devnet RPC endpoint') | ||
| } | ||
|
|
||
| const detectedNetwork = detectSvmNetwork(this.context.chain) | ||
| if (flags.network && flags.network !== detectedNetwork) { | ||
| this.error(`--network ${flags.network} does not match the connected RPC cluster (${detectedNetwork}). Use --rpc to point at a matching endpoint or omit --network.`) | ||
| } | ||
|
|
||
| const network: SvmNetwork = flags.network ?? detectedNetwork | ||
| const apiConfig: GenesisApiConfig = { | ||
| baseUrl: flags.apiUrl ?? getDefaultApiUrl(network), | ||
| } | ||
| const payerSigner = this.context.payer ?? this.context.signer | ||
|
|
||
| const spinner = ora('Looking up creator rewards...').start() | ||
|
|
||
| const emptyResult = { buckets: [], signatures: [], wallet: wallet.toString() } | ||
|
|
||
| let result | ||
| try { | ||
| result = await claimCreatorRewards(this.context.umi, apiConfig, { | ||
| network, | ||
| payer: payerSigner.publicKey, | ||
| wallet, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }) | ||
| } catch (error) { | ||
| if (isGenesisApiError(error) && error.message === NO_REWARDS_MESSAGE) { | ||
| spinner.info(`No rewards to claim for ${wallet}`) | ||
| return emptyResult | ||
| } | ||
|
|
||
| spinner.fail('Failed to fetch creator rewards from Genesis API') | ||
| throw error | ||
| } | ||
|
|
||
| if (result.transactions.length === 0) { | ||
| spinner.info(`No rewards to claim for ${wallet}`) | ||
| return emptyResult | ||
| } | ||
|
|
||
| spinner.stop() | ||
|
|
||
| let claimable: ClaimablePreview[] = [] | ||
| try { | ||
| claimable = await this.fetchClaimablePreview(this.context.umi, wallet) | ||
| if (claimable.length > 0) { | ||
| this.printPreview(wallet, claimable) | ||
| } | ||
| } catch (error) { | ||
| this.warn(`Could not load claimable preview for ${wallet}: ${(error as Error).message}. Continuing with claim submission.`) | ||
| } | ||
|
|
||
| spinner.start(`Signing and sending ${result.transactions.length} transaction${result.transactions.length === 1 ? '' : 's'}...`) | ||
|
|
||
| const allowedCommitments = ['processed', 'confirmed', 'finalized'] as const | ||
| const commitment = allowedCommitments.includes(this.context.commitment as typeof allowedCommitments[number]) | ||
| ? (this.context.commitment as typeof allowedCommitments[number]) | ||
| : 'confirmed' | ||
|
|
||
| const results: { explorer: string; signature: string }[] = [] | ||
| for (const [index, tx] of result.transactions.entries()) { | ||
| spinner.text = `Sending transaction ${index + 1} of ${result.transactions.length}...` | ||
| const signed = await payerSigner.signTransaction(tx) | ||
| const signatureBytes = await this.context.umi.rpc.sendTransaction(signed, { | ||
| commitment, | ||
| preflightCommitment: commitment, | ||
| }) | ||
| await this.context.umi.rpc.confirmTransaction(signatureBytes, { | ||
| commitment, | ||
| strategy: { type: 'blockhash', ...result.blockhash }, | ||
| }) | ||
|
|
||
| const signature = base58.deserialize(signatureBytes)[0] | ||
| results.push({ | ||
| explorer: generateExplorerUrl(this.context.explorer, this.context.chain, signature, 'transaction'), | ||
| signature, | ||
| }) | ||
| } | ||
|
|
||
| spinner.succeed(`Claimed creator rewards across ${results.length} bucket${results.length === 1 ? '' : 's'}`) | ||
|
|
||
| this.log('') | ||
| this.logSuccess(`Creator rewards claimed for ${wallet}`) | ||
| this.log('') | ||
| this.log('Transactions:') | ||
| for (const { explorer, signature } of results) { | ||
| this.log(` ${signature}`) | ||
| this.log(` ${explorer}`) | ||
| } | ||
|
|
||
| return { | ||
| buckets: claimable.map((b) => ({ | ||
| address: b.address, | ||
| pending: b.pending.toString(), | ||
| quoteMint: b.quoteMint, | ||
| type: b.type, | ||
| })), | ||
| signatures: results, | ||
| wallet: wallet.toString(), | ||
| } | ||
| } | ||
|
|
||
| private async fetchClaimablePreview(umi: Umi, wallet: PublicKey): Promise<ClaimablePreview[]> { | ||
| const [bcBuckets, raydiumBuckets] = await Promise.all([ | ||
| getBondingCurveBucketV2GpaBuilder(umi) | ||
| .whereField('key', Key.BondingCurveBucketV2) | ||
| .where(BC_CREATOR_FEE_WALLET_OFFSET, wallet) | ||
| .getDeserialized(), | ||
| getRaydiumCpmmBucketV2GpaBuilder(umi) | ||
| .whereField('key', Key.RaydiumCpmmBucketV2) | ||
| .where(RAYDIUM_CREATOR_FEE_WALLET_OFFSET, wallet) | ||
| .getDeserialized(), | ||
| ]) | ||
|
|
||
| const previews: ClaimablePreview[] = [] | ||
|
|
||
| for (const bucket of bcBuckets) { | ||
| const pending = bucket.creatorFeeAccrued - bucket.creatorFeeClaimed | ||
| if (pending <= 0n) continue | ||
| previews.push({ | ||
| address: bucket.publicKey.toString(), | ||
| pending, | ||
| quoteMint: getQuoteMint(bucket), | ||
| type: 'Bonding Curve', | ||
| }) | ||
| } | ||
|
|
||
| for (const bucket of raydiumBuckets) { | ||
| // Raydium claim is special: the LP may have uncollected fees that the | ||
| // claim transaction collects first. We can't preview those without | ||
| // loading the pool state, so we display only the bucket-tracked accrued. | ||
| const pending = bucket.creatorFeeAccrued - bucket.creatorFeeClaimed | ||
| if (pending <= 0n) continue | ||
| previews.push({ | ||
| address: bucket.publicKey.toString(), | ||
| pending, | ||
| quoteMint: getQuoteMint(bucket), | ||
| type: 'Raydium CPMM', | ||
| }) | ||
| } | ||
|
|
||
| return previews | ||
| } | ||
|
|
||
| private printPreview(wallet: PublicKey, claimable: ClaimablePreview[]): void { | ||
| this.log('') | ||
| this.log(`Claimable creator rewards for ${wallet}:`) | ||
| for (const item of claimable) { | ||
| this.log(` ${item.type.padEnd(13)} ${item.address} pending: ${formatAmount(item.pending, item.quoteMint)}`) | ||
| } | ||
|
|
||
| const totalsByQuote = new Map<string, bigint>() | ||
| for (const item of claimable) { | ||
| totalsByQuote.set(item.quoteMint, (totalsByQuote.get(item.quoteMint) ?? 0n) + item.pending) | ||
| } | ||
|
|
||
| if (claimable.length > 1 || totalsByQuote.size > 1) { | ||
| this.log('') | ||
| this.log('Total pending:') | ||
| for (const [quoteMint, total] of totalsByQuote) { | ||
| this.log(` ${formatAmount(total, quoteMint)}`) | ||
| } | ||
| } | ||
|
|
||
| this.log('') | ||
| } | ||
| } | ||
|
|
||
| function getQuoteMint(bucket: BondingCurveBucketV2 | RaydiumCpmmBucketV2): string { | ||
| const quoteMint = unwrapOptionRecursively(bucket.bucket.quoteMint) | ||
| return quoteMint?.toString() ?? '<missing>' | ||
| } | ||
|
|
||
| function formatAmount(amount: bigint, quoteMint: string): string { | ||
| if (quoteMint === WSOL_MINT) { | ||
| return `${formatLamports(amount)} SOL` | ||
| } | ||
|
|
||
| return `${amount.toString()} (quote: ${quoteMint})` | ||
| } | ||
|
|
||
| function formatLamports(lamports: bigint): string { | ||
| const negative = lamports < 0n | ||
| const abs = negative ? -lamports : lamports | ||
| const whole = abs / 1_000_000_000n | ||
| const frac = (abs % 1_000_000_000n).toString().padStart(9, '0').replace(/0+$/, '') | ||
| return `${negative ? '-' : ''}${whole}${frac ? `.${frac}` : ''}` | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
61 changes: 61 additions & 0 deletions
61
test/commands/genesis/genesis.claim-creator-rewards.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { expect } from 'chai' | ||
| import { spawn } from 'node:child_process' | ||
|
|
||
| import { DEVNET_RPC_URL } from '../../../src/lib/util.js' | ||
| import { CLI_PATH, KEYPAIR_PATH } from '../../runCli' | ||
|
|
||
| const stripAnsi = (s: string) => s.replace(/\[[\d;]*m/g, '') | ||
|
|
||
| // We can't reuse runCli here because it auto-appends -r TEST_RPC, which | ||
| // would override the devnet RPC this command needs to reach the Genesis | ||
| // API and run the GPA scan against devnet state. | ||
| const runCliRaw = (args: string[]) => | ||
| new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { | ||
| const child = spawn('node', [CLI_PATH, ...args], { stdio: ['pipe', 'pipe', 'pipe'] }) | ||
| let stdout = '' | ||
| let stderr = '' | ||
| child.stdout.on('data', (d) => { stdout += d.toString() }) | ||
| child.stderr.on('data', (d) => { stderr += d.toString() }) | ||
| child.on('error', reject) | ||
| child.on('close', (code) => resolve({ stdout, stderr, code: code ?? 0 })) | ||
| }) | ||
|
|
||
| describe('genesis claim-creator-rewards', () => { | ||
| // System program: a real, valid pubkey guaranteed to never be the | ||
| // creator-fee wallet on any bucket. Exercises the full API round-trip | ||
| // (validation, GPA scan, 400 mapping, friendly path) without needing | ||
| // any on-chain setup or a funded keypair. | ||
| const SYSTEM_PROGRAM = '11111111111111111111111111111111' | ||
|
|
||
| it('reports no rewards for a wallet with no buckets (devnet API)', async function () { | ||
| this.timeout(30000) | ||
|
|
||
| const { stdout, stderr, code } = await runCliRaw([ | ||
| 'genesis', 'claim-creator-rewards', | ||
| '--wallet', SYSTEM_PROGRAM, | ||
| '--network', 'solana-devnet', | ||
| '-r', DEVNET_RPC_URL, | ||
| '-k', KEYPAIR_PATH, | ||
| ]) | ||
|
|
||
| expect(code).to.equal(0) | ||
| const out = stripAnsi(stdout + stderr) | ||
| expect(out).to.contain('No rewards to claim') | ||
| expect(out).to.contain(SYSTEM_PROGRAM) | ||
| }) | ||
|
MarkSackerberg marked this conversation as resolved.
|
||
|
|
||
| it('rejects an invalid --wallet before reaching the API', async function () { | ||
| this.timeout(15000) | ||
|
|
||
| const { stdout, stderr, code } = await runCliRaw([ | ||
| 'genesis', 'claim-creator-rewards', | ||
| '--wallet', 'not-a-pubkey', | ||
| '-r', DEVNET_RPC_URL, | ||
| '-k', KEYPAIR_PATH, | ||
| ]) | ||
|
|
||
| expect(code).to.not.equal(0) | ||
| const out = stripAnsi(stdout + stderr) | ||
| expect(out).to.contain('--wallet must be a valid public key') | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.