Skip to content

Commit f8e124d

Browse files
committed
fix(sdk-coin-near): enhance token enablement validation
Improve NEAR token enablement blind signing validation to follow protocol specification with proper error handling and security checks. WP-5782 TICKET: WP-5782
1 parent 1eaa33b commit f8e124d

File tree

2 files changed

+139
-91
lines changed

2 files changed

+139
-91
lines changed

modules/sdk-coin-near/src/near.ts

Lines changed: 136 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
import { BaseCoin as StaticsBaseCoin, CoinFamily, coins, Nep141Token, Networks } from '@bitgo/statics';
4646

4747
import { KeyPair as NearKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib';
48-
import { TxData, TransactionExplanation } from './lib/iface';
48+
import { TransactionExplanation } from './lib/iface';
4949
import nearUtils from './lib/utils';
5050
import { MAX_GAS_LIMIT_FOR_FT_TRANSFER } from './lib/constants';
5151

@@ -1036,6 +1036,18 @@ export class Near extends BaseCoin {
10361036
});
10371037

10381038
if (!_.isEqual(filteredOutputs, filteredRecipients)) {
1039+
// For enabletoken, provide more specific error messages for address mismatches
1040+
if (txParams.type === 'enabletoken') {
1041+
const mismatchedAddresses = txParams.recipients
1042+
?.filter(
1043+
(recipient, index) => !filteredOutputs[index] || recipient.address !== filteredOutputs[index].address
1044+
)
1045+
.map((recipient) => recipient.address);
1046+
1047+
if (mismatchedAddresses && mismatchedAddresses.length > 0) {
1048+
throw new Error(`Address mismatch: ${mismatchedAddresses.join(', ')}`);
1049+
}
1050+
}
10391051
throw new Error('Tx outputs does not match with expected txParams recipients');
10401052
}
10411053
for (const recipients of txParams.recipients) {
@@ -1082,141 +1094,198 @@ export class Near extends BaseCoin {
10821094
}
10831095
}
10841096

1085-
// Validates that the transaction matches what the user expects
10861097
private validateTokenEnablementTransaction(
10871098
transaction: Transaction,
10881099
explainedTx: TransactionExplanation,
10891100
txParams: TransactionParams
10901101
): void {
1091-
const transactionData = transaction.toJson();
1092-
1093-
// Validate each aspect of the transaction separately
10941102
this.validateTxType(txParams, explainedTx);
1095-
this.validateSigner(transactionData);
1096-
this.validateReceiver(transactionData, explainedTx);
1097-
this.validatePublicKey(transactionData, explainedTx);
1098-
this.validateActions(transactionData, explainedTx);
1099-
this.validateAddresses(txParams, explainedTx);
1103+
this.validateSigner(transaction);
1104+
this.validateRawReceiver(transaction, txParams);
1105+
this.validatePublicKey(transaction);
1106+
this.validateRawActions(transaction, txParams);
1107+
this.validateBeneficiary(explainedTx, txParams);
1108+
this.validateTokenOutput(explainedTx, txParams);
11001109
}
11011110

