diff --git a/compose/common.yml b/compose/common.yml index ba483dd3fe7..9aaaf873dd0 100644 --- a/compose/common.yml +++ b/compose/common.yml @@ -176,7 +176,8 @@ services: '--node-config', '/config/cardano-node/config.json', '--log-level-websocket', - 'Warning' + 'Warning', + '--include-cbor' ] depends_on: cardano-node: diff --git a/demo/projection-typeorm.js b/demo/projection-typeorm.js index 79ccea5ef3c..691228d6850 100644 --- a/demo/projection-typeorm.js +++ b/demo/projection-typeorm.js @@ -6,19 +6,14 @@ const { withTypeormTransaction, typeormTransactionCommit, TypeormStabilityWindowBuffer, - BlockDataEntity, BlockEntity, - StakePoolEntity, - PoolRegistrationEntity, - PoolRetirementEntity, - OutputEntity, - AssetEntity, - TokensEntity, - storeBlock, - storeAssets, + BlockEntity, + CredentialEntity, + TransactionEntity, storeUtxo, - storeStakePools, - storeStakePoolMetadataJob, + storeBlock, + storeCredentials, + storeTransactions, isRecoverableTypeormError } = require('@cardano-sdk/projection-typeorm'); const { OgmiosObservableCardanoNode } = require('@cardano-sdk/ogmios'); @@ -32,14 +27,9 @@ const logger = { }; const entities = [ - BlockDataEntity, BlockEntity, - StakePoolEntity, - PoolRegistrationEntity, - PoolRetirementEntity, - AssetEntity, - TokensEntity, - OutputEntity + CredentialEntity, + TransactionEntity, ]; const extensions = { pgBoss: true @@ -101,26 +91,17 @@ Bootstrap.fromCardanoNode({ logger }) .pipe( - Mappers.withCertificates(), - Mappers.withStakePools(), - Mappers.withMint(), Mappers.withUtxo(), - // Single-tenant example - // Mappers.filterProducedUtxoByAddresses({ - // addresses: [ - // 'addr_test1qpgn04xka0857kh6859za75tfvlrlu2lft0yc9z87598yjezw8yvpkv977yj5va20xmd9vw5fczfl3uu4expskz8adfqpydths' - // ] - // }), + Mappers.withAddresses(), shareRetryBackoff( (evt$) => evt$.pipe( withTypeormTransaction({ dataSource$, logger }, extensions), - storeBlock(), - buffer.storeBlockData(), - storeAssets(), storeUtxo(), - storeStakePools(), - storeStakePoolMetadataJob(), + storeBlock(), + storeCredentials(), + storeTransactions(), + // buffer.storeBlockData(), typeormTransactionCommit() ), { shouldRetry: isRecoverableTypeormError } diff --git a/packages/cardano-services/src/Projection/prepareTypeormProjection.ts b/packages/cardano-services/src/Projection/prepareTypeormProjection.ts index a00f8d050c5..eb071b4d567 100644 --- a/packages/cardano-services/src/Projection/prepareTypeormProjection.ts +++ b/packages/cardano-services/src/Projection/prepareTypeormProjection.ts @@ -3,6 +3,7 @@ import { AssetEntity, BlockDataEntity, BlockEntity, + CredentialEntity, CurrentPoolMetricsEntity, DataSourceExtensions, HandleEntity, @@ -17,21 +18,25 @@ import { StakeKeyRegistrationEntity, StakePoolEntity, TokensEntity, + TransactionEntity, createStorePoolMetricsUpdateJob, createStoreStakePoolMetadataJob, storeAddresses, storeAssets, storeBlock, + storeCredentials, storeHandleMetadata, storeHandles, storeNftMetadata, storeStakeKeyRegistrations, storeStakePoolRewardsJob, storeStakePools, + storeTransactions, storeUtxo, willStoreAddresses, willStoreAssets, willStoreBlockData, + willStoreCredentials, willStoreHandleMetadata, willStoreHandles, willStoreNftMetadata, @@ -39,6 +44,7 @@ import { willStoreStakePoolMetadataJob, willStoreStakePoolRewardsJob, willStoreStakePools, + willStoreTransactions, willStoreUtxo } from '@cardano-sdk/projection-typeorm'; import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; @@ -57,6 +63,7 @@ export enum ProjectionName { StakePoolMetadataJob = 'stake-pool-metadata-job', StakePoolMetricsJob = 'stake-pool-metrics-job', StakePoolRewardsJob = 'stake-pool-rewards-job', + Transaction = 'transaction', UTXO = 'utxo' } @@ -112,6 +119,7 @@ export const storeOperators = { storeAddresses: storeAddresses(), storeAssets: storeAssets(), storeBlock: storeBlock(), + storeCredentials: storeCredentials(), storeHandleMetadata: storeHandleMetadata(), storeHandles: storeHandles(), storeNftMetadata: storeNftMetadata(), @@ -123,6 +131,7 @@ export const storeOperators = { storeStakePoolMetadataJob: createStoreStakePoolMetadataJob()(), storeStakePoolRewardsJob: storeStakePoolRewardsJob(), storeStakePools: storeStakePools(), + storeTransactions: storeTransactions(), storeUtxo: storeUtxo() }; type StoreOperators = typeof storeOperators; @@ -138,6 +147,7 @@ type WillStore = { const willStore: Partial = { storeAddresses: willStoreAddresses, storeAssets: willStoreAssets, + storeCredentials: willStoreCredentials, storeHandleMetadata: willStoreHandleMetadata, storeHandles: willStoreHandles, storeNftMetadata: willStoreNftMetadata, @@ -145,6 +155,7 @@ const willStore: Partial = { storeStakePoolMetadataJob: willStoreStakePoolMetadataJob, storeStakePoolRewardsJob: willStoreStakePoolRewardsJob, storeStakePools: willStoreStakePools, + storeTransactions: willStoreTransactions, storeUtxo: willStoreUtxo }; @@ -153,6 +164,7 @@ const entities = { asset: AssetEntity, block: BlockEntity, blockData: BlockDataEntity, + credential: CredentialEntity, currentPoolMetrics: CurrentPoolMetricsEntity, handle: HandleEntity, handleMetadata: HandleMetadataEntity, @@ -165,7 +177,8 @@ const entities = { poolRewards: PoolRewardsEntity, stakeKeyRegistration: StakeKeyRegistrationEntity, stakePool: StakePoolEntity, - tokens: TokensEntity + tokens: TokensEntity, + transaction: TransactionEntity }; export const allEntities = Object.values(entities); type Entities = typeof entities; @@ -176,6 +189,7 @@ const storeEntities: Partial> = { storeAddresses: ['address'], storeAssets: ['asset'], storeBlock: ['block', 'blockData'], + storeCredentials: ['credential', 'transaction', 'output'], storeHandleMetadata: ['handleMetadata', 'output'], storeHandles: ['handle', 'asset', 'tokens', 'output'], storeNftMetadata: ['asset'], @@ -186,6 +200,7 @@ const storeEntities: Partial> = { storeStakePoolMetadataJob: ['stakePool', 'currentPoolMetrics', 'poolMetadata'], storeStakePoolRewardsJob: ['poolRewards', 'stakePool'], storeStakePools: ['stakePool', 'currentPoolMetrics', 'poolMetadata', 'poolDelisted'], + storeTransactions: ['block', 'transaction'], storeUtxo: ['tokens', 'output'] }; @@ -193,6 +208,7 @@ const entityInterDependencies: Partial> = { address: ['stakeKeyRegistration'], asset: ['block', 'nftMetadata'], blockData: ['block'], + credential: [], currentPoolMetrics: ['stakePool'], handle: ['asset'], handleMetadata: ['output'], @@ -203,7 +219,8 @@ const entityInterDependencies: Partial> = { poolRetirement: ['block'], stakeKeyRegistration: ['block'], stakePool: ['block', 'poolRegistration', 'poolRetirement'], - tokens: ['asset'] + tokens: ['asset'], + transaction: ['block', 'credential'] }; export const getEntities = (entityNames: EntityName[]): Entity[] => { @@ -241,6 +258,7 @@ const mapperInterDependencies: Partial> = { const storeMapperDependencies: Partial> = { storeAddresses: ['withAddresses'], storeAssets: ['withMint'], + storeCredentials: ['withAddresses', 'withCertificates', 'withUtxo'], storeHandleMetadata: ['withHandleMetadata'], storeHandles: ['withHandles'], storeNftMetadata: ['withNftMetadata'], @@ -260,6 +278,7 @@ const storeInterDependencies: Partial> = { storeStakePoolMetadataJob: ['storeBlock'], storeStakePoolRewardsJob: ['storeBlock'], storeStakePools: ['storeBlock'], + storeTransactions: ['storeCredentials', 'storeBlock'], storeUtxo: ['storeBlock', 'storeAssets'] }; @@ -273,6 +292,7 @@ const projectionStoreDependencies: Record = { 'stake-pool-metadata-job': ['storeStakePoolMetadataJob'], 'stake-pool-metrics-job': ['storePoolMetricsUpdateJob'], 'stake-pool-rewards-job': ['storeStakePoolRewardsJob'], + transaction: ['storeCredentials', 'storeTransactions', 'storeUtxo'], utxo: ['storeUtxo'] }; diff --git a/packages/core/src/Cardano/types/Transaction.ts b/packages/core/src/Cardano/types/Transaction.ts index b6e6a1fe79e..df6bf0dd504 100644 --- a/packages/core/src/Cardano/types/Transaction.ts +++ b/packages/core/src/Cardano/types/Transaction.ts @@ -12,6 +12,7 @@ import { ProposalProcedure, VotingProcedures } from './Governance'; import { RewardAccount } from '../Address'; import { Script } from './Script'; import { TxBodyCBOR } from '../../CBOR/TxBodyCBOR'; +import { TxCBOR } from '../../CBOR'; import { bytesToHex, hexToBytes } from '../../util/misc'; /** transaction hash as hex string */ @@ -159,6 +160,7 @@ export interface OnChainTx extends Omit, 'witness' | 'auxiliaryData'> { witness: Omit; auxiliaryData?: Omit; + cbor?: TxCBOR; } export interface HydratedTx extends TxWithInputSource { diff --git a/packages/ogmios/src/ogmiosToCore/tx.ts b/packages/ogmios/src/ogmiosToCore/tx.ts index 0b7f1ff9582..23116780096 100644 --- a/packages/ogmios/src/ogmiosToCore/tx.ts +++ b/packages/ogmios/src/ogmiosToCore/tx.ts @@ -5,7 +5,8 @@ import { NotImplementedError, Serialization, SerializationError, - SerializationFailure + SerializationFailure, + TxCBOR } from '@cardano-sdk/core'; import { CommonBlock } from './types'; import { Schema } from '@cardano-ogmios/client'; @@ -432,6 +433,7 @@ const mapCommonTx = (tx: Schema.Transaction): Cardano.OnChainTx => { withdrawals: mapWithdrawals(tx.withdrawals) }) }, + cbor: tx.cbor ? TxCBOR(tx.cbor) : undefined, id: Cardano.TransactionId(tx.id), // At the time of writing Byron transactions didn't set this property inputSource: tx.spends ? Cardano.InputSource[tx.spends] : Cardano.InputSource.inputs, diff --git a/packages/projection-typeorm/src/entity/Credential.entity.ts b/packages/projection-typeorm/src/entity/Credential.entity.ts new file mode 100644 index 00000000000..024eb1ceae5 --- /dev/null +++ b/packages/projection-typeorm/src/entity/Credential.entity.ts @@ -0,0 +1,26 @@ +import { Column, Entity, Index, ManyToMany, PrimaryColumn } from 'typeorm'; +import { Hash28ByteBase16 } from '@cardano-sdk/crypto'; +import { TransactionEntity } from './Transaction.entity'; + +export enum CredentialType { + PaymentKey = 'payment_key', + PaymentScript = 'payment_script', + StakeKey = 'stake_key', + StakeScript = 'stake_script' +} + +@Entity() +export class CredentialEntity { + @Index() + @PrimaryColumn('varchar') + credentialHash?: Hash28ByteBase16; + + @Column('enum', { enum: CredentialType, nullable: false }) + credentialType?: CredentialType; + + @ManyToMany(() => TransactionEntity, (transaction) => transaction.credentials, { onDelete: 'CASCADE' }) + transactions?: TransactionEntity[]; +} + +export const credentialEntityComparator = (c1: CredentialEntity, c2: CredentialEntity) => + c1.credentialHash === c2.credentialHash && c1.credentialType === c2.credentialType; diff --git a/packages/projection-typeorm/src/entity/Transaction.entity.ts b/packages/projection-typeorm/src/entity/Transaction.entity.ts new file mode 100644 index 00000000000..065b20437bc --- /dev/null +++ b/packages/projection-typeorm/src/entity/Transaction.entity.ts @@ -0,0 +1,27 @@ +import { BlockEntity } from './Block.entity'; +import { Cardano, TxCBOR } from '@cardano-sdk/core'; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, PrimaryColumn } from 'typeorm'; +import { CredentialEntity } from './Credential.entity'; +import { OnDeleteCascadeRelationOptions } from './util'; + +@Entity() +export class TransactionEntity { + @Index() + @PrimaryColumn('varchar') + txId?: Cardano.TransactionId; + + @Column('varchar', { nullable: false }) + cbor?: TxCBOR; + + @ManyToOne(() => BlockEntity, OnDeleteCascadeRelationOptions) + @JoinColumn({ name: 'block_id' }) + block?: BlockEntity; + + @ManyToMany(() => CredentialEntity, (credential) => credential.transactions, { onDelete: 'CASCADE' }) + @JoinTable({ + inverseJoinColumn: { name: 'credential_id', referencedColumnName: 'credentialHash' }, + joinColumn: { name: 'transaction_id', referencedColumnName: 'txId' }, + name: 'transaction_credentials' + }) + credentials?: CredentialEntity[]; +} diff --git a/packages/projection-typeorm/src/entity/index.ts b/packages/projection-typeorm/src/entity/index.ts index d7bfef6aea6..90f70f294e6 100644 --- a/packages/projection-typeorm/src/entity/index.ts +++ b/packages/projection-typeorm/src/entity/index.ts @@ -2,6 +2,7 @@ export * from './Address.entity'; export * from './Asset.entity'; export * from './Block.entity'; export * from './BlockData.entity'; +export * from './Credential.entity'; export * from './CurrentPoolMetrics.entity'; export * from './Handle.entity'; export * from './HandleMetadata.entity'; @@ -15,4 +16,5 @@ export * from './PoolRewards.entity'; export * from './StakeKey.entity'; export * from './StakeKeyRegistration.entity'; export * from './StakePool.entity'; +export * from './Transaction.entity'; export * from './Tokens.entity'; diff --git a/packages/projection-typeorm/src/operators/index.ts b/packages/projection-typeorm/src/operators/index.ts index def7690f9b8..06111346b79 100644 --- a/packages/projection-typeorm/src/operators/index.ts +++ b/packages/projection-typeorm/src/operators/index.ts @@ -1,6 +1,7 @@ export * from './storeAddresses'; export * from './storeAssets'; export * from './storeBlock'; +export * from './storeCredentials'; export * from './storeHandles'; export * from './storeHandleMetadata'; export * from './storeNftMetadata'; @@ -10,6 +11,7 @@ export * from './storeStakeKeyRegistrations'; export * from './storeStakePools'; export * from './storeStakePoolMetadataJob'; export * from './storeStakePoolRewardsJob'; +export * from './storeTransactions'; export * from './storeUtxo'; export * from './util'; export * from './withTypeormTransaction'; diff --git a/packages/projection-typeorm/src/operators/storeCredentials.ts b/packages/projection-typeorm/src/operators/storeCredentials.ts new file mode 100644 index 00000000000..be442a9f1e8 --- /dev/null +++ b/packages/projection-typeorm/src/operators/storeCredentials.ts @@ -0,0 +1,164 @@ +import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; +import { CredentialEntity, CredentialType, OutputEntity, credentialEntityComparator } from '../entity'; +import { Hash28ByteBase16 } from '@cardano-sdk/crypto'; +import { Mappers } from '@cardano-sdk/projection'; +import { Repository } from 'typeorm'; +import { typeormOperator } from './util'; +import uniqWith from 'lodash/uniqWith.js'; + +export interface WithTxCredentials { + credentialsByTx: Record; +} + +export const willStoreCredentials = ({ utxoByTx }: Mappers.WithUtxo) => Object.keys(utxoByTx).length > 0; + +const addInputCredentials = async ( + utxoByTx: Record, + utxoRepository: Repository, + addCredentialFromAddress: (txId: Cardano.TransactionId, address: Mappers.Address) => void +) => { + for (const txHash of Object.keys(utxoByTx) as Cardano.TransactionId[]) { + const txInLookups = utxoByTx[txHash].consumed.map(({ txId, index: outputIndex }) => ({ + outputIndex, + txId + })); + + const outputEntities = await utxoRepository.find({ + select: { address: true, outputIndex: true, txId: true }, + where: txInLookups.map(({ txId, outputIndex }) => ({ + outputIndex, + txId + })) + }); + + for (const hydratedTxIn of outputEntities) { + if (hydratedTxIn.address) { + addCredentialFromAddress(txHash, Mappers.credentialsFromAddress(hydratedTxIn.address)); + } + } + } +}; + +const addOutputCredentials = ( + addressesByTx: Record, + addCredentialFromAddress: (txId: Cardano.TransactionId, address: Mappers.Address) => void +) => { + for (const txId of Object.keys(addressesByTx) as Cardano.TransactionId[]) { + for (const address of addressesByTx[txId]) { + addCredentialFromAddress(txId, address); + } + } +}; + +const addCertificateCredentials = ( + credentialsByTx: Record, + addCredential: ( + txId: Cardano.TransactionId, + credentialHash: Hash28ByteBase16, + credentialType: CredentialType + ) => Map +) => { + for (const txId of Object.keys(credentialsByTx) as Cardano.TransactionId[]) { + for (const credential of credentialsByTx[txId]) { + addCredential( + txId, + credential.hash, + credential.type === 0 ? CredentialType.StakeKey : CredentialType.StakeScript + ); + } + } +}; + +type AddressPart = 'payment' | 'stake'; +const credentialTypeMap: { [key: number]: { payment: CredentialType | null; stake: CredentialType } } = { + [Cardano.AddressType.BasePaymentKeyStakeKey]: { payment: CredentialType.PaymentKey, stake: CredentialType.StakeKey }, + [Cardano.AddressType.EnterpriseKey]: { payment: CredentialType.PaymentKey, stake: CredentialType.StakeKey }, + [Cardano.AddressType.BasePaymentKeyStakeScript]: { + payment: CredentialType.PaymentKey, + stake: CredentialType.StakeScript + }, + [Cardano.AddressType.BasePaymentScriptStakeKey]: { + payment: CredentialType.PaymentScript, + stake: CredentialType.StakeKey + }, + [Cardano.AddressType.BasePaymentScriptStakeScript]: { + payment: CredentialType.PaymentScript, + stake: CredentialType.StakeScript + }, + [Cardano.AddressType.EnterpriseScript]: { payment: CredentialType.PaymentScript, stake: CredentialType.StakeScript }, + [Cardano.AddressType.RewardKey]: { payment: null, stake: CredentialType.StakeKey }, + [Cardano.AddressType.RewardScript]: { payment: null, stake: CredentialType.StakeScript } +}; + +export const storeCredentials = typeormOperator< + Mappers.WithUtxo & Mappers.WithAddresses & Mappers.WithCertificates, + WithTxCredentials +>(async (evt) => { + const { + addressesByTx, + block: { body: txs }, + eventType, + queryRunner, + stakeCredentialsByTx, + utxoByTx + } = evt; + + const txToCredentials = new Map(); + + // produced credentials will be automatically deleted via block cascade + if (txs.length === 0 || eventType !== ChainSyncEventType.RollForward) { + return { credentialsByTx: Object.fromEntries(txToCredentials) }; + } + const utxoRepository = queryRunner.manager.getRepository(OutputEntity); + const addCredential = ( + txId: Cardano.TransactionId, + credentialHash: Hash28ByteBase16, + credentialType: CredentialType + ) => + txToCredentials.set( + txId, + uniqWith([...(txToCredentials.get(txId) || []), { credentialHash, credentialType }], credentialEntityComparator) + ); + + const credentialTypeFromAddressType = (type: Cardano.AddressType, part: AddressPart) => { + const credential = credentialTypeMap[type]; + if (!credential) { + // FIXME: map byron address, pointer script, pointer key type + return null; + } + return credential[part]; + }; + + const addCredentialFromAddress = ( + txId: Cardano.TransactionId, + { paymentCredentialHash, stakeCredential, type }: Mappers.Address + ) => { + const paymentCredentialType = credentialTypeFromAddressType(type, 'payment'); + if (paymentCredentialHash && paymentCredentialType) { + addCredential(txId, paymentCredentialHash, paymentCredentialType); + } + + if (stakeCredential) { + const stakeCredentialType = credentialTypeFromAddressType(type, 'stake'); + // FIXME: support pointers + if (stakeCredentialType && typeof stakeCredential === 'string') { + addCredential(txId, stakeCredential, stakeCredentialType); + } + } + }; + + await addInputCredentials(utxoByTx, utxoRepository, addCredentialFromAddress); + addOutputCredentials(addressesByTx, addCredentialFromAddress); + addCertificateCredentials(stakeCredentialsByTx, addCredential); + + // insert new credentials & ignore conflicts of existing ones + await queryRunner.manager + .createQueryBuilder() + .insert() + .into(CredentialEntity) + .values([...txToCredentials.values()].flat()) + .orIgnore() + .execute(); + + return { credentialsByTx: Object.fromEntries(txToCredentials) }; +}); diff --git a/packages/projection-typeorm/src/operators/storeTransactions.ts b/packages/projection-typeorm/src/operators/storeTransactions.ts new file mode 100644 index 00000000000..7aeb0cd657c --- /dev/null +++ b/packages/projection-typeorm/src/operators/storeTransactions.ts @@ -0,0 +1,60 @@ +import { Cardano, ChainSyncEventType } from '@cardano-sdk/core'; +import { Hash28ByteBase16 } from '@cardano-sdk/crypto'; +import { TransactionEntity } from '../entity'; +import { WithBlock } from '@cardano-sdk/projection'; +import { WithTxCredentials } from './storeCredentials'; +import { typeormOperator } from './util'; + +export const willStoreTransactions = ({ block: { body } }: WithBlock) => body.length > 0; + +export const storeTransactions = typeormOperator(async (evt) => { + const { + block: { body: txs, header }, + credentialsByTx, + eventType, + queryRunner + } = evt; + + // produced txs will be automatically deleted via block cascade + if (txs.length === 0 || eventType !== ChainSyncEventType.RollForward) return; + + const transactionEntities = new Array(); + for (const tx of txs) { + const credentials = credentialsByTx[tx.id] || []; + const txEntity: TransactionEntity = { + block: header, + cbor: tx.cbor, + credentials, + txId: tx.id + }; + transactionEntities.push(txEntity); + } + + await queryRunner.manager + .createQueryBuilder() + .insert() + .into(TransactionEntity) + .values(transactionEntities) + .orIgnore() + .execute(); + + // Bulk insert relationships + await queryRunner.manager + .createQueryBuilder() + .insert() + .into('transaction_credentials') + .values( + Object.entries(credentialsByTx).reduce( + (arr, [txId, credentials]) => [ + ...arr, + ...credentials.map((credential) => ({ + credential_id: credential.credentialHash!, + transaction_id: txId as Cardano.TransactionId + })) + ], + new Array<{ transaction_id: Cardano.TransactionId; credential_id: Hash28ByteBase16 }>() + ) + ) + .orIgnore() + .execute(); +}); diff --git a/packages/projection/src/operators/Mappers/certificates/withCertificates.ts b/packages/projection/src/operators/Mappers/certificates/withCertificates.ts index 7302534fb08..aa7a625054d 100644 --- a/packages/projection/src/operators/Mappers/certificates/withCertificates.ts +++ b/packages/projection/src/operators/Mappers/certificates/withCertificates.ts @@ -1,6 +1,6 @@ import { Cardano } from '@cardano-sdk/core'; -import { WithBlock } from '../../../types'; import { unifiedProjectorOperator } from '../../utils'; +import uniqWith from 'lodash/uniqWith.js'; export interface OnChainCertificate { pointer: Cardano.Pointer; @@ -9,29 +9,61 @@ export interface OnChainCertificate { export interface WithCertificates { certificates: OnChainCertificate[]; + stakeCredentialsByTx: Record; } -const blockCertificates = ({ - block: { +const isNotPhase2ValidationErrorTx = (tx: Cardano.OnChainTx) => + !Cardano.util.isPhase2ValidationErrTx(tx); + +const credentialComparator = (c1: Cardano.Credential, c2: Cardano.Credential) => + c1.hash === c2.hash && c1.type === c2.type; + +/** Adds flat array of certificates to event as well as a record of stake credentials grouped by transaction id. */ +export const withCertificates = unifiedProjectorOperator<{}, WithCertificates>((evt) => { + let blockCertificates: OnChainCertificate[] = []; + const txToStakeCredentials = new Map(); + + const { header: { slot }, body - } -}: WithBlock) => - body - .filter((tx) => !Cardano.util.isPhase2ValidationErrTx(tx)) - .flatMap(({ body: { certificates = [] } }, txIndex) => - certificates.map((certificate, certIndex) => ({ + } = evt.block; + const txs = body.filter(isNotPhase2ValidationErrorTx); + + const addCredential = (txId: Cardano.TransactionId, credential: Cardano.Credential) => + txToStakeCredentials.set( + txId, + uniqWith([...(txToStakeCredentials.get(txId) || []), credential], credentialComparator) + ); + + for (const [ + txIndex, + { + id: txId, + body: { certificates = [] } + } + ] of txs.filter(isNotPhase2ValidationErrorTx).entries()) { + const certs = new Array(); + + for (const [certIndex, certificate] of certificates.entries()) { + certs.push({ certificate, pointer: { certIndex: Cardano.CertIndex(certIndex), slot: Cardano.Slot(slot), txIndex: Cardano.TxIndex(txIndex) } - })) - ); + }); + + if ('stakeCredential' in certificate && certificate.stakeCredential) { + addCredential(txId, certificate.stakeCredential); + } + } + blockCertificates = [...blockCertificates, ...certs]; + } -/** Map ChainSyncEvents to a flat array of certificates. */ -export const withCertificates = unifiedProjectorOperator<{}, WithCertificates>((evt) => ({ - ...evt, - certificates: blockCertificates(evt) -})); + return { + ...evt, + certificates: blockCertificates, + stakeCredentialsByTx: Object.fromEntries(txToStakeCredentials) + }; +}); diff --git a/packages/projection/src/operators/Mappers/index.ts b/packages/projection/src/operators/Mappers/index.ts index 7f23f8d8de2..e07a24d365e 100644 --- a/packages/projection/src/operators/Mappers/index.ts +++ b/packages/projection/src/operators/Mappers/index.ts @@ -6,3 +6,4 @@ export * from './withHandleMetadata'; export * from './withNftMetadata'; export * from './withCIP67'; export * from './withAddresses'; +export { credentialsFromAddress } from './util'; diff --git a/packages/projection/src/operators/Mappers/util.ts b/packages/projection/src/operators/Mappers/util.ts index 4aeaeac24d4..520fb7a0e8f 100644 --- a/packages/projection/src/operators/Mappers/util.ts +++ b/packages/projection/src/operators/Mappers/util.ts @@ -1,4 +1,6 @@ +import { Address } from './withAddresses'; import { Asset, Cardano, Handle } from '@cardano-sdk/core'; +import { Hash28ByteBase16 } from '@cardano-sdk/crypto'; import { Logger } from 'ts-log'; /** Up to 100k transactions per block. Fits in 64-bit signed integer. */ @@ -12,3 +14,41 @@ export const assetNameToUTF8Handle = (assetName: Cardano.AssetName, logger: Logg } return handle; }; + +export const credentialsFromAddress = (address: Cardano.PaymentAddress): Address => { + const parsed = Cardano.Address.fromString(address)!; + let paymentCredentialHash: Hash28ByteBase16 | undefined; + let stakeCredentialHash: Hash28ByteBase16 | undefined; + let pointer: Cardano.Pointer | undefined; + const type = parsed.getType(); + switch (type) { + case Cardano.AddressType.BasePaymentKeyStakeKey: + case Cardano.AddressType.BasePaymentKeyStakeScript: + case Cardano.AddressType.BasePaymentScriptStakeKey: + case Cardano.AddressType.BasePaymentScriptStakeScript: { + const baseAddress = parsed.asBase()!; + paymentCredentialHash = baseAddress.getPaymentCredential().hash; + stakeCredentialHash = baseAddress.getStakeCredential().hash; + break; + } + case Cardano.AddressType.EnterpriseKey: + case Cardano.AddressType.EnterpriseScript: { + const enterpriseAddress = parsed.asEnterprise()!; + paymentCredentialHash = enterpriseAddress.getPaymentCredential().hash; + break; + } + case Cardano.AddressType.PointerKey: + case Cardano.AddressType.PointerScript: { + const pointerAddress = parsed.asPointer()!; + paymentCredentialHash = pointerAddress.getPaymentCredential().hash; + pointer = pointerAddress.getStakePointer(); + break; + } + } + return { + address, + paymentCredentialHash, + stakeCredential: stakeCredentialHash || pointer, + type + }; +}; diff --git a/packages/projection/src/operators/Mappers/withAddresses.ts b/packages/projection/src/operators/Mappers/withAddresses.ts index 02ba2a4ec2c..ed87db96e6f 100644 --- a/packages/projection/src/operators/Mappers/withAddresses.ts +++ b/packages/projection/src/operators/Mappers/withAddresses.ts @@ -1,6 +1,7 @@ import { Cardano } from '@cardano-sdk/core'; import { Hash28ByteBase16 } from '@cardano-sdk/crypto'; import { WithUtxo } from './withUtxo'; +import { credentialsFromAddress } from './util'; import { unifiedProjectorOperator } from '../utils'; import uniq from 'lodash/uniq.js'; @@ -15,46 +16,24 @@ export interface Address { export interface WithAddresses { addresses: Address[]; + addressesByTx: Record; } /** Collect all unique addresses from produced utxo */ -export const withAddresses = unifiedProjectorOperator((evt) => ({ - ...evt, - addresses: uniq(evt.utxo.produced.map(([_, txOut]) => txOut.address)).map((address): Address => { - const parsed = Cardano.Address.fromString(address)!; - let paymentCredentialHash: Hash28ByteBase16 | undefined; - let stakeCredentialHash: Hash28ByteBase16 | undefined; - let pointer: Cardano.Pointer | undefined; - const type = parsed.getType(); - switch (type) { - case Cardano.AddressType.BasePaymentKeyStakeKey: - case Cardano.AddressType.BasePaymentKeyStakeScript: - case Cardano.AddressType.BasePaymentScriptStakeKey: - case Cardano.AddressType.BasePaymentScriptStakeScript: { - const baseAddress = parsed.asBase()!; - paymentCredentialHash = baseAddress.getPaymentCredential().hash; - stakeCredentialHash = baseAddress.getStakeCredential().hash; - break; - } - case Cardano.AddressType.EnterpriseKey: - case Cardano.AddressType.EnterpriseScript: { - const enterpriseAddress = parsed.asEnterprise()!; - paymentCredentialHash = enterpriseAddress.getPaymentCredential().hash; - break; - } - case Cardano.AddressType.PointerKey: - case Cardano.AddressType.PointerScript: { - const pointerAddress = parsed.asPointer()!; - paymentCredentialHash = pointerAddress.getPaymentCredential().hash; - pointer = pointerAddress.getStakePointer(); - break; - } - } - return { - address, - paymentCredentialHash, - stakeCredential: stakeCredentialHash || pointer, - type - }; - }) -})); +export const withAddresses = unifiedProjectorOperator((evt) => { + const addressesByTx = { + ...Object.entries(evt.utxoByTx).reduce( + (map, [txId, utxo]) => ({ + ...map, + [txId]: uniq(utxo.produced.map(([_, txOut]) => txOut.address)).map(credentialsFromAddress) + }), + new Map() + ) + } as Record; + + return { + ...evt, + addresses: uniq(Object.values(addressesByTx).flat()), + addressesByTx + }; +}); diff --git a/packages/projection/src/operators/Mappers/withUtxo.ts b/packages/projection/src/operators/Mappers/withUtxo.ts index 5eac409e28d..54320338cfe 100644 --- a/packages/projection/src/operators/Mappers/withUtxo.ts +++ b/packages/projection/src/operators/Mappers/withUtxo.ts @@ -6,30 +6,51 @@ import { unifiedProjectorOperator } from '../utils'; export type ProducedUtxo = [Cardano.TxIn, Cardano.TxOut]; +export interface WithProducedUTxO { + produced: Array; +} +export interface WithConsumedTxIn { + /** Refers to `compactUtxoId` of a previously produced utxo */ + consumed: Cardano.TxIn[]; +} export interface WithUtxo { - utxo: { - produced: Array; - /** Refers to `compactUtxoId` of a previously produced utxo */ - consumed: Cardano.TxIn[]; - }; + /** Complete utxo set from block including all transactions */ + utxo: WithConsumedTxIn & WithProducedUTxO; + /** Utxo set grouped by transaction id */ + utxoByTx: Record; } export const withUtxo = unifiedProjectorOperator<{}, WithUtxo>((evt) => { - const produced = evt.block.body.flatMap(({ body: { outputs, collateralReturn }, inputSource, id }) => - (inputSource === Cardano.InputSource.inputs ? outputs : collateralReturn ? [collateralReturn] : []).map( - (txOut, outputIndex): [Cardano.TxIn, Cardano.TxOut] => [ - { - index: outputIndex, - txId: id - }, - txOut - ] - ) - ); - const consumed = evt.block.body.flatMap(({ body: { inputs, collaterals }, inputSource }) => - inputSource === Cardano.InputSource.inputs ? inputs : collaterals || [] - ); - return { ...evt, utxo: { consumed, produced } }; + const txToUtxos = new Map(); + + for (const { + body: { collaterals, inputs, outputs, collateralReturn }, + inputSource, + id + } of evt.block.body) { + txToUtxos.set(id, { + consumed: inputSource === Cardano.InputSource.inputs ? inputs : collaterals || [], + produced: (inputSource === Cardano.InputSource.inputs ? outputs : collateralReturn ? [collateralReturn] : []).map( + (txOut, outputIndex): [Cardano.TxIn, Cardano.TxOut] => [ + { + index: outputIndex, + txId: id + }, + txOut + ] + ) + }); + } + + const utxoByTx = Object.fromEntries(txToUtxos); + return { + ...evt, + utxo: { + consumed: Object.values(utxoByTx).flatMap((tx) => tx.consumed), + produced: Object.values(utxoByTx).flatMap((tx) => tx.produced) + }, + utxoByTx + }; }); export interface FilterByPaymentAddresses { @@ -42,7 +63,21 @@ export const filterProducedUtxoByAddresses = evt$.pipe( map((evt) => ({ ...evt, - utxo: { ...evt.utxo, produced: evt.utxo.produced.filter(([_, { address }]) => addresses.includes(address)) } + utxo: { ...evt.utxo, produced: evt.utxo.produced.filter(([_, { address }]) => addresses.includes(address)) }, + utxoByTx: { + ...Object.fromEntries( + Object.entries(evt.utxoByTx).reduce( + (txToUtxo, [txId, utxos]) => ({ + ...txToUtxo, + [txId]: { + ...utxos, + produced: utxos.produced.filter(([_, { address }]) => addresses.includes(address)) + } + }), + new Map() + ) + ) + } as Record })) ); @@ -56,7 +91,22 @@ export const filterProducedUtxoByAssetsPresence = utxo: { ...evt.utxo, produced: evt.utxo.produced.filter(([_, { value }]) => value.assets && value.assets.size > 0) - } + }, + utxoByTx: { + ...evt.utxoByTx, + ...Object.fromEntries( + Object.entries(evt.utxoByTx).reduce( + (txToUtxo, [txId, utxos]) => ({ + ...txToUtxo, + [txId]: { + ...utxos, + produced: utxos.produced.filter(([_, { value }]) => value.assets && value.assets.size > 0) + } + }), + new Map() + ) + ) + } as Record })) ); @@ -96,6 +146,37 @@ export const filterProducedUtxoByAssetPolicyId = } ]) => assets && assets.size > 0 ) + }, + utxoByTx: { + ...evt.utxoByTx, + ...Object.entries(evt.utxoByTx).reduce( + (txToUtxo, [txId, utxos]) => ({ + ...txToUtxo, + [txId]: { + ...utxos, + produced: { + ...utxos.produced, + ...utxos.produced.map(([txIn, txOut]) => [ + txIn, + { + ...txOut, + value: { + ...txOut.value, + assets: txOut.value.assets + ? new Map( + [...txOut.value.assets.entries()].filter(([assetId]) => + policyIds.includes(Cardano.AssetId.getPolicyId(assetId)) + ) + ) + : undefined + } + } + ]) + } + } + }), + new Map() + ) } })) );