UNPKG

@bronlabs/intents-sdk

Version:
317 lines 14 kB
import { HttpsProxyAgent } from 'https-proxy-agent'; import * as ed25519 from '@noble/ed25519'; import fetch from 'node-fetch'; import { log, expRetry, memoize } from '../utils.js'; import { Big } from 'big.js'; import { ethers } from "ethers"; export class CantonNetwork { constructor(validatorApiUrl, ledgerApiUrl, scanApiUrl, authUrl, clientId, clientSecret, senderPartyId, daUtilitiesApiUrl) { this.nativeAssetDecimals = 10; this.retryDelay = 5000; this.accessTokenExpiresAt = 0; this.validatorApiUrl = validatorApiUrl; this.scanApiUrl = scanApiUrl || validatorApiUrl; this.ledgerApiUrl = ledgerApiUrl; this.authUrl = authUrl || 'https://mainnet-canton-mpch.eu.auth0.com'; this.daUtilitiesApiUrl = daUtilitiesApiUrl || 'https://api.utilities.digitalasset.com'; this.proxyAgent = process.env.HTTP_PROXY ? new HttpsProxyAgent(process.env.HTTP_PROXY, { rejectUnauthorized: false }) : undefined; this.clientId = clientId; this.clientSecret = clientSecret; this.senderPartyId = senderPartyId; } async getDecimals(tokenAddress) { if (tokenAddress === '0x0') { return this.nativeAssetDecimals; } const [tokenIssuer, tokenInstrumentId] = tokenAddress.split(':::'); return await memoize(`cc-decimals-${tokenIssuer}-${tokenInstrumentId}`, 86_400_000, async () => { const resp = await fetch(`${this.daUtilitiesApiUrl}/api/token-standard/v0/registrars/${tokenIssuer}/registry/metadata/v1/instruments/${tokenInstrumentId}`, { method: 'GET', agent: this.proxyAgent }); if (!resp.ok) { throw new Error(`Failed to get token metadata from ${this.daUtilitiesApiUrl}/api/token-standard/v0/registrars/${tokenIssuer}/registry/metadata/v1/instruments/${tokenInstrumentId}: ${resp.status} - ${await resp.text()}`); } return (await resp.json()).decimals; }); } async getTxData(txHash, tokenAddress) { const updateId = txHash.split(':')[0]; const baseEventFormat = { filtersByParty: {}, filtersForAnyParty: {}, verbose: false }; const fetchUpdate = (id) => this.nodeRequest({ method: 'POST', node: this.ledgerApiUrl, uri: `/v2/updates/update-by-id`, body: { updateId: id, updateFormat: { includeTransactions: { transactionShape: 'TRANSACTION_SHAPE_LEDGER_EFFECTS', eventFormat: baseEventFormat } } } }); const json = await fetchUpdate(updateId); if (!json || json.error || !json.update?.Transaction) { throw new Error(`Couldn't get Canton tx data for ${txHash}: ${json?.error || 'unknown error'}`); } const txValue = json.update.Transaction.value; if (txValue.updateId !== txHash) { throw new Error(`Invalid Canton tx hash: order = ${txHash}, tx = ${json.update.Transaction.value?.updateId}`); } const events = txValue.events; const transferFactoryEvent = events.find(e => e.ExercisedEvent?.choice === 'TransferFactory_Transfer'); const transferFactoryResultTag = transferFactoryEvent?.ExercisedEvent?.exerciseResult?.output?.tag; if (!transferFactoryResultTag) { log.error(`Transaction ${txHash} has missing transferFactoryResultTag: ${JSON.stringify(txValue)}`); return; } const isSuccess = tokenAddress === '0x0' ? transferFactoryResultTag === 'TransferInstructionResult_Completed' : transferFactoryResultTag === 'TransferInstructionResult_Pending'; if (!isSuccess) { log.error(`Transaction ${txHash} failed: ${(transferFactoryResultTag ?? 'Unknown')}`); return { to: "", token: "", amount: 0n, confirmed: true }; } if (tokenAddress === '0x0') { const transfer = events.find(e => e.ExercisedEvent?.choice === 'AmuletRules_Transfer'); const output = transfer?.ExercisedEvent?.choiceArgument?.transfer?.outputs[0] || { receiver: '', amount: '0' }; const amount = BigInt(Big(output.amount).mul(Big(10).pow(this.nativeAssetDecimals)).toFixed(0)); return { to: output.receiver, token: tokenAddress, amount, confirmed: true }; } // tokens const arg = transferFactoryEvent?.ExercisedEvent?.choiceArgument?.transfer; const resultOutput = transferFactoryEvent?.ExercisedEvent?.exerciseResult?.output; const contractId = resultOutput?.value?.transferInstructionCid; const receiver = arg?.receiver; const amount = arg?.amount; const txTokenAddress = arg?.instrumentId?.admin + ':::' + arg?.instrumentId?.id; const tokenDecimals = await this.getDecimals(txTokenAddress); const eventsByContract = await this.nodeRequest({ node: this.ledgerApiUrl, method: 'POST', uri: `/v2/events/events-by-contract-id`, body: { contractId, eventFormat: { ...baseEventFormat, verbose: true } }, retry: false }); const offset = eventsByContract.archived?.archivedEvent?.offset; if (offset === undefined) { log.info(`No archived events found for ${txHash}, probably token transaction not accounted yet, return...`); return; } const filters = await this.buildIdentifierFilter({ partyIds: [receiver] }); const receiverUpdates = await this.nodeRequest({ node: this.ledgerApiUrl, method: 'POST', uri: `/v2/updates?limit=100&stream_idle_timeout_ms=10000`, body: { filter: filters, verbose: false, beginExclusive: offset - 1, endInclusive: offset }, retry: false }); const receiverUpdateId = receiverUpdates?.[0]?.update?.Transaction?.value?.updateId; const result = await fetchUpdate(receiverUpdateId); const resultEvents = result.update.Transaction.value?.events; const resultAcceptEvent = resultEvents.find(e => e.ExercisedEvent?.choice === 'TransferInstruction_Accept'); const tokenResultTag = resultAcceptEvent?.ExercisedEvent?.exerciseResult?.output?.tag; if (!tokenResultTag) { log.error(`Transaction ${txHash} has missing tokenResultTag: ${JSON.stringify(resultAcceptEvent)}`); return; } if (tokenResultTag === "TransferInstructionResult_Completed") { return { to: receiver, token: txTokenAddress, amount: ethers.parseUnits(amount, tokenDecimals), confirmed: true }; } else { log.error(`Transaction ${txHash} has wrong tokenResultTag: ${tokenResultTag}`); return { to: "", token: "", amount: 0n, confirmed: true }; } } async transfer(privateKey, to, value, tokenAddress) { if (tokenAddress != '0x0') { throw new Error("Canton does not support tokens"); } const publicKey = ed25519.utils.getExtendedPublicKey(Buffer.from(privateKey, 'hex')).point.toHex().toUpperCase(); let nonce; try { const resp = await this.nodeRequest({ method: 'GET', uri: `/v0/scan-proxy/transfer-command-counter/${this.senderPartyId}`, retry: false }); nonce = parseInt(resp.transfer_command_counter.contract.payload.nextNonce, 10); } catch (e) { if (e instanceof Error && e.message.includes('No TransferCommandCounter found for party')) { nonce = 0; } else { throw e; } } const prepareSend = await this.nodeRequest({ method: 'POST', uri: `/v0/admin/external-party/transfer-preapproval/prepare-send`, body: { "sender_party_id": this.senderPartyId, "receiver_party_id": to, "amount": Big(value.toString()).div(Big(10).pow(this.nativeAssetDecimals)).toFixed(this.nativeAssetDecimals), "expires_at": new Date(Date.now() + 86400000).toISOString(), "nonce": nonce } }); const { update_id } = await this.nodeRequest({ method: 'POST', uri: `/v0/admin/external-party/transfer-preapproval/submit-send`, body: { "submission": { "party_id": this.senderPartyId, "transaction": prepareSend.transaction, "signed_tx_hash": Buffer.from(await ed25519.sign(prepareSend.tx_hash, Buffer.from(privateKey, 'hex'))).toString('hex').toUpperCase(), "public_key": publicKey } } }); return expRetry(async () => { log.info(`Waiting for transaction ${update_id} confirmation...`); const result = await fetch(`${this.scanApiUrl}/v2/updates/${update_id}`, { method: 'GET', agent: this.proxyAgent }); if (!result.ok) { throw new Error(`Couldn't get Canton tx data for ${update_id}: ${result.status} ${result.statusText}`); } const json = await result.json(); if (!json || json.error) { throw new Error(`Couldn't get Canton tx data for ${update_id}: ${json?.error || 'unknown error'}`); } const event = Object.values(json.events_by_id) .find((e) => e.event_type == 'exercised_event' && e.choice == 'ExternalPartyAmuletRules_CreateTransferCommand'); const transferCommandCid = event?.exercise_result?.transferCommandCid; if (!transferCommandCid) { throw new Error(`Couldn't find transfer command cid in Canton tx data for ${update_id}`); } return `${update_id}:${transferCommandCid}`; }, 10); } async buildIdentifierFilter(opts) { const { partyIds, templateIds = [], interfaceIds = [] } = opts; const cumulative = []; for (const t of templateIds) { cumulative.push({ identifierFilter: { TemplateFilter: { value: { templateId: t, includeInterfaceView: true, includeCreatedEventBlob: false } } } }); } for (const iface of interfaceIds) { cumulative.push({ identifierFilter: { InterfaceFilter: { value: { interfaceId: iface, includeInterfaceView: true, includeCreatedEventBlob: false } } } }); } if (partyIds && partyIds.length) { const filtersByParty = {}; for (const p of partyIds) filtersByParty[p] = { cumulative }; return { filtersByParty }; } return { filtersForAnyParty: { cumulative } }; } async getAccessToken() { if (this.accessToken && this.accessTokenExpiresAt > Date.now() + 30_000) { return this.accessToken; } const response = await fetch(`${this.authUrl}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 'client_id': this.clientId, 'client_secret': this.clientSecret, 'audience': 'https://canton.network.global', 'grant_type': 'client_credentials' }) }); if (!response.ok) { throw new Error(`Failed to get access token from ${this.authUrl}/oauth/token: ${response.status} - ${await response.text()}`); } const data = await response.json(); this.accessToken = data.access_token; this.accessTokenExpiresAt = Date.now() + (data.expires_in * 1000); return this.accessToken; } async nodeRequest({ method, uri, body = undefined, node = undefined, retry = true }) { return expRetry(async () => { const start = Date.now(); const accessToken = await this.getAccessToken(); const resp = await fetch(`${node || this.validatorApiUrl}${uri}`, { method, headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, agent: this.proxyAgent }); let msg = `${method} ${node || this.validatorApiUrl}${uri} - ${resp.status} ${resp.statusText} (${Date.now() - start}ms)`; if (!resp.ok) { msg += `: ${await resp.text()}`; log.error(msg); throw new Error(`Failed to ${msg}`); } log.info(msg); return await resp.json(); }, retry ? 3 : 0); } } //# sourceMappingURL=canton.js.map