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
228 changes: 227 additions & 1 deletion modules/sdk-coin-near/src/near.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ 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';

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,212 @@ export class Near extends BaseCoin {
}
auditEddsaPrivateKey(prv, publicKey ?? '');
}

private validateTokenEnablementTransaction(
Copy link
Contributor

Choose a reason for hiding this comment

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

JSDoc

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');
}

// Validate base58 part after "ed25519:"
const base58Part = transactionData.publicKey.substring(8);
if (!base58Part || base58Part.length !== 44) {
// ed25519 keys are 32 bytes = 44 base58 chars
throw new Error('Error on token enablements: invalid ed25519 public key format');
}

// Validate it's actually valid base58
let decoded;
try {
decoded = nearAPI.utils.serialize.base_decode(base58Part);
} catch {
throw new Error('Error on token enablements: invalid base58 encoding in public key');
}

if (!decoded || decoded.length !== 32) {
throw new Error('Error on token enablements: invalid ed25519 public key length');
}
}

// 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