From f85ba3e603136721a8a1fd450899ff22bb9cda61 Mon Sep 17 00:00:00 2001 From: seekdaseek Date: Tue, 6 Oct 2026 10:30:48 +0300 Subject: [PATCH] feat: optional receiver on swap and swap2 The output goes to the receiver's associated token account; the input still comes from the owner, who still signs. Without receiver the transaction is unchanged. --- packages/dynamic-bonding-curve/CHANGELOG.md | 6 + packages/dynamic-bonding-curve/docs.md | 5 + .../src/services/pool.ts | 36 ++- .../src/services/program.ts | 10 +- packages/dynamic-bonding-curve/src/types.ts | 14 ++ .../tests/swapReceiver.test.ts | 218 ++++++++++++++++++ 6 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 packages/dynamic-bonding-curve/tests/swapReceiver.test.ts diff --git a/packages/dynamic-bonding-curve/CHANGELOG.md b/packages/dynamic-bonding-curve/CHANGELOG.md index ad0c5098..b2416a22 100644 --- a/packages/dynamic-bonding-curve/CHANGELOG.md +++ b/packages/dynamic-bonding-curve/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the Dynamic Bonding Curve SDK will be documented in this file. +## [Unreleased] + +### Added + +- Optional `receiver` on `swap`, `swap2` and `swap2WithTransferHook`: the output goes to the receiver's associated token account while the input still comes from `owner`, who still signs. When the output is SOL and the receiver is not `owner`, the receiver keeps wrapped SOL (closing its account would need its signature). Without `receiver` the transaction is the same as before. + ## [1.5.13] - 2026-09-24 ### Changed diff --git a/packages/dynamic-bonding-curve/docs.md b/packages/dynamic-bonding-curve/docs.md index 7b7a6002..5bb4518b 100644 --- a/packages/dynamic-bonding-curve/docs.md +++ b/packages/dynamic-bonding-curve/docs.md @@ -2339,6 +2339,7 @@ interface SwapParams { poolAddress: PublicKey // The pool address referralTokenAccount: PublicKey | null // The referral token account (optional) payer?: PublicKey // The payer of the transaction (optional) + receiver?: PublicKey // The owner of the output token account (optional, defaults to owner) } ``` @@ -2393,6 +2394,7 @@ const transaction = await client.pool.swap({ - If the transaction fails with "insufficient balance", check that you have enough tokens plus fees for the transaction. - The pool address can be derived using `deriveDbcPoolAddress`. - The `payer` parameter is optional. If not provided, the owner will be used as the payer to fund ATA creation. +- The `receiver` parameter is optional. If provided, the output goes to the receiver's associated token account, which `payer` (or `owner`) creates if needed. The input still comes from `owner`'s account and `owner` still signs; the receiver does not sign. If the output is SOL and the receiver is not `owner`, the receiver gets wrapped SOL in its own wrapped SOL account, which it unwraps itself. --- @@ -2480,6 +2482,7 @@ interface Swap2Params { swapBaseForQuote: boolean // True for base->quote, false for quote->base referralTokenAccount: PublicKey | null // The referral token account (optional) payer?: PublicKey // The payer of the transaction (optional) + receiver?: PublicKey // The owner of the output token account (optional, defaults to owner) } & ( | { swapMode: SwapMode.ExactIn // Swap exact input amount @@ -2551,6 +2554,7 @@ const transaction = await client.pool.swap2({ - The `maximumAmountIn` parameter protects against slippage. Set it to a value slightly higher than the expected input. - The `referralTokenAccount` parameter is an optional token account. If provided, the referral fee will be applied to the transaction. - The `payer` parameter is optional. If not provided, the owner will be used as the payer to fund ATA creation. +- The `receiver` parameter is optional. If provided, the output goes to the receiver's associated token account, which `payer` (or `owner`) creates if needed. The input still comes from `owner`'s account and `owner` still signs; the receiver does not sign. If the output is SOL and the receiver is not `owner`, the receiver gets wrapped SOL in its own wrapped SOL account, which it unwraps itself. --- @@ -2573,6 +2577,7 @@ interface Swap2Params { swapBaseForQuote: boolean // True for base->quote, false for quote->base referralTokenAccount: PublicKey | null // The referral token account (optional) payer?: PublicKey // The payer of the transaction (optional) + receiver?: PublicKey // The owner of the output token account (optional, defaults to owner) } & ( | { swapMode: SwapMode.ExactIn // Swap exact input amount diff --git a/packages/dynamic-bonding-curve/src/services/pool.ts b/packages/dynamic-bonding-curve/src/services/pool.ts index 2452badc..78c5a3b4 100644 --- a/packages/dynamic-bonding-curve/src/services/pool.ts +++ b/packages/dynamic-bonding-curve/src/services/pool.ts @@ -53,6 +53,7 @@ export class PoolService extends DynamicBondingCurveProgram { swapBaseForQuote, owner, payer, + receiver, pool, referralTokenAccount, } = params @@ -108,7 +109,8 @@ export class PoolService extends DynamicBondingCurveProgram { inputMint, outputMint, inputTokenProgram, - outputTokenProgram + outputTokenProgram, + receiver ) // add SOL wrapping instructions if needed @@ -125,10 +127,12 @@ export class PoolService extends DynamicBondingCurveProgram { // add postInstructions for SOL unwrapping const postInstructions: TransactionInstruction[] = [] if ( - [inputMint.toBase58(), outputMint.toBase58()].includes( - NATIVE_MINT.toBase58() - ) + inputMint.equals(NATIVE_MINT) || + (outputMint.equals(NATIVE_MINT) && + (!receiver || receiver.equals(owner))) ) { + // A receiver other than the owner keeps its wrapped SOL: closing + // that account needs the receiver's signature. const unwrapIx = unwrapSOLInstruction(owner, owner) unwrapIx && postInstructions.push(unwrapIx) } @@ -187,6 +191,7 @@ export class PoolService extends DynamicBondingCurveProgram { swapMode, owner, payer, + receiver, referralTokenAccount, } = params @@ -252,7 +257,8 @@ export class PoolService extends DynamicBondingCurveProgram { inputMint, outputMint, inputTokenProgram, - outputTokenProgram + outputTokenProgram, + receiver ) // add SOL wrapping instructions if needed @@ -274,10 +280,12 @@ export class PoolService extends DynamicBondingCurveProgram { // add postInstructions for SOL unwrapping const postInstructions: TransactionInstruction[] = [] if ( - [inputMint.toBase58(), outputMint.toBase58()].includes( - NATIVE_MINT.toBase58() - ) + inputMint.equals(NATIVE_MINT) || + (outputMint.equals(NATIVE_MINT) && + (!receiver || receiver.equals(owner))) ) { + // A receiver other than the owner keeps its wrapped SOL: closing + // that account needs the receiver's signature. const unwrapIx = unwrapSOLInstruction(owner, owner) unwrapIx && postInstructions.push(unwrapIx) } @@ -334,6 +342,7 @@ export class PoolService extends DynamicBondingCurveProgram { swapMode, owner, payer, + receiver, referralTokenAccount, } = params @@ -392,7 +401,8 @@ export class PoolService extends DynamicBondingCurveProgram { inputMint, outputMint, inputTokenProgram, - outputTokenProgram + outputTokenProgram, + receiver ) if (inputMint.equals(NATIVE_MINT)) { @@ -412,10 +422,12 @@ export class PoolService extends DynamicBondingCurveProgram { const postInstructions: TransactionInstruction[] = [] if ( - [inputMint.toBase58(), outputMint.toBase58()].includes( - NATIVE_MINT.toBase58() - ) + inputMint.equals(NATIVE_MINT) || + (outputMint.equals(NATIVE_MINT) && + (!receiver || receiver.equals(owner))) ) { + // A receiver other than the owner keeps its wrapped SOL: closing + // that account needs the receiver's signature. const unwrapIx = unwrapSOLInstruction(owner, owner) unwrapIx && postInstructions.push(unwrapIx) } diff --git a/packages/dynamic-bonding-curve/src/services/program.ts b/packages/dynamic-bonding-curve/src/services/program.ts index f2442f38..6fbe36e6 100644 --- a/packages/dynamic-bonding-curve/src/services/program.ts +++ b/packages/dynamic-bonding-curve/src/services/program.ts @@ -986,13 +986,19 @@ export class DynamicBondingCurveProgram { .transaction() } + /** + * Get or create `owner`'s token accounts for both mints. When `tokenBOwner` + * is given, the token B account belongs to it instead (for example a swap's + * output going to another wallet). + */ protected async prepareTokenAccounts( owner: PublicKey, payer: PublicKey, tokenAMint: PublicKey, tokenBMint: PublicKey, tokenAProgram: PublicKey, - tokenBProgram: PublicKey + tokenBProgram: PublicKey, + tokenBOwner?: PublicKey ): Promise<{ ataTokenA: PublicKey ataTokenB: PublicKey @@ -1015,7 +1021,7 @@ export class DynamicBondingCurveProgram { getOrCreateATAInstruction( this.connection, tokenBMint, - owner, + tokenBOwner ?? owner, payer, true, tokenBProgram, diff --git a/packages/dynamic-bonding-curve/src/types.ts b/packages/dynamic-bonding-curve/src/types.ts index c9679dfb..b1b850d1 100644 --- a/packages/dynamic-bonding-curve/src/types.ts +++ b/packages/dynamic-bonding-curve/src/types.ts @@ -529,6 +529,13 @@ export type SwapParams = { swapBaseForQuote: boolean referralTokenAccount: PublicKey | null payer?: PublicKey + /** + * Owner of the output token account. Defaults to `owner`. The input still + * comes from `owner`'s token account and `owner` still signs; the receiver + * does not sign. When the output is SOL and the receiver is not `owner`, + * the receiver gets wrapped SOL in its own wrapped SOL account. + */ + receiver?: PublicKey } export type Swap2Params = { @@ -537,6 +544,13 @@ export type Swap2Params = { swapBaseForQuote: boolean referralTokenAccount: PublicKey | null payer?: PublicKey + /** + * Owner of the output token account. Defaults to `owner`. The input still + * comes from `owner`'s token account and `owner` still signs; the receiver + * does not sign. When the output is SOL and the receiver is not `owner`, + * the receiver gets wrapped SOL in its own wrapped SOL account. + */ + receiver?: PublicKey } & ( | { swapMode: SwapMode.ExactIn diff --git a/packages/dynamic-bonding-curve/tests/swapReceiver.test.ts b/packages/dynamic-bonding-curve/tests/swapReceiver.test.ts new file mode 100644 index 00000000..8fda094f --- /dev/null +++ b/packages/dynamic-bonding-curve/tests/swapReceiver.test.ts @@ -0,0 +1,218 @@ +import { + Keypair, + PublicKey, + Connection, + sendAndConfirmTransaction, + Transaction, +} from '@solana/web3.js' +import { test, describe, beforeEach, expect } from 'vitest' +import { buildTestCurveConfig, fundSol, LOCALNET_RPC_URL } from './utils/common' +import { + deriveDbcPoolAddress, + DynamicBondingCurveClient, + SwapMode, +} from '../src' +import { BN } from 'bn.js' +import { getAssociatedTokenAddressSync, NATIVE_MINT } from '@solana/spl-token' + +const connection = new Connection(LOCALNET_RPC_URL, 'confirmed') + +async function tokenBalance(account: PublicKey): Promise { + const info = await connection.getAccountInfo(account) + if (!info) return null + const { value } = await connection.getTokenAccountBalance(account) + return BigInt(value.amount) +} + +function signers(tx: Transaction): string[] { + return tx.signatures.map((s) => s.publicKey.toBase58()) +} + +describe('swap with receiver Tests', { timeout: 60000 }, () => { + let partner: Keypair + let user: Keypair + let receiver: Keypair + let poolCreator: Keypair + let dbcClient: DynamicBondingCurveClient + let config: Keypair + let pool: PublicKey + let baseMint: Keypair + + beforeEach(async () => { + partner = Keypair.generate() + user = Keypair.generate() + // never funded and never signs + receiver = Keypair.generate() + poolCreator = Keypair.generate() + config = Keypair.generate() + baseMint = Keypair.generate() + + for (const account of [partner, user, poolCreator]) { + await fundSol(connection, account.publicKey) + } + + dbcClient = new DynamicBondingCurveClient(connection, 'confirmed') + + const createConfigTx = await dbcClient.partner.createConfig({ + config: config.publicKey, + feeClaimer: partner.publicKey, + leftoverReceiver: partner.publicKey, + payer: partner.publicKey, + quoteMint: NATIVE_MINT, + ...buildTestCurveConfig(), + }) + createConfigTx.feePayer = partner.publicKey + await sendAndConfirmTransaction(connection, createConfigTx, [ + partner, + config, + ]) + + const createPoolTx = await dbcClient.creator.createPool({ + baseMint: baseMint.publicKey, + config: config.publicKey, + name: 'TEST', + symbol: 'TEST', + uri: 'https://ipfs.io/ipfs/QmdcU6CRSNr6qYmyQAGjvFyZajEs9W1GH51rddCFw7S6p2', + payer: poolCreator.publicKey, + poolCreator: poolCreator.publicKey, + }) + createPoolTx.feePayer = poolCreator.publicKey + await sendAndConfirmTransaction(connection, createPoolTx, [ + baseMint, + poolCreator, + ]) + + pool = deriveDbcPoolAddress( + NATIVE_MINT, + baseMint.publicKey, + config.publicKey + ) + }) + + test('swap: the output goes to the receiver, the owner pays and signs alone', async () => { + const swapTx = await dbcClient.pool.swap({ + amountIn: new BN(1000000000), + minimumAmountOut: new BN(1), + swapBaseForQuote: false, + owner: user.publicKey, + pool, + referralTokenAccount: null, + receiver: receiver.publicKey, + }) + swapTx.feePayer = user.publicKey + await sendAndConfirmTransaction(connection, swapTx, [user]) + + expect(signers(swapTx)).toEqual([user.publicKey.toBase58()]) + const receiverBase = getAssociatedTokenAddressSync( + baseMint.publicKey, + receiver.publicKey + ) + const userBase = getAssociatedTokenAddressSync( + baseMint.publicKey, + user.publicKey + ) + expect((await tokenBalance(receiverBase))! > 0n).toBe(true) + expect(await tokenBalance(userBase)).toBeNull() + expect(await connection.getBalance(receiver.publicKey)).toBe(0) + }) + + test('swap2 ExactIn: the output goes to the receiver', async () => { + const swapTx = await dbcClient.pool.swap2({ + swapMode: SwapMode.ExactIn, + amountIn: new BN(1000000000), + minimumAmountOut: new BN(1), + swapBaseForQuote: false, + owner: user.publicKey, + pool, + referralTokenAccount: null, + receiver: receiver.publicKey, + }) + swapTx.feePayer = user.publicKey + await sendAndConfirmTransaction(connection, swapTx, [user]) + + expect(signers(swapTx)).toEqual([user.publicKey.toBase58()]) + const receiverBase = getAssociatedTokenAddressSync( + baseMint.publicKey, + receiver.publicKey + ) + expect((await tokenBalance(receiverBase))! > 0n).toBe(true) + expect( + await tokenBalance( + getAssociatedTokenAddressSync( + baseMint.publicKey, + user.publicKey + ) + ) + ).toBeNull() + }) + + test('swap2 ExactOut: the receiver gets exactly the amount out', async () => { + const amountOut = new BN(1000000) + const swapTx = await dbcClient.pool.swap2({ + swapMode: SwapMode.ExactOut, + amountOut, + maximumAmountIn: new BN(1000000000), + swapBaseForQuote: false, + owner: user.publicKey, + pool, + referralTokenAccount: null, + receiver: receiver.publicKey, + }) + swapTx.feePayer = user.publicKey + await sendAndConfirmTransaction(connection, swapTx, [user]) + + const receiverBase = getAssociatedTokenAddressSync( + baseMint.publicKey, + receiver.publicKey + ) + expect(await tokenBalance(receiverBase)).toBe( + BigInt(amountOut.toString()) + ) + }) + + test('swap2 selling into SOL: the receiver keeps wrapped SOL, nothing of its is closed', async () => { + // the owner buys first, so it holds base tokens and no wrapped SOL account + const buyTx = await dbcClient.pool.swap2({ + swapMode: SwapMode.ExactIn, + amountIn: new BN(1000000000), + minimumAmountOut: new BN(1), + swapBaseForQuote: false, + owner: user.publicKey, + pool, + referralTokenAccount: null, + }) + buyTx.feePayer = user.publicKey + await sendAndConfirmTransaction(connection, buyTx, [user]) + const userBase = getAssociatedTokenAddressSync( + baseMint.publicKey, + user.publicKey + ) + const bought = (await tokenBalance(userBase))! + expect( + await tokenBalance( + getAssociatedTokenAddressSync(NATIVE_MINT, user.publicKey) + ) + ).toBeNull() + + const sellTx = await dbcClient.pool.swap2({ + swapMode: SwapMode.ExactIn, + amountIn: new BN((bought / 2n).toString()), + minimumAmountOut: new BN(1), + swapBaseForQuote: true, + owner: user.publicKey, + pool, + referralTokenAccount: null, + receiver: receiver.publicKey, + }) + sellTx.feePayer = user.publicKey + await sendAndConfirmTransaction(connection, sellTx, [user]) + + expect(signers(sellTx)).toEqual([user.publicKey.toBase58()]) + expect(await tokenBalance(userBase)).toBe(bought - bought / 2n) + const receiverWsol = getAssociatedTokenAddressSync( + NATIVE_MINT, + receiver.publicKey + ) + expect((await tokenBalance(receiverWsol))! > 0n).toBe(true) + }) +})