From a9289f3798e7e8cfc8a46b096414f7e219dd619b Mon Sep 17 00:00:00 2001 From: Angel Castillo Date: Fri, 16 Sep 2022 16:26:15 +0800 Subject: [PATCH] feat(core): added a new inspector for extracting minting and burning information from transactions --- packages/core/src/util/txInspector.ts | 120 ++++++++++++-- packages/core/test/util/txInspector.test.ts | 165 +++++++++++++++++--- 2 files changed, 248 insertions(+), 37 deletions(-) diff --git a/packages/core/src/util/txInspector.ts b/packages/core/src/util/txInspector.ts index 4edec469f9e..e8f2c33c84c 100644 --- a/packages/core/src/util/txInspector.ts +++ b/packages/core/src/util/txInspector.ts @@ -1,21 +1,27 @@ import { Address, + AssetFingerprint, + AssetName, Certificate, CertificateType, Ed25519KeyHash, Lovelace, + PolicyId, RewardAccount, + Script, + ScriptType, StakeAddressCertificate, StakeDelegationCertificate, TokenMap, TxAlonzo, TxIn, - Value + Value, + nativeScriptPolicyId, + util } from '../Cardano'; import { BigIntMath } from '@cardano-sdk/util'; -import { coalesceValueQuantities, resolveInputValue, subtractValueQuantities } from '../Cardano/util'; +import { assetNameFromAssetId, policyIdFromAssetId, removeNegativesFromTokenMap } from '../Asset/util'; import { inputsWithAddresses, isAddressWithin } from '../Address/util'; -import { removeNegativesFromTokenMap } from '../Asset/util'; type Inspector = (tx: TxAlonzo) => Inspection; type Inspectors = { [k: string]: Inspector }; @@ -34,6 +40,16 @@ export interface SentInspection { } export type SignedCertificatesInspection = Certificate[]; +export interface MintedAsset { + script?: Script; + policyId: PolicyId; + fingerprint: AssetFingerprint; + assetName: AssetName; + quantity: bigint; +} + +export type AssetsMintedInspection = MintedAsset[]; + // Inspector types interface SentInspectorArgs { addresses?: Address[]; @@ -52,6 +68,7 @@ export type SignedCertificatesInspector = ( rewardAccounts: RewardAccount[], certificateTypes?: CertificateType[] ) => Inspector; +export type AssetsMintedInspector = Inspector; /** * Inspects a transaction for values (coins + assets) in inputs @@ -65,10 +82,10 @@ export const totalAddressInputsValueInspector: TotalAddressInputsValueInspector (ownAddresses, getHistoricalTxs) => (tx) => { const receivedInputs = tx.body.inputs.filter((input) => isAddressWithin(ownAddresses)(input)); const receivedInputsValues = receivedInputs - .map((input) => resolveInputValue(input, getHistoricalTxs())) + .map((input) => util.resolveInputValue(input, getHistoricalTxs())) .filter((value): value is Value => !!value); - return coalesceValueQuantities(receivedInputsValues); + return util.coalesceValueQuantities(receivedInputsValues); }; /** @@ -80,7 +97,7 @@ export const totalAddressInputsValueInspector: TotalAddressInputsValueInspector */ export const totalAddressOutputsValueInspector: SendReceiveValueInspector = (ownAddresses) => (tx) => { const receivedOutputs = tx.body.outputs.filter((out) => isAddressWithin(ownAddresses)(out)); - return coalesceValueQuantities(receivedOutputs.map((output) => output.value)); + return util.coalesceValueQuantities(receivedOutputs.map((output) => output.value)); }; /** @@ -91,7 +108,7 @@ export const totalAddressOutputsValueInspector: SendReceiveValueInspector = (own * @param {CertificateType[]} [certificateTypes] certificates of these types will be checked. All if not provided */ export const signedCertificatesInspector: SignedCertificatesInspector = - (rewardAccounts: RewardAccount[], certificateTypes?: CertificateType[]) => (tx: TxAlonzo) => { + (rewardAccounts: RewardAccount[], certificateTypes?: CertificateType[]) => (tx) => { if (!tx.body.certificates || tx.body.certificates.length === 0) return []; const stakeKeyHashes = rewardAccounts?.map((account) => Ed25519KeyHash.fromRewardAccount(account)); const certificates = certificateTypes @@ -115,7 +132,7 @@ export const signedCertificatesInspector: SignedCertificatesInspector = */ export const sentInspector: SentInspector = ({ addresses, rewardAccounts }) => - (tx: TxAlonzo) => ({ + (tx) => ({ certificates: rewardAccounts?.length ? signedCertificatesInspector(rewardAccounts)(tx) : [], inputs: addresses?.length ? inputsWithAddresses(tx, addresses) : [] }); @@ -124,6 +141,7 @@ export const sentInspector: SentInspector = * Inspects a transaction for net value (coins + assets) sent by the provided addresses. * * @param {Address[]} ownAddresses own wallet's addresses + * @param historicalTxs A list of historical transaction * @returns {Value} net value sent */ export const valueSentInspector: TotalAddressInputsValueInspector = (ownAddresses, historicalTxs) => (tx) => { @@ -131,7 +149,7 @@ export const valueSentInspector: TotalAddressInputsValueInspector = (ownAddresse if (sentInspector({ addresses: ownAddresses })(tx).inputs.length === 0) return { coins: 0n }; const totalOutputValue = totalAddressOutputsValueInspector(ownAddresses)(tx); const totalInputValue = totalAddressInputsValueInspector(ownAddresses, historicalTxs)(tx); - const diff = subtractValueQuantities([totalInputValue, totalOutputValue]); + const diff = util.subtractValueQuantities([totalInputValue, totalOutputValue]); if (diff.assets) assets = removeNegativesFromTokenMap(diff.assets); return { @@ -144,13 +162,14 @@ export const valueSentInspector: TotalAddressInputsValueInspector = (ownAddresse * Inspects a transaction for net value (coins + assets) received by the provided addresses. * * @param {Address[]} ownAddresses own wallet's addresses + * @param historicalTxs A list of historical transaction * @returns {Value} net value received */ export const valueReceivedInspector: TotalAddressInputsValueInspector = (ownAddresses, historicalTxs) => (tx) => { let assets: TokenMap = new Map(); const totalOutputValue = totalAddressOutputsValueInspector(ownAddresses)(tx); const totalInputValue = totalAddressInputsValueInspector(ownAddresses, historicalTxs)(tx); - const diff = subtractValueQuantities([totalOutputValue, totalInputValue]); + const diff = util.subtractValueQuantities([totalOutputValue, totalInputValue]); if (diff.assets) assets = removeNegativesFromTokenMap(diff.assets); return { @@ -165,7 +184,7 @@ export const valueReceivedInspector: TotalAddressInputsValueInspector = (ownAddr * @param {TxAlonzo} tx transaction to inspect * @returns {DelegationInspection} array of delegation certificates */ -export const delegationInspector: DelegationInspector = (tx: TxAlonzo) => +export const delegationInspector: DelegationInspector = (tx) => (tx.body.certificates?.filter( (cert) => cert.__typename === CertificateType.StakeDelegation ) as StakeDelegationCertificate[]) ?? []; @@ -176,7 +195,7 @@ export const delegationInspector: DelegationInspector = (tx: TxAlonzo) => * @param {TxAlonzo} tx transaction to inspect * @returns {StakeKeyRegistrationInspection} array of stake key registration certificates */ -export const stakeKeyRegistrationInspector: StakeKeyRegistrationInspector = (tx: TxAlonzo) => +export const stakeKeyRegistrationInspector: StakeKeyRegistrationInspector = (tx) => (tx.body.certificates?.filter( (cert) => cert.__typename === CertificateType.StakeKeyRegistration ) as StakeAddressCertificate[]) ?? []; @@ -187,7 +206,7 @@ export const stakeKeyRegistrationInspector: StakeKeyRegistrationInspector = (tx: * @param {TxAlonzo} tx transaction to inspect * @returns {StakeKeyRegistrationInspection} array of stake key deregistration certificates */ -export const stakeKeyDeregistrationInspector: StakeKeyRegistrationInspector = (tx: TxAlonzo) => +export const stakeKeyDeregistrationInspector: StakeKeyRegistrationInspector = (tx) => (tx.body.certificates?.filter( (cert) => cert.__typename === CertificateType.StakeKeyDeregistration ) as StakeAddressCertificate[]) ?? []; @@ -198,9 +217,80 @@ export const stakeKeyDeregistrationInspector: StakeKeyRegistrationInspector = (t * @param {TxAlonzo} tx transaction to inspect * @returns {WithdrawalInspection} accumulated withdrawal quantities */ -export const withdrawalInspector: WithdrawalInspector = (tx: TxAlonzo) => +export const withdrawalInspector: WithdrawalInspector = (tx) => tx.body.withdrawals?.length ? BigIntMath.sum(tx.body.withdrawals.map(({ quantity }) => quantity)) : 0n; +/** + * Matching criteria functor definition. This functor encodes a selection criteria for minted/burned assets. + * For example to get all minted assets the following criteria could be provided: + * (quantity: bigint) => quantity > 0. + */ +export interface MatchQuantityCriteria { + (quantity: bigint): boolean; +} + +/** + * Inspects a transaction for minted/burned assets that match a given quantity criteria. + * + * @param matchQuantityCriteria A functor that represents a selection criteria for minted/burned assets. Will + * return true if the given criteria was met; otherwise; false. + * @returns A collection with the assets that match the given criteria. + */ +export const mintInspector = + (matchQuantityCriteria: MatchQuantityCriteria): AssetsMintedInspector => + (tx) => { + const assets: AssetsMintedInspection = []; + const scriptMap = new Map(); + + if (!tx.body.mint) return assets; + + // Scripts can be embedded in transaction auxiliary data and/or the transaction witness set. If this transaction + // was built by this client the script will be present in the witness set, however, if this transaction was + // queried from a remote repository that doesn't fetch the witness data of the transaction we can still check + // if the script is present in the auxiliary data. + const scripts = [...(tx.auxiliaryData?.body?.scripts || []), ...(tx.witness?.scripts || [])]; + + for (const script of scripts) { + switch (script.__type) { + case ScriptType.Native: { + const policyId = nativeScriptPolicyId(script); + if (scriptMap.has(policyId)) continue; + scriptMap.set(policyId, script); + break; + } + case ScriptType.Plutus: // TODO: Add support for plutus minting scripts. + default: + // scripts of unknown type will be ignored. + } + } + + for (const [key, value] of tx.body.mint!.entries()) { + const [policyId, assetName] = [policyIdFromAssetId(key), assetNameFromAssetId(key)]; + + const mintedAsset: MintedAsset = { + assetName, + fingerprint: AssetFingerprint.fromParts(policyId, assetName), + policyId, + quantity: value, + script: scriptMap.get(policyId) + }; + + if (matchQuantityCriteria(mintedAsset.quantity)) assets.push(mintedAsset); + } + + return assets; + }; + +/** + * Inspect the transaction and retrieves all assets minted (quantity greater than 0). + */ +export const assetsMintedInspector: AssetsMintedInspector = mintInspector((quantity: bigint) => quantity > 0); + +/** + * Inspect the transaction and retrieves all assets burned (quantity less than 0). + */ +export const assetsBurnedInspector: AssetsMintedInspector = mintInspector((quantity: bigint) => quantity < 0); + /** * Returns a function to convert lower level transaction data to a higher level object, using the provided inspectors. * @@ -208,7 +298,7 @@ export const withdrawalInspector: WithdrawalInspector = (tx: TxAlonzo) => */ export const createTxInspector = (inspectors: T): TxInspector => - (tx: TxAlonzo) => + (tx) => Object.keys(inspectors).reduce( (result, key) => { const inspector = inspectors[key]; diff --git a/packages/core/test/util/txInspector.test.ts b/packages/core/test/util/txInspector.test.ts index af887adaaef..427ebf5df97 100644 --- a/packages/core/test/util/txInspector.test.ts +++ b/packages/core/test/util/txInspector.test.ts @@ -1,21 +1,34 @@ -import * as AssetId from '../AssetId'; +import * as AssetIds from '../AssetId'; import { Address, + AssetFingerprint, + AssetId, + AssetName, BlockId, Certificate, CertificateType, Ed25519KeyHash, + Ed25519PublicKey, + Ed25519Signature, + NativeScriptKind, + PolicyId, PoolId, RewardAccount, + ScriptType, StakeAddressCertificate, StakeDelegationCertificate, + TokenMap, TransactionId, TxAlonzo, TxIn, TxOut, - Withdrawal + Withdrawal, + Witness, + util } from '../../src/Cardano'; import { + assetsBurnedInspector, + assetsMintedInspector, createTxInspector, delegationInspector, sentInspector, @@ -27,8 +40,9 @@ import { valueReceivedInspector, valueSentInspector, withdrawalInspector -} from '../../src/util/txInspector'; +} from '../../src'; +// eslint-disable-next-line max-statements describe('txInspector', () => { const sendingAddress = Address( 'addr_test1qq585l3hyxgj3nas2v3xymd23vvartfhceme6gv98aaeg9muzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475q2g7k3g' @@ -38,10 +52,10 @@ describe('txInspector', () => { ); const rewardAccount = RewardAccount('stake_test1up7pvfq8zn4quy45r2g572290p9vf99mr9tn7r9xrgy2l2qdsf58d'); const stakeKeyHash = Ed25519KeyHash.fromRewardAccount(rewardAccount); - + const poolId = PoolId('pool1euf2nh92ehqfw7rpd4s9qgq34z8dg4pvfqhjmhggmzk95gcd402'); const delegationCert: StakeDelegationCertificate = { __typename: CertificateType.StakeDelegation, - poolId: PoolId('pool1euf2nh92ehqfw7rpd4s9qgq34z8dg4pvfqhjmhggmzk95gcd402'), + poolId, stakeKeyHash }; const keyRegistrationCert: StakeAddressCertificate = { @@ -69,19 +83,19 @@ describe('txInspector', () => { outputs: [ { value: { - assets: new Map([[AssetId.TSLA, 5n]]), + assets: new Map([[AssetIds.TSLA, 5n]]), coins: 4_500_000n } }, { value: { - assets: new Map([[AssetId.PXL, 15n]]), + assets: new Map([[AssetIds.PXL, 15n]]), coins: 5_000_000n } }, { value: { - assets: new Map([[AssetId.TSLA, 25n]]), + assets: new Map([[AssetIds.TSLA, 25n]]), coins: 2_000_000n } } @@ -91,10 +105,73 @@ describe('txInspector', () => { } as unknown as TxAlonzo ]; + const mockPolicy1 = 'b8fdbcbe003cef7e47eb5307d328e10191952bd02901a850699e7e35'; + const mockPolicy2 = '5ba141e401cfebf1929d539e48d14f4b20679c5409526814e0f17121'; + const mockPolicy3 = '00000000000000000000000000000000000000000000000000000000'; + + const mockTokenName1 = '00000000000000'; + const mockTokenName2 = 'ffffffffffffff'; + const mockTokenName3 = 'aaaaaaaaaaaaaa'; + + const txMetadatum = new Map([ + [ + 721n, + util.metadatum.jsonToMetadatum({ + b8fdbcbe003cef7e47eb5307d328e10191952bd02901a850699e7e35: { + 'NFT-001': { + image: ['ipfs://some_hash1'], + name: 'One', + version: '1.0' + } + } + }) + ] + ]); + + const mockScript1 = { + __type: ScriptType.Native, + kind: NativeScriptKind.RequireAllOf, + scripts: [ + { + __type: ScriptType.Native, + keyHash: Ed25519KeyHash('24accb6ca2690388f067175d773871f5640de57bf11aec0be258d6c7'), + kind: NativeScriptKind.RequireSignature + } + ] + }; + + const mockScript2 = { + __type: ScriptType.Native, + kind: NativeScriptKind.RequireAllOf, + scripts: [ + { + __type: ScriptType.Native, + keyHash: Ed25519KeyHash('00accb6ca2690388f067175d773871f5640de57bf11aec0be258d6c7'), + kind: NativeScriptKind.RequireSignature + } + ] + }; + + const auxiliaryData = { + body: { + blob: txMetadatum, + scripts: [mockScript2] + } + }; + const buildMockTx = ( - args: { inputs?: TxIn[]; outputs?: TxOut[]; certificates?: Certificate[]; withdrawals?: Withdrawal[] } = {} + args: { + inputs?: TxIn[]; + outputs?: TxOut[]; + certificates?: Certificate[]; + withdrawals?: Withdrawal[]; + mint?: TokenMap; + witness?: Witness; + includeAuxData?: boolean; + } = {} ): TxAlonzo => ({ + auxiliaryData: args.includeAuxData ? auxiliaryData : undefined, blockHeader: { blockNo: 200, hash: BlockId('0dbe461fb5f981c0d01615332b8666340eb1a692b3034f46bcb5f5ea4172b2ed'), @@ -110,6 +187,14 @@ describe('txInspector', () => { txId: TransactionId('bb217abaca60fc0ca68c1555eca6a96d2478547818ae76ce6836133f3cc546e0') } ], + mint: + args.mint ?? + new Map([ + [AssetId('b8fdbcbe003cef7e47eb5307d328e10191952bd02901a850699e7e3500000000000000'), 1n], + [AssetId('5ba141e401cfebf1929d539e48d14f4b20679c5409526814e0f17121ffffffffffffff'), 100_000n], + [AssetId('00000000000000000000000000000000000000000000000000000000aaaaaaaaaaaaaa'), -1n] + ]), + outputs: args.outputs ?? [ { address: receivingAddress, @@ -119,8 +204,8 @@ describe('txInspector', () => { address: receivingAddress, value: { assets: new Map([ - [AssetId.PXL, 3n], - [AssetId.TSLA, 4n] + [AssetIds.PXL, 3n], + [AssetIds.TSLA, 4n] ]), coins: 2_000_000n } @@ -128,14 +213,14 @@ describe('txInspector', () => { { address: receivingAddress, value: { - assets: new Map([[AssetId.PXL, 6n]]), + assets: new Map([[AssetIds.PXL, 6n]]), coins: 2_000_000n } }, { address: sendingAddress, value: { - assets: new Map([[AssetId.PXL, 1n]]), + assets: new Map([[AssetIds.PXL, 1n]]), coins: 2_000_000n } } @@ -144,7 +229,8 @@ describe('txInspector', () => { withdrawals: args.withdrawals }, id: TransactionId('e3a443363eb6ee3d67c5e75ec10b931603787581a948d68fa3b2cd3ff2e0d2ad'), - index: 0 + index: 0, + witness: args.witness ?? { scripts: [mockScript1], signatures: new Map() } } as TxAlonzo); describe('transaction sent inspector', () => { @@ -176,7 +262,7 @@ describe('txInspector', () => { test( 'a transaction with certificates including the reward account' + - ' and inputs cointaining provided addresses' + + ' and inputs containing provided addresses' + ' produces an inspection containing those certificates and inputs', () => { const tx = buildMockTx({ certificates: [delegationCert, keyRegistrationCert] }); @@ -219,15 +305,15 @@ describe('txInspector', () => { const txProperties = inspectTx(tx); expect(txProperties.totalInputsValue).toEqual({ assets: new Map([ - [AssetId.TSLA, 5n], - [AssetId.PXL, 15n] + [AssetIds.TSLA, 5n], + [AssetIds.PXL, 15n] ]), coins: 9_500_000n }); expect(txProperties.totalOutputsValue).toEqual({ assets: new Map([ - [AssetId.TSLA, 4n], - [AssetId.PXL, 9n] + [AssetIds.TSLA, 4n], + [AssetIds.PXL, 9n] ]), coins: 9_000_000n }); @@ -264,13 +350,13 @@ describe('txInspector', () => { expect(txProperties.valueSent).toEqual({ assets: new Map([ - [AssetId.TSLA, 5n], - [AssetId.PXL, 14n] + [AssetIds.TSLA, 5n], + [AssetIds.PXL, 14n] ]), coins: 7_500_000n }); expect(txProperties.valueReceived).toEqual({ - assets: new Map([[AssetId.PXL, 9n]]), + assets: new Map([[AssetIds.PXL, 9n]]), coins: 7_000_000n }); }); @@ -308,6 +394,7 @@ describe('txInspector', () => { const inspectTx = createTxInspector({ stakeKeyRegistration: stakeKeyRegistrationInspector }); + const txProperties = inspectTx(tx); expect(txProperties.stakeKeyRegistration[0].stakeKeyHash).toEqual(keyRegistrationCert.stakeKeyHash); @@ -405,4 +492,38 @@ describe('txInspector', () => { } ); }); + + describe('mint and burn transaction inspector', () => { + test('inspects a transaction that mints and burns tokens and can retrieve the minting details', () => { + const tx = buildMockTx({ includeAuxData: true }); + const inspectTx = createTxInspector({ burned: assetsBurnedInspector, minted: assetsMintedInspector }); + const { minted, burned } = inspectTx(tx); + + expect(minted.length).toEqual(2); + expect(burned.length).toEqual(1); + expect(minted[0].assetName).toEqual(mockTokenName1); + expect(minted[0].policyId).toEqual(mockPolicy1); + expect(minted[0].fingerprint).toEqual( + AssetFingerprint.fromParts(PolicyId(mockPolicy1), AssetName(mockTokenName1)) + ); + expect(minted[0].quantity).toEqual(1n); + expect(minted[0].script).toEqual(mockScript1); + + expect(minted[1].assetName).toEqual(mockTokenName2); + expect(minted[1].policyId).toEqual(mockPolicy2); + expect(minted[1].fingerprint).toEqual( + AssetFingerprint.fromParts(PolicyId(mockPolicy2), AssetName(mockTokenName2)) + ); + expect(minted[1].quantity).toEqual(100_000n); + expect(minted[1].script).toEqual(mockScript2); + + expect(burned[0].assetName).toEqual(mockTokenName3); + expect(burned[0].policyId).toEqual(mockPolicy3); + expect(burned[0].fingerprint).toEqual( + AssetFingerprint.fromParts(PolicyId(mockPolicy3), AssetName(mockTokenName3)) + ); + expect(burned[0].quantity).toEqual(-1n); + expect(burned[0].script).toBeUndefined(); + }); + }); });