diff --git a/.changeset/unlucky-chicken-listen.md b/.changeset/unlucky-chicken-listen.md new file mode 100644 index 00000000000..d77cb1c6a91 --- /dev/null +++ b/.changeset/unlucky-chicken-listen.md @@ -0,0 +1,6 @@ +--- +'@audius/sdk': patch +'@audius/spl': patch +--- + +Improve error messages from Claimable Tokens program diff --git a/packages/libs/src/sdk/sdk.ts b/packages/libs/src/sdk/sdk.ts index f2fc1dbd734..9f060eafb45 100644 --- a/packages/libs/src/sdk/sdk.ts +++ b/packages/libs/src/sdk/sdk.ts @@ -192,21 +192,24 @@ const initializeServices = (config: SdkConfig) => { config.services?.claimableTokensClient ?? new ClaimableTokensClient({ ...getDefaultClaimableTokensConfig(servicesConfig), - solanaWalletAdapter + solanaWalletAdapter, + logger }) const rewardManagerClient = config.services?.rewardManagerClient ?? new RewardManagerClient({ ...getDefaultRewardManagerClentConfig(servicesConfig), - solanaWalletAdapter + solanaWalletAdapter, + logger }) const paymentRouterClient = config.services?.paymentRouterClient ?? new PaymentRouterClient({ ...getDefaultPaymentRouterClientConfig(servicesConfig), - solanaWalletAdapter + solanaWalletAdapter, + logger }) const services: ServicesContainer = { diff --git a/packages/libs/src/sdk/services/Solana/programs/BaseSolanaProgramClient.ts b/packages/libs/src/sdk/services/Solana/programs/BaseSolanaProgramClient.ts index c41ebb1e463..53393bbda31 100644 --- a/packages/libs/src/sdk/services/Solana/programs/BaseSolanaProgramClient.ts +++ b/packages/libs/src/sdk/services/Solana/programs/BaseSolanaProgramClient.ts @@ -3,12 +3,14 @@ import { ComputeBudgetProgram, Connection, PublicKey, + Transaction, TransactionMessage, VersionedTransaction } from '@solana/web3.js' import { z } from 'zod' import { parseParams } from '../../../utils/parseParams' +import { LoggerService } from '../../Logger' import type { SolanaWalletAdapter } from '../types' import { @@ -39,11 +41,13 @@ const priorityToPercentileMap: Record< export class BaseSolanaProgramClient { /** The Solana RPC client. */ public readonly connection: Connection + protected readonly logger: LoggerService constructor( config: BaseSolanaProgramConfigInternal, protected wallet: SolanaWalletAdapter ) { this.connection = new Connection(config.rpcEndpoint, config.rpcConfig) + this.logger = config.logger } /** @@ -168,7 +172,10 @@ export class BaseSolanaProgramClient { return this.wallet.publicKey! } - private async getLookupTableAccounts(lookupTableKeys: PublicKey[]) { + /** + * Fetches the address look up tables for populating transaction objects + */ + protected async getLookupTableAccounts(lookupTableKeys: PublicKey[]) { return await Promise.all( lookupTableKeys.map(async (accountKey) => { const res = await this.connection.getAddressLookupTable(accountKey) @@ -179,4 +186,24 @@ export class BaseSolanaProgramClient { }) ) } + + /** + * Normalizes the instructions as TransactionInstruction whether from + * versioned transactions or legacy transactions. + */ + protected async getInstructions( + transaction: VersionedTransaction | Transaction + ) { + if ('version' in transaction) { + const lookupTableAccounts = await this.getLookupTableAccounts( + transaction.message.addressTableLookups.map((k) => k.accountKey) + ) + const decompiled = TransactionMessage.decompile(transaction.message, { + addressLookupTableAccounts: lookupTableAccounts + }) + return decompiled.instructions + } else { + return transaction.instructions + } + } } diff --git a/packages/libs/src/sdk/services/Solana/programs/ClaimableTokensClient/ClaimableTokensClient.ts b/packages/libs/src/sdk/services/Solana/programs/ClaimableTokensClient/ClaimableTokensClient.ts index f868943b1e7..5cc60c1e116 100644 --- a/packages/libs/src/sdk/services/Solana/programs/ClaimableTokensClient/ClaimableTokensClient.ts +++ b/packages/libs/src/sdk/services/Solana/programs/ClaimableTokensClient/ClaimableTokensClient.ts @@ -1,9 +1,17 @@ -import { ClaimableTokensProgram } from '@audius/spl' +import { + ClaimableTokensErrorCode, + ClaimableTokensErrorMessages, + ClaimableTokensInstruction, + ClaimableTokensProgram +} from '@audius/spl' +import { SendTransactionOptions } from '@solana/wallet-adapter-base' import { TransactionMessage, VersionedTransaction, Secp256k1Program, - PublicKey + PublicKey, + Transaction, + SendTransactionError } from '@solana/web3.js' import { productionConfig } from '../../../../config/production' @@ -12,6 +20,7 @@ import { mintFixedDecimalMap } from '../../../../utils/mintFixedDecimalMap' import { parseParams } from '../../../../utils/parseParams' import type { Mint } from '../../types' import { BaseSolanaProgramClient } from '../BaseSolanaProgramClient' +import { CustomInstructionError } from '../CustomInstructionError' import { getDefaultClaimableTokensConfig } from './getDefaultConfig' import { @@ -25,6 +34,31 @@ import { ClaimableTokensConfig } from './types' +export class ClaimableTokensError extends Error { + override name = 'ClaimableTokensError' + public code: number + public instructionName: string + public customErrorName?: string + constructor({ + code, + instructionName, + cause + }: { + code: number + instructionName: string + cause?: Error + }) { + super( + ClaimableTokensErrorMessages[code as ClaimableTokensErrorCode] ?? + `Unknown error: ${code}`, + { cause } + ) + this.code = code + this.instructionName = instructionName + this.customErrorName = ClaimableTokensErrorCode[code] + } +} + /** * Connected client to the ClaimableTokens Solana program. * @@ -90,10 +124,7 @@ export class ClaimableTokensClient extends BaseSolanaProgramClient { instructions: [createUserBankInstruction] }).compileToLegacyMessage() const transaction = new VersionedTransaction(message) - const signature = await this.wallet.sendTransaction( - transaction, - this.connection - ) + const signature = await this.sendTransaction(transaction) const confirmationStrategy = { ...confirmationStrategyArgs, signature } await this.connection.confirmTransaction( confirmationStrategy, @@ -195,4 +226,43 @@ export class ClaimableTokensClient extends BaseSolanaProgramClient { claimableTokensPDA: this.authorities[mint] }) } + + /** + * Override the sendTransaction method to provide some more friendly errors + * back to the consumer for ClaimableTokens instructions + */ + public override async sendTransaction( + transaction: Transaction | VersionedTransaction, + sendOptions?: SendTransactionOptions | undefined + ): Promise { + try { + return await super.sendTransaction(transaction, sendOptions) + } catch (e) { + if (e instanceof SendTransactionError) { + try { + const error = CustomInstructionError.parseSendTransactionError(e) + if (error) { + const instructions = await this.getInstructions(transaction) + const instruction = instructions[error.instructionIndex] + if (instruction && instruction.programId.equals(this.programId)) { + const decodedInstruction = + ClaimableTokensProgram.decodeInstruction(instruction) + throw new ClaimableTokensError({ + code: error.code, + instructionName: + ClaimableTokensInstruction[ + decodedInstruction.data.instruction + ] ?? 'Unknown', + cause: e + }) + } + } + } catch (e) { + // If failed to provide user friendly error, surface original error + this.logger.warn('Failed to parse ClaimableTokensError error', e) + } + } + throw e + } + } } diff --git a/packages/libs/src/sdk/services/Solana/programs/ClaimableTokensClient/getDefaultConfig.ts b/packages/libs/src/sdk/services/Solana/programs/ClaimableTokensClient/getDefaultConfig.ts index 87080a7cc09..8b2bd6bc4ee 100644 --- a/packages/libs/src/sdk/services/Solana/programs/ClaimableTokensClient/getDefaultConfig.ts +++ b/packages/libs/src/sdk/services/Solana/programs/ClaimableTokensClient/getDefaultConfig.ts @@ -1,6 +1,7 @@ import { PublicKey } from '@solana/web3.js' import type { SdkServicesConfig } from '../../../../config/types' +import { Logger } from '../../../Logger' import type { ClaimableTokensConfigInternal } from './types' @@ -12,5 +13,6 @@ export const getDefaultClaimableTokensConfig = ( mints: { wAUDIO: new PublicKey(config.solana.wAudioTokenMint), USDC: new PublicKey(config.solana.usdcTokenMint) - } + }, + logger: new Logger() }) diff --git a/packages/libs/src/sdk/services/Solana/programs/CustomInstructionError.ts b/packages/libs/src/sdk/services/Solana/programs/CustomInstructionError.ts new file mode 100644 index 00000000000..c9490f11ac4 --- /dev/null +++ b/packages/libs/src/sdk/services/Solana/programs/CustomInstructionError.ts @@ -0,0 +1,32 @@ +import { SendTransactionError } from '@solana/web3.js' + +type CustomInstructionErrorMessage = { + InstructionError: [number, { Custom: number }] +} + +export class CustomInstructionError extends Error { + constructor(public instructionIndex: number, public code: number) { + super( + JSON.stringify({ + InstructionError: [instructionIndex, { Custom: code }] + }) + ) + } + + public static parseSendTransactionError(error: SendTransactionError) { + try { + const parsed = JSON.parse( + error.transactionError.message + ) as CustomInstructionErrorMessage + if (typeof parsed?.InstructionError?.[1]?.Custom === 'number') { + return new CustomInstructionError( + parsed.InstructionError[0], + parsed.InstructionError[1].Custom + ) + } + return null + } catch (e) { + return null + } + } +} diff --git a/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/getDefaultConfig.ts b/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/getDefaultConfig.ts index e106048e270..f568a1e89f3 100644 --- a/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/getDefaultConfig.ts +++ b/packages/libs/src/sdk/services/Solana/programs/PaymentRouterClient/getDefaultConfig.ts @@ -1,6 +1,7 @@ import { PublicKey } from '@solana/web3.js' import { SdkServicesConfig } from '../../../../config/types' +import { Logger } from '../../../Logger' import { PaymentRouterClientConfigInternal } from './types' @@ -12,5 +13,6 @@ export const getDefaultPaymentRouterClientConfig = ( mints: { USDC: new PublicKey(config.solana.usdcTokenMint), wAUDIO: new PublicKey(config.solana.wAudioTokenMint) - } + }, + logger: new Logger() }) diff --git a/packages/libs/src/sdk/services/Solana/programs/RewardManagerClient/RewardManagerClient.ts b/packages/libs/src/sdk/services/Solana/programs/RewardManagerClient/RewardManagerClient.ts index 3e751058658..ea527df4b82 100644 --- a/packages/libs/src/sdk/services/Solana/programs/RewardManagerClient/RewardManagerClient.ts +++ b/packages/libs/src/sdk/services/Solana/programs/RewardManagerClient/RewardManagerClient.ts @@ -1,7 +1,8 @@ import { RewardManagerInstruction, RewardManagerErrorCode, - RewardManagerProgram + RewardManagerProgram, + RewardManagerErrorMessages } from '@audius/spl' import type { RewardManagerStateData } from '@audius/spl/dist/types/reward-manager/types' import { SendTransactionOptions } from '@solana/wallet-adapter-base' @@ -17,6 +18,7 @@ import { productionConfig } from '../../../../config/production' import { mergeConfigWithDefaults } from '../../../../utils/mergeConfigs' import { parseParams } from '../../../../utils/parseParams' import { BaseSolanaProgramClient } from '../BaseSolanaProgramClient' +import { CustomInstructionError } from '../CustomInstructionError' import { getDefaultRewardManagerClentConfig } from './getDefaultConfig' import { @@ -33,36 +35,6 @@ import { GetSubmittedAttestationsSchema } from './types' -type CustomInstructionErrorMessage = { - InstructionError: [number, { Custom: number }] -} - -/** - * Mapping of custom instruction error codes to error messages - * @see {@link https://github.com/AudiusProject/audius-protocol/blob/2a37bcff1bb1a82efdf187d1723b3457dc0dcb9b/solana-programs/reward-manager/program/src/error.rs solana-programs/reward-manager/program/src/errors.rs} - */ -const codeMessageMap: Record = { - [RewardManagerErrorCode.IncorrectOwner]: - 'Input account owner is not the program address', - [RewardManagerErrorCode.SignCollision]: - 'Signature with an already met principal', - [RewardManagerErrorCode.WrongSigner]: 'Unexpected signer met', - [RewardManagerErrorCode.NotEnoughSigners]: "Isn't enough signers keys", - [RewardManagerErrorCode.Secp256InstructionMissing]: - 'Secp256 instruction missing', - [RewardManagerErrorCode.InstructionLoadError]: 'Instruction load error', - [RewardManagerErrorCode.RepeatedSenders]: 'Repeated sender', - [RewardManagerErrorCode.SignatureVerificationFailed]: - 'Signature verification failed', - [RewardManagerErrorCode.OperatorCollision]: - 'Some signers have same operators', - [RewardManagerErrorCode.AlreadySent]: 'Funds already sent', - [RewardManagerErrorCode.IncorrectMessages]: 'Incorrect messages', - [RewardManagerErrorCode.MessagesOverflow]: 'Messages overflow', - [RewardManagerErrorCode.MathOverflow]: 'Math overflow', - [RewardManagerErrorCode.InvalidRecipient]: 'Invalid Recipient' -} - export class RewardManagerError extends Error { override name = 'RewardManagerError' public code: number @@ -78,7 +50,7 @@ export class RewardManagerError extends Error { cause?: Error }) { super( - codeMessageMap[code as RewardManagerErrorCode] ?? + RewardManagerErrorMessages[code as RewardManagerErrorCode] ?? `Unknown error: ${code}`, { cause } ) @@ -325,57 +297,26 @@ export class RewardManagerClient extends BaseSolanaProgramClient { } catch (e) { if (e instanceof SendTransactionError) { try { - const error = JSON.parse( - e.transactionError.message - ) as CustomInstructionErrorMessage - if (error && error.InstructionError) { - const instructionIndex = error.InstructionError[0] - const code = error.InstructionError[1]?.Custom - - // Parse the different transaction types differently - if ('instructions' in transaction) { - // Legacy Transaction - const instruction = transaction.instructions[instructionIndex] - // Check error instruction is from RewardManagerProgram - if (instruction && instruction.programId.equals(this.programId)) { - const decodedInstruction = - RewardManagerProgram.decodeInstruction(instruction) - throw new RewardManagerError({ - code, - instructionName: - RewardManagerInstruction[ - decodedInstruction.data.instruction - ] ?? 'Unknown', - cause: e - }) - } - } else { - // VersionedTransaction - const instruction = - transaction.message.compiledInstructions[instructionIndex] - // Check error instruction is from RewardManagerProgram - if ( - instruction && - transaction.message.staticAccountKeys[ - instruction.programIdIndex - ]?.equals(this.programId) - ) { - throw new RewardManagerError({ - code, - instructionName: - RewardManagerInstruction[instruction!.data[0] as number] ?? - 'Unknown', - cause: e - }) - } + const error = CustomInstructionError.parseSendTransactionError(e) + if (error) { + const instructions = await this.getInstructions(transaction) + const instruction = instructions[error.instructionIndex] + if (instruction && instruction.programId.equals(this.programId)) { + const decodedInstruction = + RewardManagerProgram.decodeInstruction(instruction) + throw new RewardManagerError({ + code: error.code, + instructionName: + RewardManagerInstruction[ + decodedInstruction.data.instruction + ] ?? 'Unknown', + cause: e + }) } } } catch (e) { - if (e instanceof RewardManagerError) { - throw e - } // If failed to provide user friendly error, surface original error - console.warn('Failed to parse RewardManagerError error', e) + this.logger.warn('Failed to parse RewardManagerError error', e) } } throw e diff --git a/packages/libs/src/sdk/services/Solana/programs/RewardManagerClient/getDefaultConfig.ts b/packages/libs/src/sdk/services/Solana/programs/RewardManagerClient/getDefaultConfig.ts index 17c72c2c280..d367445ffa0 100644 --- a/packages/libs/src/sdk/services/Solana/programs/RewardManagerClient/getDefaultConfig.ts +++ b/packages/libs/src/sdk/services/Solana/programs/RewardManagerClient/getDefaultConfig.ts @@ -1,6 +1,7 @@ import { PublicKey } from '@solana/web3.js' import type { SdkServicesConfig } from '../../../../config/types' +import { Logger } from '../../../Logger' import type { RewardManagerClientConfigInternal } from './types' @@ -9,5 +10,6 @@ export const getDefaultRewardManagerClentConfig = ( ): RewardManagerClientConfigInternal => ({ programId: new PublicKey(config.solana.rewardManagerProgramAddress), rpcEndpoint: config.solana.rpcEndpoint, - rewardManagerState: new PublicKey(config.solana.rewardManagerStateAddress) + rewardManagerState: new PublicKey(config.solana.rewardManagerStateAddress), + logger: new Logger() }) diff --git a/packages/libs/src/sdk/services/Solana/programs/types.ts b/packages/libs/src/sdk/services/Solana/programs/types.ts index 178a5a51b89..27aa878122a 100644 --- a/packages/libs/src/sdk/services/Solana/programs/types.ts +++ b/packages/libs/src/sdk/services/Solana/programs/types.ts @@ -5,6 +5,7 @@ import { } from '@solana/web3.js' import { z } from 'zod' +import { LoggerService } from '../../Logger' import { PublicKeySchema } from '../types' export type BaseSolanaProgramConfigInternal = { @@ -12,6 +13,7 @@ export type BaseSolanaProgramConfigInternal = { rpcEndpoint: string /** Configuration to use for the RPC connection. */ rpcConfig?: ConnectionConfig + logger: LoggerService } export const PrioritySchema = z.enum([ diff --git a/packages/spl/src/claimable-tokens/constants.ts b/packages/spl/src/claimable-tokens/constants.ts index c7669d90662..565913e819c 100644 --- a/packages/spl/src/claimable-tokens/constants.ts +++ b/packages/spl/src/claimable-tokens/constants.ts @@ -2,3 +2,30 @@ export enum ClaimableTokensInstruction { Create = 0, Transfer = 1 } + +/** + * Custom error codes from the Claimable Tokens program + * @see {@link https://github.com/AudiusProject/audius-protocol/blob/2a37bcff1bb1a82efdf187d1723b3457dc0dcb9b/solana-programs/claimable-tokens/program/src/error.rs solana-programs/claimable-tokens/program/src/error.rs} + */ +export enum ClaimableTokensErrorCode { + SignatureVerificationFailed = 0, + Secp256InstructionLosing, + InstructionLoadError, + NonceVerificationError +} + +/** + * The UI friendly error messages for each error code. + * @see {@link https://github.com/AudiusProject/audius-protocol/blob/2a37bcff1bb1a82efdf187d1723b3457dc0dcb9b/solana-programs/claimable-tokens/program/src/error.rs solana-programs/claimable-tokens/program/src/error.rs} + */ +export const ClaimableTokensErrorMessages: Record< + ClaimableTokensErrorCode, + string +> = { + [ClaimableTokensErrorCode.SignatureVerificationFailed]: + 'Signature verification failed', + [ClaimableTokensErrorCode.Secp256InstructionLosing]: + 'Secp256 instruction losing', + [ClaimableTokensErrorCode.InstructionLoadError]: 'Instruction load error', + [ClaimableTokensErrorCode.NonceVerificationError]: 'Nonce verification failed' +} diff --git a/packages/spl/src/index.ts b/packages/spl/src/index.ts index c30d12beeaf..411277ba86d 100644 --- a/packages/spl/src/index.ts +++ b/packages/spl/src/index.ts @@ -1,9 +1,7 @@ export { ClaimableTokensProgram } from './claimable-tokens/ClaimableTokensProgram' -export { - RewardManagerInstruction, - RewardManagerErrorCode -} from './reward-manager/constants' +export * from './claimable-tokens/constants' export { RewardManagerProgram } from './reward-manager/RewardManagerProgram' +export * from './reward-manager/constants' export { Secp256k1Program } from './secp256k1/Secp256k1Program' export { ethAddress } from './layout-utils' export * from './associated-token' diff --git a/packages/spl/src/reward-manager/constants.ts b/packages/spl/src/reward-manager/constants.ts index 07fa78f42f2..067daaaf231 100644 --- a/packages/spl/src/reward-manager/constants.ts +++ b/packages/spl/src/reward-manager/constants.ts @@ -10,7 +10,7 @@ export enum RewardManagerInstruction { } /** - * Possible custom error messages from the Reward Manager program + * Custom error codes from the Reward Manager program * @see {@link https://github.com/AudiusProject/audius-protocol/blob/2a37bcff1bb1a82efdf187d1723b3457dc0dcb9b/solana-programs/reward-manager/program/src/error.rs solana-programs/reward-manager/program/src/errors.rs} */ export enum RewardManagerErrorCode { @@ -29,3 +29,32 @@ export enum RewardManagerErrorCode { MathOverflow, InvalidRecipient } + +/** + * The UI friendly error messages for each error code. + * @see {@link https://github.com/AudiusProject/audius-protocol/blob/2a37bcff1bb1a82efdf187d1723b3457dc0dcb9b/solana-programs/reward-manager/program/src/error.rs solana-programs/reward-manager/program/src/errors.rs} + */ +export const RewardManagerErrorMessages: Record< + RewardManagerErrorCode, + string +> = { + [RewardManagerErrorCode.IncorrectOwner]: + 'Input account owner is not the program address', + [RewardManagerErrorCode.SignCollision]: + 'Signature with an already met principal', + [RewardManagerErrorCode.WrongSigner]: 'Unexpected signer met', + [RewardManagerErrorCode.NotEnoughSigners]: "Isn't enough signers keys", + [RewardManagerErrorCode.Secp256InstructionMissing]: + 'Secp256 instruction missing', + [RewardManagerErrorCode.InstructionLoadError]: 'Instruction load error', + [RewardManagerErrorCode.RepeatedSenders]: 'Repeated sender', + [RewardManagerErrorCode.SignatureVerificationFailed]: + 'Signature verification failed', + [RewardManagerErrorCode.OperatorCollision]: + 'Some signers have same operators', + [RewardManagerErrorCode.AlreadySent]: 'Funds already sent', + [RewardManagerErrorCode.IncorrectMessages]: 'Incorrect messages', + [RewardManagerErrorCode.MessagesOverflow]: 'Messages overflow', + [RewardManagerErrorCode.MathOverflow]: 'Math overflow', + [RewardManagerErrorCode.InvalidRecipient]: 'Invalid Recipient' +} diff --git a/packages/web/src/common/store/tipping/sagas.ts b/packages/web/src/common/store/tipping/sagas.ts index 593dc30d62e..a621ff6ce63 100644 --- a/packages/web/src/common/store/tipping/sagas.ts +++ b/packages/web/src/common/store/tipping/sagas.ts @@ -405,7 +405,7 @@ function* sendTipAsync() { senderHandle: sender.handle, recipientHandle: receiver.handle, amount, - error: e.message, + error: 'transactionMessage' in e ? e.transactionMessage : e.message, device, source })