mnee
Version:
A simple package for interacting with the MNEE USD
680 lines (616 loc) • 25.1 kB
text/typescript
import {
Hash,
P2PKH,
PrivateKey,
PublicKey,
Script,
Transaction,
TransactionSignature,
UnlockingScript,
Utils,
} from '@bsv/sdk';
import {
Environment,
GetSignatures,
MNEEBalance,
MNEEConfig,
MneeInscription,
SdkConfig,
MNEEOperation,
MneeSync,
MNEEUtxo,
ParseTxResponse,
SendMNEE,
SignatureRequest,
SignatureResponse,
TxHistory,
TxHistoryResponse,
TxOperation,
AddressHistoryParams,
} from './mnee.types.js';
import CosignTemplate from './mneeCosignTemplate.js';
import { applyInscription } from './utils/applyInscription.js';
import { parseCosignerScripts, parseInscription, parseSyncToTxHistory } from './utils/helper.js';
import {
MNEE_PROXY_API_URL,
SANDBOX_MNEE_API_URL,
PROD_TOKEN_ID,
PROD_ADDRESS,
DEV_ADDRESS,
QA_ADDRESS,
STAGE_ADDRESS,
PROD_APPROVER,
QA_TOKEN_ID,
DEV_TOKEN_ID,
STAGE_TOKEN_ID,
PUBLIC_PROD_MNEE_API_TOKEN,
PUBLIC_SANDBOX_MNEE_API_TOKEN,
} from './constants.js';
export class MNEEService {
private mneeApiKey: string;
private mneeConfig: MNEEConfig | undefined;
private mneeApi: string;
constructor(config: SdkConfig) {
if (config.environment !== 'production' && config.environment !== 'sandbox') {
throw new Error('Invalid environment. Must be either "production" or "sandbox"');
}
const isProd = config.environment === 'production';
if (config?.apiKey) {
this.mneeApiKey = config.apiKey;
} else {
this.mneeApiKey = isProd ? PUBLIC_PROD_MNEE_API_TOKEN : PUBLIC_SANDBOX_MNEE_API_TOKEN;
}
this.mneeApi = isProd ? MNEE_PROXY_API_URL : SANDBOX_MNEE_API_URL;
this.getCosignerConfig();
}
public async getCosignerConfig(): Promise<MNEEConfig | undefined> {
try {
const response = await fetch(`${this.mneeApi}/v1/config?auth_token=${this.mneeApiKey}`, { method: 'GET' });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data: MNEEConfig = await response.json();
this.mneeConfig = data;
return data;
} catch (error) {
console.error('Failed to fetch config:', error);
return undefined;
}
}
public toAtomicAmount(amount: number): number {
if (!this.mneeConfig) throw new Error('Config not fetched');
return Math.round(amount * 10 ** this.mneeConfig.decimals);
}
public fromAtomicAmount(amount: number): number {
if (!this.mneeConfig) throw new Error('Config not fetched');
return amount / 10 ** this.mneeConfig.decimals;
}
private async createInscription(recipient: string, amount: number, config: MNEEConfig) {
const inscriptionData = {
p: 'bsv-20',
op: 'transfer',
id: config.tokenId,
amt: amount.toString(),
};
return {
lockingScript: applyInscription(
new CosignTemplate().lock(recipient, PublicKey.fromString(config.approver)),
{
dataB64: Buffer.from(JSON.stringify(inscriptionData)).toString('base64'),
contentType: 'application/bsv-20',
},
),
satoshis: 1,
};
}
private async getUtxos(
address: string | string[],
ops: MNEEOperation[] = ['transfer', 'deploy+mint'],
): Promise<MNEEUtxo[]> {
try {
const arrayAddress = Array.isArray(address) ? address : [address];
const response = await fetch(`${this.mneeApi}/v1/utxos?auth_token=${this.mneeApiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(arrayAddress),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data: MNEEUtxo[] = await response.json();
if (ops.length) {
return data.filter((utxo) =>
ops.includes(utxo.data.bsv21.op.toLowerCase() as 'transfer' | 'burn' | 'deploy+mint'),
);
}
return data;
} catch (error) {
console.error('Failed to fetch UTXOs:', error);
return [];
}
}
private async fetchRawTx(txid: string): Promise<Transaction> {
const resp = await fetch(`${this.mneeApi}/v1/tx/${txid}?auth_token=${this.mneeApiKey}`);
if (resp.status === 404) throw new Error('Transaction not found');
if (resp.status !== 200) {
throw new Error(`${resp.status} - Failed to fetch rawtx for txid: ${txid}`);
}
const { rawtx } = await resp.json();
return Transaction.fromHex(Buffer.from(rawtx, 'base64').toString('hex'));
}
private async getSignatures(
request: GetSignatures,
privateKey: PrivateKey,
): Promise<{
sigResponses?: SignatureResponse[];
error?: { message: string; cause?: any };
}> {
try {
const DEFAULT_SIGHASH_TYPE = 65;
let tx: Transaction;
switch (request.format) {
case 'beef':
tx = Transaction.fromHexBEEF(request.rawtx);
break;
case 'ef':
tx = Transaction.fromHexEF(request.rawtx);
break;
default:
tx = Transaction.fromHex(request.rawtx);
break;
}
const sigResponses: SignatureResponse[] = request.sigRequests.flatMap((sigReq: SignatureRequest) => {
return [privateKey].map((privKey: PrivateKey) => {
const preimage = TransactionSignature.format({
sourceTXID: sigReq.prevTxid,
sourceOutputIndex: sigReq.outputIndex,
sourceSatoshis: sigReq.satoshis,
transactionVersion: tx.version,
otherInputs: tx.inputs.filter((_, index) => index !== sigReq.inputIndex),
inputIndex: sigReq.inputIndex,
outputs: tx.outputs,
inputSequence: tx.inputs[sigReq.inputIndex].sequence || 0,
subscript: sigReq.script
? Script.fromHex(sigReq.script)
: new P2PKH().lock(privKey.toPublicKey().toAddress()),
lockTime: tx.lockTime,
scope: sigReq.sigHashType || DEFAULT_SIGHASH_TYPE,
});
const rawSignature = privKey.sign(Hash.sha256(preimage));
const sig = new TransactionSignature(
rawSignature.r,
rawSignature.s,
sigReq.sigHashType || DEFAULT_SIGHASH_TYPE,
);
return {
sig: Utils.toHex(sig.toChecksigFormat()),
pubKey: privKey.toPublicKey().toString(),
inputIndex: sigReq.inputIndex,
sigHashType: sigReq.sigHashType || DEFAULT_SIGHASH_TYPE,
csIdx: sigReq.csIdx,
};
});
});
return Promise.resolve({ sigResponses });
} catch (err: any) {
console.error('getSignatures error', err);
return {
error: {
message: err.message ?? 'unknown',
cause: err.cause,
},
};
}
}
public async transfer(request: SendMNEE[], wif: string): Promise<{ txid?: string; rawtx?: string; error?: string }> {
try {
const config = this.mneeConfig || (await this.getCosignerConfig());
if (!config) throw new Error('Config not fetched');
const totalAmount = request.reduce((sum, req) => sum + req.amount, 0);
if (totalAmount <= 0) return { error: 'Invalid amount' };
const totalAtomicTokenAmount = this.toAtomicAmount(totalAmount);
const privateKey = PrivateKey.fromWif(wif);
const address = privateKey.toAddress();
const utxos = await this.getUtxos(address);
const totalUtxoAmount = utxos.reduce((sum, utxo) => sum + (utxo.data.bsv21.amt || 0), 0);
if (totalUtxoAmount < totalAtomicTokenAmount) {
return { error: 'Insufficient MNEE balance' };
}
const fee =
request.find((req) => req.address === config.burnAddress) !== undefined
? 0
: config.fees.find(
(fee: { min: number; max: number }) =>
totalAtomicTokenAmount >= fee.min && totalAtomicTokenAmount <= fee.max,
)?.fee;
if (fee === undefined) return { error: 'Fee ranges inadequate' };
const tx = new Transaction(1, [], [], 0);
let tokensIn = 0;
const signingAddresses: string[] = [];
let changeAddress = '';
while (tokensIn < totalAtomicTokenAmount + fee) {
const utxo = utxos.shift();
if (!utxo) return { error: 'Insufficient MNEE balance' };
const sourceTransaction = await this.fetchRawTx(utxo.txid);
if (!sourceTransaction) return { error: 'Failed to fetch source transaction' };
signingAddresses.push(utxo.owners[0]);
changeAddress = changeAddress || utxo.owners[0];
tx.addInput({
sourceTXID: utxo.txid,
sourceOutputIndex: utxo.vout,
sourceTransaction,
unlockingScript: new UnlockingScript(),
});
tokensIn += utxo.data.bsv21.amt;
}
for (const req of request) {
tx.addOutput(await this.createInscription(req.address, this.toAtomicAmount(req.amount), config));
}
if (fee > 0) tx.addOutput(await this.createInscription(config.feeAddress, fee, config));
const change = tokensIn - totalAtomicTokenAmount - fee;
if (change > 0) {
tx.addOutput(await this.createInscription(changeAddress, change, config));
}
const sigRequests: SignatureRequest[] = tx.inputs.map((input, index) => {
if (!input.sourceTXID) throw new Error('Source TXID is undefined');
return {
prevTxid: input.sourceTXID,
outputIndex: input.sourceOutputIndex,
inputIndex: index,
address: signingAddresses[index],
script: input.sourceTransaction?.outputs[input.sourceOutputIndex].lockingScript.toHex(),
satoshis: input.sourceTransaction?.outputs[input.sourceOutputIndex].satoshis || 1,
sigHashType:
TransactionSignature.SIGHASH_ALL |
TransactionSignature.SIGHASH_ANYONECANPAY |
TransactionSignature.SIGHASH_FORKID,
};
});
const rawtx = tx.toHex();
const res = await this.getSignatures({ rawtx, sigRequests }, privateKey);
if (!res?.sigResponses) return { error: 'Failed to get signatures' };
for (const sigResponse of res.sigResponses) {
tx.inputs[sigResponse.inputIndex].unlockingScript = new Script()
.writeBin(Utils.toArray(sigResponse.sig, 'hex'))
.writeBin(Utils.toArray(sigResponse.pubKey, 'hex'));
}
const base64Tx = Utils.toBase64(tx.toBinary());
const response = await fetch(`${this.mneeApi}/v1/transfer?auth_token=${this.mneeApiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rawtx: base64Tx }),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const { rawtx: responseRawtx } = await response.json();
if (!responseRawtx) return { error: 'Failed to broadcast transaction' };
const decodedBase64AsBinary = Utils.toArray(responseRawtx, 'base64');
const tx2 = Transaction.fromBinary(decodedBase64AsBinary);
return { txid: tx2.id('hex'), rawtx: Utils.toHex(decodedBase64AsBinary) };
} catch (error) {
let errorMessage = 'Transaction submission failed';
if (error instanceof Error) {
errorMessage = error.message;
if (error.message.includes('HTTP error')) {
// Add more specific error handling if needed based on response status
console.error('HTTP error details:', error);
}
}
console.error('Failed to transfer tokens:', errorMessage);
return { error: errorMessage };
}
}
public async getBalance(address: string): Promise<MNEEBalance> {
try {
const config = this.mneeConfig || (await this.getCosignerConfig());
if (!config) throw new Error('Config not fetched');
const utxos = await this.getUtxos(address);
const balance = utxos.reduce((acc, utxo) => {
if (utxo.data.bsv21.op === 'transfer') {
acc += utxo.data.bsv21.amt;
}
return acc;
}, 0);
const decimalAmount = this.fromAtomicAmount(balance);
return { address, amount: balance, decimalAmount };
} catch (error) {
console.error('Failed to fetch balance:', error);
return { address, amount: 0, decimalAmount: 0 };
}
}
public async getBalances(addresses: string[]): Promise<MNEEBalance[]> {
try {
const config = this.mneeConfig || (await this.getCosignerConfig());
if (!config) throw new Error('Config not fetched');
const utxos = await this.getUtxos(addresses);
return addresses.map((addr) => {
const addressUtxos = utxos.filter((utxo) => utxo.owners.includes(addr));
const balance = addressUtxos.reduce((acc, utxo) => {
if (utxo.data.bsv21.op === 'transfer') {
acc += utxo.data.bsv21.amt;
}
return acc;
}, 0);
return { address: addr, amount: balance, decimalAmount: this.fromAtomicAmount(balance) };
});
} catch (error) {
console.error('Failed to fetch balances:', error);
return addresses.map((addr) => ({ address: addr, amount: 0, decimalAmount: 0 }));
}
}
public async validateMneeTx(rawTx: string, request?: SendMNEE[]) {
try {
const config = this.mneeConfig || (await this.getCosignerConfig());
if (!config) throw new Error('Config not fetched');
const tx = Transaction.fromHex(rawTx);
const scripts = tx.outputs.map((output) => output.lockingScript);
const parsedScripts = parseCosignerScripts(scripts);
if (!request) {
parsedScripts.forEach((parsed) => {
if (parsed?.cosigner !== '' && parsed?.cosigner !== config.approver) {
throw new Error('Invalid or missing cosigner');
}
});
} else {
request.forEach((req, idx) => {
const { address, amount } = req;
const cosigner = parsedScripts.find((parsed) => parsed?.cosigner === config.approver);
if (!cosigner) {
throw new Error(`Cosigner not found for address: ${address} at index: ${idx}`);
}
const addressFromScript = parsedScripts.find((parsed) => parsed?.address === address);
if (!addressFromScript) {
throw new Error(`Address not found in script for address: ${address} at index: ${idx}`);
}
const script = tx.outputs[idx].lockingScript;
const inscription = parseInscription(script);
const content = inscription?.file?.content;
if (!content) throw new Error('Invalid inscription content');
const inscriptionData = Utils.toUTF8(content);
if (!inscriptionData) throw new Error('Invalid inscription content');
const inscriptionJson: MneeInscription = JSON.parse(inscriptionData);
if (inscriptionJson.p !== 'bsv-20') throw new Error(`Invalid bsv 20 protocol: ${inscriptionJson.p}`);
if (inscriptionJson.op !== 'transfer') throw new Error(`Invalid operation: ${inscriptionJson.op}`);
if (inscriptionJson.id !== config.tokenId) throw new Error(`Invalid token id: ${inscriptionJson.id}`);
if (inscriptionJson.amt !== this.toAtomicAmount(amount).toString()) {
throw new Error(`Invalid amount: ${inscriptionJson.amt}`);
}
});
}
return true;
} catch (error) {
console.error(error);
return false;
}
}
private async getMneeSyncs(
addresses: string | string[],
fromScore = 0,
limit = 100,
): Promise<{ address: string; syncs: MneeSync[] }[]> {
try {
const addressArray = Array.isArray(addresses) ? addresses : [addresses];
const response = await fetch(
`${this.mneeApi}/v1/sync?auth_token=${this.mneeApiKey}&from=${fromScore}&limit=${limit}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressArray),
},
);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data: MneeSync[] = await response.json();
// Group syncs by address
const syncsByAddress = addressArray.map((address) => {
const filteredSyncs = data.filter((sync) => sync.senders.includes(address) || sync.receivers.includes(address));
return { address, syncs: filteredSyncs };
});
return syncsByAddress;
} catch (error) {
console.error('Failed to fetch syncs:', error);
return Array.isArray(addresses)
? addresses.map((address) => ({ address, syncs: [] }))
: [{ address: addresses, syncs: [] }];
}
}
public async getRecentTxHistory(address: string, fromScore?: number, limit?: number): Promise<TxHistoryResponse> {
try {
const config = this.mneeConfig || (await this.getCosignerConfig());
if (!config) throw new Error('Config not fetched');
const syncsByAddress = await this.getMneeSyncs(address, fromScore, limit);
const { syncs } = syncsByAddress[0]; // We're only requesting one address
if (!syncs || syncs.length === 0) return { address, history: [], nextScore: fromScore || 0 };
const txHistory: TxHistory[] = [];
for (const sync of syncs) {
const historyItem = parseSyncToTxHistory(sync, address, config);
if (historyItem) {
txHistory.push(historyItem);
}
}
const nextScore = txHistory.length > 0 ? txHistory[txHistory.length - 1].score : fromScore || 0;
if (limit && txHistory.length > limit) {
return {
address,
history: txHistory.slice(0, limit),
nextScore,
};
}
return {
address,
history: txHistory,
nextScore,
};
} catch (error) {
console.error('Failed to fetch tx history:', error);
return { address, history: [], nextScore: fromScore || 0 };
}
}
public async getRecentTxHistories(params: AddressHistoryParams[]): Promise<TxHistoryResponse[]> {
try {
const config = this.mneeConfig || (await this.getCosignerConfig());
if (!config) throw new Error('Config not fetched');
// Group addressParams by fromScore and limit to batch requests efficiently
const groupedParams: Record<string, AddressHistoryParams[]> = {};
params.forEach((param) => {
const key = `${param.fromScore || 0}:${param.limit || 100}`;
if (!groupedParams[key]) {
groupedParams[key] = [];
}
groupedParams[key].push(param);
});
// Process each group in parallel
const groupPromises = Object.entries(groupedParams).map(async ([key, addressParams]) => {
const [fromScore, limit] = key.split(':').map(Number);
const addresses = addressParams.map((p) => p.address);
const syncsByAddress = await this.getMneeSyncs(addresses, fromScore, limit);
// Process each address's syncs
return syncsByAddress.map(({ address, syncs }) => {
const param = addressParams.find((p) => p.address === address);
if (!syncs || syncs.length === 0) {
return {
address,
history: [],
nextScore: param?.fromScore || 0,
};
}
const txHistory: TxHistory[] = [];
for (const sync of syncs) {
const historyItem = parseSyncToTxHistory(sync, address, config);
if (historyItem) {
txHistory.push(historyItem);
}
}
const nextScore = txHistory.length > 0 ? txHistory[txHistory.length - 1].score : param?.fromScore || 0;
const paramLimit = param?.limit;
if (paramLimit && txHistory.length > paramLimit) {
return {
address,
history: txHistory.slice(0, paramLimit),
nextScore,
};
}
return {
address,
history: txHistory,
nextScore,
};
});
});
// Flatten the results
const results = await Promise.all(groupPromises);
return results.flat();
} catch (error) {
console.error('Failed to fetch tx histories:', error);
return params.map(({ address, fromScore }) => ({
address,
history: [],
nextScore: fromScore || 0,
}));
}
}
private async parseTransaction(tx: Transaction, config: MNEEConfig): Promise<ParseTxResponse> {
const txid = tx.id('hex');
const outScripts = tx.outputs.map((output) => output.lockingScript);
const sourceTxs = tx.inputs.map((input) => {
return { txid: input.sourceTXID, vout: input.sourceOutputIndex };
});
let inputs = [];
let outputs = [];
let inputTotal = 0n;
let outputTotal = 0n;
let environment: Environment = 'production';
let type: TxOperation = 'transfer';
for (const sourceTx of sourceTxs) {
if (!sourceTx.txid) continue;
const fetchedTx = await this.fetchRawTx(sourceTx.txid);
const output = fetchedTx.outputs[sourceTx.vout];
const parsedCosigner = parseCosignerScripts([output.lockingScript])[0];
if (parsedCosigner?.address === config.mintAddress) {
type = txid === config.tokenId.split('_')[0] ? 'deploy' : 'mint';
}
const insc = parseInscription(output.lockingScript);
const content = insc?.file?.content;
if (!content) continue;
const inscriptionData = Utils.toUTF8(content);
if (!inscriptionData) continue;
const inscriptionJson: MneeInscription = JSON.parse(inscriptionData);
if (inscriptionJson) {
const isProdToken = inscriptionJson.id === PROD_TOKEN_ID;
const isProdApprover = parsedCosigner.cosigner === PROD_APPROVER;
const isEmptyCosigner = parsedCosigner.cosigner === '';
const isMint = inscriptionJson.op === 'deploy+mint';
const isProdAddress = parsedCosigner.address === PROD_ADDRESS;
const isDevAddress = parsedCosigner.address === DEV_ADDRESS;
const isQaAddress = parsedCosigner.address === QA_ADDRESS;
const isStageAddress = parsedCosigner.address === STAGE_ADDRESS;
if (!isProdToken || !isProdApprover) {
if (isEmptyCosigner && isMint && isProdAddress) {
environment = 'production';
type = 'mint';
} else {
environment = 'sandbox';
}
}
if (type === 'transfer' && (isProdAddress || isDevAddress || isQaAddress || isStageAddress)) {
type = 'mint';
}
inputTotal += BigInt(inscriptionJson.amt);
inputs.push({
address: parsedCosigner.address,
amount: parseInt(inscriptionJson.amt),
});
}
}
for (const script of outScripts) {
const parsedCosigner = parseCosignerScripts([script])[0];
const insc = parseInscription(script);
const content = insc?.file?.content;
if (!content) continue;
const inscriptionData = Utils.toUTF8(content);
if (!inscriptionData) continue;
const inscriptionJson = JSON.parse(inscriptionData);
if (inscriptionJson) {
if (inscriptionJson.op === 'burn') {
type = 'burn';
}
const isProdToken = inscriptionJson.id === PROD_TOKEN_ID;
const isProdApprover = parsedCosigner.cosigner === PROD_APPROVER;
const isEmptyCosigner = parsedCosigner.cosigner === '';
const isProdAddress = parsedCosigner.address === PROD_ADDRESS;
const isDeploy = inscriptionJson.op === 'deploy+mint';
if (isDeploy) {
type = 'deploy';
}
if (!isProdToken || !isProdApprover) {
if (isEmptyCosigner && isProdAddress) {
environment = 'production';
} else {
environment = 'sandbox';
}
}
outputTotal += BigInt(inscriptionJson.amt);
outputs.push({
address: parsedCosigner.address,
amount: parseInt(inscriptionJson.amt),
});
}
}
if (type !== 'deploy' && inputTotal !== outputTotal) {
throw new Error('Inputs and outputs are not equal');
}
if (txid === PROD_TOKEN_ID.split('_')[0]) {
environment = 'production';
} else if ([DEV_TOKEN_ID, QA_TOKEN_ID, STAGE_TOKEN_ID].some((id) => txid === id.split('_')[0])) {
environment = 'sandbox';
}
return { txid, environment, type, inputs, outputs };
}
public async parseTx(txid: string): Promise<ParseTxResponse> {
const config = this.mneeConfig || (await this.getCosignerConfig());
if (!config) throw new Error('Config not fetched');
const tx = await this.fetchRawTx(txid);
if (!tx) throw new Error('Failed to fetch transaction');
return await this.parseTransaction(tx, config);
}
public async parseTxFromRawTx(rawTxHex: string): Promise<ParseTxResponse> {
const tx = Transaction.fromHex(rawTxHex);
const config = this.mneeConfig || (await this.getCosignerConfig());
if (!config) throw new Error('Config not fetched');
return await this.parseTransaction(tx, config);
}
}