diff --git a/ccip-sdk/src/cct/solana/index.test.ts b/ccip-sdk/src/cct/solana/index.test.ts index 1ecf1c4e..fa2f77d1 100644 --- a/ccip-sdk/src/cct/solana/index.test.ts +++ b/ccip-sdk/src/cct/solana/index.test.ts @@ -3,7 +3,13 @@ import { describe, it } from 'node:test' import { Connection } from '@solana/web3.js' -import { SolanaTokenManager } from './index.ts' +import { + type RegisterAdminMethod, + type TokenAuthorityType, + REGISTER_ADMIN_METHODS, + SolanaTokenManager, + TOKEN_AUTHORITY_TYPES, +} from './index.ts' import type { GetTokenPoolStateParams, GetTokenPoolStateResult, @@ -28,6 +34,8 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.deployToken, 'function') assert.equal(typeof cct.generateUnsignedCreateTokenAccount, 'function') assert.equal(typeof cct.createTokenAccount, 'function') + assert.equal(typeof cct.generateUnsignedTransferAuthority, 'function') + assert.equal(typeof cct.transferAuthority, 'function') // Token admin registry operations assert.equal(typeof cct.generateUnsignedAcceptAdmin, 'function') @@ -74,6 +82,16 @@ describe('SolanaTokenManager (cct/solana)', () => { assert.equal(typeof cct.getTokenPoolState, 'function') }) + it('exports public operation constants', () => { + const authorityType: TokenAuthorityType = TOKEN_AUTHORITY_TYPES.MINT + const registrationMethod: RegisterAdminMethod = REGISTER_ADMIN_METHODS.OWNER + + assert.equal(authorityType, 'mint') + assert.equal(TOKEN_AUTHORITY_TYPES.FREEZE, 'freeze') + assert.equal(registrationMethod, 'owner') + assert.equal(REGISTER_ADMIN_METHODS.CCIP_ADMIN, 'ccip-admin') + }) + it('creates from a connection provider', async (t) => { const chain = stubChain() const connection = new Connection('http://localhost:8899') diff --git a/ccip-sdk/src/cct/solana/index.ts b/ccip-sdk/src/cct/solana/index.ts index 067525e0..d2a88cb2 100644 --- a/ccip-sdk/src/cct/solana/index.ts +++ b/ccip-sdk/src/cct/solana/index.ts @@ -17,11 +17,16 @@ import { type ExecuteCreateTokenAccountResult, type ExecuteDeployTokenParams, type ExecuteDeployTokenResult, + type ExecuteTransferAuthorityParams, + type ExecuteTransferAuthorityResult, type GenerateCreateTokenAccountParams, type GenerateCreateTokenAccountResult, type GenerateDeployTokenParams, type GenerateDeployTokenResult, + type GenerateTransferAuthorityParams, + type GenerateTransferAuthorityResult, CreateTokenAccount, + TransferAuthority, } from './token/operations/index.ts' import { type ExecuteAcceptAdminParams, @@ -144,6 +149,7 @@ export class SolanaTokenManager extends TokenManager readonly chain: SolanaChain // Token operations readonly #createTokenAccount = new CreateTokenAccount() + readonly #transferAuthority = new TransferAuthority() // Token admin registry operations readonly #acceptAdmin = new AcceptAdmin() @@ -309,6 +315,66 @@ export class SolanaTokenManager extends TokenManager return this.#createTokenAccount.execute(this.chain, opts) } + /** + * Builds unsigned instructions for an immediate SPL Token mint and/or freeze authority transfer. + * + * @remarks + * Once confirmed, the current authority loses the selected roles. Set `authorityTypes` to + * `['mint']`, `['freeze']`, or both. Set `newAuthority` to null to permanently revoke the selected + * roles; a revoked role cannot be transferred or restored. All selected roles must have the same + * current authority. The instructions are atomic: no role changes if any selected transfer fails. + * `authority` defaults to `payer`. For an SPL Token multisig authority, provide `multisigSigners` + * and collect member signatures externally. + * + * @throws {@link CCTParamsInvalidError} If an address or authority role selection is invalid. + * @throws {@link CCIPTokenMintNotFoundError} If the mint does not exist. + * @throws {@link CCIPTokenMintInvalidError} If the mint is not owned by an SPL Token program. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * const unsigned = await cct.generateUnsignedTransferAuthority({ + * payer: currentAuthority, + * tokenAddress: mint, + * newAuthority, + * authorityTypes: ['mint'], + * }) + * ``` + */ + generateUnsignedTransferAuthority( + opts: GenerateTransferAuthorityParams, + ): Promise { + return this.#transferAuthority.generate(this.chain, opts) + } + + /** + * Immediately transfers SPL Token mint and/or freeze authority using the executing wallet. + * + * @remarks + * Once confirmed, the current authority loses the selected roles. Set `authorityTypes` to + * `['mint']`, `['freeze']`, or both. Set `newAuthority` to null to permanently revoke the selected + * roles; a revoked role cannot be transferred or restored. All selected roles must have the same + * current authority. The transaction is atomic: no role changes if any selected transfer fails. + * SPL Token multisig authorities require `multisigSigners` and external member signatures; use + * {@link generateUnsignedTransferAuthority}. + * + * @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions. + * @throws {@link CCTParamsInvalidError} If an address or authority role selection is invalid, or + * `authority` does not match the executing wallet. + * @throws {@link CCIPTokenMintNotFoundError} If the mint does not exist. + * @throws {@link CCIPTokenMintInvalidError} If the mint is not owned by an SPL Token program. + * @throws {@link CCTTxFailedError} If simulation or the SPL Token program rejects the transaction. + * + * @example + * ```ts + * const cct = SolanaTokenManager.fromChain(chain) + * await cct.transferAuthority({ wallet, tokenAddress: mint, newAuthority, authorityTypes: ['mint'] }) + * ``` + */ + transferAuthority(opts: ExecuteTransferAuthorityParams): Promise { + return this.#transferAuthority.execute(this.chain, opts) + } + /** * Builds unsigned SPL Token multisig creation instructions. * The pool signer PDA occupies `threshold` slots; non-pool signers must meet the threshold independently. @@ -1672,6 +1738,8 @@ export { deriveTokenPoolSignerPda, resolveTokenPoolProgram, } from './programs/token-pool.ts' +export { TOKEN_AUTHORITY_TYPES } from './token/operations/transfer-authority.ts' +export { REGISTER_ADMIN_METHODS } from './token-admin-registry/operations/register-admin.ts' export type { TransactionResult } from '../operation.ts' export type { SerializedSolanaTxEncoding } from './serialize.ts' export type * from './token/operations/index.ts' diff --git a/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts b/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts index 0437d089..303f5e54 100644 --- a/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token-admin-registry/operations/index.ts @@ -3,6 +3,13 @@ export * from './append-to-lookup-table.ts' export * from './create-lookup-table.ts' export * from './get-supported-tokens.ts' export * from './get-token-admin-registry.ts' -export * from './register-admin.ts' +export { RegisterAdmin } from './register-admin.ts' +export type { + ExecuteRegisterAdminParams, + ExecuteRegisterAdminResult, + GenerateRegisterAdminParams, + GenerateRegisterAdminResult, + RegisterAdminMethod, +} from './register-admin.ts' export * from './set-pool.ts' export * from './transfer-admin.ts' diff --git a/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts b/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts index 3df93173..d77c6892 100644 --- a/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts +++ b/ccip-sdk/src/cct/solana/token-admin-registry/operations/register-admin.ts @@ -21,7 +21,7 @@ import { submit } from '../../submit.ts' import { parsePublicKey, validateAuthorityMatchesWallet } from '../../validate.ts' /** Authorization paths used to register a token in the TokenAdminRegistry. */ -const REGISTER_ADMIN_METHODS = { +export const REGISTER_ADMIN_METHODS = { OWNER: 'owner', CCIP_ADMIN: 'ccip-admin', } as const diff --git a/ccip-sdk/src/cct/solana/token/operations/index.ts b/ccip-sdk/src/cct/solana/token/operations/index.ts index e397c117..255c8d3f 100644 --- a/ccip-sdk/src/cct/solana/token/operations/index.ts +++ b/ccip-sdk/src/cct/solana/token/operations/index.ts @@ -1,2 +1,10 @@ export * from './create-token-account.ts' export * from './deploy-token.ts' +export { TransferAuthority } from './transfer-authority.ts' +export type { + ExecuteTransferAuthorityParams, + ExecuteTransferAuthorityResult, + GenerateTransferAuthorityParams, + GenerateTransferAuthorityResult, + TokenAuthorityType, +} from './transfer-authority.ts' diff --git a/ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts b/ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts new file mode 100644 index 00000000..838f6503 --- /dev/null +++ b/ccip-sdk/src/cct/solana/token/operations/transfer-authority.test.ts @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { Keypair, PublicKey } from '@solana/web3.js' + +import { CCIPTokenMintInvalidError, CCIPTokenMintNotFoundError } from '../../../../errors/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import { SolanaTokenManager } from '../../index.ts' + +const TOKEN = Keypair.generate().publicKey.toBase58() +const PAYER = Keypair.generate().publicKey.toBase58() +const AUTHORITY = Keypair.generate().publicKey.toBase58() +const NEW_AUTHORITY = Keypair.generate().publicKey.toBase58() +const MULTISIG = Keypair.generate().publicKey.toBase58() +const MULTISIG_SIGNER_1 = Keypair.generate().publicKey.toBase58() +const MULTISIG_SIGNER_2 = Keypair.generate().publicKey.toBase58() +const HASH = Keypair.generate().publicKey.toBase58() +const WALLET = { + publicKey: Keypair.generate().publicKey, + signTransaction: async (tx: T) => tx, +} + +function chain(mintOwner: PublicKey | null = TOKEN_PROGRAM_ID): SolanaChain { + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + connection: { + getAccountInfo: async () => (mintOwner ? { owner: mintOwner } : null), + }, + } as unknown as SolanaChain +} + +function submitChain(): SolanaChain { + return { + ...chain(), + connection: { + getAccountInfo: async () => ({ owner: TOKEN_PROGRAM_ID }), + simulateTransaction: async () => ({ value: { err: null, logs: [], unitsConsumed: 1 } }), + getLatestBlockhash: async () => ({ + blockhash: PublicKey.default.toBase58(), + lastValidBlockHeight: 1, + }), + sendTransaction: async () => HASH, + confirmTransaction: async () => ({ value: { err: null } }), + }, + } as unknown as SolanaChain +} + +function generate(opts: Record = {}, mintOwner?: PublicKey | null) { + return SolanaTokenManager.fromChain(chain(mintOwner)).generateUnsignedTransferAuthority({ + tokenAddress: TOKEN, + payer: PAYER, + authority: AUTHORITY, + newAuthority: NEW_AUTHORITY, + authorityTypes: ['mint', 'freeze'], + ...opts, + }) +} + +describe('TransferAuthority (cct/solana)', () => { + describe('generate', () => { + it('builds selected mint and freeze authority transfers', async () => { + const unsigned = await generate() + + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.mainIndex, 0) + assert.equal(unsigned.instructions.length, 2) + assert.deepEqual( + unsigned.instructions.map((instruction) => ({ + programId: instruction.programId.toBase58(), + authorityType: instruction.data[1], + mint: instruction.keys[0]!.pubkey.toBase58(), + authority: instruction.keys[1]!.pubkey.toBase58(), + newAuthority: instruction.data.subarray(3).toString('hex'), + })), + [ + { + programId: TOKEN_PROGRAM_ID.toBase58(), + authorityType: 0, // MintTokens + mint: TOKEN, + authority: AUTHORITY, + newAuthority: new PublicKey(NEW_AUTHORITY).toBuffer().toString('hex'), + }, + { + programId: TOKEN_PROGRAM_ID.toBase58(), + authorityType: 1, // FreezeAccount + mint: TOKEN, + authority: AUTHORITY, + newAuthority: new PublicKey(NEW_AUTHORITY).toBuffer().toString('hex'), + }, + ], + ) + }) + + it('builds only the selected authority transfer for Token-2022', async () => { + const unsigned = await generate({ authorityTypes: ['freeze'] }, TOKEN_2022_PROGRAM_ID) + + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.instructions[0]!.programId.toBase58(), TOKEN_2022_PROGRAM_ID.toBase58()) + assert.equal(unsigned.instructions[0]!.data[1], 1) // FreezeAccount + }) + + it('includes SPL multisig member signers', async () => { + const unsigned = await generate({ + authority: MULTISIG, + authorityTypes: ['mint'], + multisigSigners: [MULTISIG_SIGNER_1, MULTISIG_SIGNER_2], + }) + + assert.deepEqual( + unsigned.instructions[0]!.keys.map(({ pubkey, isSigner, isWritable }) => ({ + pubkey: pubkey.toBase58(), + isSigner, + isWritable, + })), + [ + { pubkey: TOKEN, isSigner: false, isWritable: true }, + { pubkey: MULTISIG, isSigner: false, isWritable: false }, + { pubkey: MULTISIG_SIGNER_1, isSigner: true, isWritable: false }, + { pubkey: MULTISIG_SIGNER_2, isSigner: true, isWritable: false }, + ], + ) + }) + + it('builds authority revocation with a null new authority', async () => { + const unsigned = await generate({ authorityTypes: ['mint'], newAuthority: null }) + const [instruction] = unsigned.instructions + + assert.ok(instruction) + assert.equal(instruction.data[0], 6) // SetAuthority + assert.equal(instruction.data[1], 0) // MintTokens + assert.equal(instruction.data[2], 0) // COption::None + assert.equal(instruction.data.length, 3) + }) + + it('defaults authority to payer', async () => { + const unsigned = await generate({ authority: undefined }) + + assert.equal(unsigned.instructions[0]!.keys[1]!.pubkey.toBase58(), PAYER) + }) + }) + + describe('validation', () => { + it('rejects invalid public keys', async () => { + for (const param of ['tokenAddress', 'newAuthority', 'authority']) { + await assert.rejects( + () => generate({ [param]: 'invalid' }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === param, + ) + } + }) + + it('rejects invalid multisig signers', async () => { + for (const [multisigSigners, param] of [ + ['invalid', 'multisigSigners'], + [['invalid'], 'multisigSigners[0]'], + ]) { + await assert.rejects( + () => generate({ multisigSigners }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === param, + ) + } + }) + + it('reports invalid authority role selections', async () => { + const cases: [unknown, string][] = [ + [undefined, 'must be an array'], + [[], 'must not be empty'], + [['mint', 'mint'], 'must not contain duplicates'], + [['close'], 'must contain only mint and/or freeze'], + ] + for (const [authorityTypes, message] of cases) { + await assert.rejects( + () => generate({ authorityTypes }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'authorityTypes' && + err.message.includes(message), + ) + } + }) + + it('rejects missing and non-token mints', async () => { + await assert.rejects( + () => generate({}, null), + (err: unknown) => err instanceof CCIPTokenMintNotFoundError, + ) + await assert.rejects( + () => generate({}, Keypair.generate().publicKey), + (err: unknown) => err instanceof CCIPTokenMintInvalidError, + ) + }) + }) + + describe('execute', () => { + it('signs, submits, and returns the tx hash', async () => { + const result = await SolanaTokenManager.fromChain(submitChain()).transferAuthority({ + tokenAddress: TOKEN, + newAuthority: NEW_AUTHORITY, + authorityTypes: ['mint'], + wallet: WALLET, + }) + + assert.deepEqual(result, { hash: HASH }) + }) + + it('requires unsigned generation for SPL multisig authorities', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).transferAuthority({ + tokenAddress: TOKEN, + newAuthority: NEW_AUTHORITY, + authority: MULTISIG, + authorityTypes: ['mint'], + multisigSigners: [MULTISIG_SIGNER_1], + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && err.context.param === 'multisigSigners', + ) + }) + + it('rejects a non-wallet authority for signed transfer', async () => { + await assert.rejects( + () => + SolanaTokenManager.fromChain(chain()).transferAuthority({ + tokenAddress: TOKEN, + newAuthority: NEW_AUTHORITY, + authority: AUTHORITY, + authorityTypes: ['mint'], + wallet: WALLET, + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'transferAuthority' && + err.context.param === 'authority', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts b/ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts new file mode 100644 index 00000000..77683d0d --- /dev/null +++ b/ccip-sdk/src/cct/solana/token/operations/transfer-authority.ts @@ -0,0 +1,174 @@ +import { AuthorityType, createSetAuthorityInstruction } from '@solana/spl-token' +import type { PublicKey, TransactionInstruction } from '@solana/web3.js' + +import { ChainFamily } from '../../../../networks.ts' +import type { SolanaChain } from '../../../../solana/index.ts' +import type { UnsignedSolanaTx } from '../../../../solana/types.ts' +import { resolveTokenProgram } from '../../../../solana/utils.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { + type SolanaExecuteParams, + type SolanaGenerateParams, + SolanaOperation, +} from '../../operation.ts' +import { submit } from '../../submit.ts' +import { parsePublicKey, validateAuthorityMatchesWallet } from '../../validate.ts' + +/** SPL Token authority roles that can be transferred. */ +export const TOKEN_AUTHORITY_TYPES = { + MINT: 'mint', + FREEZE: 'freeze', +} as const + +/** SPL Token authority role that can be transferred. */ +export type TokenAuthorityType = (typeof TOKEN_AUTHORITY_TYPES)[keyof typeof TOKEN_AUTHORITY_TYPES] + +type TransferAuthorityParams = { + /** SPL token mint address. */ + tokenAddress: string + /** Address to receive the selected authority roles, or null to revoke them permanently. */ + newAuthority: string | null + /** Current authority. Defaults to `payer` for single-signer transactions. */ + authority?: string + /** SPL Token multisig member addresses. Required when authority is an SPL Token multisig. */ + multisigSigners?: string[] + /** Authority roles to transfer. */ + authorityTypes: TokenAuthorityType[] +} + +type ParsedTransferAuthorityParams = { + tokenAddress: PublicKey + newAuthority: PublicKey | null + authority: PublicKey + multisigSigners: PublicKey[] + authorityTypes: TokenAuthorityType[] +} + +/** Parameters for unsigned Solana SPL Token authority transfer. */ +export type GenerateTransferAuthorityParams = SolanaGenerateParams + +/** Unsigned Solana SPL Token authority transfer result. */ +export type GenerateTransferAuthorityResult = UnsignedSolanaTx + +/** Parameters for executing Solana SPL Token authority transfer. */ +export type ExecuteTransferAuthorityParams = SolanaExecuteParams + +/** Result of executing Solana SPL Token authority transfer. */ +export type ExecuteTransferAuthorityResult = TransactionResult + +const SPL_AUTHORITY_TYPES: Record = { + mint: AuthorityType.MintTokens, + freeze: AuthorityType.FreezeAccount, +} + +/** + * Immediately transfers mint authority, freeze authority, or both for an SPL Token mint; there is + * no propose-and-accept step. + * + * @remarks + * Once confirmed, the current authority loses the selected roles. All selected roles must have the + * same current authority. Supply `multisigSigners` when that authority is an SPL Token multisig. Set + * `newAuthority` to null to revoke the selected roles permanently; a revoked mint or freeze + * authority cannot be transferred. The instructions share one atomic Solana transaction, so no role + * changes if any selected transfer fails. + */ +export class TransferAuthority extends SolanaOperation< + TransferAuthorityParams, + UnsignedSolanaTx, + ParsedTransferAuthorityParams +> { + readonly name = 'transferAuthority' + + /** Parses public keys and validates the selected authority roles. */ + protected override parse(params: GenerateTransferAuthorityParams): ParsedTransferAuthorityParams { + const authorityTypes = params.authorityTypes + if (!Array.isArray(authorityTypes)) { + throw new CCTParamsInvalidError(this.name, 'authorityTypes', 'must be an array') + } + if (authorityTypes.length === 0) { + throw new CCTParamsInvalidError(this.name, 'authorityTypes', 'must not be empty') + } + if (new Set(authorityTypes).size !== authorityTypes.length) { + throw new CCTParamsInvalidError(this.name, 'authorityTypes', 'must not contain duplicates') + } + if (authorityTypes.some((type) => !Object.values(TOKEN_AUTHORITY_TYPES).includes(type))) { + throw new CCTParamsInvalidError( + this.name, + 'authorityTypes', + 'must contain only mint and/or freeze', + ) + } + + if (params.multisigSigners !== undefined && !Array.isArray(params.multisigSigners)) { + throw new CCTParamsInvalidError(this.name, 'multisigSigners', 'must be an array') + } + + const payer = parsePublicKey(this.name, 'payer', params.payer) + return { + tokenAddress: parsePublicKey(this.name, 'tokenAddress', params.tokenAddress), + newAuthority: + params.newAuthority === null + ? null + : parsePublicKey(this.name, 'newAuthority', params.newAuthority), + authority: + params.authority === undefined + ? payer + : parsePublicKey(this.name, 'authority', params.authority), + multisigSigners: (params.multisigSigners ?? []).map((signer, i) => + parsePublicKey(this.name, `multisigSigners[${i}]`, signer), + ), + authorityTypes, + } + } + + /** Builds one SPL Token `SetAuthority` instruction for each selected authority role. */ + protected async buildUnsigned( + chain: SolanaChain, + opts: ParsedTransferAuthorityParams, + ): Promise { + const tokenProgram = await resolveTokenProgram(chain.connection, opts.tokenAddress) + const instructions: TransactionInstruction[] = opts.authorityTypes.map((authorityType) => + createSetAuthorityInstruction( + opts.tokenAddress, + opts.authority, + SPL_AUTHORITY_TYPES[authorityType], + opts.newAuthority, + opts.multisigSigners, + tokenProgram, + ), + ) + + chain.logger.debug( + `${this.name}: token = ${opts.tokenAddress.toBase58()}, authorityTypes = ${opts.authorityTypes.join(',')}, newAuthority = ${opts.newAuthority?.toBase58() ?? 'revoked'}`, + ) + return { family: ChainFamily.Solana, instructions, mainIndex: 0 } + } + + /** Generate, sign, simulate, send, and confirm with the current authority wallet. */ + override async execute( + chain: SolanaChain, + params: ExecuteTransferAuthorityParams, + ): Promise { + const { wallet, computeUnits, parsed } = this.prepareWalletExecution(params) + + if (parsed.multisigSigners.length > 0) { + throw new CCTParamsInvalidError( + this.name, + 'multisigSigners', + 'requires externally signed transactions; use generateUnsignedTransferAuthority', + ) + } + + if (params.authority !== undefined) { + validateAuthorityMatchesWallet( + this.name, + parsed.authority, + wallet.publicKey, + 'transferAuthority requires authority to be the executing wallet. Use generateUnsignedTransferAuthority for externally signed transactions.', + ) + } + + return submit(chain, wallet, await this.buildUnsigned(chain, parsed), this.name, computeUnits) + } +}