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
4 changes: 4 additions & 0 deletions ccip-sdk/src/cct/solana/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ describe('SolanaTokenManager (cct/solana)', () => {
assert.equal(typeof cct.setChainRateLimit, 'function')
assert.equal(typeof cct.generateUnsignedSetRateLimitAdmin, 'function')
assert.equal(typeof cct.setRateLimitAdmin, 'function')
assert.equal(typeof cct.generateUnsignedTransferOwnership, 'function')
assert.equal(typeof cct.transferOwnership, 'function')
assert.equal(typeof cct.generateUnsignedAcceptOwnership, 'function')
assert.equal(typeof cct.acceptOwnership, 'function')
assert.equal(typeof cct.generateUnsignedEditChainRemoteConfig, 'function')
assert.equal(typeof cct.editChainRemoteConfig, 'function')
assert.equal(typeof cct.generateUnsignedRemoveFromAllowlist, 'function')
Expand Down
127 changes: 127 additions & 0 deletions ccip-sdk/src/cct/solana/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import {
type BaseGetTokenPoolStateResult,
type BurnMintPoolProgramRef,
type CustomPoolProgramRef,
type ExecuteAcceptOwnershipParams,
type ExecuteAcceptOwnershipResult,
type ExecuteAppendRemotePoolAddressesParams,
type ExecuteAppendRemotePoolAddressesResult,
type ExecuteApplyChainUpdatesParams,
Expand All @@ -86,6 +88,10 @@ import {
type ExecuteSetChainRateLimitResult,
type ExecuteSetRateLimitAdminParams,
type ExecuteSetRateLimitAdminResult,
type ExecuteTransferOwnershipParams,
type ExecuteTransferOwnershipResult,
type GenerateAcceptOwnershipParams,
type GenerateAcceptOwnershipResult,
type GenerateAppendRemotePoolAddressesParams,
type GenerateAppendRemotePoolAddressesResult,
type GenerateApplyChainUpdatesParams,
Expand All @@ -108,12 +114,15 @@ import {
type GenerateSetChainRateLimitResult,
type GenerateSetRateLimitAdminParams,
type GenerateSetRateLimitAdminResult,
type GenerateTransferOwnershipParams,
type GenerateTransferOwnershipResult,
type GetTokenPoolRemotesParams,
type GetTokenPoolRemotesResult,
type GetTokenPoolStateParams,
type GetTokenPoolStateResult,
type LockReleaseGetTokenPoolStateResult,
type LockReleasePoolProgramRef,
AcceptOwnership,
AppendRemotePoolAddresses,
ApplyChainUpdates,
ConfigureAllowlist,
Expand All @@ -127,6 +136,7 @@ import {
RemoveFromAllowlist,
SetChainRateLimit,
SetRateLimitAdmin,
TransferOwnership,
} from './token-pool/operations/index.ts'

/** CCT admin facade for Solana. */
Expand All @@ -146,6 +156,7 @@ export class SolanaTokenManager extends TokenManager<typeof ChainFamily.Solana>
readonly #transferAdmin = new TransferAdmin()

// Token pool operations
readonly #acceptOwnership = new AcceptOwnership()
readonly #appendRemotePoolAddresses = new AppendRemotePoolAddresses()
readonly #applyChainUpdates = new ApplyChainUpdates()
readonly #configureAllowlist = new ConfigureAllowlist()
Expand All @@ -159,6 +170,7 @@ export class SolanaTokenManager extends TokenManager<typeof ChainFamily.Solana>
readonly #removeFromAllowlist = new RemoveFromAllowlist()
readonly #setChainRateLimit = new SetChainRateLimit()
readonly #setRateLimitAdmin = new SetRateLimitAdmin()
readonly #transferOwnership = new TransferOwnership()

/** Creates a Solana CCT manager for an existing chain. */
constructor(chain: SolanaChain) {
Expand Down Expand Up @@ -892,6 +904,121 @@ export class SolanaTokenManager extends TokenManager<typeof ChainFamily.Solana>
return this.#setRateLimitAdmin.execute(this.chain, opts)
}

/**
* Builds an unsigned instruction that proposes a new owner for an initialized Solana token pool.
* Pass canonical `poolType` or a compatible `poolProgramAddress`; `authority` defaults to `payer`.
* The operation reads pool state and rejects the current owner or default public key. The proposed
* owner must accept ownership separately before the transfer takes effect.
*
* @see {@link transferOwnership}
* @see {@link generateUnsignedAcceptOwnership}
*
* @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid.
* @throws {@link CCIPTokenPoolStateNotFoundError} If the token pool account does not exist.
*
* @example
* ```ts
* const cct = SolanaTokenManager.fromChain(chain)
* const unsigned = await cct.generateUnsignedTransferOwnership({
* tokenAddress: mint,
* poolType: 'burn-mint',
* newOwner,
* payer,
* authority,
* })
* ```
*/
generateUnsignedTransferOwnership(
opts: GenerateTransferOwnershipParams,
): Promise<GenerateTransferOwnershipResult> {
return this.#transferOwnership.generate(this.chain, opts)
}

/**
* Proposes a new owner for an initialized Solana token pool using the current owner wallet.
* It rejects the current owner or default public key. The proposed owner must accept ownership
* separately before the transfer takes effect.
*
* @see {@link generateUnsignedTransferOwnership}
* @see {@link acceptOwnership}
*
* @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions.
* @throws {@link CCTParamsInvalidError} If a pool parameter is invalid or the authority differs
* from the executing wallet.
* @throws {@link CCIPTokenPoolStateNotFoundError} If the token pool account does not exist.
* @throws {@link CCTTxFailedError} If the wallet is not the pool owner or simulation/submission fails.
*
* @example
* ```ts
* const cct = SolanaTokenManager.fromChain(chain)
* await cct.transferOwnership({
* tokenAddress: mint,
* poolType: 'burn-mint',
* newOwner,
* wallet,
* })
* ```
*/
transferOwnership(opts: ExecuteTransferOwnershipParams): Promise<ExecuteTransferOwnershipResult> {
return this.#transferOwnership.execute(this.chain, opts)
}

/**
* Builds an unsigned instruction that accepts pending ownership of an initialized Solana token
* pool. Pass canonical `poolType` or a compatible `poolProgramAddress`; `authority` defaults to
* `payer`. The operation reads pool state and requires it to be the proposed owner.
*
* @see {@link acceptOwnership}
* @see {@link generateUnsignedTransferOwnership}
*
* @throws {@link CCTParamsInvalidError} If a pool parameter or public key is invalid.
* @throws {@link CCIPTokenPoolStateNotFoundError} If the token pool account does not exist.
*
* @example
* ```ts
* const cct = SolanaTokenManager.fromChain(chain)
* const unsigned = await cct.generateUnsignedAcceptOwnership({
* tokenAddress: mint,
* poolType: 'burn-mint',
* payer,
* authority,
* })
* ```
*/
generateUnsignedAcceptOwnership(
opts: GenerateAcceptOwnershipParams,
): Promise<GenerateAcceptOwnershipResult> {
return this.#acceptOwnership.generate(this.chain, opts)
}

/**
* Accepts pending ownership of an initialized Solana token pool using the proposed owner wallet.
* It verifies the wallet is the proposed owner before submitting.
*
* @see {@link generateUnsignedAcceptOwnership}
* @see {@link transferOwnership}
*
* @throws {@link CCIPWalletInvalidError} If `wallet` cannot sign Solana transactions.
* @throws {@link CCTParamsInvalidError} If a pool parameter is invalid or the authority differs
* from the executing wallet.
* @throws {@link CCIPTokenPoolStateNotFoundError} If the token pool account does not exist.
* @throws {@link CCTTxFailedError} If the wallet is not the proposed owner or
* simulation/submission fails.
*
* @example
* ```ts
* const cct = SolanaTokenManager.fromChain(chain)
* await cct.acceptOwnership({
* tokenAddress: mint,
* poolType: 'burn-mint',
* wallet,
* })
* ```
*/
acceptOwnership(opts: ExecuteAcceptOwnershipParams): Promise<ExecuteAcceptOwnershipResult> {
return this.#acceptOwnership.execute(this.chain, opts)
}

/**
* Builds an unsigned instruction that sets inbound and outbound rate limits for an initialized
* Solana token pool remote-chain config. Pass canonical `poolType` or a compatible
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'

import { BorshAccountsCoder } from '@coral-xyz/anchor'
import { Keypair, PublicKey } from '@solana/web3.js'

import { ChainFamily } from '../../../../networks.ts'
import { tokenPoolCoder } from '../../../../solana/idl/token-pool-coder.ts'
import type { SolanaChain } from '../../../../solana/index.ts'
import { CCTParamsInvalidError } from '../../../errors.ts'
import { SolanaTokenManager } from '../../index.ts'
import { deriveTokenPoolConfigPda, resolveTokenPoolProgram } from '../../programs/token-pool.ts'

const TOKEN = Keypair.generate().publicKey.toBase58()
const PAYER = Keypair.generate().publicKey.toBase58()
const AUTHORITY = Keypair.generate().publicKey.toBase58()
const HASH = Keypair.generate().publicKey.toBase58()
const WALLET = {
publicKey: Keypair.generate().publicKey,
signTransaction: async <T>(tx: T) => tx,
}

function stateData(proposedOwner = AUTHORITY): Buffer {
const key = PublicKey.default.toBuffer()
return Buffer.concat([
BorshAccountsCoder.accountDiscriminator('State'),
Buffer.from([1]),
key,
new PublicKey(TOKEN).toBuffer(),
Buffer.from([6]),
key,
key,
key,
new PublicKey(proposedOwner).toBuffer(),
key,
key,
key,
key,
key,
Buffer.from([0, 0]),
Buffer.alloc(4),
key,
])
}

function chain(proposedOwner = AUTHORITY): SolanaChain {
return {
logger: { debug() {}, info() {}, warn() {}, error() {} },
connection: {
getAccountInfo: async () => ({ owner: PublicKey.default, data: stateData(proposedOwner) }),
},
} as unknown as SolanaChain
}

function submitChain(): SolanaChain {
return {
...chain(WALLET.publicKey.toBase58()),
connection: {
simulateTransaction: async () => ({ value: { err: null, logs: [], unitsConsumed: 1 } }),
getLatestBlockhash: async () => ({
blockhash: PublicKey.default.toBase58(),
lastValidBlockHeight: 1,
}),
sendTransaction: async () => HASH,
confirmTransaction: async () => ({ value: { err: null } }),
getAccountInfo: async () => ({
owner: PublicKey.default,
data: stateData(WALLET.publicKey.toBase58()),
}),
},
} as unknown as SolanaChain
}

function generate(opts = {}) {
return SolanaTokenManager.fromChain(chain()).generateUnsignedAcceptOwnership({
tokenAddress: TOKEN,
poolType: 'burn-mint',
payer: PAYER,
authority: AUTHORITY,
...opts,
})
}

describe('AcceptOwnership (cct/solana)', () => {
describe('generate', () => {
it('builds the ownership-acceptance instruction', async () => {
const unsigned = await generate()
const [instruction] = unsigned.instructions
const poolProgram = resolveTokenPoolProgram('burn-mint')
const decoded = tokenPoolCoder.instruction.decode(instruction!.data)

assert.equal(unsigned.family, ChainFamily.Solana)
assert.equal(unsigned.mainIndex, 0)
assert.equal(instruction!.programId.toBase58(), poolProgram.toBase58())
assert.deepEqual(
instruction!.keys.map(({ pubkey, isSigner, isWritable }) => ({
pubkey: pubkey.toBase58(),
isSigner,
isWritable,
})),
[
{
pubkey: deriveTokenPoolConfigPda(poolProgram, new PublicKey(TOKEN)).toBase58(),
isSigner: false,
isWritable: true,
},
{ pubkey: TOKEN, isSigner: false, isWritable: false },
{ pubkey: AUTHORITY, isSigner: true, isWritable: false },
],
)
assert.ok(decoded)
assert.equal(decoded.name, 'acceptOwnership')
})

it('defaults authority to payer', async () => {
const unsigned = await SolanaTokenManager.fromChain(
chain(PAYER),
).generateUnsignedAcceptOwnership({
tokenAddress: TOKEN,
poolType: 'burn-mint',
payer: PAYER,
})

assert.equal(unsigned.instructions[0]!.keys[2]!.pubkey.toBase58(), PAYER)
})

it('supports a compatible custom pool program', async () => {
const poolProgramAddress = Keypair.generate().publicKey.toBase58()
const unsigned = await generate({ poolType: undefined, poolProgramAddress })

assert.equal(unsigned.instructions[0]?.programId.toBase58(), poolProgramAddress)
})
})

describe('validation', () => {
it('rejects an authority that is not the proposed owner', async () => {
await assert.rejects(
() => generate({ authority: PAYER }),
(err: unknown) =>
err instanceof CCTParamsInvalidError &&
err.context.param === 'authority' &&
err.message.includes('must be the proposed owner'),
)
})

it('rejects when there is no proposed owner', async () => {
const cct = SolanaTokenManager.fromChain(chain(PublicKey.default.toBase58()))

await assert.rejects(
() =>
cct.generateUnsignedAcceptOwnership({
tokenAddress: TOKEN,
poolType: 'burn-mint',
payer: PAYER,
}),
(err: unknown) =>
err instanceof CCTParamsInvalidError &&
err.context.param === 'authority' &&
err.message.includes('no proposed owner'),
)
})

it('rejects invalid public keys', async () => {
for (const [opts, param] of [
[{ tokenAddress: 'invalid' }, 'tokenAddress'],
[{ authority: 'invalid' }, 'authority'],
]) {
await assert.rejects(
() => generate(opts),
(err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === param,
)
}
})
})

describe('execute', () => {
it('signs, submits, and returns the tx hash', async () => {
const result = await SolanaTokenManager.fromChain(submitChain()).acceptOwnership({
tokenAddress: TOKEN,
poolType: 'burn-mint',
wallet: WALLET,
})

assert.deepEqual(result, { hash: HASH })
})

it('rejects a non-wallet authority for signed acceptance', async () => {
await assert.rejects(
() =>
SolanaTokenManager.fromChain(chain()).acceptOwnership({
tokenAddress: TOKEN,
poolType: 'burn-mint',
authority: AUTHORITY,
wallet: WALLET,
}),
(err: unknown) =>
err instanceof CCTParamsInvalidError &&
err.context.operation === 'acceptOwnership' &&
err.context.param === 'authority',
)
})
})
})
Loading
Loading