Skip to content

Commit e3ad57d

Browse files
committed
feat(sdk-coin-near): token enablement transaction validation
TICKET: WP-5782
1 parent 8b0b018 commit e3ad57d

File tree

2 files changed

+901
-1
lines changed

2 files changed

+901
-1
lines changed

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

Lines changed: 227 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ 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';
4749
import { MAX_GAS_LIMIT_FOR_FT_TRANSFER } from './lib/constants';
4850

@@ -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,212 @@ export class Near extends BaseCoin {
10551073
}
10561074
auditEddsaPrivateKey(prv, publicKey ?? '');
10571075
}
1076+
1077+
private validateTokenEnablementTransaction(
1078+
transaction: Transaction,
1079+
explainedTx: TransactionExplanation,
1080+
txParams: TransactionParams
1081+
): void {
1082+
const transactionData = transaction.toJson();
1083+
this.validateTxType(txParams, explainedTx);
1084+
this.validateSigner(transactionData);
1085+
this.validateRawReceiver(transactionData, txParams);
1086+
this.validatePublicKey(transactionData);
1087+
this.validateRawActions(transactionData, txParams);
1088+
this.validateBeneficiary(explainedTx, txParams);
1089+
this.validateTokenOutput(explainedTx, txParams);
1090+
}
1091+
1092+
// Validates that the signer ID exists in the transaction
1093+
private validateSigner(transactionData: TxData): void {
1094+
if (!transactionData.signerId) {
1095+
throw new Error('Error on token enablements: missing signer ID in transaction');
1096+
}
1097+
}
1098+
1099+
private validateBeneficiary(explainedTx: TransactionExplanation, txParams: TransactionParams): void {
1100+
if (!explainedTx.outputs || explainedTx.outputs.length === 0) {
1101+
throw new Error('Error on token enablements: transaction has no outputs to validate beneficiary');
1102+
}
1103+
1104+
// NEAR token enablements only support a single recipient
1105+
if (!txParams.recipients || txParams.recipients.length === 0) {
1106+
throw new Error('Error on token enablements: missing recipients in transaction parameters');
1107+
}
1108+
1109+
if (txParams.recipients.length !== 1) {
1110+
throw new Error('Error on token enablements: token enablement only supports a single recipient');
1111+
}
1112+
1113+
if (explainedTx.outputs.length !== 1) {
1114+
throw new Error('Error on token enablements: transaction must have exactly 1 output');
1115+
}
1116+
1117+
const output = explainedTx.outputs[0];
1118+
const recipient = txParams.recipients[0];
1119+
1120+
if (!recipient?.address) {
1121+
throw new Error('Error on token enablements: missing beneficiary address in transaction parameters');
1122+
}
1123+
1124+
if (output.address !== recipient.address) {
1125+
throw new Error('Error on token enablements: transaction beneficiary mismatch with user expectation');
1126+
}
1127+
}
1128+
1129+
// Validates that the raw transaction receiverId matches the expected token contract
1130+
private validateRawReceiver(transactionData: TxData, txParams: TransactionParams): void {
1131+
if (!transactionData.receiverId) {
1132+
throw new Error('Error on token enablements: missing receiver ID in transaction');
1133+
}
1134+
1135+
const recipient = txParams.recipients?.[0];
1136+
if (!recipient?.tokenName) {
1137+
throw new Error('Error on token enablements: missing token name in transaction parameters');
1138+
}
1139+
1140+
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(recipient.tokenName);
1141+
if (!tokenInstance) {
1142+
throw new Error(`Error on token enablements: unknown token '${recipient.tokenName}'`);
1143+
}
1144+
1145+
if (transactionData.receiverId !== tokenInstance.contractAddress) {
1146+
throw new Error(
1147+
`Error on token enablements: receiver contract mismatch - expected '${tokenInstance.contractAddress}', got '${transactionData.receiverId}'`
1148+
);
1149+
}
1150+
}
1151+
1152+
// Validates token output information from explained transaction
1153+
private validateTokenOutput(explainedTx: TransactionExplanation, txParams: TransactionParams): void {
1154+
if (!explainedTx.outputs || explainedTx.outputs.length !== 1) {
1155+
throw new Error('Error on token enablements: transaction must have exactly 1 output');
1156+
}
1157+
1158+
const output = explainedTx.outputs[0];
1159+
const recipient = txParams.recipients?.[0];
1160+
1161+
if (!output.tokenName) {
1162+
throw new Error('Error on token enablements: missing token name in transaction output');
1163+
}
1164+
1165+
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(output.tokenName);
1166+
if (!tokenInstance) {
1167+
throw new Error(`Error on token enablements: unknown token '${output.tokenName}'`);
1168+
}
1169+
1170+
if (recipient?.tokenName && recipient.tokenName !== output.tokenName) {
1171+
throw new Error(
1172+
`Error on token enablements: token mismatch - user expects '${recipient.tokenName}', transaction has '${output.tokenName}'`
1173+
);
1174+
}
1175+
}
1176+
1177+
private validatePublicKey(transactionData: TxData): void {
1178+
if (!transactionData.publicKey) {
1179+
throw new Error('Error on token enablements: missing public key in transaction');
1180+
}
1181+
1182+
// Validate ed25519 format: "ed25519:base58_encoded_key"
1183+
if (!transactionData.publicKey.startsWith('ed25519:')) {
1184+
throw new Error('Error on token enablements: unsupported key type, expected ed25519');
1185+
}
1186+
1187+
// Validate base58 part after "ed25519:"
1188+
const base58Part = transactionData.publicKey.substring(8);
1189+
if (!base58Part || base58Part.length !== 44) {
1190+
// ed25519 keys are 32 bytes = 44 base58 chars
1191+
throw new Error('Error on token enablements: invalid ed25519 public key format');
1192+
}
1193+
1194+
// Validate it's actually valid base58
1195+
let decoded;
1196+
try {
1197+
decoded = nearAPI.utils.serialize.base_decode(base58Part);
1198+
} catch {
1199+
throw new Error('Error on token enablements: invalid base58 encoding in public key');
1200+
}
1201+
1202+
if (!decoded || decoded.length !== 32) {
1203+
throw new Error('Error on token enablements: invalid ed25519 public key length');
1204+
}
1205+
}
1206+
1207+
// Validates the raw transaction actions according to NEAR protocol spec
1208+
private validateRawActions(transactionData: TxData, txParams: TransactionParams): void {
1209+
// Must have exactly 1 action (NEAR spec requirement)
1210+
if (!transactionData.actions || transactionData.actions.length !== 1) {
1211+
throw new Error('Error on token enablements: must have exactly 1 action');
1212+
}
1213+
1214+
const action = transactionData.actions[0];
1215+
1216+
// Must be a functionCall action (not transfer)
1217+
if (!action.functionCall) {
1218+
throw new Error('Error on token enablements: action must be a function call');
1219+
}
1220+
1221+
// Must be storage_deposit method (NEAR spec requirement)
1222+
if (action.functionCall.methodName !== 'storage_deposit') {
1223+
throw new Error(
1224+
`Error on token enablements: invalid method '${action.functionCall.methodName}', expected 'storage_deposit'`
1225+
);
1226+
}
1227+
1228+
// Validate args structure (should be JSON object)
1229+
if (!action.functionCall.args || typeof action.functionCall.args !== 'object') {
1230+
throw new Error('Error on token enablements: invalid or missing function call arguments');
1231+
}
1232+
1233+
// Validate deposit exists and is valid
1234+
if (!action.functionCall.deposit) {
1235+
throw new Error('Error on token enablements: missing deposit in function call');
1236+
}
1237+
1238+
const depositAmount = new BigNumber(action.functionCall.deposit);
1239+
if (depositAmount.isNaN() || depositAmount.isLessThan(0)) {
1240+
throw new Error('Error on token enablements: invalid deposit amount in function call');
1241+
}
1242+
1243+
// Validate gas exists and is valid
1244+
if (!action.functionCall.gas) {
1245+
throw new Error('Error on token enablements: missing gas in function call');
1246+
}
1247+
1248+
const gasAmount = new BigNumber(action.functionCall.gas);
1249+
if (gasAmount.isNaN() || gasAmount.isLessThan(0)) {
1250+
throw new Error('Error on token enablements: invalid gas amount in function call');
1251+
}
1252+
1253+
// Validate deposit amount against expected storage deposit (merged from validateActions)
1254+
const recipient = txParams.recipients?.[0];
1255+
if (recipient?.tokenName) {
1256+
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(recipient.tokenName);
1257+
if (tokenInstance?.storageDepositAmount && action.functionCall.deposit !== tokenInstance.storageDepositAmount) {
1258+
throw new Error(
1259+
`Error on token enablements: deposit amount ${action.functionCall.deposit} does not match expected storage deposit ${tokenInstance.storageDepositAmount}`
1260+
);
1261+
}
1262+
}
1263+
1264+
// Validate user-specified amount matches deposit (merged from validateActions)
1265+
if (
1266+
recipient?.amount !== undefined &&
1267+
recipient.amount !== '0' &&
1268+
recipient.amount !== action.functionCall.deposit
1269+
) {
1270+
throw new Error(
1271+
`Error on token enablements: user specified amount '${recipient.amount}' does not match storage deposit '${action.functionCall.deposit}'`
1272+
);
1273+
}
1274+
}
1275+
1276+
private validateTxType(txParams: TransactionParams, explainedTx: TransactionExplanation): void {
1277+
const expectedType = TransactionType.StorageDeposit;
1278+
const actualType = explainedTx.type;
1279+
1280+
if (actualType !== expectedType) {
1281+
throw new Error(`Invalid transaction type on token enablement: expected "${expectedType}", got "${actualType}".`);
1282+
}
1283+
}
10581284
}

0 commit comments

Comments
 (0)