11021111
// Validates that the signer ID exists in the transaction
1103-
private validateSigner(transactionData: TxData): void {
1112+
private validateSigner(transaction: Transaction): void {
1113+
const transactionData = transaction.toJson();
11041114
if (!transactionData.signerId) {
11051115
throw new Error('Error on token enablements: missing signer ID in transaction');
11061116
}
11071117
}
11081118

1109-
// Validates that the receiver ID exists in the transaction
1110-
private validateReceiver(transactionData: TxData, explainedTx: TransactionExplanation): void {
1119+
private validateBeneficiary(explainedTx: TransactionExplanation, txParams: TransactionParams): void {
1120+
if (!explainedTx.outputs || explainedTx.outputs.length === 0) {
1121+
throw new Error('Error on token enablements: transaction has no outputs to validate beneficiary');
1122+
}
1123+
1124+
const output = explainedTx.outputs[0];
1125+
const recipient = txParams.recipients?.[0];
1126+
1127+
if (!recipient?.address) {
1128+
throw new Error('Error on token enablements: missing beneficiary address in transaction parameters');
1129+
}
1130+
1131+
if (output.address !== recipient.address) {
1132+
throw new Error('Error on token enablements: transaction beneficiary mismatch with user expectation');
1133+
}
1134+
}
1135+
1136+
// Validates that the raw transaction receiverId matches the expected token contract
1137+
private validateRawReceiver(transaction: Transaction, txParams: TransactionParams): void {
1138+
const transactionData = transaction.toJson();
1139+
11111140
if (!transactionData.receiverId) {
11121141
throw new Error('Error on token enablements: missing receiver ID in transaction');
11131142
}
11141143

1115-
// Get the expected contract address for the token being enabled
1116-
if (!explainedTx.outputs[0]?.tokenName) {
1144+
const recipient = txParams.recipients?.[0];
1145+
if (!recipient?.tokenName) {
1146+
throw new Error('Error on token enablements: missing token name in transaction parameters');
1147+
}
1148+
1149+
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(recipient.tokenName);
1150+
if (!tokenInstance) {
1151+
throw new Error(`Error on token enablements: unknown token '${recipient.tokenName}'`);
1152+
}
1153+
1154+
if (transactionData.receiverId !== tokenInstance.contractAddress) {
1155+
throw new Error(
1156+
`Error on token enablements: receiver contract mismatch - expected '${tokenInstance.contractAddress}', got '${transactionData.receiverId}'`
1157+
);
1158+
}
1159+
}
1160+
1161+
// Validates token output information from explained transaction
1162+
private validateTokenOutput(explainedTx: TransactionExplanation, txParams: TransactionParams): void {
1163+
if (!explainedTx.outputs || explainedTx.outputs.length !== 1) {
1164+
throw new Error('Error on token enablements: transaction must have exactly 1 output');
1165+
}
1166+
1167+
const output = explainedTx.outputs[0];
1168+
const recipient = txParams.recipients?.[0];
1169+
1170+
if (!output.tokenName) {
11171171
throw new Error('Error on token enablements: missing token name in transaction output');
11181172
}
1119-
const tokenName = explainedTx.outputs[0].tokenName;
1120-
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(tokenName);
1173+
1174+
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(output.tokenName);
11211175
if (!tokenInstance) {
1122-
throw new Error(`Error on token enablements: unknown token '${tokenName}'`);
1176+
throw new Error(`Error on token enablements: unknown token '${output.tokenName}'`);
11231177
}
1124-
const expectedContractAddress = tokenInstance?.contractAddress;
11251178

1126-
if (transactionData.receiverId !== expectedContractAddress) {
1127-
throw new Error('Error on token enablements: receiver contract mismatch');
1179+
if (recipient?.tokenName && recipient.tokenName !== output.tokenName) {
1180+
throw new Error(
1181+
`Error on token enablements: token mismatch - user expects '${recipient.tokenName}', transaction has '${output.tokenName}'`
1182+
);
11281183
}
11291184
}
11301185

1131-
// Validates that the public key exists in the transaction
1132-
private validatePublicKey(transactionData: TxData, explainedTx: TransactionExplanation): void {
1186+
private validatePublicKey(transaction: Transaction): void {
1187+
const transactionData = transaction.toJson();
1188+
11331189
if (!transactionData.publicKey) {
11341190
throw new Error('Error on token enablements: missing public key in transaction');
11351191
}
1136-
// NEAR public keys must be in the format "ed25519:base58_encoded_key"
1192+
1193+
// Validate ed25519 format: "ed25519:base58_encoded_key"
11371194
if (!transactionData.publicKey.startsWith('ed25519:')) {
1138-
throw new Error('Error on token enablements: invalid public key format, must start with "ed25519:"');
1195+
throw new Error('Error on token enablements: unsupported key type, expected ed25519');
11391196
}
11401197

1141-
// Extract the base58 part and validate it
1142-
const base58Part = transactionData.publicKey.substring(8); // Remove "ed25519:" prefix
1143-
if (!base58Part || base58Part.length === 0) {
1144-
throw new Error('Error on token enablements: invalid public key format, missing base58 encoded key');
1198+
// Validate base58 part after "ed25519:"
1199+
const base58Part = transactionData.publicKey.substring(8);
1200+
if (!base58Part || base58Part.length !== 44) {
1201+
// ed25519 keys are 32 bytes = 44 base58 chars
1202+
throw new Error('Error on token enablements: invalid ed25519 public key format');
11451203
}
11461204

1147-
// Validate base58 encoding (basic check)
1205+
// Validate it's actually valid base58
11481206
try {
1149-
const decoded = base58.decode(base58Part);
1207+
const decoded = nearAPI.utils.serialize.base_decode(base58Part);
11501208
if (decoded.length !== 32) {
1151-
throw new Error('Error on token enablements: invalid public key length, must be 32 bytes when decoded');
1209+
throw new Error('Error on token enablements: invalid ed25519 public key length');
11521210
}
1153-
} catch (error) {
1154-
throw new Error('Error on token enablements: invalid public key base58 encoding');
1211+
} catch {
1212+
throw new Error('Error on token enablements: invalid base58 encoding in public key');
11551213
}
11561214
}
11571215

1158-
// Validates that the actions exist in the transaction and follow NEAR token enablement spec
1159-
private validateActions(transactionData: TxData, explainedTx: TransactionExplanation): void {
1160-
// Check that actions array exists and is not empty
1161-
if (!transactionData.actions || transactionData.actions.length === 0) {
1162-
throw new Error('Error on token enablements: missing actions in transaction');
1163-
}
1216+
// Validates the raw transaction actions according to NEAR protocol spec
1217+
private validateRawActions(transaction: Transaction, txParams: TransactionParams): void {
1218+
const transactionData = transaction.toJson();
11641219

1165-
// Token enablement must have exactly ONE action (NEAR spec requirement)
1166-
if (transactionData.actions.length !== 1) {
1167-
throw new Error(
1168-
`Error on token enablements: must have exactly 1 action, found ${transactionData.actions.length}`
1169-
);
1220+
// Must have exactly 1 action (NEAR spec requirement)
1221+
if (!transactionData.actions || transactionData.actions.length !== 1) {
1222+
throw new Error('Error on token enablements: must have exactly 1 action');
11701223
}
11711224

11721225
const action = transactionData.actions[0];
11731226

1174-
// The action must be a FunctionCall (not Transfer or other action types)
1227+
// Must be a functionCall action (not transfer)
11751228
if (!action.functionCall) {
1176-
throw new Error('Error on token enablements: action must be a FunctionCall, not Transfer or other type');
1229+
throw new Error('Error on token enablements: action must be a function call');
11771230
}
11781231

1179-
// Method name must be exactly "storage_deposit" (NEAR NEP-145 requirement)
1232+
// Must be storage_deposit method (NEAR spec requirement)
11801233
if (action.functionCall.methodName !== 'storage_deposit') {
11811234
throw new Error(
1182-
`Error on token enablements: invalid method name "${action.functionCall.methodName}", must be "storage_deposit"`
1235+
`Error on token enablements: invalid method '${action.functionCall.methodName}', expected 'storage_deposit'`
11831236
);
11841237
}
11851238

1186-
// Validate function call arguments structure
1239+
// Validate args structure (should be JSON object)
11871240
if (!action.functionCall.args || typeof action.functionCall.args !== 'object') {
1188-
throw new Error('Error on token enablements: missing or invalid function call arguments');
1241+
throw new Error('Error on token enablements: invalid or missing function call arguments');
11891242
}
11901243

1191-
// Validate gas amount is reasonable (not zero, not excessively high)
1192-
const gas = action.functionCall.gas;
1193-
if (!gas || gas === '0') {
1194-
throw new Error('Error on token enablements: gas amount cannot be zero');
1244+
// Validate deposit exists and is valid
1245+
if (!action.functionCall.deposit) {
1246+
throw new Error('Error on token enablements: missing deposit in function call');
11951247
}
11961248

1197-
// Validate deposit amount exists and is valid
1198-
const deposit = action.functionCall.deposit;
1199-
if (!deposit) {
1200-
throw new Error('Error on token enablements: missing deposit amount');
1249+
const depositAmount = new BigNumber(action.functionCall.deposit);
1250+
if (depositAmount.isNaN() || depositAmount.isLessThan(0)) {
1251+
throw new Error('Error on token enablements: invalid deposit amount in function call');
12011252
}
12021253

1203-
const depositBN = new BigNumber(deposit);
1204-
if (depositBN.isNaN() || depositBN.isLessThan(0)) {
1205-
throw new Error('Error on token enablements: invalid deposit amount format');
1254+
// Validate gas exists and is valid
1255+
if (!action.functionCall.gas) {
1256+
throw new Error('Error on token enablements: missing gas in function call');
12061257
}
12071258

1208-
// Cross-validate deposit amount with explained transaction
1209-
if (explainedTx.outputs?.[0]?.amount && deposit !== explainedTx.outputs[0].amount) {
1259+
const gasAmount = new BigNumber(action.functionCall.gas);
1260+
if (gasAmount.isNaN() || gasAmount.isLessThan(0)) {
1261+
throw new Error('Error on token enablements: invalid gas amount in function call');
1262+
}
1263+
1264+
// Validate deposit amount against expected storage deposit (merged from validateActions)
1265+
const recipient = txParams.recipients?.[0];
1266+
if (recipient?.tokenName) {
1267+
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(recipient.tokenName);
1268+
if (tokenInstance?.storageDepositAmount && action.functionCall.deposit !== tokenInstance.storageDepositAmount) {
1269+
throw new Error(
1270+
`Error on token enablements: deposit amount ${action.functionCall.deposit} does not match expected storage deposit ${tokenInstance.storageDepositAmount}`
1271+
);
1272+
}
1273+
}
1274+
1275+
// Validate user-specified amount matches deposit (merged from validateActions)
1276+
if (
1277+
recipient?.amount !== undefined &&
1278+
recipient.amount !== '0' &&
1279+
recipient.amount !== action.functionCall.deposit
1280+
) {
12101281
throw new Error(
1211-
`Error on token enablements: deposit amount mismatch - action has "${deposit}", explained tx has "${explainedTx.outputs[0].amount}"`
1282+
`Error on token enablements: user specified amount '${recipient.amount}' does not match storage deposit '${action.functionCall.deposit}'`
12121283
);
12131284
}
12141285
}
12151286

1216-
// Validates that the transaction type matches the expected type for the given txParams
12171287
private validateTxType(txParams: TransactionParams, explainedTx: TransactionExplanation): void {
12181288
if (txParams.type === 'enabletoken') {
1219-
// For NEAR token enablement, we expect TransactionType.StorageDeposit
12201289
const expectedType = TransactionType.StorageDeposit;
12211290
const actualType = explainedTx.type;
12221291

@@ -1227,27 +1296,4 @@ export class Near extends BaseCoin {
12271296
}
12281297
}
12291298
}
1230-
1231-
//Validates that addresses match between parameters and explained transaction
1232-
private validateAddresses(txParams: TransactionParams, explainedTx: TransactionExplanation): void {
1233-
if (!txParams.recipients) {
1234-
// This is ok since sometimes users do not input recipients for consolidation requests as they are generated by the server
1235-
return;
1236-
}
1237-
if (!explainedTx.outputs) {
1238-
throw new Error('Error on token enablements: transaction has no outputs but recipients were expected');
1239-
}
1240-
1241-
if (txParams.recipients.length !== explainedTx.outputs.length) {
1242-
throw new Error('Error on token enablements: output count does not match recipients count');
1243-
}
1244-
1245-
const mismatchedAddresses = txParams.recipients
1246-
.filter((recipient, index) => recipient.address !== explainedTx.outputs[index].address)
1247-
.map((recipient) => recipient.address);
1248-
1249-
if (mismatchedAddresses.length > 0) {
1250-
throw new Error(`Address mismatch: ${mismatchedAddresses.join(', ')}`);
1251-
}
1252-
}
12531299
}

modules/sdk-coin-near/test/unit/tokenEnablementValidation.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ describe('NEAR Token Enablement Validation', function () {
152152
};
153153

154154
// This SHOULD throw an error because the addresses don't match
155-
await basecoin.verifyTransaction(verifyOptions).should.be.rejectedWith('Address mismatch: wrong.address.near');
155+
await basecoin
156+
.verifyTransaction(verifyOptions)
157+
.should.be.rejectedWith('Error on token enablements: transaction beneficiary mismatch with user expectation');
156158
});
157159

158160
/**

0 commit comments

Comments
 (0)