Skip to content

Commit 48ac3b8

Browse files
committed
feat(sdk-coin-near): token enablement transaction validation
NEAR token enablement blind signing validation TICKET: WP-5782
1 parent 8b0b018 commit 48ac3b8

2 files changed

Lines changed: 702 additions & 1 deletion

File tree

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

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

0 commit comments

Comments
 (0)