From ac398c3e57086c9f512928b9c525564bd3a52ea7 Mon Sep 17 00:00:00 2001 From: Yolley Date: Fri, 10 Apr 2026 13:38:18 +0800 Subject: [PATCH 1/4] feat: support transfer hook, partner link --- lerna.json | 2 +- packages/common/package.json | 2 +- packages/distributor/package.json | 2 +- packages/eslint-config/package.json | 2 +- packages/launchpad/package.json | 2 +- packages/staking/package.json | 2 +- .../__tests__/solana/streamClient.spec.ts | 302 +++++++++++++- packages/stream/package.json | 2 +- packages/stream/solana/StreamClient.ts | 394 +++++++++++------- packages/stream/solana/instructions.ts | 14 + packages/stream/solana/types.ts | 2 + 11 files changed, 562 insertions(+), 164 deletions(-) diff --git a/lerna.json b/lerna.json index 0bc5388c..9dcfcbd9 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "packages": [ "packages/*" ], - "version": "11.3.1", + "version": "11.4.0", "$schema": "node_modules/lerna/schemas/lerna-schema.json", "command": { "run": { diff --git a/packages/common/package.json b/packages/common/package.json index 3b0f31c6..a89131fe 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@streamflow/common", - "version": "11.3.1", + "version": "11.4.0", "description": "Common utilities and types used by streamflow packages.", "homepage": "https://github.com/streamflow-finance/js-sdk/", "type": "module", diff --git a/packages/distributor/package.json b/packages/distributor/package.json index 8136b6ec..ed59fad8 100644 --- a/packages/distributor/package.json +++ b/packages/distributor/package.json @@ -1,6 +1,6 @@ { "name": "@streamflow/distributor", - "version": "11.3.1", + "version": "11.4.0", "description": "JavaScript SDK to interact with Streamflow Airdrop protocol.", "homepage": "https://github.com/streamflow-finance/js-sdk/", "main": "./dist/cjs/index.cjs", diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 2e4ee358..a578ca75 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@streamflow/eslint-config", - "version": "11.3.1", + "version": "11.4.0", "description": "ESLint configuration for Streamflow protocol.", "homepage": "https://github.com/streamflow-finance/js-sdk/", "engines": { diff --git a/packages/launchpad/package.json b/packages/launchpad/package.json index afbf64cd..b8e30412 100644 --- a/packages/launchpad/package.json +++ b/packages/launchpad/package.json @@ -1,6 +1,6 @@ { "name": "@streamflow/launchpad", - "version": "11.3.1", + "version": "11.4.0", "description": "JavaScript SDK to interact with Streamflow Launchpad protocol.", "homepage": "https://github.com/streamflow-finance/js-sdk/", "main": "./dist/cjs/index.cjs", diff --git a/packages/staking/package.json b/packages/staking/package.json index f6c14400..fba1fdea 100644 --- a/packages/staking/package.json +++ b/packages/staking/package.json @@ -1,6 +1,6 @@ { "name": "@streamflow/staking", - "version": "11.3.1", + "version": "11.4.0", "description": "JavaScript SDK to interact with Streamflow Staking protocol.", "homepage": "https://github.com/streamflow-finance/js-sdk/", "main": "./dist/cjs/index.cjs", diff --git a/packages/stream/__tests__/solana/streamClient.spec.ts b/packages/stream/__tests__/solana/streamClient.spec.ts index d2c41851..d679c196 100644 --- a/packages/stream/__tests__/solana/streamClient.spec.ts +++ b/packages/stream/__tests__/solana/streamClient.spec.ts @@ -54,20 +54,69 @@ vi.mock("@streamflow/common", async (importOriginal) => { }; }); +vi.mock("@solana/spl-token", async (importOriginal) => { + const actual = await importOriginal() as Record; + return { + ...actual, + getTransferHook: vi.fn(), + }; +}); + vi.mock("@coral-xyz/anchor", () => ({ Program: vi.fn().mockImplementation(() => ({ programId: new PublicKey("11111111111111111111111111111111"), methods: { create: vi.fn(() => ({ - accountsPartial: vi.fn(() => ({ - instruction: vi.fn(() => - Promise.resolve({ - keys: [], - programId: new PublicKey("11111111111111111111111111111111"), - data: Buffer.alloc(0), + accountsPartial: vi.fn(() => { + let extraAccounts: any[] = []; + return { + remainingAccounts: vi.fn((accounts) => { + extraAccounts = accounts; + return { + instruction: vi.fn(() => + Promise.resolve({ + keys: extraAccounts, + programId: new PublicKey("11111111111111111111111111111111"), + data: Buffer.alloc(0), + }), + ), + }; + }), + instruction: vi.fn(() => + Promise.resolve({ + keys: extraAccounts, + programId: new PublicKey("11111111111111111111111111111111"), + data: Buffer.alloc(0), + }), + ), + }; + }), + })), + cancel: vi.fn(() => ({ + accountsPartial: vi.fn(() => { + let extraAccounts: any[] = []; + return { + remainingAccounts: vi.fn((accounts) => { + extraAccounts = accounts; + return { + instruction: vi.fn(() => + Promise.resolve({ + keys: extraAccounts, + programId: new PublicKey("11111111111111111111111111111111"), + data: Buffer.alloc(0), + }), + ), + }; }), - ), - })), + instruction: vi.fn(() => + Promise.resolve({ + keys: extraAccounts, + programId: new PublicKey("11111111111111111111111111111111"), + data: Buffer.alloc(0), + }), + ), + }; + }), })), }, })), @@ -79,6 +128,7 @@ vi.mock("../../solana/lib/utils.js", () => ({ sendAndConfirmStreamRawTransaction: vi.fn(), extractSolanaErrorCode: vi.fn(), calculateTotalAmountToDeposit: vi.fn(), + decodeStream: vi.fn(), })); describe("SolanaStreamClient Transaction Builders", async () => { @@ -108,6 +158,12 @@ describe("SolanaStreamClient Transaction Builders", async () => { await import("../../solana/lib/utils.js"), ).sendAndConfirmStreamRawTransaction; const mockExtractSolanaErrorCode = vi.mocked(await import("../../solana/lib/utils.js")).extractSolanaErrorCode; + const mockCalculateTotalAmountToDeposit = vi.mocked( + await import("../../solana/lib/utils.js"), + ).calculateTotalAmountToDeposit; + const mockDecodeStream = vi.mocked(await import("../../solana/lib/utils.js")).decodeStream; + const mockGetTransferHook = vi.mocked(await import("@solana/spl-token")).getTransferHook; + const { TOKEN_2022_PROGRAM_ID, getExtraAccountMetaAddress } = await import("@solana/spl-token"); beforeEach(async () => { vi.clearAllMocks(); @@ -165,6 +221,9 @@ describe("SolanaStreamClient Transaction Builders", async () => { ]); mockSendAndConfirmStreamRawTransaction.mockResolvedValue(undefined as any); mockExtractSolanaErrorCode.mockReturnValue("custom_error_code"); + mockCalculateTotalAmountToDeposit.mockImplementation((amount) => amount); + mockDecodeStream.mockReset(); + mockGetTransferHook.mockReturnValue(null); // Mock connection on instance (instance as any).connection = mockConnection; @@ -270,6 +329,233 @@ describe("SolanaStreamClient Transaction Builders", async () => { }); }); + describe("transfer hook accounts", () => { + const pk = (seed: number) => new PublicKey(new Uint8Array(32).fill(seed)); + + const createDecodedStream = () => ({ + magic: new BN(0), + version: new BN(0), + createdAt: new BN(0), + withdrawnAmount: new BN(100), + canceledAt: new BN(0), + end: new BN(9999999999), + lastWithdrawnAt: new BN(0), + sender: pk(1), + senderTokens: pk(2), + recipient: pk(3), + recipientTokens: pk(4), + mint: pk(5), + escrowTokens: pk(6), + streamflowTreasury: pk(7), + streamflowTreasuryTokens: pk(8), + streamflowFeeTotal: new BN(50), + streamflowFeeWithdrawn: new BN(10), + streamflowFeePercent: 0, + partnerFeeTotal: new BN(20), + partnerFeeWithdrawn: new BN(5), + partnerFeePercent: 0, + partner: pk(9), + partnerTokens: pk(10), + start: new BN(0), + depositedAmount: new BN(1000), + period: new BN(9999999999), + amountPerPeriod: new BN(0), + cliff: new BN(0), + cliffAmount: new BN(0), + cancelableBySender: true, + cancelableByRecipient: false, + automaticWithdrawal: false, + transferableBySender: true, + transferableByRecipient: false, + canTopup: true, + name: "hooked stream", + withdrawFrequency: new BN(0), + isPda: false, + nonce: 0, + closed: false, + currentPauseStart: new BN(0), + pauseCumulative: new BN(0), + lastRateChangeTime: new BN(1), + fundsUnlockedAtLastRateChange: new BN(400), + oldMetadata: pk(11), + payer: pk(12), + bump: 0, + }); + + test("adds transfer hook accounts to linear create instructions", async () => { + const senderPublicKey = pk(13); + const transferHookProgramId = pk(14); + const validationPubkey = getExtraAccountMetaAddress(pk(16), transferHookProgramId); + + mockGetMintAndProgram.mockResolvedValue({ + tokenProgramId: TOKEN_2022_PROGRAM_ID, + mint: {} as any, + }); + mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); + + const result = await instance.prepareCreateLinearStreamInstructions( + { + recipient: pk(15).toBase58(), + amount: new BN(1000), + tokenId: pk(16).toBase58(), + name: "Hooked Stream", + cliffAmount: new BN(100), + amountPerPeriod: new BN(50), + period: 86400, + start: 10, + cliff: 10, + cancelableBySender: true, + cancelableByRecipient: false, + transferableBySender: true, + transferableByRecipient: false, + canTopup: true, + automaticWithdrawal: false, + withdrawalFrequency: 0, + canPause: false, + canUpdateRate: false, + }, + { + sender: { publicKey: senderPublicKey }, + }, + ); + + expect(result.ixs.at(-1)?.keys.slice(-2)).toEqual([ + { pubkey: validationPubkey, isSigner: false, isWritable: false }, + { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, + ]); + }); + + test("adds partnerLink before transfer hook accounts for aligned create remaining accounts", async () => { + const senderPublicKey = pk(13); + const transferHookProgramId = pk(14); + const partnerLinkPublicKey = pk(18); + const validationPubkey = getExtraAccountMetaAddress(pk(16), transferHookProgramId); + + mockGetMintAndProgram.mockResolvedValue({ + tokenProgramId: TOKEN_2022_PROGRAM_ID, + mint: {} as any, + }); + mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); + + const result = await instance.prepareCreateAlignedUnlockInstructions( + { + recipient: pk(15).toBase58(), + amount: new BN(1000), + tokenId: pk(16).toBase58(), + name: "Hooked Aligned Stream", + cliffAmount: new BN(100), + amountPerPeriod: new BN(50), + period: 86400, + start: 10, + cliff: 10, + cancelableBySender: true, + cancelableByRecipient: false, + transferableBySender: true, + transferableByRecipient: false, + canTopup: true, + partnerLink: { address: partnerLinkPublicKey.toBase58(), isSigner: true }, + minPrice: new BN(1), + maxPrice: new BN(2), + minPercentage: new BN(0), + maxPercentage: new BN(100), + }, + { + sender: { publicKey: senderPublicKey }, + }, + ); + + expect(result.ixs.at(-1)?.keys).toEqual([ + { pubkey: partnerLinkPublicKey, isSigner: true, isWritable: false }, + { pubkey: validationPubkey, isSigner: false, isWritable: false }, + { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, + ]); + }); + + test("adds transfer hook validation and program accounts to withdraw instructions", async () => { + const streamPublicKey = pk(17); + const transferHookProgramId = pk(14); + const decodedStream = createDecodedStream(); + const validationPubkey = getExtraAccountMetaAddress(decodedStream.mint, transferHookProgramId); + + mockGetMintAndProgram.mockResolvedValue({ + tokenProgramId: TOKEN_2022_PROGRAM_ID, + mint: {} as any, + }); + mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); + mockDecodeStream.mockReturnValue(decodedStream as any); + (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); + + const result = await instance.prepareWithdrawInstructions( + { id: streamPublicKey.toBase58() }, + { + invoker: { publicKey: pk(13) }, + checkTokenAccounts: false, + }, + ); + + expect(result.at(-1)?.keys.slice(-2)).toEqual([ + { pubkey: validationPubkey, isSigner: false, isWritable: false }, + { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, + ]); + }); + + test("adds transfer hook validation and program accounts to linear cancel instructions", async () => { + const streamPublicKey = pk(17); + const transferHookProgramId = pk(14); + const decodedStream = createDecodedStream(); + const validationPubkey = getExtraAccountMetaAddress(decodedStream.mint, transferHookProgramId); + + mockGetMintAndProgram.mockResolvedValue({ + tokenProgramId: TOKEN_2022_PROGRAM_ID, + mint: {} as any, + }); + mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); + mockDecodeStream.mockReturnValue(decodedStream as any); + (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); + + const result = await instance.prepareCancelLinearStream( + { id: streamPublicKey.toBase58() }, + { + invoker: { publicKey: pk(13) }, + checkTokenAccounts: false, + }, + ); + + expect(result.at(-1)?.keys.slice(-2)).toEqual([ + { pubkey: validationPubkey, isSigner: false, isWritable: false }, + { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, + ]); + }); + + test("adds transfer hook validation and program accounts to aligned cancel remaining accounts", async () => { + const streamPublicKey = pk(17); + const transferHookProgramId = pk(14); + const decodedStream = createDecodedStream(); + const validationPubkey = getExtraAccountMetaAddress(decodedStream.mint, transferHookProgramId); + + mockGetMintAndProgram.mockResolvedValue({ + tokenProgramId: TOKEN_2022_PROGRAM_ID, + mint: {} as any, + }); + mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); + mockDecodeStream.mockReturnValue(decodedStream as any); + (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); + + const result = await instance.prepareCancelAlignedUnlockInstructions( + { id: streamPublicKey.toBase58() }, + { + invoker: { publicKey: pk(13) }, + checkTokenAccounts: false, + }, + ); + + expect(result.at(-1)?.keys).toEqual([ + { pubkey: validationPubkey, isSigner: false, isWritable: false }, + { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, + ]); + }); + }); + describe("buildCreateMultipleTransactions", () => { test("should build multiple transactions for multiple recipients without signing", async () => { // Arrange diff --git a/packages/stream/package.json b/packages/stream/package.json index 3b9baff2..db274f48 100644 --- a/packages/stream/package.json +++ b/packages/stream/package.json @@ -1,6 +1,6 @@ { "name": "@streamflow/stream", - "version": "11.3.1", + "version": "11.4.0", "description": "JavaScript SDK to interact with Streamflow protocol.", "homepage": "https://github.com/streamflow-finance/js-sdk/", "main": "./dist/cjs/index.cjs", diff --git a/packages/stream/solana/StreamClient.ts b/packages/stream/solana/StreamClient.ts index c3e3a01d..8a0127f2 100644 --- a/packages/stream/solana/StreamClient.ts +++ b/packages/stream/solana/StreamClient.ts @@ -4,124 +4,139 @@ import BN from "bn.js"; import bs58 from "bs58"; import { Buffer } from "buffer"; import type PQueue from "p-queue"; -import { ASSOCIATED_TOKEN_PROGRAM_ID, NATIVE_MINT } from "@solana/spl-token"; import { + ASSOCIATED_TOKEN_PROGRAM_ID, + getExtraAccountMetaAddress, + getTransferHook, + type Mint, + NATIVE_MINT, + TOKEN_2022_PROGRAM_ID, +} from "@solana/spl-token"; +import { + type AccountMeta, + type Commitment, Connection, + type ConnectionConfig, + type DataSizeFilter, Keypair, + type MemcmpFilter, PublicKey, SystemProgram, SYSVAR_RENT_PUBKEY, + type TransactionInstruction, TransactionMessage, VersionedTransaction, - type TransactionInstruction, - type Commitment, - type ConnectionConfig, - type MemcmpFilter, - type DataSizeFilter, } from "@solana/web3.js"; import { + assertHasPublicKey, ata, - checkOrCreateAtaBatch, - signAndExecuteTransaction, - getProgramAccounts, - prepareWrappedAccount, - prepareTransaction, - prepareBaseInstructions, - getMintAndProgram, - executeTransaction, - executeMultipleTransactions, buildSendThrottler, + checkOrCreateAtaBatch, createVersionedTransaction, - isDev, + executeMultipleTransactions, + executeTransaction, + getBN, + getMintAndProgram, + getMultipleAccountsInfoBatched, + getProgramAccounts, + invariant, type IProgramAccount, + isDev, + prepareBaseInstructions, + prepareTransaction, + prepareWrappedAccount, + signAndExecuteTransaction, type ThrottleParams, - getMultipleAccountsInfoBatched, } from "@streamflow/common"; import * as borsh from "borsh"; import { Program } from "@coral-xyz/anchor"; -import { getBN, assertHasPublicKey, invariant } from "@streamflow/common"; import { - type MetadataRecipientHashMap, - Contract, - type BatchItem, - type ICreateStreamExt, - type IInteractStreamExt, - type ITopUpStreamExt, - type ITransactionExtWithInstructions, - type ISearchStreams, - type ICreateStreamInstructions, AlignedContract, + type AlignedUnlocksContract, + type BatchItem, + Contract, type DecodedStream, - type OracleType, - type IPrepareCreateStreamExt, - type IPrepareStreamExt, type ICancelData, ICluster, + type ICreateAlignedStreamData, + type ICreateMultiError, type ICreateMultipleStreamData, type ICreateResult, type ICreateStreamData, + type ICreateStreamExt, + type ICreateStreamInstructions, + type IFees, type IGetAllData, type IGetFeesData, type IGetOneData, - type IFees, + type IInteractStreamExt, type IMultiTransactionResult, + type IPrepareCreateStreamExt, + type IPrepareStreamExt, + type ISearchStreams, type ITopUpData, + type ITopUpStreamExt, + type ITransactionExtWithInstructions, type ITransactionResult, type ITransferData, type IUpdateData, type IWithdrawData, - StreamDirection, - StreamType, + type MetadataRecipientHashMap, + type OracleType, type Stream, - type ICreateMultiError, - type ICreateAlignedStreamData, type StreamClientOptions, - type AlignedUnlocksContract, + StreamDirection, + StreamType, } from "./types.js"; import { + calculateTotalAmountToDeposit, decodeStream, extractSolanaErrorCode, sendAndConfirmStreamRawTransaction, signAllTransactionWithRecipients, - calculateTotalAmountToDeposit, } from "./lib/utils.js"; import { + ALIGNED_COMPUTE_LIMIT, + ALIGNED_PRECISION_FACTOR_POW, + DEFAULT_AUTO_CLAIM_FEE_SOL, + DEFAULT_CREATION_FEE_SOL, + DEFAULT_STREAMFLOW_FEE, + FEE_ORACLE_PUBLIC_KEY, + ORIGINAL_CONTRACT_SENDER_OFFSET, + PARTNER_ORACLE_PROGRAM_ID, + PARTNERS_SCHEMA, PROGRAM_ID, - STREAMFLOW_TREASURY_PUBLIC_KEY, + STREAM_STRUCT_OFFSET_OLD_METADATA_KEY, STREAM_STRUCT_OFFSET_RECIPIENT, STREAM_STRUCT_OFFSET_SENDER, + STREAM_STRUCT_OFFSETS, + STREAMFLOW_TREASURY_PUBLIC_KEY, TX_FINALITY_CONFIRMED, + WITHDRAW_AVAILABLE_AMOUNT, WITHDRAWOR, WITHDRAWOR_PUBLIC_KEY, - FEE_ORACLE_PUBLIC_KEY, - DEFAULT_STREAMFLOW_FEE, - PARTNER_ORACLE_PROGRAM_ID, - PARTNERS_SCHEMA, - STREAM_STRUCT_OFFSETS, - ORIGINAL_CONTRACT_SENDER_OFFSET, - ALIGNED_PRECISION_FACTOR_POW, - ALIGNED_COMPUTE_LIMIT, - WITHDRAW_AVAILABLE_AMOUNT, - DEFAULT_CREATION_FEE_SOL, - DEFAULT_AUTO_CLAIM_FEE_SOL, - STREAM_STRUCT_OFFSET_OLD_METADATA_KEY, } from "./constants.js"; import { - withdrawStreamInstruction, cancelStreamInstruction, - transferStreamInstruction, - topupStreamInstruction, createStreamInstruction, createStreamV2Instruction, createUncheckedStreamInstruction, createUncheckedStreamV2Instruction, + topupStreamInstruction, + transferStreamInstruction, updateStreamInstruction, + withdrawStreamInstruction, } from "./instructions.js"; import type { IPartnerLayout } from "./instructionTypes.js"; import type { StreamflowAlignedUnlocks as AlignedUnlocksProgramType } from "./descriptor/streamflow_aligned_unlocks.js"; import StreamflowAlignedUnlocksIDL from "./descriptor/idl/streamflow_aligned_unlocks.json"; -import { deriveContractPDA, deriveEscrowPDA, deriveStreamMetadataPDA, deriveTestOraclePDA } from "./lib/derive-accounts.js"; +import { + deriveContractPDA, + deriveEscrowPDA, + deriveStreamMetadataPDA, + deriveTestOraclePDA, +} from "./lib/derive-accounts.js"; import { isCreateAlignedStreamData } from "./contractUtils.js"; import { createClient, transformContract } from "./api-public/index.js"; @@ -162,7 +177,7 @@ export class SolanaStreamClient { commitment?: Commitment | ConnectionConfig, programId?: string, sendRate?: number, - sendThrottler?: PQueue + sendThrottler?: PQueue, ); /** @@ -207,8 +222,8 @@ export class SolanaStreamClient { const sendThrottler = !sendScheduler ? buildSendThrottler(1) : "sendRate" in sendScheduler - ? buildSendThrottler(sendScheduler.sendRate ?? 1, sendScheduler.sendInterval) - : sendScheduler; + ? buildSendThrottler(sendScheduler.sendRate ?? 1, sendScheduler.sendInterval) + : sendScheduler; this.schedulingParams = { ...schedulingOptions, sendThrottler, @@ -392,6 +407,10 @@ export class SolanaStreamClient { return { ixs, metadata, metadataPubKey }; } + /** + * Builds instructions for creating an aligned unlock stream. + * For Token-2022 mints with a transfer hook, partner link and hook accounts are appended as remaining accounts. + */ async prepareCreateAlignedUnlockInstructions( streamParams: ICreateAlignedStreamData, extParams: IPrepareCreateStreamExt, @@ -407,6 +426,7 @@ export class SolanaStreamClient { transferableBySender, transferableByRecipient, partner, + partnerLink, recipient, cliffAmount, amountPerPeriod, @@ -441,10 +461,11 @@ export class SolanaStreamClient { invariant(metadataPubKey, "Metadata public key is required"); - let tokenProgramId = streamTokenProgramId ? new PublicKey(streamTokenProgramId) : undefined; - if (!tokenProgramId) { - tokenProgramId = (await getMintAndProgram(this.connection, mintPublicKey)).tokenProgramId; - } + const { mint: mintAccount, tokenProgramId: detectedTokenProgramId } = await getMintAndProgram( + this.connection, + mintPublicKey, + ); + const tokenProgramId = streamTokenProgramId ? new PublicKey(streamTokenProgramId) : detectedTokenProgramId; const partnerPublicKey = partner ? new PublicKey(partner) : sender.publicKey; const streamflowProgramPublicKey = new PublicKey(this.programId); @@ -470,8 +491,14 @@ export class SolanaStreamClient { const encodedUIntArray = new TextEncoder().encode(streamName); const streamNameArray = Array.from(encodedUIntArray); - - const createIx = await this.alignedProxyProgram.methods + const remainingAccounts = [ + ...(partnerLink + ? [{ pubkey: new PublicKey(partnerLink.address), isSigner: partnerLink.isSigner, isWritable: false }] + : []), + ...(await this.getTransferHookAccounts({ mint: mintPublicKey, mintAccount, tokenProgramId })), + ]; + + const createMethod = this.alignedProxyProgram.methods .create({ startTime: new BN(start), netAmountDeposited: depositedAmount, @@ -515,8 +542,11 @@ export class SolanaStreamClient { priceOracle: oracle, tokenProgram: tokenProgramId, streamflowProgram: this.programId, - }) - .instruction(); + }); + + const createIx = await ( + remainingAccounts.length > 0 ? createMethod.remainingAccounts(remainingAccounts) : createMethod + ).instruction(); ixs.push(createIx); @@ -551,6 +581,7 @@ export class SolanaStreamClient { automaticWithdrawal = false, withdrawalFrequency = 0, partner, + partnerLink, tokenProgramId: streamTokenProgramId, nonce, }: ICreateStreamData, @@ -579,10 +610,11 @@ export class SolanaStreamClient { this.programId, ); - let tokenProgramId = streamTokenProgramId ? new PublicKey(streamTokenProgramId) : undefined; - if (!tokenProgramId) { - tokenProgramId = (await getMintAndProgram(this.connection, mintPublicKey)).tokenProgramId; - } + const { mint: mintAccount, tokenProgramId: detectedTokenProgramId } = await getMintAndProgram( + this.connection, + mintPublicKey, + ); + const tokenProgramId = streamTokenProgramId ? new PublicKey(streamTokenProgramId) : detectedTokenProgramId; const senderTokens = await ata(mintPublicKey, sender.publicKey, tokenProgramId); const recipientTokens = await ata(mintPublicKey, recipientPublicKey, tokenProgramId); const streamflowTreasuryTokens = await ata(mintPublicKey, STREAMFLOW_TREASURY_PUBLIC_KEY, tokenProgramId); @@ -610,6 +642,9 @@ export class SolanaStreamClient { associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID, withdrawor: WITHDRAWOR_PUBLIC_KEY, systemProgram: SystemProgram.programId, + partnerLink: partnerLink + ? { pubkey: new PublicKey(partnerLink.address), isSigner: partnerLink.isSigner } + : undefined, }; const baseData = { @@ -631,11 +666,14 @@ export class SolanaStreamClient { withdrawFrequency: new BN(automaticWithdrawal ? withdrawalFrequency : period), }; - if (nonce != null) { - ixs.push(await createStreamV2Instruction({ ...baseData, nonce }, this.programId, accounts)); - } else { - ixs.push(await createStreamInstruction(baseData, this.programId, accounts)); - } + const createIx = + nonce != null + ? await createStreamV2Instruction({ ...baseData, nonce }, this.programId, accounts) + : await createStreamInstruction(baseData, this.programId, accounts); + + this.addTransferHookAccounts(createIx, { mint: mintPublicKey, mintAccount, tokenProgramId }); + + ixs.push(createIx); return { ixs, metadata, metadataPubKey }; } @@ -701,6 +739,7 @@ export class SolanaStreamClient { automaticWithdrawal = false, withdrawalFrequency = 0, partner, + partnerLink, nonce, }: ICreateStreamData, { sender, metadataPubKeys, isNative = false, computePrice, computeLimit }: IPrepareCreateStreamExt, @@ -728,7 +767,7 @@ export class SolanaStreamClient { this.programId, ); - const { tokenProgramId } = await getMintAndProgram(this.connection, mintPublicKey); + const { mint: mintAccount, tokenProgramId } = await getMintAndProgram(this.connection, mintPublicKey); const senderTokens = await ata(mintPublicKey, sender.publicKey, tokenProgramId); const partnerPublicKey = partner ? new PublicKey(partner) : sender.publicKey; @@ -755,6 +794,9 @@ export class SolanaStreamClient { tokenProgram: tokenProgramId, withdrawor: WITHDRAWOR_PUBLIC_KEY, systemProgram: SystemProgram.programId, + partnerLink: partnerLink + ? { pubkey: new PublicKey(partnerLink.address), isSigner: partnerLink.isSigner } + : undefined, }; const baseData = { @@ -784,6 +826,7 @@ export class SolanaStreamClient { this.programId, accounts, ); + this.addTransferHookAccounts(createInstruction, { mint: mintPublicKey, mintAccount, tokenProgramId }); ixs.push(createInstruction); } else { const rentToExempt = await this.connection.getMinimumBalanceForRentExemption(METADATA_ACC_SIZE); @@ -795,6 +838,7 @@ export class SolanaStreamClient { newAccountPubkey: metadataPubKey, }); const createInstruction = await createUncheckedStreamInstruction(baseData, this.programId, accounts); + this.addTransferHookAccounts(createInstruction, { mint: mintPublicKey, mintAccount, tokenProgramId }); ixs.push(createMetadataInstruction, createInstruction); } @@ -1180,7 +1224,7 @@ export class SolanaStreamClient { const data = decodeStream(escrow.data); const { sender, recipient, mint, streamflowTreasury, partner, recipientTokens, escrowTokens } = data; - const { tokenProgramId } = await getMintAndProgram(this.connection, mint); + const { mint: mintAccount, tokenProgramId } = await getMintAndProgram(this.connection, mint); const streamflowTreasuryTokens = await ata(mint, STREAMFLOW_TREASURY_PUBLIC_KEY, tokenProgramId); const partnerTokens = await ata(mint, partner, tokenProgramId); const ataIx = await this.getCreateATAInstructions( @@ -1191,22 +1235,22 @@ export class SolanaStreamClient { tokenProgramId, ); - ixs.push( - ...ataIx, - await withdrawStreamInstruction(amount, this.programId, { - partner, - partnerTokens, - mint, - streamflowTreasuryTokens, - recipientTokens, - escrowTokens, - authority: invoker.publicKey, - recipient: invoker.publicKey, - metadata: streamPublicKey, - streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, - tokenProgram: tokenProgramId, - }), - ); + const withdrawIx = await withdrawStreamInstruction(amount, this.programId, { + partner, + partnerTokens, + mint, + streamflowTreasuryTokens, + recipientTokens, + escrowTokens, + authority: invoker.publicKey, + recipient: invoker.publicKey, + metadata: streamPublicKey, + streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, + tokenProgram: tokenProgramId, + }); + this.addTransferHookAccounts(withdrawIx, { mint, mintAccount, tokenProgramId }); + + ixs.push(...ataIx, withdrawIx); return ixs; } @@ -1253,11 +1297,9 @@ export class SolanaStreamClient { const decoded = decodeStream(account.data); const isAlignedUnlock = this.isAlignedUnlock(streamPublicKey, decoded.sender); - const ixs = isAlignedUnlock - ? await this.prepareCancelAlignedUnlockInstructions(cancelData, extParams) - : await this.prepareCancelLinearStream(cancelData, extParams); - - return ixs; + return isAlignedUnlock + ? this.prepareCancelAlignedUnlockInstructions(cancelData, extParams) + : this.prepareCancelLinearStream(cancelData, extParams); } /** @@ -1279,7 +1321,7 @@ export class SolanaStreamClient { const streamData = decodeStream(escrowAcc.data); const { sender, recipient, mint, streamflowTreasury, partner, escrowTokens } = streamData; - const { tokenProgramId } = await getMintAndProgram(this.connection, mint); + const { mint: mintAccount, tokenProgramId } = await getMintAndProgram(this.connection, mint); const ixs: TransactionInstruction[] = prepareBaseInstructions(this.connection, { computePrice, computeLimit: computeLimit ?? ALIGNED_COMPUTE_LIMIT, @@ -1292,20 +1334,21 @@ export class SolanaStreamClient { tokenProgramId, ); - const cancelIx = await this.alignedProxyProgram.methods - .cancel() - .accountsPartial({ - mint, - partner, - recipient, - escrowTokens, - sender: invoker.publicKey, - streamMetadata: streamPublicKey, - streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, - tokenProgram: tokenProgramId, - streamflowProgram: this.programId, - }) - .instruction(); + const cancelMethod = this.alignedProxyProgram.methods.cancel().accountsPartial({ + mint, + partner, + recipient, + escrowTokens, + sender: invoker.publicKey, + streamMetadata: streamPublicKey, + streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, + tokenProgram: tokenProgramId, + streamflowProgram: this.programId, + }); + const remainingAccounts = await this.getTransferHookAccounts({ mint, mintAccount, tokenProgramId }); + const cancelIx = await ( + remainingAccounts.length > 0 ? cancelMethod.remainingAccounts(remainingAccounts) : cancelMethod + ).instruction(); ixs.push(...ataIx, cancelIx); @@ -1332,7 +1375,7 @@ export class SolanaStreamClient { const data = decodeStream(escrowAcc.data); const { sender, recipient, partner, streamflowTreasury, mint, senderTokens, recipientTokens, escrowTokens } = data; - const { tokenProgramId } = await getMintAndProgram(this.connection, mint); + const { mint: mintAccount, tokenProgramId } = await getMintAndProgram(this.connection, mint); const streamflowTreasuryTokens = await ata(mint, STREAMFLOW_TREASURY_PUBLIC_KEY, tokenProgramId); const partnerTokens = await ata(mint, partner, tokenProgramId); @@ -1348,24 +1391,24 @@ export class SolanaStreamClient { tokenProgramId, ); - ixs.push( - ...ixsAta, - await cancelStreamInstruction(this.programId, { - sender, - senderTokens, - recipient, - recipientTokens, - streamflowTreasuryTokens, - partner, - partnerTokens, - mint, - escrowTokens, - authority: invoker.publicKey, - metadata: streamPublicKey, - streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, - tokenProgram: tokenProgramId, - }), - ); + const cancelIx = await cancelStreamInstruction(this.programId, { + sender, + senderTokens, + recipient, + recipientTokens, + streamflowTreasuryTokens, + partner, + partnerTokens, + mint, + escrowTokens, + authority: invoker.publicKey, + metadata: streamPublicKey, + streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, + tokenProgram: tokenProgramId, + }); + await this.addTransferHookAccounts(cancelIx, { mint, mintAccount, tokenProgramId }); + + ixs.push(...ixsAta, cancelIx); return ixs; } @@ -1492,30 +1535,33 @@ export class SolanaStreamClient { } const { mint, partner, senderTokens, escrowTokens } = decodeStream(escrow?.data); - const { tokenProgramId } = await getMintAndProgram(this.connection, mint); + const { mint: mintAccount, tokenProgramId } = await getMintAndProgram(this.connection, mint); const streamflowTreasuryTokens = await ata(mint, STREAMFLOW_TREASURY_PUBLIC_KEY, tokenProgramId); const partnerTokens = await ata(mint, partner, tokenProgramId); + const totalFee = await this.getTotalFee({ address: partner.toBase58() }); + const totalAmountToTransfer = calculateTotalAmountToDeposit(amount, totalFee); if (isNative) { - ixs.push(...(await prepareWrappedAccount(this.connection, invoker.publicKey, amount))); + ixs.push(...(await prepareWrappedAccount(this.connection, invoker.publicKey, totalAmountToTransfer))); } - ixs.push( - await topupStreamInstruction(amount, this.programId, { - sender: invoker.publicKey, - senderTokens, - metadata: streamPublicKey, - escrowTokens, - streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, - streamflowTreasuryTokens: streamflowTreasuryTokens, - partner: partner, - partnerTokens: partnerTokens, - mint, - tokenProgram: tokenProgramId, - withdrawor: WITHDRAWOR_PUBLIC_KEY, - systemProgram: SystemProgram.programId, - }), - ); + const topupIx = await topupStreamInstruction(amount, this.programId, { + sender: invoker.publicKey, + senderTokens, + metadata: streamPublicKey, + escrowTokens, + streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, + streamflowTreasuryTokens: streamflowTreasuryTokens, + partner: partner, + partnerTokens: partnerTokens, + mint, + tokenProgram: tokenProgramId, + withdrawor: WITHDRAWOR_PUBLIC_KEY, + systemProgram: SystemProgram.programId, + }); + this.addTransferHookAccounts(topupIx, { mint, mintAccount, tokenProgramId }); + + ixs.push(topupIx); return ixs; } @@ -2010,6 +2056,56 @@ export class SolanaStreamClient { return { metadata, metadataPubKey }; } + /** + * Returns the configured transfer hook program for a Token-2022 mint, if present. + */ + private getTransferHookProgramId(mintAccount: Mint, tokenProgramId: PublicKey): PublicKey | null { + if (!tokenProgramId.equals(TOKEN_2022_PROGRAM_ID)) { + return null; + } + + return getTransferHook(mintAccount)?.programId ?? null; + } + + /** + * Returns the minimal transfer-hook account metas required by Streamflow instructions: + * the validation PDA followed by the transfer hook program id. + */ + private getTransferHookAccounts({ + mint, + mintAccount, + tokenProgramId, + }: { + mint: PublicKey; + mintAccount: Mint; + tokenProgramId: PublicKey; + }): AccountMeta[] { + const transferHookProgramId = this.getTransferHookProgramId(mintAccount, tokenProgramId); + + if (!transferHookProgramId) { + return []; + } + + return [ + { pubkey: getExtraAccountMetaAddress(mint, transferHookProgramId), isSigner: false, isWritable: false }, + { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, + ]; + } + + /** + * Appends transfer-hook account metas to an existing instruction when the mint uses Token-2022 hooks. + */ + private addTransferHookAccounts( + instruction: TransactionInstruction, + params: { + mint: PublicKey; + mintAccount: Mint; + tokenProgramId: PublicKey; + }, + ): void { + instruction.keys.push(...this.getTransferHookAccounts(params)); + } + /** * Returns insrtuctions for creating associated token accounts for the provided owners */ diff --git a/packages/stream/solana/instructions.ts b/packages/stream/solana/instructions.ts index b9412cac..1fa72a33 100644 --- a/packages/stream/solana/instructions.ts +++ b/packages/stream/solana/instructions.ts @@ -52,6 +52,7 @@ interface CreateStreamAccounts { associatedTokenProgram: PublicKey; withdrawor: PublicKey; systemProgram: PublicKey; + partnerLink?: { pubkey: PublicKey, isSigner: boolean }; } export const createStreamInstruction = async ( @@ -91,6 +92,9 @@ export const createStreamInstruction = async ( }, { pubkey: accounts.systemProgram, isSigner: false, isWritable: false }, ]; + if (accounts.partnerLink) { + keys.push({ pubkey: accounts.partnerLink.pubkey, isSigner: accounts.partnerLink.isSigner, isWritable: false }); + } let bufferData = Buffer.alloc(Layout.createStreamLayout.span); @@ -166,6 +170,7 @@ interface CreateUncheckedStreamAccounts { tokenProgram: PublicKey; withdrawor: PublicKey; systemProgram: PublicKey; + partnerLink?: { pubkey: PublicKey, isSigner: boolean }; } export const createUncheckedStreamInstruction = async ( @@ -186,6 +191,9 @@ export const createUncheckedStreamInstruction = async ( { pubkey: accounts.tokenProgram, isSigner: false, isWritable: false }, { pubkey: accounts.systemProgram, isSigner: false, isWritable: false }, ]; + if (accounts.partnerLink) { + keys.push({ pubkey: accounts.partnerLink.pubkey, isSigner: accounts.partnerLink.isSigner, isWritable: false }); + } if (accounts.payer) { keys.unshift({ @@ -291,6 +299,9 @@ export const createStreamV2Instruction = async ( }, { pubkey: accounts.systemProgram, isSigner: false, isWritable: false }, ]; + if (accounts.partnerLink) { + keys.push({ pubkey: accounts.partnerLink.pubkey, isSigner: accounts.partnerLink.isSigner, isWritable: false }); + } let bufferData = Buffer.alloc(Layout.createStreamV2Layout.span); @@ -374,6 +385,9 @@ export const createUncheckedStreamV2Instruction = async ( { pubkey: accounts.tokenProgram, isSigner: false, isWritable: false }, { pubkey: accounts.systemProgram, isSigner: false, isWritable: false }, ]; + if (accounts.partnerLink) { + keys.push({ pubkey: accounts.partnerLink.pubkey, isSigner: accounts.partnerLink.isSigner, isWritable: false }); + } if (accounts.payer) { keys.unshift({ diff --git a/packages/stream/solana/types.ts b/packages/stream/solana/types.ts index 57c2ab46..86c39538 100644 --- a/packages/stream/solana/types.ts +++ b/packages/stream/solana/types.ts @@ -37,6 +37,8 @@ export interface IBaseStreamConfig { canPause?: boolean; canUpdateRate?: boolean; partner?: string; + // signer or PartnerLink PDA used for fee derivation + partnerLink?: { address: string, isSigner: boolean }; tokenProgramId?: string | PublicKey; } From 559272cab53d3368ed779d94647568c7934968df Mon Sep 17 00:00:00 2001 From: Yolley Date: Tue, 14 Apr 2026 15:42:34 +0900 Subject: [PATCH 2/4] feat: proper transfer hook support (with extra accounts) --- .../__tests__/solana/streamClient.spec.ts | 284 ++++++++++++++++-- packages/stream/solana/StreamClient.ts | 165 ++++++---- 2 files changed, 368 insertions(+), 81 deletions(-) diff --git a/packages/stream/__tests__/solana/streamClient.spec.ts b/packages/stream/__tests__/solana/streamClient.spec.ts index d679c196..dd70fb25 100644 --- a/packages/stream/__tests__/solana/streamClient.spec.ts +++ b/packages/stream/__tests__/solana/streamClient.spec.ts @@ -59,6 +59,7 @@ vi.mock("@solana/spl-token", async (importOriginal) => { return { ...actual, getTransferHook: vi.fn(), + addExtraAccountMetasForExecute: vi.fn().mockImplementation(async () => undefined), }; }); @@ -163,7 +164,8 @@ describe("SolanaStreamClient Transaction Builders", async () => { ).calculateTotalAmountToDeposit; const mockDecodeStream = vi.mocked(await import("../../solana/lib/utils.js")).decodeStream; const mockGetTransferHook = vi.mocked(await import("@solana/spl-token")).getTransferHook; - const { TOKEN_2022_PROGRAM_ID, getExtraAccountMetaAddress } = await import("@solana/spl-token"); + const mockAddExtraAccountMetasForExecute = vi.mocked(await import("@solana/spl-token")).addExtraAccountMetasForExecute; + const { TOKEN_2022_PROGRAM_ID } = await import("@solana/spl-token"); beforeEach(async () => { vi.clearAllMocks(); @@ -224,6 +226,8 @@ describe("SolanaStreamClient Transaction Builders", async () => { mockCalculateTotalAmountToDeposit.mockImplementation((amount) => amount); mockDecodeStream.mockReset(); mockGetTransferHook.mockReturnValue(null); + mockAddExtraAccountMetasForExecute.mockReset(); + mockAddExtraAccountMetasForExecute.mockResolvedValue(undefined as any); // Mock connection on instance (instance as any).connection = mockConnection; @@ -385,19 +389,21 @@ describe("SolanaStreamClient Transaction Builders", async () => { test("adds transfer hook accounts to linear create instructions", async () => { const senderPublicKey = pk(13); const transferHookProgramId = pk(14); - const validationPubkey = getExtraAccountMetaAddress(pk(16), transferHookProgramId); + const mint = pk(16); + const senderTokens = pk(19); mockGetMintAndProgram.mockResolvedValue({ tokenProgramId: TOKEN_2022_PROGRAM_ID, mint: {} as any, }); mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); + mockAta.mockResolvedValue(senderTokens); const result = await instance.prepareCreateLinearStreamInstructions( { recipient: pk(15).toBase58(), amount: new BN(1000), - tokenId: pk(16).toBase58(), + tokenId: mint.toBase58(), name: "Hooked Stream", cliffAmount: new BN(100), amountPerPeriod: new BN(50), @@ -419,29 +425,42 @@ describe("SolanaStreamClient Transaction Builders", async () => { }, ); - expect(result.ixs.at(-1)?.keys.slice(-2)).toEqual([ - { pubkey: validationPubkey, isSigner: false, isWritable: false }, - { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, - ]); + expect(result.ixs.at(-1)).toBeDefined(); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(1); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 1, + (instance as any).connection, + result.ixs.at(-1), + transferHookProgramId, + senderTokens, + mint, + expect.any(PublicKey), + senderPublicKey, + 1n, + "confirmed", + ); }); test("adds partnerLink before transfer hook accounts for aligned create remaining accounts", async () => { const senderPublicKey = pk(13); const transferHookProgramId = pk(14); const partnerLinkPublicKey = pk(18); - const validationPubkey = getExtraAccountMetaAddress(pk(16), transferHookProgramId); + const mint = pk(16); + const senderTokens = pk(19); + const proxyTokens = pk(20); mockGetMintAndProgram.mockResolvedValue({ tokenProgramId: TOKEN_2022_PROGRAM_ID, mint: {} as any, }); mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); + mockAta.mockResolvedValueOnce(senderTokens).mockResolvedValueOnce(proxyTokens); const result = await instance.prepareCreateAlignedUnlockInstructions( { recipient: pk(15).toBase58(), amount: new BN(1000), - tokenId: pk(16).toBase58(), + tokenId: mint.toBase58(), name: "Hooked Aligned Stream", cliffAmount: new BN(100), amountPerPeriod: new BN(50), @@ -464,18 +483,40 @@ describe("SolanaStreamClient Transaction Builders", async () => { }, ); - expect(result.ixs.at(-1)?.keys).toEqual([ - { pubkey: partnerLinkPublicKey, isSigner: true, isWritable: false }, - { pubkey: validationPubkey, isSigner: false, isWritable: false }, - { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, - ]); + expect(result.ixs.at(-1)?.keys).toEqual([{ pubkey: partnerLinkPublicKey, isSigner: true, isWritable: false }]); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(2); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 1, + (instance as any).connection, + result.ixs.at(-1), + transferHookProgramId, + senderTokens, + mint, + proxyTokens, + senderPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 2, + (instance as any).connection, + result.ixs.at(-1), + transferHookProgramId, + proxyTokens, + mint, + expect.any(PublicKey), + expect.any(PublicKey), + 1n, + "confirmed", + ); }); test("adds transfer hook validation and program accounts to withdraw instructions", async () => { const streamPublicKey = pk(17); const transferHookProgramId = pk(14); const decodedStream = createDecodedStream(); - const validationPubkey = getExtraAccountMetaAddress(decodedStream.mint, transferHookProgramId); + const streamflowTreasuryTokens = pk(21); + const partnerTokens = pk(22); mockGetMintAndProgram.mockResolvedValue({ tokenProgramId: TOKEN_2022_PROGRAM_ID, @@ -484,6 +525,7 @@ describe("SolanaStreamClient Transaction Builders", async () => { mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); mockDecodeStream.mockReturnValue(decodedStream as any); (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); + mockAta.mockResolvedValueOnce(streamflowTreasuryTokens).mockResolvedValueOnce(partnerTokens); const result = await instance.prepareWithdrawInstructions( { id: streamPublicKey.toBase58() }, @@ -493,17 +535,51 @@ describe("SolanaStreamClient Transaction Builders", async () => { }, ); - expect(result.at(-1)?.keys.slice(-2)).toEqual([ - { pubkey: validationPubkey, isSigner: false, isWritable: false }, - { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, - ]); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(3); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 1, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + decodedStream.recipientTokens, + streamPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 2, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + streamflowTreasuryTokens, + streamPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 3, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + partnerTokens, + streamPublicKey, + 1n, + "confirmed", + ); }); test("adds transfer hook validation and program accounts to linear cancel instructions", async () => { const streamPublicKey = pk(17); const transferHookProgramId = pk(14); const decodedStream = createDecodedStream(); - const validationPubkey = getExtraAccountMetaAddress(decodedStream.mint, transferHookProgramId); + const streamflowTreasuryTokens = pk(21); + const partnerTokens = pk(22); mockGetMintAndProgram.mockResolvedValue({ tokenProgramId: TOKEN_2022_PROGRAM_ID, @@ -512,6 +588,7 @@ describe("SolanaStreamClient Transaction Builders", async () => { mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); mockDecodeStream.mockReturnValue(decodedStream as any); (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); + mockAta.mockResolvedValueOnce(streamflowTreasuryTokens).mockResolvedValueOnce(partnerTokens); const result = await instance.prepareCancelLinearStream( { id: streamPublicKey.toBase58() }, @@ -521,17 +598,66 @@ describe("SolanaStreamClient Transaction Builders", async () => { }, ); - expect(result.at(-1)?.keys.slice(-2)).toEqual([ - { pubkey: validationPubkey, isSigner: false, isWritable: false }, - { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, - ]); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(4); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 1, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + decodedStream.recipientTokens, + streamPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 2, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + streamflowTreasuryTokens, + streamPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 3, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + partnerTokens, + streamPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 4, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + decodedStream.senderTokens, + streamPublicKey, + 1n, + "confirmed", + ); }); test("adds transfer hook validation and program accounts to aligned cancel remaining accounts", async () => { const streamPublicKey = pk(17); const transferHookProgramId = pk(14); const decodedStream = createDecodedStream(); - const validationPubkey = getExtraAccountMetaAddress(decodedStream.mint, transferHookProgramId); + const senderTokens = pk(19); + const recipientTokens = pk(20); + const streamflowTreasuryTokens = pk(21); + const partnerTokens = pk(22); + const proxyTokens = pk(23); mockGetMintAndProgram.mockResolvedValue({ tokenProgramId: TOKEN_2022_PROGRAM_ID, @@ -540,6 +666,12 @@ describe("SolanaStreamClient Transaction Builders", async () => { mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); mockDecodeStream.mockReturnValue(decodedStream as any); (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); + mockAta + .mockResolvedValueOnce(senderTokens) + .mockResolvedValueOnce(recipientTokens) + .mockResolvedValueOnce(streamflowTreasuryTokens) + .mockResolvedValueOnce(partnerTokens) + .mockResolvedValueOnce(proxyTokens); const result = await instance.prepareCancelAlignedUnlockInstructions( { id: streamPublicKey.toBase58() }, @@ -549,10 +681,104 @@ describe("SolanaStreamClient Transaction Builders", async () => { }, ); - expect(result.at(-1)?.keys).toEqual([ - { pubkey: validationPubkey, isSigner: false, isWritable: false }, - { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, - ]); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(5); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 1, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + recipientTokens, + streamPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 2, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + streamflowTreasuryTokens, + streamPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 3, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + partnerTokens, + streamPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 4, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.escrowTokens, + decodedStream.mint, + proxyTokens, + streamPublicKey, + 1n, + "confirmed", + ); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 5, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + proxyTokens, + decodedStream.mint, + senderTokens, + expect.any(PublicKey), + 1n, + "confirmed", + ); + }); + + test("adds transfer hook validation and program accounts to topup instructions", async () => { + const streamPublicKey = pk(17); + const transferHookProgramId = pk(14); + const decodedStream = createDecodedStream(); + + mockGetMintAndProgram.mockResolvedValue({ + tokenProgramId: TOKEN_2022_PROGRAM_ID, + mint: {} as any, + }); + mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); + mockDecodeStream.mockReturnValue(decodedStream as any); + (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); + (instance as any).getTotalFee = vi.fn().mockResolvedValue(0); + + const result = await instance.prepareTopupInstructions( + { id: streamPublicKey.toBase58(), amount: new BN(100) }, + { + invoker: { publicKey: pk(13) }, + isNative: false, + }, + ); + + expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(1); + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + 1, + (instance as any).connection, + result.at(-1), + transferHookProgramId, + decodedStream.senderTokens, + decodedStream.mint, + decodedStream.escrowTokens, + pk(13), + 1n, + "confirmed", + ); }); }); diff --git a/packages/stream/solana/StreamClient.ts b/packages/stream/solana/StreamClient.ts index 8a0127f2..afd79c52 100644 --- a/packages/stream/solana/StreamClient.ts +++ b/packages/stream/solana/StreamClient.ts @@ -6,14 +6,13 @@ import { Buffer } from "buffer"; import type PQueue from "p-queue"; import { ASSOCIATED_TOKEN_PROGRAM_ID, - getExtraAccountMetaAddress, + addExtraAccountMetasForExecute, getTransferHook, type Mint, NATIVE_MINT, TOKEN_2022_PROGRAM_ID, } from "@solana/spl-token"; import { - type AccountMeta, type Commitment, Connection, type ConnectionConfig, @@ -466,11 +465,13 @@ export class SolanaStreamClient { mintPublicKey, ); const tokenProgramId = streamTokenProgramId ? new PublicKey(streamTokenProgramId) : detectedTokenProgramId; + const senderTokens = await ata(mintPublicKey, sender.publicKey, tokenProgramId); const partnerPublicKey = partner ? new PublicKey(partner) : sender.publicKey; const streamflowProgramPublicKey = new PublicKey(this.programId); - const escrowPDA = deriveEscrowPDA(streamflowProgramPublicKey, metadataPubKey); + const proxyMetadata = deriveContractPDA(this.alignedProxyProgram.programId, metadataPubKey); + const proxyTokens = await ata(mintPublicKey, proxyMetadata, tokenProgramId); const oracle = priceOracle ?? deriveTestOraclePDA(this.alignedProxyProgram.programId, mintPublicKey, sender.publicKey); @@ -491,12 +492,6 @@ export class SolanaStreamClient { const encodedUIntArray = new TextEncoder().encode(streamName); const streamNameArray = Array.from(encodedUIntArray); - const remainingAccounts = [ - ...(partnerLink - ? [{ pubkey: new PublicKey(partnerLink.address), isSigner: partnerLink.isSigner, isWritable: false }] - : []), - ...(await this.getTransferHookAccounts({ mint: mintPublicKey, mintAccount, tokenProgramId })), - ]; const createMethod = this.alignedProxyProgram.methods .create({ @@ -544,9 +539,20 @@ export class SolanaStreamClient { streamflowProgram: this.programId, }); - const createIx = await ( - remainingAccounts.length > 0 ? createMethod.remainingAccounts(remainingAccounts) : createMethod - ).instruction(); + const remainingAccounts = partnerLink + ? [{ pubkey: new PublicKey(partnerLink.address), isSigner: partnerLink.isSigner, isWritable: false }] + : []; + const createIx = await (remainingAccounts.length > 0 ? createMethod.remainingAccounts(remainingAccounts) : createMethod).instruction(); + + await this.addTransferHookAccounts(createIx, { + mint: mintPublicKey, + mintAccount, + tokenProgramId, + potentialTransfers: [ + { source: senderTokens, destination: proxyTokens, owner: sender.publicKey }, + { source: proxyTokens, destination: escrowPDA, owner: proxyMetadata }, + ], + }); ixs.push(createIx); @@ -671,7 +677,12 @@ export class SolanaStreamClient { ? await createStreamV2Instruction({ ...baseData, nonce }, this.programId, accounts) : await createStreamInstruction(baseData, this.programId, accounts); - this.addTransferHookAccounts(createIx, { mint: mintPublicKey, mintAccount, tokenProgramId }); + await this.addTransferHookAccounts(createIx, { + mint: mintPublicKey, + mintAccount, + tokenProgramId, + potentialTransfers: [{ source: senderTokens, destination: escrowTokens, owner: sender.publicKey }], + }); ixs.push(createIx); @@ -826,7 +837,12 @@ export class SolanaStreamClient { this.programId, accounts, ); - this.addTransferHookAccounts(createInstruction, { mint: mintPublicKey, mintAccount, tokenProgramId }); + await this.addTransferHookAccounts(createInstruction, { + mint: mintPublicKey, + mintAccount, + tokenProgramId, + potentialTransfers: [{ source: senderTokens, destination: escrowTokens, owner: sender.publicKey }], + }); ixs.push(createInstruction); } else { const rentToExempt = await this.connection.getMinimumBalanceForRentExemption(METADATA_ACC_SIZE); @@ -838,7 +854,12 @@ export class SolanaStreamClient { newAccountPubkey: metadataPubKey, }); const createInstruction = await createUncheckedStreamInstruction(baseData, this.programId, accounts); - this.addTransferHookAccounts(createInstruction, { mint: mintPublicKey, mintAccount, tokenProgramId }); + await this.addTransferHookAccounts(createInstruction, { + mint: mintPublicKey, + mintAccount, + tokenProgramId, + potentialTransfers: [{ source: senderTokens, destination: escrowTokens, owner: sender.publicKey }], + }); ixs.push(createMetadataInstruction, createInstruction); } @@ -1248,7 +1269,16 @@ export class SolanaStreamClient { streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, tokenProgram: tokenProgramId, }); - this.addTransferHookAccounts(withdrawIx, { mint, mintAccount, tokenProgramId }); + await this.addTransferHookAccounts(withdrawIx, { + mint, + mintAccount, + tokenProgramId, + potentialTransfers: [ + { source: escrowTokens, destination: recipientTokens, owner: streamPublicKey }, + { source: escrowTokens, destination: streamflowTreasuryTokens, owner: streamPublicKey }, + { source: escrowTokens, destination: partnerTokens, owner: streamPublicKey }, + ], + }); ixs.push(...ataIx, withdrawIx); @@ -1322,6 +1352,12 @@ export class SolanaStreamClient { const streamData = decodeStream(escrowAcc.data); const { sender, recipient, mint, streamflowTreasury, partner, escrowTokens } = streamData; const { mint: mintAccount, tokenProgramId } = await getMintAndProgram(this.connection, mint); + const senderTokens = await ata(mint, sender, tokenProgramId); + const recipientTokens = await ata(mint, recipient, tokenProgramId); + const streamflowTreasuryTokens = await ata(mint, STREAMFLOW_TREASURY_PUBLIC_KEY, tokenProgramId); + const partnerTokens = await ata(mint, partner, tokenProgramId); + const proxyMetadata = deriveContractPDA(this.alignedProxyProgram.programId, streamPublicKey); + const proxyTokens = await ata(mint, proxyMetadata, tokenProgramId); const ixs: TransactionInstruction[] = prepareBaseInstructions(this.connection, { computePrice, computeLimit: computeLimit ?? ALIGNED_COMPUTE_LIMIT, @@ -1345,10 +1381,20 @@ export class SolanaStreamClient { tokenProgram: tokenProgramId, streamflowProgram: this.programId, }); - const remainingAccounts = await this.getTransferHookAccounts({ mint, mintAccount, tokenProgramId }); - const cancelIx = await ( - remainingAccounts.length > 0 ? cancelMethod.remainingAccounts(remainingAccounts) : cancelMethod - ).instruction(); + const cancelIx = await cancelMethod.instruction(); + + await this.addTransferHookAccounts(cancelIx, { + mint, + mintAccount, + tokenProgramId, + potentialTransfers: [ + { source: escrowTokens, destination: recipientTokens, owner: streamPublicKey }, + { source: escrowTokens, destination: streamflowTreasuryTokens, owner: streamPublicKey }, + { source: escrowTokens, destination: partnerTokens, owner: streamPublicKey }, + { source: escrowTokens, destination: proxyTokens, owner: streamPublicKey }, + { source: proxyTokens, destination: senderTokens, owner: proxyMetadata }, + ], + }); ixs.push(...ataIx, cancelIx); @@ -1378,7 +1424,6 @@ export class SolanaStreamClient { const { mint: mintAccount, tokenProgramId } = await getMintAndProgram(this.connection, mint); const streamflowTreasuryTokens = await ata(mint, STREAMFLOW_TREASURY_PUBLIC_KEY, tokenProgramId); const partnerTokens = await ata(mint, partner, tokenProgramId); - const ixs: TransactionInstruction[] = prepareBaseInstructions(this.connection, { computePrice, computeLimit, @@ -1406,7 +1451,17 @@ export class SolanaStreamClient { streamflowTreasury: STREAMFLOW_TREASURY_PUBLIC_KEY, tokenProgram: tokenProgramId, }); - await this.addTransferHookAccounts(cancelIx, { mint, mintAccount, tokenProgramId }); + await this.addTransferHookAccounts(cancelIx, { + mint, + mintAccount, + tokenProgramId, + potentialTransfers: [ + { source: escrowTokens, destination: recipientTokens, owner: streamPublicKey }, + { source: escrowTokens, destination: streamflowTreasuryTokens, owner: streamPublicKey }, + { source: escrowTokens, destination: partnerTokens, owner: streamPublicKey }, + { source: escrowTokens, destination: senderTokens, owner: streamPublicKey }, + ], + }); ixs.push(...ixsAta, cancelIx); @@ -1559,7 +1614,12 @@ export class SolanaStreamClient { withdrawor: WITHDRAWOR_PUBLIC_KEY, systemProgram: SystemProgram.programId, }); - this.addTransferHookAccounts(topupIx, { mint, mintAccount, tokenProgramId }); + await this.addTransferHookAccounts(topupIx, { + mint, + mintAccount, + tokenProgramId, + potentialTransfers: [{ source: senderTokens, destination: escrowTokens, owner: invoker.publicKey }], + }); ixs.push(topupIx); @@ -2068,42 +2128,43 @@ export class SolanaStreamClient { } /** - * Returns the minimal transfer-hook account metas required by Streamflow instructions: - * the validation PDA followed by the transfer hook program id. - */ - private getTransferHookAccounts({ - mint, - mintAccount, - tokenProgramId, - }: { - mint: PublicKey; - mintAccount: Mint; - tokenProgramId: PublicKey; - }): AccountMeta[] { - const transferHookProgramId = this.getTransferHookProgramId(mintAccount, tokenProgramId); - - if (!transferHookProgramId) { - return []; - } - - return [ - { pubkey: getExtraAccountMetaAddress(mint, transferHookProgramId), isSigner: false, isWritable: false }, - { pubkey: transferHookProgramId, isSigner: false, isWritable: false }, - ]; - } - - /** - * Appends transfer-hook account metas to an existing instruction when the mint uses Token-2022 hooks. + * Appends transfer-hook extra metas for each potential token transfer performed by the instruction. + * + * Transfer hook may rely on additional accounts/PDAs - derivation path is configured on the transfer hook metadata account, so for each potential transfer we need to check whether these accounts are needed and add them. */ - private addTransferHookAccounts( + private async addTransferHookAccounts( instruction: TransactionInstruction, params: { mint: PublicKey; mintAccount: Mint; tokenProgramId: PublicKey; + potentialTransfers: { + source: PublicKey; + destination: PublicKey; + owner: PublicKey; + }[]; }, - ): void { - instruction.keys.push(...this.getTransferHookAccounts(params)); + ): Promise { + const { mint, mintAccount, tokenProgramId, potentialTransfers } = params; + const transferHookProgramId = this.getTransferHookProgramId(mintAccount, tokenProgramId); + + if (!transferHookProgramId) { + return; + } + + for (const transfer of potentialTransfers) { + await addExtraAccountMetasForExecute( + this.connection, + instruction, + transferHookProgramId, + transfer.source, + mint, + transfer.destination, + transfer.owner, + 1n, + this.getCommitment(), + ); + } } /** From 0c487ea111a92d60aae62e552e6a2db894f9a80a Mon Sep 17 00:00:00 2001 From: Yolley Date: Tue, 14 Apr 2026 15:49:16 +0900 Subject: [PATCH 3/4] fix invoker type --- packages/stream/__tests__/solana/streamClient.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream/__tests__/solana/streamClient.spec.ts b/packages/stream/__tests__/solana/streamClient.spec.ts index dd70fb25..b1c58a48 100644 --- a/packages/stream/__tests__/solana/streamClient.spec.ts +++ b/packages/stream/__tests__/solana/streamClient.spec.ts @@ -1,6 +1,6 @@ import { BN } from "bn.js"; import { describe, expect, test, beforeEach, vi } from "vitest"; -import { PublicKey, type VersionedTransaction } from "@solana/web3.js"; +import { type Keypair, PublicKey, type VersionedTransaction } from "@solana/web3.js"; import { SolanaStreamClient } from "../../solana/StreamClient.js"; import { ICluster } from "../../solana/types.js"; @@ -761,7 +761,7 @@ describe("SolanaStreamClient Transaction Builders", async () => { const result = await instance.prepareTopupInstructions( { id: streamPublicKey.toBase58(), amount: new BN(100) }, { - invoker: { publicKey: pk(13) }, + invoker: { publicKey: pk(13) } as unknown as Keypair, isNative: false, }, ); From a7001945900420f55dd3df29d39b88bbed7d88ec Mon Sep 17 00:00:00 2001 From: Yolley Date: Wed, 15 Apr 2026 00:19:43 +0900 Subject: [PATCH 4/4] feat: add IPrepareTopUpstreamExt, refactor tests --- .../__tests__/solana/streamClient.spec.ts | 653 ++++++++---------- packages/stream/solana/StreamClient.ts | 4 +- packages/stream/solana/types.ts | 10 +- 3 files changed, 284 insertions(+), 383 deletions(-) diff --git a/packages/stream/__tests__/solana/streamClient.spec.ts b/packages/stream/__tests__/solana/streamClient.spec.ts index b1c58a48..e9f02719 100644 --- a/packages/stream/__tests__/solana/streamClient.spec.ts +++ b/packages/stream/__tests__/solana/streamClient.spec.ts @@ -5,6 +5,8 @@ import { type Keypair, PublicKey, type VersionedTransaction } from "@solana/web3 import { SolanaStreamClient } from "../../solana/StreamClient.js"; import { ICluster } from "../../solana/types.js"; +const createTestPublicKey = (seed: number): PublicKey => new PublicKey(new Uint8Array(32).fill(seed)); + // Mock Web Crypto API for Node.js test environment Object.defineProperty(globalThis, "crypto", { value: { @@ -334,7 +336,25 @@ describe("SolanaStreamClient Transaction Builders", async () => { }); describe("transfer hook accounts", () => { - const pk = (seed: number) => new PublicKey(new Uint8Array(32).fill(seed)); + const publicKeys = { + sender: createTestPublicKey(1), + senderTokens: createTestPublicKey(2), + recipient: createTestPublicKey(3), + recipientTokens: createTestPublicKey(4), + mint: createTestPublicKey(5), + stream: createTestPublicKey(17), + escrowTokens: createTestPublicKey(6), + streamflowTreasury: createTestPublicKey(7), + streamflowTreasuryTokens: createTestPublicKey(8), + partner: createTestPublicKey(9), + partnerTokens: createTestPublicKey(10), + oldMetadata: createTestPublicKey(11), + payer: createTestPublicKey(12), + invoker: createTestPublicKey(13), + transferHookProgram: createTestPublicKey(14), + partnerLink: createTestPublicKey(18), + proxyTokens: createTestPublicKey(23), + } as const; const createDecodedStream = () => ({ magic: new BN(0), @@ -344,22 +364,22 @@ describe("SolanaStreamClient Transaction Builders", async () => { canceledAt: new BN(0), end: new BN(9999999999), lastWithdrawnAt: new BN(0), - sender: pk(1), - senderTokens: pk(2), - recipient: pk(3), - recipientTokens: pk(4), - mint: pk(5), - escrowTokens: pk(6), - streamflowTreasury: pk(7), - streamflowTreasuryTokens: pk(8), + sender: publicKeys.sender, + senderTokens: publicKeys.senderTokens, + recipient: publicKeys.recipient, + recipientTokens: publicKeys.recipientTokens, + mint: publicKeys.mint, + escrowTokens: publicKeys.escrowTokens, + streamflowTreasury: publicKeys.streamflowTreasury, + streamflowTreasuryTokens: publicKeys.streamflowTreasuryTokens, streamflowFeeTotal: new BN(50), streamflowFeeWithdrawn: new BN(10), streamflowFeePercent: 0, partnerFeeTotal: new BN(20), partnerFeeWithdrawn: new BN(5), partnerFeePercent: 0, - partner: pk(9), - partnerTokens: pk(10), + partner: publicKeys.partner, + partnerTokens: publicKeys.partnerTokens, start: new BN(0), depositedAmount: new BN(1000), period: new BN(9999999999), @@ -381,404 +401,279 @@ describe("SolanaStreamClient Transaction Builders", async () => { pauseCumulative: new BN(0), lastRateChangeTime: new BN(1), fundsUnlockedAtLastRateChange: new BN(400), - oldMetadata: pk(11), - payer: pk(12), + oldMetadata: PublicKey.default, + payer: PublicKey.default, bump: 0, }); - test("adds transfer hook accounts to linear create instructions", async () => { - const senderPublicKey = pk(13); - const transferHookProgramId = pk(14); - const mint = pk(16); - const senderTokens = pk(19); - + const setupTransferHookMint = () => { mockGetMintAndProgram.mockResolvedValue({ tokenProgramId: TOKEN_2022_PROGRAM_ID, mint: {} as any, }); - mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); - mockAta.mockResolvedValue(senderTokens); - - const result = await instance.prepareCreateLinearStreamInstructions( - { - recipient: pk(15).toBase58(), - amount: new BN(1000), - tokenId: mint.toBase58(), - name: "Hooked Stream", - cliffAmount: new BN(100), - amountPerPeriod: new BN(50), - period: 86400, - start: 10, - cliff: 10, - cancelableBySender: true, - cancelableByRecipient: false, - transferableBySender: true, - transferableByRecipient: false, - canTopup: true, - automaticWithdrawal: false, - withdrawalFrequency: 0, - canPause: false, - canUpdateRate: false, - }, - { - sender: { publicKey: senderPublicKey }, - }, - ); + mockGetTransferHook.mockReturnValue({ programId: publicKeys.transferHookProgram } as any); + }; - expect(result.ixs.at(-1)).toBeDefined(); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(1); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 1, - (instance as any).connection, - result.ixs.at(-1), - transferHookProgramId, - senderTokens, - mint, - expect.any(PublicKey), - senderPublicKey, - 1n, - "confirmed", - ); - }); + const setupDecodedStreamInstruction = (...ataAddresses: PublicKey[]) => { + const decodedStream = createDecodedStream(); - test("adds partnerLink before transfer hook accounts for aligned create remaining accounts", async () => { - const senderPublicKey = pk(13); - const transferHookProgramId = pk(14); - const partnerLinkPublicKey = pk(18); - const mint = pk(16); - const senderTokens = pk(19); - const proxyTokens = pk(20); + setupTransferHookMint(); + mockDecodeStream.mockReturnValue(decodedStream as any); + (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); - mockGetMintAndProgram.mockResolvedValue({ - tokenProgramId: TOKEN_2022_PROGRAM_ID, - mint: {} as any, + ataAddresses.forEach((address) => { + mockAta.mockResolvedValueOnce(address); }); - mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); - mockAta.mockResolvedValueOnce(senderTokens).mockResolvedValueOnce(proxyTokens); - - const result = await instance.prepareCreateAlignedUnlockInstructions( - { - recipient: pk(15).toBase58(), - amount: new BN(1000), - tokenId: mint.toBase58(), - name: "Hooked Aligned Stream", - cliffAmount: new BN(100), - amountPerPeriod: new BN(50), - period: 86400, - start: 10, - cliff: 10, - cancelableBySender: true, - cancelableByRecipient: false, - transferableBySender: true, - transferableByRecipient: false, - canTopup: true, - partnerLink: { address: partnerLinkPublicKey.toBase58(), isSigner: true }, - minPrice: new BN(1), - maxPrice: new BN(2), - minPercentage: new BN(0), - maxPercentage: new BN(100), - }, - { - sender: { publicKey: senderPublicKey }, - }, - ); - expect(result.ixs.at(-1)?.keys).toEqual([{ pubkey: partnerLinkPublicKey, isSigner: true, isWritable: false }]); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(2); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 1, - (instance as any).connection, - result.ixs.at(-1), - transferHookProgramId, - senderTokens, - mint, - proxyTokens, - senderPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 2, - (instance as any).connection, - result.ixs.at(-1), - transferHookProgramId, - proxyTokens, - mint, - expect.any(PublicKey), - expect.any(PublicKey), - 1n, - "confirmed", - ); - }); - - test("adds transfer hook validation and program accounts to withdraw instructions", async () => { - const streamPublicKey = pk(17); - const transferHookProgramId = pk(14); - const decodedStream = createDecodedStream(); - const streamflowTreasuryTokens = pk(21); - const partnerTokens = pk(22); + return decodedStream; + }; - mockGetMintAndProgram.mockResolvedValue({ - tokenProgramId: TOKEN_2022_PROGRAM_ID, - mint: {} as any, + const expectTransferHookCalls = ( + instruction: unknown, + calls: ReadonlyArray<{ source: PublicKey; destination: unknown; owner: unknown }>, + ) => { + calls.forEach(({ source, destination, owner }, index) => { + expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( + index + 1, + (instance as any).connection, + instruction, + publicKeys.transferHookProgram, + source, + publicKeys.mint, + destination, + owner, + 1n, + "confirmed", + ); }); - mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); - mockDecodeStream.mockReturnValue(decodedStream as any); - (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); - mockAta.mockResolvedValueOnce(streamflowTreasuryTokens).mockResolvedValueOnce(partnerTokens); + }; - const result = await instance.prepareWithdrawInstructions( - { id: streamPublicKey.toBase58() }, - { - invoker: { publicKey: pk(13) }, - checkTokenAccounts: false, - }, - ); + describe("transfer hook: linear", () => { + test("adds transfer hook accounts to create instructions", async () => { + setupTransferHookMint(); + mockAta.mockResolvedValue(publicKeys.senderTokens); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(3); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 1, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - decodedStream.recipientTokens, - streamPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 2, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - streamflowTreasuryTokens, - streamPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 3, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - partnerTokens, - streamPublicKey, - 1n, - "confirmed", - ); - }); - - test("adds transfer hook validation and program accounts to linear cancel instructions", async () => { - const streamPublicKey = pk(17); - const transferHookProgramId = pk(14); - const decodedStream = createDecodedStream(); - const streamflowTreasuryTokens = pk(21); - const partnerTokens = pk(22); + const result = await instance.prepareCreateLinearStreamInstructions( + { + recipient: publicKeys.recipient.toBase58(), + amount: new BN(1000), + tokenId: publicKeys.mint.toBase58(), + name: "Hooked Stream", + cliffAmount: new BN(100), + amountPerPeriod: new BN(50), + period: 86400, + start: 10, + cliff: 10, + cancelableBySender: true, + cancelableByRecipient: false, + transferableBySender: true, + transferableByRecipient: false, + canTopup: true, + automaticWithdrawal: false, + withdrawalFrequency: 0, + canPause: false, + canUpdateRate: false, + }, + { + sender: { publicKey: publicKeys.sender }, + }, + ); - mockGetMintAndProgram.mockResolvedValue({ - tokenProgramId: TOKEN_2022_PROGRAM_ID, - mint: {} as any, + expect(result.ixs.at(-1)).toBeDefined(); + expectTransferHookCalls(result.ixs.at(-1), [ + { + source: publicKeys.senderTokens, + destination: expect.any(PublicKey), + owner: publicKeys.sender, + }, + ]); }); - mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); - mockDecodeStream.mockReturnValue(decodedStream as any); - (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); - mockAta.mockResolvedValueOnce(streamflowTreasuryTokens).mockResolvedValueOnce(partnerTokens); - const result = await instance.prepareCancelLinearStream( - { id: streamPublicKey.toBase58() }, - { - invoker: { publicKey: pk(13) }, - checkTokenAccounts: false, - }, - ); + test("adds transfer hook validation accounts to withdraw instructions", async () => { + const decodedStream = setupDecodedStreamInstruction( + publicKeys.streamflowTreasuryTokens, + publicKeys.partnerTokens, + ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(4); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 1, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - decodedStream.recipientTokens, - streamPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 2, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - streamflowTreasuryTokens, - streamPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 3, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - partnerTokens, - streamPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 4, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - decodedStream.senderTokens, - streamPublicKey, - 1n, - "confirmed", - ); - }); + const result = await instance.prepareWithdrawInstructions( + { id: publicKeys.stream.toBase58() }, + { + invoker: { publicKey: publicKeys.invoker }, + checkTokenAccounts: false, + }, + ); - test("adds transfer hook validation and program accounts to aligned cancel remaining accounts", async () => { - const streamPublicKey = pk(17); - const transferHookProgramId = pk(14); - const decodedStream = createDecodedStream(); - const senderTokens = pk(19); - const recipientTokens = pk(20); - const streamflowTreasuryTokens = pk(21); - const partnerTokens = pk(22); - const proxyTokens = pk(23); + expectTransferHookCalls(result.at(-1), [ + { + source: decodedStream.escrowTokens, + destination: decodedStream.recipientTokens, + owner: publicKeys.stream, + }, + { + source: decodedStream.escrowTokens, + destination: publicKeys.streamflowTreasuryTokens, + owner: publicKeys.stream, + }, + { + source: decodedStream.escrowTokens, + destination: publicKeys.partnerTokens, + owner: publicKeys.stream, + }, + ]); + }); - mockGetMintAndProgram.mockResolvedValue({ - tokenProgramId: TOKEN_2022_PROGRAM_ID, - mint: {} as any, + test("adds transfer hook validation accounts to cancel instructions", async () => { + const decodedStream = setupDecodedStreamInstruction( + publicKeys.streamflowTreasuryTokens, + publicKeys.partnerTokens, + ); + + const result = await instance.prepareCancelLinearStream( + { id: publicKeys.stream.toBase58() }, + { + invoker: { publicKey: publicKeys.invoker }, + checkTokenAccounts: false, + }, + ); + + expectTransferHookCalls(result.at(-1), [ + { + source: decodedStream.escrowTokens, + destination: decodedStream.recipientTokens, + owner: publicKeys.stream, + }, + { + source: decodedStream.escrowTokens, + destination: publicKeys.streamflowTreasuryTokens, + owner: publicKeys.stream, + }, + { + source: decodedStream.escrowTokens, + destination: publicKeys.partnerTokens, + owner: publicKeys.stream, + }, + { + source: decodedStream.escrowTokens, + destination: decodedStream.senderTokens, + owner: publicKeys.stream, + }, + ]); }); - mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); - mockDecodeStream.mockReturnValue(decodedStream as any); - (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); - mockAta - .mockResolvedValueOnce(senderTokens) - .mockResolvedValueOnce(recipientTokens) - .mockResolvedValueOnce(streamflowTreasuryTokens) - .mockResolvedValueOnce(partnerTokens) - .mockResolvedValueOnce(proxyTokens); - - const result = await instance.prepareCancelAlignedUnlockInstructions( - { id: streamPublicKey.toBase58() }, - { - invoker: { publicKey: pk(13) }, - checkTokenAccounts: false, - }, - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(5); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 1, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - recipientTokens, - streamPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 2, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - streamflowTreasuryTokens, - streamPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 3, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - partnerTokens, - streamPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 4, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.escrowTokens, - decodedStream.mint, - proxyTokens, - streamPublicKey, - 1n, - "confirmed", - ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 5, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - proxyTokens, - decodedStream.mint, - senderTokens, - expect.any(PublicKey), - 1n, - "confirmed", - ); + test("adds transfer hook validation accounts to topup instructions", async () => { + const decodedStream = setupDecodedStreamInstruction(); + (instance as any).getTotalFee = vi.fn().mockResolvedValue(0); + + const result = await instance.prepareTopupInstructions( + { id: publicKeys.stream.toBase58(), amount: new BN(100) }, + { + invoker: { publicKey: publicKeys.invoker } as unknown as Keypair, + isNative: false, + }, + ); + + expectTransferHookCalls(result.at(-1), [ + { + source: decodedStream.senderTokens, + destination: decodedStream.escrowTokens, + owner: publicKeys.invoker, + }, + ]); + }); }); - test("adds transfer hook validation and program accounts to topup instructions", async () => { - const streamPublicKey = pk(17); - const transferHookProgramId = pk(14); - const decodedStream = createDecodedStream(); + describe("transfer hook: aligned", () => { + test("adds partnerLink before transfer hook accounts for create remaining accounts", async () => { + setupTransferHookMint(); + mockAta.mockResolvedValueOnce(publicKeys.senderTokens).mockResolvedValueOnce(publicKeys.proxyTokens); - mockGetMintAndProgram.mockResolvedValue({ - tokenProgramId: TOKEN_2022_PROGRAM_ID, - mint: {} as any, + const result = await instance.prepareCreateAlignedUnlockInstructions( + { + recipient: publicKeys.recipient.toBase58(), + amount: new BN(1000), + tokenId: publicKeys.mint.toBase58(), + name: "Hooked Aligned Stream", + cliffAmount: new BN(100), + amountPerPeriod: new BN(50), + period: 86400, + start: 10, + cliff: 10, + cancelableBySender: true, + cancelableByRecipient: false, + transferableBySender: true, + transferableByRecipient: false, + canTopup: true, + partnerLink: { address: publicKeys.partnerLink.toBase58(), isSigner: true }, + minPrice: new BN(1), + maxPrice: new BN(2), + minPercentage: new BN(0), + maxPercentage: new BN(100), + }, + { + sender: { publicKey: publicKeys.sender }, + }, + ); + + expect(result.ixs.at(-1)?.keys).toEqual([{ pubkey: publicKeys.partnerLink, isSigner: true, isWritable: false }]); + expectTransferHookCalls(result.ixs.at(-1), [ + { + source: publicKeys.senderTokens, + destination: publicKeys.proxyTokens, + owner: publicKeys.sender, + }, + { + source: publicKeys.proxyTokens, + destination: expect.any(PublicKey), + owner: expect.any(PublicKey), + }, + ]); }); - mockGetTransferHook.mockReturnValue({ programId: transferHookProgramId } as any); - mockDecodeStream.mockReturnValue(decodedStream as any); - (instance as any).connection.getAccountInfo.mockResolvedValue({ data: Buffer.alloc(1) }); - (instance as any).getTotalFee = vi.fn().mockResolvedValue(0); - const result = await instance.prepareTopupInstructions( - { id: streamPublicKey.toBase58(), amount: new BN(100) }, - { - invoker: { publicKey: pk(13) } as unknown as Keypair, - isNative: false, - }, - ); + test("adds transfer hook validation accounts to cancel remaining accounts", async () => { + const decodedStream = setupDecodedStreamInstruction( + publicKeys.senderTokens, + publicKeys.recipientTokens, + publicKeys.streamflowTreasuryTokens, + publicKeys.partnerTokens, + publicKeys.proxyTokens, + ); + + const result = await instance.prepareCancelAlignedUnlockInstructions( + { id: publicKeys.stream.toBase58() }, + { + invoker: { publicKey: publicKeys.invoker }, + checkTokenAccounts: false, + }, + ); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenCalledTimes(1); - expect(mockAddExtraAccountMetasForExecute).toHaveBeenNthCalledWith( - 1, - (instance as any).connection, - result.at(-1), - transferHookProgramId, - decodedStream.senderTokens, - decodedStream.mint, - decodedStream.escrowTokens, - pk(13), - 1n, - "confirmed", - ); + expectTransferHookCalls(result.at(-1), [ + { + source: decodedStream.escrowTokens, + destination: publicKeys.recipientTokens, + owner: publicKeys.stream, + }, + { + source: decodedStream.escrowTokens, + destination: publicKeys.streamflowTreasuryTokens, + owner: publicKeys.stream, + }, + { + source: decodedStream.escrowTokens, + destination: publicKeys.partnerTokens, + owner: publicKeys.stream, + }, + { + source: decodedStream.escrowTokens, + destination: publicKeys.proxyTokens, + owner: publicKeys.stream, + }, + { + source: publicKeys.proxyTokens, + destination: publicKeys.senderTokens, + owner: expect.any(PublicKey), + }, + ]); + }); }); }); diff --git a/packages/stream/solana/StreamClient.ts b/packages/stream/solana/StreamClient.ts index afd79c52..e0a89ab4 100644 --- a/packages/stream/solana/StreamClient.ts +++ b/packages/stream/solana/StreamClient.ts @@ -72,7 +72,7 @@ import { type IInteractStreamExt, type IMultiTransactionResult, type IPrepareCreateStreamExt, - type IPrepareStreamExt, + type IPrepareStreamExt, type IPrepareTopUpstreamExt, type ISearchStreams, type ITopUpData, type ITopUpStreamExt, @@ -1573,7 +1573,7 @@ export class SolanaStreamClient { */ public async prepareTopupInstructions( { id, amount }: ITopUpData, - { invoker, isNative, computePrice, computeLimit }: ITopUpStreamExt, + { invoker, isNative, computePrice, computeLimit }: IPrepareTopUpstreamExt, ): Promise { if (!invoker.publicKey) { throw new Error("Invoker's PublicKey is not available, check passed wallet adapter!"); diff --git a/packages/stream/solana/types.ts b/packages/stream/solana/types.ts index 86c39538..cc7db86d 100644 --- a/packages/stream/solana/types.ts +++ b/packages/stream/solana/types.ts @@ -428,11 +428,17 @@ export interface IPrepareStreamExt extends Omit { } } -export interface ITopUpStreamExt extends ITransactionExt { - invoker: SignerWalletAdapter | Keypair; +export interface IPrepareTopUpstreamExt extends ITransactionExt { + invoker: { + publicKey: PublicKey | null; + } isNative?: boolean; } +export interface ITopUpStreamExt extends IPrepareTopUpstreamExt { + invoker: SignerWalletAdapter | Keypair; +} + export interface ICreateStreamInstructions { ixs: TransactionInstruction[]; metadata: Keypair | undefined;