Skip to content

Commit 98a7631

Browse files
authored
Merge pull request #6958 from BitGo/WP-5782/near-token-enablement-validation
Wp 5782/ near token enablement validation
2 parents 82d46d7 + 2150024 commit 98a7631

File tree

2 files changed

+898
-2
lines changed

2 files changed

+898
-2
lines changed

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

Lines changed: 224 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,17 @@ import {
3636
SignedTransaction,
3737
SignTransactionOptions as BaseSignTransactionOptions,
3838
TokenEnablementConfig,
39-
TransactionExplanation,
39+
TransactionParams,
40+
TransactionType,
4041
VerifyAddressOptions,
4142
VerifyTransactionOptions,
4243
} from '@bitgo/sdk-core';
4344
import { BaseCoin as StaticsBaseCoin, CoinFamily, coins, Nep141Token, Networks } from '@bitgo/statics';
4445

4546
import { KeyPair as NearKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib';
47+
import { TransactionExplanation, TxData } from './lib/iface';
4648
import nearUtils from './lib/utils';
47-
import { MAX_GAS_LIMIT_FOR_FT_TRANSFER } from './lib/constants';
49+
import { MAX_GAS_LIMIT_FOR_FT_TRANSFER, STORAGE_DEPOSIT } from './lib/constants';
4850

4951
export interface SignTransactionOptions extends BaseSignTransactionOptions {
5052
txPrebuild: TransactionPrebuild;
@@ -1000,6 +1002,10 @@ export class Near extends BaseCoin {
10001002
const explainedTx = transaction.explainTransaction();
10011003

10021004
// users do not input recipients for consolidation requests as they are generated by the server
1005+
if (txParams.type === 'enabletoken' && params.verification?.verifyTokenEnablement) {
1006+
this.validateTokenEnablementTransaction(transaction, explainedTx, txParams);
1007+
}
1008+
10031009
if (txParams.recipients !== undefined) {
10041010
if (txParams.type === 'enabletoken') {
10051011
const tokenName = explainedTx.outputs[0].tokenName;
@@ -1031,6 +1037,18 @@ export class Near extends BaseCoin {
10311037
});
10321038

10331039
if (!_.isEqual(filteredOutputs, filteredRecipients)) {
1040+
// For enabletoken, provide more specific error messages for address mismatches
1041+
if (txParams.type === 'enabletoken' && params.verification?.verifyTokenEnablement) {
1042+
const mismatchedAddresses = txParams.recipients
1043+
?.filter(
1044+
(recipient, index) => !filteredOutputs[index] || recipient.address !== filteredOutputs[index].address
1045+
)
1046+
.map((recipient) => recipient.address);
1047+
1048+
if (mismatchedAddresses && mismatchedAddresses.length > 0) {
1049+
throw new Error(`Address mismatch: ${mismatchedAddresses.join(', ')}`);
1050+
}
1051+
}
10341052
throw new Error('Tx outputs does not match with expected txParams recipients');
10351053
}
10361054
for (const recipients of txParams.recipients) {
@@ -1055,4 +1073,208 @@ export class Near extends BaseCoin {
10551073
}
10561074
auditEddsaPrivateKey(prv, publicKey ?? '');
10571075
}
1076+
1077+
/**
1078+
* Validates a token enablement transaction by performing checks
1079+
* for NEAR protocol compliance and ensuring txParams matches transaction data.
1080+
*
1081+
* @param transaction - The NEAR transaction object to validate
1082+
* @param explainedTx - The same transaction data in explained format with parsed outputs and metadata
1083+
* @param txParams - The transaction parameters containing recipients and configuration
1084+
* @throws {Error} When any validation check fails, with descriptive error messages
1085+
* @private
1086+
*/
1087+
private validateTokenEnablementTransaction(
1088+
transaction: Transaction,
1089+
explainedTx: TransactionExplanation,
1090+
txParams: TransactionParams
1091+
): void {
1092+
const transactionData = transaction.toJson();
1093+
this.validateTxType(txParams, explainedTx);
1094+
this.validateSigner(transactionData);
1095+
this.validateRawReceiver(transactionData, txParams);
1096+
this.validatePublicKey(transactionData);
1097+
this.validateRawActions(transactionData, txParams);
1098+
this.validateBeneficiary(explainedTx, txParams);
1099+
this.validateTokenOutput(explainedTx, txParams);
1100+
}
1101+
1102+
// Validates that the signer ID exists in the transaction
1103+
private validateSigner(transactionData: TxData): void {
1104+
if (!transactionData.signerId) {
1105+
throw new Error('Error on token enablements: missing signer ID in transaction');
1106+
}
1107+
}
1108+
1109+
private validateBeneficiary(explainedTx: TransactionExplanation, txParams: TransactionParams): void {
1110+
if (!explainedTx.outputs || explainedTx.outputs.length === 0) {
1111+
throw new Error('Error on token enablements: transaction has no outputs to validate beneficiary');
1112+
}
1113+
1114+
// NEAR token enablements only support a single recipient
1115+
if (!txParams.recipients || txParams.recipients.length === 0) {
1116+
throw new Error('Error on token enablements: missing recipients in transaction parameters');
1117+
}
1118+
1119+
if (txParams.recipients.length !== 1) {
1120+
throw new Error('Error on token enablements: token enablement only supports a single recipient');
1121+
}
1122+
1123+
if (explainedTx.outputs.length !== 1) {
1124+
throw new Error('Error on token enablements: transaction must have exactly 1 output');
1125+
}
1126+
1127+
const output = explainedTx.outputs[0];
1128+
const recipient = txParams.recipients[0];
1129+
1130+
if (!recipient?.address) {
1131+
throw new Error('Error on token enablements: missing beneficiary address in transaction parameters');
1132+
}
1133+
1134+
if (output.address !== recipient.address) {
1135+
throw new Error('Error on token enablements: transaction beneficiary mismatch with user expectation');
1136+
}
1137+
}
1138+
1139+
// Validates that the raw transaction receiverId matches the expected token contract
1140+
private validateRawReceiver(transactionData: TxData, txParams: TransactionParams): void {
1141+
if (!transactionData.receiverId) {
1142+
throw new Error('Error on token enablements: missing receiver ID in transaction');
1143+
}
1144+
1145+
const recipient = txParams.recipients?.[0];
1146+
if (!recipient?.tokenName) {
1147+
throw new Error('Error on token enablements: missing token name in transaction parameters');
1148+
}
1149+
1150+
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(recipient.tokenName);
1151+
if (!tokenInstance) {
1152+
throw new Error(`Error on token enablements: unknown token '${recipient.tokenName}'`);
1153+
}
1154+
1155+
if (transactionData.receiverId !== tokenInstance.contractAddress) {
1156+
throw new Error(
1157+
`Error on token enablements: receiver contract mismatch - expected '${tokenInstance.contractAddress}', got '${transactionData.receiverId}'`
1158+
);
1159+
}
1160+
}
1161+
1162+
// Validates token output information from explained transaction
1163+
private validateTokenOutput(explainedTx: TransactionExplanation, txParams: TransactionParams): void {
1164+
if (!explainedTx.outputs || explainedTx.outputs.length !== 1) {
1165+
throw new Error('Error on token enablements: transaction must have exactly 1 output');
1166+
}
1167+
1168+
const output = explainedTx.outputs[0];
1169+
const recipient = txParams.recipients?.[0];
1170+
1171+
if (!output.tokenName) {
1172+
throw new Error('Error on token enablements: missing token name in transaction output');
1173+
}
1174+
1175+
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(output.tokenName);
1176+
if (!tokenInstance) {
1177+
throw new Error(`Error on token enablements: unknown token '${output.tokenName}'`);
1178+
}
1179+
1180+
if (recipient?.tokenName && recipient.tokenName !== output.tokenName) {
1181+
throw new Error(
1182+
`Error on token enablements: token mismatch - user expects '${recipient.tokenName}', transaction has '${output.tokenName}'`
1183+
);
1184+
}
1185+
}
1186+
1187+
private validatePublicKey(transactionData: TxData): void {
1188+
if (!transactionData.publicKey) {
1189+
throw new Error('Error on token enablements: missing public key in transaction');
1190+
}
1191+
1192+
// Validate ed25519 format: "ed25519:base58_encoded_key"
1193+
if (!transactionData.publicKey.startsWith('ed25519:')) {
1194+
throw new Error('Error on token enablements: unsupported key type, expected ed25519');
1195+
}
1196+
1197+
const base58Part = transactionData.publicKey.substring(8);
1198+
if (!this.isValidPub(base58Part)) {
1199+
throw new Error('Error on token enablements: invalid public key format');
1200+
}
1201+
}
1202+
1203+
// Validates the raw transaction actions according to NEAR protocol spec
1204+
private validateRawActions(transactionData: TxData, txParams: TransactionParams): void {
1205+
// Must have exactly 1 action (NEAR spec requirement)
1206+
if (!transactionData.actions || transactionData.actions.length !== 1) {
1207+
throw new Error('Error on token enablements: must have exactly 1 action');
1208+
}
1209+
1210+
const action = transactionData.actions[0];
1211+
1212+
// Must be a functionCall action (not transfer)
1213+
if (!action.functionCall) {
1214+
throw new Error('Error on token enablements: action must be a function call');
1215+
}
1216+
1217+
// Must be storage_deposit method (NEAR spec requirement)
1218+
if (action.functionCall.methodName !== 'storage_deposit') {
1219+
throw new Error(
1220+
`Error on token enablements: invalid method '${action.functionCall.methodName}', expected '${STORAGE_DEPOSIT}'`
1221+
);
1222+
}
1223+
1224+
// Validate args structure (should be JSON object)
1225+
if (!action.functionCall.args || typeof action.functionCall.args !== 'object') {
1226+
throw new Error('Error on token enablements: invalid or missing function call arguments');
1227+
}
1228+
1229+
// Validate deposit exists and is valid
1230+
if (!action.functionCall.deposit) {
1231+
throw new Error('Error on token enablements: missing deposit in function call');
1232+
}
1233+
1234+
const depositAmount = new BigNumber(action.functionCall.deposit);
1235+
if (depositAmount.isNaN() || depositAmount.isLessThan(0)) {
1236+
throw new Error('Error on token enablements: invalid deposit amount in function call');
1237+
}
1238+
1239+
// Validate gas exists and is valid
1240+
if (!action.functionCall.gas) {
1241+
throw new Error('Error on token enablements: missing gas in function call');
1242+
}
1243+
1244+
const gasAmount = new BigNumber(action.functionCall.gas);
1245+
if (gasAmount.isNaN() || gasAmount.isLessThan(0)) {
1246+
throw new Error('Error on token enablements: invalid gas amount in function call');
1247+
}
1248+
1249+
// Validate deposit amount against expected storage deposit (merged from validateActions)
1250+
const recipient = txParams.recipients?.[0];
1251+
if (recipient?.tokenName) {
1252+
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(recipient.tokenName);
1253+
if (tokenInstance?.storageDepositAmount && action.functionCall.deposit !== tokenInstance.storageDepositAmount) {
1254+
throw new Error(
1255+
`Error on token enablements: deposit amount ${action.functionCall.deposit} does not match expected storage deposit ${tokenInstance.storageDepositAmount}`
1256+
);
1257+
}
1258+
}
1259+
1260+
// Validate user-specified amount matches deposit (merged from validateActions)
1261+
if (
1262+
recipient?.amount !== undefined &&
1263+
recipient.amount !== '0' &&
1264+
recipient.amount !== action.functionCall.deposit
1265+
) {
1266+
throw new Error(
1267+
`Error on token enablements: user specified amount '${recipient.amount}' does not match storage deposit '${action.functionCall.deposit}'`
1268+
);
1269+
}
1270+
}
1271+
1272+
private validateTxType(txParams: TransactionParams, explainedTx: TransactionExplanation): void {
1273+
const expectedType = TransactionType.StorageDeposit;
1274+
const actualType = explainedTx.type;
1275+
1276+
if (actualType !== expectedType) {
1277+
throw new Error(`Invalid transaction type on token enablement: expected "${expectedType}", got "${actualType}".`);
1278+
}
1279+
}
10581280
}

0 commit comments

Comments
 (0)