From f7912605234af09886faf5f068ed88c93df2e711 Mon Sep 17 00:00:00 2001 From: rajangarg047 Date: Mon, 22 Jun 2026 13:45:31 -0400 Subject: [PATCH] feat(sdk-coin-sol): derive SPL token (ATA) deposit addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend SOL deriveAddress to support SPL token deposit addresses. When a tokenName (e.g. sol:usdc) is supplied, derive the native owner address as before, then return the owner's Associated Token Account (ATA) for that token's mint — using the same getAssociatedTokenAccountAddress helper (statics mint + programId lookup, token-2022 aware) that the rest of the SOL coin uses, so the derived address matches what BitGo assigns as the token receive address. - sdk-core: add tokenName to DeriveAddressOptions. - express: accept tokenName on the address/derive request body. - tests: exact ATA for sol:usdc at a known index, parity with getAssociatedTokenAccountAddress, and unknown-token error. WCN-1054 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/typedRoutes/api/v2/deriveAddress.ts | 5 ++++ modules/sdk-coin-sol/src/sol.ts | 16 ++++++++++++- modules/sdk-coin-sol/test/unit/sol.ts | 23 +++++++++++++++++++ .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 7 ++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/modules/express/src/typedRoutes/api/v2/deriveAddress.ts b/modules/express/src/typedRoutes/api/v2/deriveAddress.ts index 049d929a5d..9bcc881368 100644 --- a/modules/express/src/typedRoutes/api/v2/deriveAddress.ts +++ b/modules/express/src/typedRoutes/api/v2/deriveAddress.ts @@ -39,6 +39,11 @@ export const DeriveAddressBody = { format: optional(CreateAddressFormat), /** Wallet version, to disambiguate derivation strategy (e.g. EVM forwarder vs MPC) */ walletVersion: optional(t.number), + /** + * Token name (e.g. `sol:usdc`) to derive a token deposit address instead of the native one. + * For Solana this returns the wallet's Associated Token Account (ATA) for the token's mint. + */ + tokenName: optional(t.string), /** * Wallet base address (the wallet contract address for EVM wallets). Required to derive * per-index forwarder receive addresses for legacy multisig EVM wallets (versions 1/2/4). diff --git a/modules/sdk-coin-sol/src/sol.ts b/modules/sdk-coin-sol/src/sol.ts index 65dd0ba894..33ab266bd2 100644 --- a/modules/sdk-coin-sol/src/sol.ts +++ b/modules/sdk-coin-sol/src/sol.ts @@ -728,7 +728,8 @@ export class Sol extends BaseCoin { * @returns the derived address, the index used, and the HD derivation path */ async deriveAddress(params: DeriveAddressOptions): Promise { - const { address, derivationPath } = await deriveMPCWalletAddress( + // Derive the native owner (wallet) address from the commonKeychain. + const { address: ownerAddress, derivationPath } = await deriveMPCWalletAddress( { // extractCommonKeychain validates the commonKeychain is present at runtime keychains: (params.keychains ?? []) as TssVerifyAddressOptions['keychains'], @@ -740,6 +741,19 @@ export class Sol extends BaseCoin { (publicKey) => this.getAddressFromPublicKey(publicKey) ); + // No token requested: return the native SOL receive address. + if (!params.tokenName) { + return { address: ownerAddress, index: params.index, derivationPath }; + } + + // Token requested: the deposit address is the owner's Associated Token Account (ATA) for the + // token's mint, derived the same way SOL token addresses are produced elsewhere in this coin. + const token = getSolTokenFromTokenName(params.tokenName); + if (!token || token.tokenAddress === undefined || token.programId === undefined) { + throw new Error(`unknown or unsupported SOL token: ${params.tokenName}`); + } + const address = await getAssociatedTokenAccountAddress(token.tokenAddress, ownerAddress, true, token.programId); + return { address, index: params.index, derivationPath }; } diff --git a/modules/sdk-coin-sol/test/unit/sol.ts b/modules/sdk-coin-sol/test/unit/sol.ts index f11fb40b8d..065680911f 100644 --- a/modules/sdk-coin-sol/test/unit/sol.ts +++ b/modules/sdk-coin-sol/test/unit/sol.ts @@ -4189,6 +4189,29 @@ describe('SOL:', function () { it('should throw if keychains are missing', async function () { await assert.rejects(async () => await basecoin.deriveAddress({ keychains: [], index: 0 }), /keychains/); }); + + it('should derive the SPL token (ATA) address when tokenName is given', async function () { + // owner = native address at index 1 (7YAesf…); ATA for sol:usdc: + const result = await basecoin.deriveAddress({ keychains, index: 1, tokenName: 'sol:usdc' }); + result.address.should.equal('FG1XMJdXBQ5uYaNoKposABg6erxsvzbwC283W2ipnjQB'); + result.address.should.not.equal(address); // not the native address + result.index.should.equal(1); + }); + + it('the derived token ATA matches getAssociatedTokenAccountAddress for the owner+mint', async function () { + const native = await basecoin.deriveAddress({ keychains, index: 3 }); + const token = await basecoin.deriveAddress({ keychains, index: 3, tokenName: 'sol:usdc' }); + const mint = (coins.get('sol:usdc') as unknown as { tokenAddress: string }).tokenAddress; + const expectedAta = await getAssociatedTokenAccountAddress(mint, native.address, true); + token.address.should.equal(expectedAta); + }); + + it('should throw for an unknown token name', async function () { + await assert.rejects( + async () => await basecoin.deriveAddress({ keychains, index: 1, tokenName: 'sol:not-a-real-token' }), + /unknown or unsupported SOL token/ + ); + }); }); describe('getAddressFromPublicKey', () => { diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index fd845e2173..986fae8eb9 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -244,6 +244,13 @@ export interface DeriveAddressOptions keychains?: ({ pub: string } | { commonKeychain: string })[]; /** Wallet version, used to disambiguate derivation strategy for some coin families. */ walletVersion?: number; + /** + * Token name (e.g. `sol:usdc`, `tsol:usdt`) to derive a token deposit address instead of the + * native receive address. For Solana this resolves to the SPL mint and returns the wallet's + * Associated Token Account (ATA) for that mint. Ignored by coins/tokens that reuse the native + * address (e.g. ERC-20 on EVM). + */ + tokenName?: string; /** * Wallet base address (the wallet contract address for EVM wallets). Required to derive * per-index forwarder (CREATE2) receive addresses for legacy multisig EVM wallets.