Skip to content
Open
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
226 changes: 224 additions & 2 deletions modules/sdk-coin-near/src/near.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,17 @@ import {
SignedTransaction,
SignTransactionOptions as BaseSignTransactionOptions,
TokenEnablementConfig,
TransactionExplanation,
TransactionParams,
TransactionType,
VerifyAddressOptions,
VerifyTransactionOptions,
} from '@bitgo/sdk-core';
import { BaseCoin as StaticsBaseCoin, CoinFamily, coins, Nep141Token, Networks } from '@bitgo/statics';

import { KeyPair as NearKeyPair, Transaction, TransactionBuilder, TransactionBuilderFactory } from './lib';
import { TransactionExplanation, TxData } from './lib/iface';
import nearUtils from './lib/utils';
import { MAX_GAS_LIMIT_FOR_FT_TRANSFER } from './lib/constants';
import { MAX_GAS_LIMIT_FOR_FT_TRANSFER, STORAGE_DEPOSIT } from './lib/constants';

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

// users do not input recipients for consolidation requests as they are generated by the server
if (txParams.type === 'enabletoken' && params.verification?.verifyTokenEnablement) {
this.validateTokenEnablementTransaction(transaction, explainedTx, txParams);
}

if (txParams.recipients !== undefined) {
if (txParams.type === 'enabletoken') {
const tokenName = explainedTx.outputs[0].tokenName;
Expand Down Expand Up @@ -1031,6 +1037,18 @@ export class Near extends BaseCoin {
});

if (!_.isEqual(filteredOutputs, filteredRecipients)) {
// For enabletoken, provide more specific error messages for address mismatches
if (txParams.type === 'enabletoken' && params.verification?.verifyTokenEnablement) {
const mismatchedAddresses = txParams.recipients
?.filter(
(recipient, index) => !filteredOutputs[index] || recipient.address !== filteredOutputs[index].address
)
.map((recipient) => recipient.address);

if (mismatchedAddresses && mismatchedAddresses.length > 0) {
throw new Error(`Address mismatch: ${mismatchedAddresses.join(', ')}`);
}
}
throw new Error('Tx outputs does not match with expected txParams recipients');
}
for (const recipients of txParams.recipients) {
Expand All @@ -1055,4 +1073,208 @@ export class Near extends BaseCoin {
}
auditEddsaPrivateKey(prv, publicKey ?? '');
}

/**
* Validates a token enablement transaction by performing checks
* for NEAR protocol compliance and ensuring txParams matches transaction data.
*
* @param transaction - The NEAR transaction object to validate
* @param explainedTx - The same transaction data in explained format with parsed outputs and metadata
* @param txParams - The transaction parameters containing recipients and configuration
* @throws {Error} When any validation check fails, with descriptive error messages
* @private
*/
private validateTokenEnablementTransaction(
transaction: Transaction,
explainedTx: TransactionExplanation,
txParams: TransactionParams
): void {
const transactionData = transaction.toJson();
this.validateTxType(txParams, explainedTx);
this.validateSigner(transactionData);
this.validateRawReceiver(transactionData, txParams);
this.validatePublicKey(transactionData);
this.validateRawActions(transactionData, txParams);
this.validateBeneficiary(explainedTx, txParams);
this.validateTokenOutput(explainedTx, txParams);
}

// Validates that the signer ID exists in the transaction
private validateSigner(transactionData: TxData): void {
if (!transactionData.signerId) {
throw new Error('Error on token enablements: missing signer ID in transaction');
}
}

private validateBeneficiary(explainedTx: TransactionExplanation, txParams: TransactionParams): void {
if (!explainedTx.outputs || explainedTx.outputs.length === 0) {
throw new Error('Error on token enablements: transaction has no outputs to validate beneficiary');
}

// NEAR token enablements only support a single recipient
if (!txParams.recipients || txParams.recipients.length === 0) {
throw new Error('Error on token enablements: missing recipients in transaction parameters');
}

if (txParams.recipients.length !== 1) {
throw new Error('Error on token enablements: token enablement only supports a single recipient');
}

if (explainedTx.outputs.length !== 1) {
throw new Error('Error on token enablements: transaction must have exactly 1 output');
}

const output = explainedTx.outputs[0];
const recipient = txParams.recipients[0];

if (!recipient?.address) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you confirm if near token enablements supports a single recipient? because if that's the case then you may need to check for outputs.length === 1 and if it isn't then you may need to iterate over the outputs or recipients and match one with the other (also you can preliminary compare outputs.length with recipients.length and they should match).

throw new Error('Error on token enablements: missing beneficiary address in transaction parameters');
}

if (output.address !== recipient.address) {
throw new Error('Error on token enablements: transaction beneficiary mismatch with user expectation');
}
}

