Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 56 additions & 14 deletions modules/sdk-coin-sol/src/sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,8 +660,29 @@ export class Sol extends BaseCoin {
// If getAssociatedTokenAccountAddress throws an error, then we are unable to derive the ATA for that address.
// Return false and throw an error if that is the case.
try {
const tokenFromMap = getSolTokenFromTokenName(recipientFromUser.tokenName);
const mintAddress = tokenFromMap?.tokenAddress ?? recipientFromUser.tokenAddress;
// Resolve the mint from the recipient's token name, retrying with the
// chain-prefixed spelling ('usdt' -> 'tsol:usdt') when the bare name does
// not resolve to a Solana token. wallet-platform persists tokenData.tokenName
// without the chain prefix on some intent types.
const tokenFromMap =
getSolTokenFromTokenName(recipientFromUser.tokenName) ??
getSolTokenFromTokenName(`${this.getChain()}:${recipientFromUser.tokenName}`);
let mintAddress = tokenFromMap?.tokenAddress ?? recipientFromUser.tokenAddress;

// A recipient whose tokenName is a genuine token NAME (not a mint address)
// whose token is not resolvable here — a token registered outside the
// statics map, or a runtime where the statics lookup is unavailable — has
// one remaining source of truth for the mint: the explained output itself.
// Use it and prove the output is the recipient's associated token account
// for that mint. Recipients whose tokenName IS a mint address
// (unsupported-token shape) keep requiring an explicit tokenAddress: the
// tx side must not vouch for them.
if (!mintAddress && !isValidAddress(recipientFromUser.tokenName)) {
if (recipientFromTx.tokenName && isValidAddress(recipientFromTx.tokenName)) {
mintAddress = recipientFromTx.tokenName;
}
}

const programId = tokenFromMap?.programId ?? recipientFromUser.programId;

if (!mintAddress) {
Expand Down Expand Up @@ -729,24 +750,45 @@ export class Sol extends BaseCoin {
throw new Error('Tx memo does not match with expected txParams recipient memo');
}
if (txParams.recipients && !isTokenEnablementTx && !isCloseAssociatedTokenAccountTx) {
for (const recipients of txParams.recipients) {
// totalAmount based on each token
const assetName = recipients.tokenName || this.getChain();
const amount = totalAmount[assetName] || new BigNumber(0);
totalAmount[assetName] = amount.plus(recipients.amount);
}
// Canonical asset key for a token name: the mint when the token is resolvable in
// the statics map (retrying the chain-prefixed spelling for unprefixed names),
// otherwise the name/address as given.
const assetKeyFor = (tokenName: string | undefined): string => {
if (!tokenName) {
return this.getChain();
}
const token =
getSolTokenFromTokenName(tokenName) ?? getSolTokenFromTokenName(`${this.getChain()}:${tokenName}`);
return token?.tokenAddress ?? tokenName;
};

// total output amount from explainedTx
// Total output amount from explainedTx, keyed by canonical asset. Built first so
// recipients can adopt the output-side key when their own tokenName is not
// resolvable here (the output's token identity is the ground truth for the token
// actually moved — mirrors the recipient check above).
const explainedTxTotal: Record<string, BigNumber> = {};

for (const output of explainedTx.outputs) {
// Apply s390x endianness fix to output amounts before summing
const outputAmountStr = getAmountBasedOnEndianness(output.amount);
const assetName = assetKeyFor(output.tokenName);
explainedTxTotal[assetName] = (explainedTxTotal[assetName] || new BigNumber(0)).plus(outputAmountStr);
}

// total output amount based on each token
const assetName = output.tokenName || this.getChain();
const amount = explainedTxTotal[assetName] || new BigNumber(0);
explainedTxTotal[assetName] = amount.plus(outputAmountStr);
for (const [index, recipients] of txParams.recipients.entries()) {
// totalAmount based on each token
const resolvedToken = recipients.tokenName
? getSolTokenFromTokenName(recipients.tokenName) ??
getSolTokenFromTokenName(`${this.getChain()}:${recipients.tokenName}`)
: undefined;
const outputAssetKey = explainedTx.outputs[index]
? assetKeyFor(explainedTx.outputs[index].tokenName)
: undefined;
const assetName =
resolvedToken?.tokenAddress ??
recipients.tokenAddress ??
outputAssetKey ??
(recipients.tokenName || this.getChain());
totalAmount[assetName] = (totalAmount[assetName] || new BigNumber(0)).plus(recipients.amount);
}

if (!_.isEqual(explainedTxTotal, totalAmount)) {
Expand Down
137 changes: 137 additions & 0 deletions modules/sdk-coin-sol/test/unit/sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,143 @@ describe('SOL:', function () {
.should.be.rejectedWith('Tx outputs does not match with expected txParams recipients');
});

it('should succeed to verify token transaction when recipient tokenName is not chain-prefixed', async function () {
// wallet-platform persists tokenData.tokenName without the chain prefix on some
// intent types; the statics lookup must retry the chain-prefixed spelling.
const txParams = newTxParamsTokenTransfer();
const address = 'AF5H6vBkFnJuVqChRPgPQ4JRcQ5Gk25HBFhQQkyojmvg'; // Native SOL address
txParams.recipients = [{ address, amount: '1', tokenName: 'usdc' }];
const txPrebuild = newTxPrebuildTokenTransfer();
const feePayerWalletData = {
id: '5b34252f1bf349930e34020a00000000',
coin: 'tsol',
keys: [
'5b3424f91bf349930e34017500000000',
'5b3424f91bf349930e34017600000000',
'5b3424f91bf349930e34017700000000',
],
coinSpecific: {
rootAddress: '4DujymUFbQ8GBKtAwAZrQ6QqpvtBEivL48h4ta2oJGd2',
},
multisigType: 'tss',
};
const feePayerWallet = new Wallet(bitgo, basecoin, feePayerWalletData);
const validTransaction = await basecoin.verifyTransaction({
txParams,
txPrebuild,
wallet: feePayerWallet,
} as unknown as SolVerifyTransactionOptions);
validTransaction.should.equal(true);
});

it('should succeed to verify token transaction for a token name outside the statics map when the explained output carries the mint', async function () {
// The recipient declares a token by NAME that is not resolvable in the statics
// map (e.g. an AMS-registered token) and carries no tokenAddress. The explained
// output carries the actual mint as its tokenName (useTokenAddressTokenName
// fallback), which is the source of truth for the token being moved: prove the
// output is the recipient's associated token account for that mint.
const unsupportedMintAddress = resources.stakeAccount.pub;
const recipientNativeAddress = resources.authAccount2.pub;
const amount = '1000';

const ataAddress = await getAssociatedTokenAccountAddress(
unsupportedMintAddress,
recipientNativeAddress,
true,
TOKEN_PROGRAM_ID.toString()
);

const txBuilder = factory.getTokenTransferBuilder();
txBuilder.sender(wallet.pub);
txBuilder.nonce(blockHash);
txBuilder.fee({ amount: 5000 });
txBuilder.send({
address: ataAddress,
amount,
tokenName: unsupportedMintAddress,
tokenAddress: unsupportedMintAddress,
programId: TOKEN_PROGRAM_ID.toString(),
decimalPlaces: 6,
});
const tx = await txBuilder.build();

const txPrebuild = {
txBase64: tx.toBroadcastFormat(),
txInfo: { feePayer: wallet.pub, nonce: blockHash },
coin: 'tsol',
};
const txParams = {
recipients: [
{
address: recipientNativeAddress,
amount,
tokenName: 'tsol:ams-custom', // token NAME, not a mint address; not in the statics map
},
],
};

const result = await basecoin.verifyTransaction({
txParams,
txPrebuild,
wallet: walletObj,
} as unknown as SolVerifyTransactionOptions);
result.should.equal(true);
});

it('should fail to verify token transaction when the output token account belongs to a different owner', async function () {
// The output-mint fallback must still prove ownership: the ATA derived for the
// intent recipient has to equal the tx output, so a token account owned by
// someone else is rejected.
const unsupportedMintAddress = resources.stakeAccount.pub;
const recipientNativeAddress = resources.authAccount2.pub;
const otherOwnerAddress = wallet.pub;
const amount = '1000';

const otherOwnerAtaAddress = await getAssociatedTokenAccountAddress(
unsupportedMintAddress,
otherOwnerAddress,
true,
TOKEN_PROGRAM_ID.toString()
);

const txBuilder = factory.getTokenTransferBuilder();
txBuilder.sender(wallet.pub);
txBuilder.nonce(blockHash);
txBuilder.fee({ amount: 5000 });
txBuilder.send({
address: otherOwnerAtaAddress,
amount,
tokenName: unsupportedMintAddress,
tokenAddress: unsupportedMintAddress,
programId: TOKEN_PROGRAM_ID.toString(),
decimalPlaces: 6,
});
const tx = await txBuilder.build();

const txPrebuild = {
txBase64: tx.toBroadcastFormat(),
txInfo: { feePayer: wallet.pub, nonce: blockHash },
coin: 'tsol',
};
const txParams = {
recipients: [
{
address: recipientNativeAddress,
amount,
tokenName: 'tsol:ams-custom',
},
],
};

await basecoin
.verifyTransaction({
txParams,
txPrebuild,
wallet: walletObj,
} as unknown as SolVerifyTransactionOptions)
.should.be.rejectedWith('Tx outputs does not match with expected txParams recipients');
});

it('should succeed to verify transactions when recipients has extra data', async function () {
const txParams = newTxParamsWithExtraData();
const txPrebuild = newTxPrebuild();
Expand Down
12 changes: 12 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,18 @@ export interface IntentRecipient {
};
data?: string;
tokenData?: TokenTransferRecipientParams;
/**
* On-chain token mint for SOL-family token intents. wallet-platform persists this on
* consolidateToken intent recipients (see WP coins/sol/transactions/intent/consolidate.ts
* enrichedRecipients); carrying it through lets coin-level verifyTransaction resolve the
* mint without relying on a statics name lookup.
*/
tokenAddress?: string;
/**
* SPL token program for SOL-family token intents (TOKEN_PROGRAM_ID or
* TOKEN_2022_PROGRAM_ID). Persisted by wallet-platform alongside tokenAddress.
*/
tokenProgramId?: string;
}
interface PopulatedIntentBase {
intentType: string;
Expand Down
8 changes: 8 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,19 @@ export function resolveEffectiveTxParams(
const tokenName =
intentRecipient.tokenData?.tokenName ||
(chainName !== undefined && symbol && symbol !== chainName ? symbol : undefined);
// Carry token identity when the intent provides it so coin-level verification can
// resolve the mint without a statics name lookup:
// - tokenData.tokenContractAddress (EVM-style token intents)
// - recipient-level tokenAddress/tokenProgramId (SOL consolidateToken intents)
const tokenAddress = intentRecipient.tokenData?.tokenContractAddress ?? intentRecipient.tokenAddress;
const programId = intentRecipient.tokenProgramId;
return {
address: intentRecipient.address.address,
amount: intentRecipient.amount.value,
data: intentRecipient.data,
...(tokenName && { tokenName }),
...(tokenAddress && { tokenAddress }),
...(programId && { programId }),
};
});

Expand Down
52 changes: 51 additions & 1 deletion modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
resolveTssVerifyTransactionOptions,
} from '../../../../../src/bitgo/utils/tss/recipientUtils';
import { InvalidTransactionError } from '../../../../../src/bitgo/errors';
import { PopulatedIntent, TxRequest } from '../../../../../src/bitgo/utils/tss/baseTypes';
import { PopulatedIntent, TokenType, TxRequest } from '../../../../../src/bitgo/utils/tss/baseTypes';

function makeTxRequest(overrides: Partial<TxRequest> = {}): TxRequest {
return {
Expand Down Expand Up @@ -113,6 +113,56 @@ describe('recipientUtils', function () {
assert.strictEqual(result.recipients?.[0].amount, '500');
});

it('carries recipient-level SPL token identity onto fallback recipients', function () {
const txRequest = makeTxRequest({
intent: {
intentType: 'consolidateToken',
recipients: [
{
address: { address: 'HMEgbR4S2hLKfst2VZUVpHVUu4FioFPyW5iUuJvZdMvs' },
amount: { value: '1000', symbol: 'sol:usdt' },
tokenAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
tokenProgramId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
},
],
} as PopulatedIntent,
});
const result = resolveEffectiveTxParams(txRequest, undefined, 'sol');
assert.deepStrictEqual(result.recipients, [
{
address: 'HMEgbR4S2hLKfst2VZUVpHVUu4FioFPyW5iUuJvZdMvs',
amount: '1000',
data: undefined,
tokenName: 'sol:usdt',
tokenAddress: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
},
]);
});

it('carries tokenData.tokenContractAddress as tokenAddress for EVM-style token intents', function () {
const txRequest = makeTxRequest({
intent: {
intentType: 'payment',
recipients: [
{
address: { address: '0xabc' },
amount: { value: '0', symbol: 'eth' },
tokenData: {
tokenType: TokenType.ERC20,
tokenQuantity: '500',
tokenName: 'eth:usdt',
tokenContractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
},
},
],
} as PopulatedIntent,
});
const result = resolveEffectiveTxParams(txRequest, undefined, 'eth');
assert.strictEqual(result.recipients?.[0].tokenName, 'eth:usdt');
assert.strictEqual(result.recipients?.[0].tokenAddress, '0xdAC17F958D2ee523a2206206994597C13D831ec7');
});

it('resolves txType from intent.intentType when txParams.type is absent', function () {
const txRequest = makeTxRequest({ intent: { intentType: 'consolidate' } as any });
const result = resolveEffectiveTxParams(txRequest, {});
Expand Down
Loading