// Validates that the raw transaction receiverId matches the expected token contract
private validateRawReceiver(transactionData: TxData, txParams: TransactionParams): void {
if (!transactionData.receiverId) {
throw new Error('Error on token enablements: missing receiver ID in transaction');
}

const recipient = txParams.recipients?.[0];
if (!recipient?.tokenName) {
throw new Error('Error on token enablements: missing token name in transaction parameters');
}

const tokenInstance = nearUtils.getTokenInstanceFromTokenName(recipient.tokenName);
if (!tokenInstance) {
throw new Error(`Error on token enablements: unknown token '${recipient.tokenName}'`);
}

if (transactionData.receiverId !== tokenInstance.contractAddress) {
throw new Error(
`Error on token enablements: receiver contract mismatch - expected '${tokenInstance.contractAddress}', got '${transactionData.receiverId}'`
);
}
}

// Validates token output information from explained transaction
private validateTokenOutput(explainedTx: TransactionExplanation, txParams: TransactionParams): void {
if (!explainedTx.outputs || explainedTx.outputs.length !== 1) {
throw new Error('Error on token enablements: transaction must have exactly 1 output');
}

const output = explainedTx.outputs[0];
const recipient = txParams.recipients?.[0];

if (!output.tokenName) {
throw new Error('Error on token enablements: missing token name in transaction output');
}

const tokenInstance = nearUtils.getTokenInstanceFromTokenName(output.tokenName);
if (!tokenInstance) {
throw new Error(`Error on token enablements: unknown token '${output.tokenName}'`);
}

if (recipient?.tokenName && recipient.tokenName !== output.tokenName) {
throw new Error(
`Error on token enablements: token mismatch - user expects '${recipient.tokenName}', transaction has '${output.tokenName}'`
);
}
}

private validatePublicKey(transactionData: TxData): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this class has isValidPublicKey method, can we not use that?

if (!transactionData.publicKey) {
throw new Error('Error on token enablements: missing public key in transaction');
}

// Validate ed25519 format: "ed25519:base58_encoded_key"
if (!transactionData.publicKey.startsWith('ed25519:')) {
throw new Error('Error on token enablements: unsupported key type, expected ed25519');
}

const base58Part = transactionData.publicKey.substring(8);
if (!this.isValidPub(base58Part)) {
throw new Error('Error on token enablements: invalid public key format');
}
}

// Validates the raw transaction actions according to NEAR protocol spec
private validateRawActions(transactionData: TxData, txParams: TransactionParams): void {
// Must have exactly 1 action (NEAR spec requirement)
if (!transactionData.actions || transactionData.actions.length !== 1) {
throw new Error('Error on token enablements: must have exactly 1 action');
}

const action = transactionData.actions[0];

// Must be a functionCall action (not transfer)
if (!action.functionCall) {
throw new Error('Error on token enablements: action must be a function call');
}

// Must be storage_deposit method (NEAR spec requirement)
if (action.functionCall.methodName !== 'storage_deposit') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use the STORAGE_DEPOSIT from the constants?

throw new Error(
`Error on token enablements: invalid method '${action.functionCall.methodName}', expected '${STORAGE_DEPOSIT}'`
);
}

// Validate args structure (should be JSON object)
if (!action.functionCall.args || typeof action.functionCall.args !== 'object') {
throw new Error('Error on token enablements: invalid or missing function call arguments');
}

// Validate deposit exists and is valid
if (!action.functionCall.deposit) {
throw new Error('Error on token enablements: missing deposit in function call');
}

const depositAmount = new BigNumber(action.functionCall.deposit);
if (depositAmount.isNaN() || depositAmount.isLessThan(0)) {
throw new Error('Error on token enablements: invalid deposit amount in function call');
}

// Validate gas exists and is valid
if (!action.functionCall.gas) {
throw new Error('Error on token enablements: missing gas in function call');
}

const gasAmount = new BigNumber(action.functionCall.gas);
if (gasAmount.isNaN() || gasAmount.isLessThan(0)) {
throw new Error('Error on token enablements: invalid gas amount in function call');
}

// Validate deposit amount against expected storage deposit (merged from validateActions)
const recipient = txParams.recipients?.[0];
if (recipient?.tokenName) {
const tokenInstance = nearUtils.getTokenInstanceFromTokenName(recipient.tokenName);
if (tokenInstance?.storageDepositAmount && action.functionCall.deposit !== tokenInstance.storageDepositAmount) {
throw new Error(
`Error on token enablements: deposit amount ${action.functionCall.deposit} does not match expected storage deposit ${tokenInstance.storageDepositAmount}`
);
}
}

// Validate user-specified amount matches deposit (merged from validateActions)
if (
recipient?.amount !== undefined &&
recipient.amount !== '0' &&
recipient.amount !== action.functionCall.deposit
) {
throw new Error(
`Error on token enablements: user specified amount '${recipient.amount}' does not match storage deposit '${action.functionCall.deposit}'`
);
}
}

private validateTxType(txParams: TransactionParams, explainedTx: TransactionExplanation): void {
const expectedType = TransactionType.StorageDeposit;
const actualType = explainedTx.type;

if (actualType !== expectedType) {
throw new Error(`Invalid transaction type on token enablement: expected "${expectedType}", got "${actualType}".`);
}
}
}
Loading