diff --git a/packages/mesh-common/src/types/index.ts b/packages/mesh-common/src/types/index.ts index ebac87471..506208e25 100644 --- a/packages/mesh-common/src/types/index.ts +++ b/packages/mesh-common/src/types/index.ts @@ -25,3 +25,4 @@ export * from "./transaction-builder"; export * from "./deserialized"; export * from "./blueprint"; export * from "./governance"; +export * from "./tx-prototype"; diff --git a/packages/mesh-common/src/types/tx-prototype/index.ts b/packages/mesh-common/src/types/tx-prototype/index.ts new file mode 100644 index 000000000..eea524d65 --- /dev/null +++ b/packages/mesh-common/src/types/tx-prototype/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/packages/mesh-common/src/types/tx-prototype/types.ts b/packages/mesh-common/src/types/tx-prototype/types.ts new file mode 100644 index 000000000..022203f3e --- /dev/null +++ b/packages/mesh-common/src/types/tx-prototype/types.ts @@ -0,0 +1,680 @@ +/** + * A backend-agnostic, fully-resolved representation of a Cardano transaction — post + * coin-selection, post witness collection — sitting between a transaction builder's own state and + * CBOR. Root type: `TransactionPrototype`. It lives in `mesh-common` rather than in a serializer + * package because `mesh-core-cst` and `mesh-core-csl` are two backends behind one + * `IMeshTxSerializer` interface and both need the same shape; this file has no runtime imports, + * so it adds no dependency edge. + * + * Field names are snake_case verbatim (not camelCased). Serializer backends may hand this shape + * straight to a native library as JSON, matching on these exact names, so renaming a field is a + * wire-breaking change rather than a cosmetic one. + * + * NUMERIC RANGES: the Conway CDDL (IntersectMBO/cardano-ledger, + * `eras/conway/impl/cddl/data/conway.cddl`) is the sole authority for these types — no serializer + * backend's own field widths get a vote. Fields whose CDDL range exceeds + * `Number.MAX_SAFE_INTEGER` (2^53-1) are `bigint` — deliberately NOT `number | bigint`, so a + * plain numeric literal is a compile error rather than a silent precision loss at the top of + * the range. Everything left as plain `number` has a CDDL bound of `uint .size 2`/`uint .size 4` + * (or is a 0..255 byte) and cannot overflow. + * + * Where a backend's own types turn out narrower than the CDDL, that is a bug in that backend, not + * a reason to narrow these types. + * + * One live caveat: `JSON.stringify` THROWS on `bigint`, so a backend serializing this to JSON + * needs `json-bigint` or equivalent; `mesh-core-csl` already does. + */ + +export type AddressPrototype = string; +export type URLPrototype = string; + +export interface AnchorPrototype { + anchor_data_hash: string; + anchor_url: URLPrototype; +} +export type AnchorDataHashPrototype = string; +export type AssetNamePrototype = string; +export type AssetNamesPrototype = string[]; +export interface AssetsPrototype { + [k: string]: string; +} +export type NativeScriptPrototype = + | { type: "SCRIPT_PUBKEY"; value: ScriptPubkeyPrototype } + | { type: "SCRIPT_ALL"; value: ScriptAllPrototype } + | { type: "SCRIPT_ANY"; value: ScriptAnyPrototype } + | { type: "SCRIPT_N_OF_K"; value: ScriptNOfKPrototype } + | { type: "TIMELOCK_START"; value: TimelockStartPrototype } + | { type: "TIMELOCK_EXPIRY"; value: TimelockExpiryPrototype }; + +export interface AuxiliaryDataPrototype { + /** CDDL `auxiliary_data_map` key 0. */ + metadata?: TxMetadataPrototype | null; + /** CDDL `auxiliary_data_map` key 1. */ + native_scripts?: NativeScriptPrototype[] | null; + /** + * CDDL `auxiliary_data_map` keys 2 / 3 / 4 are three SEPARATE fields + * (`? 2 : [* plutus_v1_script]`, `? 3 : [* plutus_v2_script]`, `? 4 : [* plutus_v3_script]`), + * for the same reason as the witness set: the key is what records the language version. + */ + plutus_v1_scripts?: string[] | null; + plutus_v2_scripts?: string[] | null; + plutus_v3_scripts?: string[] | null; + /** + * NOT HONOURED by the `mesh-core-cst` converters. The CDDL offers three auxiliary-data + * encodings (`metadata / auxiliary_data_array / auxiliary_data_map`) and this flag picks + * between the Shelley and Alonzo (`#6.259`-tagged map) forms, but `@cardano-sdk/core`'s + * `AuxiliaryData` exposes no format setter — it decides internally from the content — so the + * encoder cannot act on this and the decoder always reports `true`. Retained because it is a + * required field of the wire shape backends expect. + */ + prefer_alonzo_format: boolean; +} +export interface ScriptPubkeyPrototype { + addr_keyhash: string; +} +export interface ScriptAllPrototype { + native_scripts: NativeScriptPrototype[]; +} +export interface ScriptAnyPrototype { + native_scripts: NativeScriptPrototype[]; +} +export interface ScriptNOfKPrototype { + /** CDDL: `script_n_of_k = (3, n : int64, [* native_script])` — `int64`, not a bounded uint, + * so the full ±9.22e18 range is legal, negatives included. */ + n: bigint; + native_scripts: NativeScriptPrototype[]; +} +export interface TimelockStartPrototype { + slot: string; +} +export interface TimelockExpiryPrototype { + slot: string; +} +export type AuxiliaryDataHashPrototype = string; +export interface AuxiliaryDataSetPrototype { + [k: string]: AuxiliaryDataPrototype; +} +export type BigIntPrototype = string; +export type BigNumPrototype = string; +export type VkeyPrototype = string; +export type CertificatePrototype = + | { type: "STAKE_REGISTRATION"; value: StakeRegistrationPrototype } + | { type: "STAKE_DEREGISTRATION"; value: StakeDeregistrationPrototype } + | { type: "STAKE_DELEGATION"; value: StakeDelegationPrototype } + | { type: "POOL_REGISTRATION"; value: PoolRegistrationPrototype } + | { type: "POOL_RETIREMENT"; value: PoolRetirementPrototype } + | { type: "COMMITTEE_HOT_AUTH"; value: CommitteeHotAuthPrototype } + | { type: "COMMITTEE_COLD_RESIGN"; value: CommitteeColdResignPrototype } + | { type: "DREP_DEREGISTRATION"; value: DRepDeregistrationPrototype } + | { type: "DREP_REGISTRATION"; value: DRepRegistrationPrototype } + | { type: "DREP_UPDATE"; value: DRepUpdatePrototype } + | { type: "STAKE_AND_VOTE_DELEGATION"; value: StakeAndVoteDelegationPrototype } + | { + type: "STAKE_REGISTRATION_AND_DELEGATION"; + value: StakeRegistrationAndDelegationPrototype; + } + | { + type: "STAKE_VOTE_REGISTRATION_AND_DELEGATION"; + value: StakeVoteRegistrationAndDelegationPrototype; + } + | { type: "VOTE_DELEGATION"; value: VoteDelegationPrototype } + | { + type: "VOTE_REGISTRATION_AND_DELEGATION"; + value: VoteRegistrationAndDelegationPrototype; + }; +export type CredTypePrototype = + | { type: "SCRIPT"; value: string } + | { type: "KEY"; value: string }; +export type RelayPrototype = + | { type: "SINGLE_HOST_ADDR"; value: SingleHostAddrPrototype } + | { type: "SINGLE_HOST_NAME"; value: SingleHostNamePrototype } + | { type: "MULTI_HOST_NAME"; value: MultiHostNamePrototype }; +/** + * @minItems 4 + * @maxItems 4 + */ +export type Ipv4Prototype = [number, number, number, number]; +/** + * @minItems 16 + * @maxItems 16 + */ +export type Ipv6Prototype = [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number +]; +export type DNSRecordAorAAAAPrototype = string; +export type DNSRecordSRVPrototype = string; +export type RelaysPrototype = RelayPrototype[]; +export type DRepPrototype = + | { type: "ALWAYS_ABSTAIN" } + | { type: "ALWAYS_NO_CONFIDENCE" } + | { type: "KEY_HASH"; value: string } + | { type: "SCRIPT_HASH"; value: string }; +export type DataOptionPrototype = + | { type: "DATA_HASH"; value: string } + | { type: "DATA"; value: PlutusDataVariant }; +/** ScriptRef is stored as a CBOR hex string */ +export type ScriptRefPrototype = string; +/** Mint uses the same structure as MultiAsset */ +export type MintPrototype = MultiAssetPrototype; +export type NetworkIdPrototype = { type: "TESTNET" } | { type: "MAINNET" }; +export type TransactionOutputsPrototype = TransactionOutputPrototype[]; +export type CostModelPrototype = string[]; +export type VoterPrototype = + | { type: "CONSTITUTIONAL_COMMITTEE_HOT_CRED"; value: CredTypePrototype } + | { type: "DREP"; value: CredTypePrototype } + | { type: "STAKING_POOL"; value: string }; +export type VoteKindPrototype = + | { type: "NO" } + | { type: "YES" } + | { type: "ABSTAIN" }; +export type GovernanceActionPrototype = + | { type: "PARAMETER_CHANGE_ACTION"; value: ParameterChangeActionPrototype } + | { type: "HARD_FORK_INITIATION_ACTION"; value: HardForkInitiationActionPrototype } + | { + type: "TREASURY_WITHDRAWALS_ACTION"; + value: TreasuryWithdrawalsActionPrototype; + } + | { type: "NO_CONFIDENCE_ACTION"; value: NoConfidenceActionPrototype } + | { type: "UPDATE_COMMITTEE_ACTION"; value: UpdateCommitteeActionPrototype } + | { type: "NEW_CONSTITUTION_ACTION"; value: NewConstitutionActionPrototype } + | { type: "INFO_ACTION" }; +/** + * @minItems 0 + * @maxItems 0 + */ +export type InfoActionPrototype = []; +export type TransactionBodiesPrototype = TransactionBodyPrototype[]; +export type RedeemerTagPrototype = + | { type: "SPEND" } + | { type: "MINT" } + | { type: "CERT" } + | { type: "REWARD" } + | { type: "VOTE" } + | { type: "VOTING_PROPOSAL" }; + +export interface ProtocolVersionPrototype { + /** CDDL: `major_protocol_version = 0 .. 12`. */ + major: number; + /** CDDL: `protocol_version = [major_protocol_version, uint .size 4]`. */ + minor: number; +} +export interface TransactionBodyPrototype { + auxiliary_data_hash?: string | null; + certs?: CertificatePrototype[] | null; + collateral?: TransactionInputPrototype[] | null; + collateral_return?: TransactionOutputPrototype | null; + current_treasury_value?: string | null; + donation?: string | null; + fee: string; + inputs: TransactionInputPrototype[]; + mint?: MintPrototype | null; + network_id?: NetworkIdPrototype | null; + outputs: TransactionOutputsPrototype; + reference_inputs?: TransactionInputPrototype[] | null; + required_signers?: string[] | null; + script_data_hash?: string | null; + total_collateral?: string | null; + ttl?: string | null; + validity_start_interval?: string | null; + voting_procedures?: VoterVotesPrototype[] | null; + voting_proposals?: VotingProposalPrototype[] | null; + withdrawals?: { + [k: string]: string; + } | null; +} +export interface StakeRegistrationPrototype { + coin?: string | null; + stake_credential: CredTypePrototype; +} +export interface StakeDeregistrationPrototype { + coin?: string | null; + stake_credential: CredTypePrototype; +} +export interface StakeDelegationPrototype { + pool_keyhash: string; + stake_credential: CredTypePrototype; +} +export interface PoolRegistrationPrototype { + pool_params: PoolParamsPrototype; +} +export interface PoolParamsPrototype { + cost: string; + margin: UnitIntervalPrototype; + operator: string; + pledge: string; + pool_metadata?: PoolMetadataPrototype | null; + pool_owners: string[]; + relays: RelaysPrototype; + reward_account: string; + vrf_keyhash: string; +} +export interface UnitIntervalPrototype { + denominator: string; + numerator: string; +} +export interface PoolMetadataPrototype { + pool_metadata_hash: string; + url: URLPrototype; +} +export interface SingleHostAddrPrototype { + ipv4?: Ipv4Prototype | null; + ipv6?: Ipv6Prototype | null; + port?: number | null; +} +export interface SingleHostNamePrototype { + dns_name: DNSRecordAorAAAAPrototype; + port?: number | null; +} +export interface MultiHostNamePrototype { + dns_name: DNSRecordSRVPrototype; +} +export interface PoolRetirementPrototype { + /** CDDL: `pool_retirement_cert = (4, pool_keyhash, epoch)` where `epoch = uint .size 8` + * (2^64-1) — NOT the narrower `epoch_interval = uint .size 4` used inside + * `protocol_param_update`. */ + epoch: bigint; + pool_keyhash: string; +} +export interface CommitteeHotAuthPrototype { + committee_cold_credential: CredTypePrototype; + committee_hot_credential: CredTypePrototype; +} +export interface CommitteeColdResignPrototype { + anchor?: AnchorPrototype | null; + committee_cold_credential: CredTypePrototype; +} +export interface DRepDeregistrationPrototype { + coin: string; + voting_credential: CredTypePrototype; +} +export interface DRepRegistrationPrototype { + anchor?: AnchorPrototype | null; + coin: string; + voting_credential: CredTypePrototype; +} +export interface DRepUpdatePrototype { + anchor?: AnchorPrototype | null; + voting_credential: CredTypePrototype; +} +export interface StakeAndVoteDelegationPrototype { + drep: DRepPrototype; + pool_keyhash: string; + stake_credential: CredTypePrototype; +} +export interface StakeRegistrationAndDelegationPrototype { + coin: string; + pool_keyhash: string; + stake_credential: CredTypePrototype; +} +export interface StakeVoteRegistrationAndDelegationPrototype { + coin: string; + drep: DRepPrototype; + pool_keyhash: string; + stake_credential: CredTypePrototype; +} +export interface VoteDelegationPrototype { + drep: DRepPrototype; + stake_credential: CredTypePrototype; +} +export interface VoteRegistrationAndDelegationPrototype { + coin: string; + drep: DRepPrototype; + stake_credential: CredTypePrototype; +} +export interface TransactionInputPrototype { + /** CDDL: `transaction_input = [transaction_id, index : uint .size 2]` — max 65535. */ + index: number; + transaction_id: string; +} +/** + * CDDL allows two encodings — `transaction_output = alonzo_transaction_output / + * babbage_transaction_output` (a 2–3 element array vs a keyed map) — and this type deliberately + * does not express the choice. `@cardano-sdk/core` derives it from content, with no setter: + * address+amount, or address+amount+datum *hash*, serialize as the Alonzo array; an inline datum + * or a `script_ref` forces the Babbage map. That mapping is deterministic, so anything this + * library encodes round-trips stably. The only casualty is byte-exact fidelity when *decoding a + * third-party* transaction that chose the Babbage map for an output we would emit as an array — + * re-encoding changes the bytes, and so the transaction hash. Judged not worth a flag. + */ +export interface TransactionOutputPrototype { + address: string; + amount: ValuePrototype; + plutus_data?: DataOptionPrototype | null; + script_ref?: ScriptRefPrototype | null; +} +export interface ValuePrototype { + coin: string; + multiasset?: MultiAssetPrototype | null; +} +export interface MultiAssetPrototype { + [k: string]: AssetsPrototype; +} +/** + * Every numeric field here is `uint .size 2` or `uint .size 4` (or `epoch_interval`, itself + * `uint .size 4`) per the Conway CDDL `protocol_param_update` map — all well inside + * `Number.MAX_SAFE_INTEGER`, so plain `number` is correct throughout. The `coin`-typed fields + * (minfee_a/b, deposits, min_pool_cost, ada_per_utxo_byte, …) are unbounded `uint` and are + * carried as `string`, which needs no widening. + */ +export interface ProtocolParamUpdatePrototype { + ada_per_utxo_byte?: string | null; + /** CDDL key 23: `uint .size 2`. */ + collateral_percentage?: number | null; + /** CDDL key 28: `epoch_interval = uint .size 4` — narrower than + * `CommitteeMemberPrototype.term_limit`, which is a full `epoch` (uint64). */ + committee_term_limit?: number | null; + cost_models?: CostmdlsPrototype | null; + drep_deposit?: string | null; + drep_inactivity_period?: number | null; + drep_voting_thresholds?: DRepVotingThresholdsPrototype | null; + execution_costs?: ExUnitPricesPrototype | null; + expansion_rate?: UnitIntervalPrototype | null; + governance_action_deposit?: string | null; + governance_action_validity_period?: number | null; + key_deposit?: string | null; + max_block_body_size?: number | null; + max_block_ex_units?: ExUnitsPrototype | null; + max_block_header_size?: number | null; + max_collateral_inputs?: number | null; + max_epoch?: number | null; + max_tx_ex_units?: ExUnitsPrototype | null; + max_tx_size?: number | null; + max_value_size?: number | null; + min_committee_size?: number | null; + min_pool_cost?: string | null; + minfee_a?: string | null; + minfee_b?: string | null; + n_opt?: number | null; + pool_deposit?: string | null; + pool_pledge_influence?: UnitIntervalPrototype | null; + pool_voting_thresholds?: PoolVotingThresholdsPrototype | null; + ref_script_coins_per_byte?: UnitIntervalPrototype | null; + treasury_growth_rate?: UnitIntervalPrototype | null; +} +export interface CostmdlsPrototype { + [k: string]: CostModelPrototype; +} +export interface DRepVotingThresholdsPrototype { + committee_no_confidence: UnitIntervalPrototype; + committee_normal: UnitIntervalPrototype; + hard_fork_initiation: UnitIntervalPrototype; + motion_no_confidence: UnitIntervalPrototype; + pp_economic_group: UnitIntervalPrototype; + pp_governance_group: UnitIntervalPrototype; + pp_network_group: UnitIntervalPrototype; + pp_technical_group: UnitIntervalPrototype; + treasury_withdrawal: UnitIntervalPrototype; + update_constitution: UnitIntervalPrototype; +} +export interface ExUnitPricesPrototype { + mem_price: UnitIntervalPrototype; + step_price: UnitIntervalPrototype; +} +export interface ExUnitsPrototype { + mem: string; + steps: string; +} +export interface PoolVotingThresholdsPrototype { + committee_no_confidence: UnitIntervalPrototype; + committee_normal: UnitIntervalPrototype; + hard_fork_initiation: UnitIntervalPrototype; + motion_no_confidence: UnitIntervalPrototype; + security_relevant_threshold: UnitIntervalPrototype; +} +export interface VoterVotesPrototype { + voter: VoterPrototype; + votes: VotePrototype[]; +} +export interface VotePrototype { + action_id: GovernanceActionIdPrototype; + voting_procedure: VotingProcedurePrototype; +} +export interface GovernanceActionIdPrototype { + /** CDDL: `gov_action_id = [transaction_id, gov_action_index : uint .size 2]` — max 65535. */ + index: number; + transaction_id: string; +} +export interface VotingProcedurePrototype { + anchor?: AnchorPrototype | null; + vote: VoteKindPrototype; +} +export interface VotingProposalPrototype { + anchor: AnchorPrototype; + deposit: string; + governance_action: GovernanceActionPrototype; + reward_account: string; +} +export interface ParameterChangeActionPrototype { + gov_action_id?: GovernanceActionIdPrototype | null; + policy_hash?: string | null; + protocol_param_updates: ProtocolParamUpdatePrototype; +} +export interface HardForkInitiationActionPrototype { + gov_action_id?: GovernanceActionIdPrototype | null; + protocol_version: ProtocolVersionPrototype; +} +export interface TreasuryWithdrawalsActionPrototype { + policy_hash?: string | null; + withdrawals: TreasuryWithdrawalsPrototype; +} +export interface TreasuryWithdrawalsPrototype { + [k: string]: string; +} +export interface NoConfidenceActionPrototype { + gov_action_id?: GovernanceActionIdPrototype | null; +} +export interface UpdateCommitteeActionPrototype { + committee: CommitteePrototype; + gov_action_id?: GovernanceActionIdPrototype | null; + members_to_remove: CredTypePrototype[]; +} +export interface CommitteePrototype { + members: CommitteeMemberPrototype[]; + quorum_threshold: UnitIntervalPrototype; +} +export interface CommitteeMemberPrototype { + stake_credential: CredTypePrototype; + /** CDDL: this is the value side of `update_committee`'s + * `{* committee_cold_credential => epoch}` map, i.e. `epoch = uint .size 8` (2^64-1). + * Deliberately NOT the same type as `ProtocolParamUpdatePrototype.committee_term_limit`, + * which is `epoch_interval = uint .size 4` — two different "term limit"s, different widths. */ + term_limit: bigint; +} +export interface NewConstitutionActionPrototype { + constitution: ConstitutionPrototype; + gov_action_id?: GovernanceActionIdPrototype | null; +} +export interface ConstitutionPrototype { + anchor: AnchorPrototype; + script_hash?: string | null; +} +export interface TransactionWitnessSetPrototype { + /** CDDL key 2. */ + bootstraps?: BootstrapWitnessPrototype[] | null; + /** CDDL key 1. */ + native_scripts?: NativeScriptPrototype[] | null; + /** CDDL key 4. */ + plutus_data?: PlutusListPrototype | null; + /** + * CDDL keys 3 / 6 / 7 are three SEPARATE fields — `? 3 : nonempty_set`, + * `? 6 : nonempty_set`, `? 7 : nonempty_set` — because a + * Plutus script's language version is not recoverable from its bytes; the witness-set key is + * what carries it. A backend that collapses all three into a single version-less script list + * cannot round-trip a V2/V3 script correctly; these types follow the CDDL instead. + */ + plutus_v1_scripts?: string[] | null; + plutus_v2_scripts?: string[] | null; + plutus_v3_scripts?: string[] | null; + /** CDDL key 5. */ + redeemers?: RedeemerPrototype[] | null; + /** CDDL key 0. */ + vkeys?: VkeywitnessPrototype[] | null; +} +export interface BootstrapWitnessPrototype { + attributes: number[]; + chain_code: number[]; + signature: string; + vkey: VkeyPrototype; +} +export interface PlutusListPrototype { + /** + * NOT HONOURED by the `mesh-core-cst` converters. CBOR permits both definite- and + * indefinite-length lists and the choice changes the bytes (hence the datum hash), but + * `@cardano-sdk/core`'s `PlutusList` exposes no encoding control. The encoder ignores this and + * the decoder never sets it. Round-tripping a datum through CST therefore normalises to + * whatever CST emits — relevant if you are trying to reproduce a specific third-party datum + * hash byte-for-byte. + */ + definite_encoding?: boolean | null; + elems: string[]; +} +export interface RedeemerPrototype { + data: PlutusDataVariant; + ex_units: ExUnitsPrototype; + index: string; + tag: RedeemerTagPrototype; +} +export interface VkeywitnessPrototype { + signature: string; + vkey: VkeyPrototype; +} +export type BlockHashPrototype = string; +export type BootstrapWitnessesPrototype = BootstrapWitnessPrototype[]; + +export type CertificateEnumPrototype = CertificatePrototype; +export type CertificatesPrototype = CertificatePrototype[]; + +export type CredentialPrototype = CredTypePrototype; +export type CredentialsPrototype = CredTypePrototype[]; +export type DRepEnumPrototype = + | { type: "ALWAYS_ABSTAIN" } + | { type: "ALWAYS_NO_CONFIDENCE" } + | { type: "KEY_HASH"; value: string } + | { type: "SCRIPT_HASH"; value: string }; +export type DataHashPrototype = string; +export type Ed25519KeyHashPrototype = string; +export type Ed25519KeyHashesPrototype = string[]; +export type Ed25519SignaturePrototype = string; +export interface GeneralTransactionMetadataPrototype { + [k: string]: string; +} +export type GenesisDelegateHashPrototype = string; +export type GenesisHashPrototype = string; +export type GenesisHashesPrototype = string[]; +export type GovernanceActionEnumPrototype = GovernanceActionPrototype; +export type GovernanceActionIdsPrototype = GovernanceActionIdPrototype[]; + +export type IntPrototype = string; +/** + * @minItems 4 + * @maxItems 4 + */ +export type KESVKeyPrototype = string; +export type LanguagePrototype = LanguageKindPrototype; +export type LanguageKindPrototype = + | { type: "PLUTUS_V1" } + | { type: "PLUTUS_V2" } + | { type: "PLUTUS_V3" }; +export type LanguagesPrototype = LanguagePrototype[]; + +export type NativeScriptsPrototype = NativeScriptPrototype[]; + +export type NetworkIdKindPrototype = NetworkIdPrototype; +export type PlutusScriptPrototype = string; +export type PlutusScriptsPrototype = string[]; +export type PoolMetadataHashPrototype = string; +export type PublicKeyPrototype = string; +export type RedeemerTagKindPrototype = RedeemerTagPrototype; +export type RedeemersPrototype = RedeemerPrototype[]; + +export type RelayEnumPrototype = RelayPrototype; +/** + * @minItems 4 + * @maxItems 4 + */ +export type RewardAddressPrototype = string; +export type RewardAddressesPrototype = string[]; +export type ScriptDataHashPrototype = string; +export type ScriptHashPrototype = string; +export type ScriptHashesPrototype = string[]; +/** ScriptRef is stored as a CBOR hex string */ +export type ScriptRefEnumPrototype = string; +export interface TransactionPrototype { + auxiliary_data?: AuxiliaryDataPrototype | null; + body: TransactionBodyPrototype; + is_valid: boolean; + witness_set: TransactionWitnessSetPrototype; +} +export type TransactionHashPrototype = string; +export type TransactionInputsPrototype = TransactionInputPrototype[]; + +export interface TransactionUnspentOutputPrototype { + input: TransactionInputPrototype; + output: TransactionOutputPrototype; +} +export type TransactionUnspentOutputsPrototype = TransactionUnspentOutputPrototype[]; + +export type VkeywitnessesPrototype = VkeywitnessPrototype[]; + +export type VoterEnumPrototype = VoterPrototype; +export type VotersPrototype = VoterPrototype[]; +export type VotingProceduresPrototype = VoterVotesPrototype[]; + +export type VotingProposalsPrototype = VotingProposalPrototype[]; + +export interface WithdrawalsPrototype { + [k: string]: string; +} + +/** + * Metadatum (tagged enum with "type" discriminator). Suffixed `*Prototype` because `mesh-common` + * already exports its own, differently-shaped `Metadatum`/`TxMetadata` (`../transaction-builder`, + * a `Map`-based shape) — the unsuffixed names would collide across this package's exports. + */ +export type MetadatumPrototype = + | { type: "INT"; value: bigint } + | { type: "BYTES"; value: number[] } // raw bytes as array + | { type: "STRING"; value: string } + | { type: "LIST"; value: MetadatumPrototype[] } + | { type: "MAP"; value: [MetadatumPrototype, MetadatumPrototype][] }; + +/** TxMetadataPrototype is a map from label (string) to MetadatumPrototype */ +export type TxMetadataPrototype = { [label: string]: MetadatumPrototype }; + +/** + * PlutusDataPrototype (tagged enum with "type" discriminator). Named `*Prototype` because + * `mesh-common` already exports its own, differently-shaped `PlutusData` (`../../data/json`). + */ +export type PlutusDataPrototype = + | { type: "INTEGER"; value: bigint } + | { type: "BYTES"; value: string } // hex string + | { type: "LIST"; value: PlutusDataPrototype[] } + | { type: "MAP"; value: [PlutusDataPrototype, PlutusDataPrototype][] } + // CDDL: `constr = #6.102([uint, [* a0]]) / ...` — the general constr tag carries an + // unqualified `uint`, i.e. the full 0..2^64-1 range. + | { type: "CONSTR"; alternative: bigint; fields: PlutusDataPrototype[] }; + +export type PlutusDataVariant = + | { + type: "CBOR"; + hex: string; + } + | { + type: "MANUAL"; + data: PlutusDataPrototype; + }; diff --git a/packages/mesh-core-csl/src/index.ts b/packages/mesh-core-csl/src/index.ts index 1a5d82504..5b40fa2ea 100644 --- a/packages/mesh-core-csl/src/index.ts +++ b/packages/mesh-core-csl/src/index.ts @@ -2,3 +2,4 @@ export * from "./utils"; export * from "./core"; export * from "./deser"; export * from "./offline-providers"; +export * from "./tx-prototype"; diff --git a/packages/mesh-core-csl/src/tx-prototype/index.ts b/packages/mesh-core-csl/src/tx-prototype/index.ts new file mode 100644 index 000000000..5b308263c --- /dev/null +++ b/packages/mesh-core-csl/src/tx-prototype/index.ts @@ -0,0 +1,117 @@ +import { js_tx_prototype_to_hex } from "@sidan-lab/whisky-js-nodejs"; +import JSONbig from "json-bigint"; + +import type { TransactionPrototype } from "@meshsdk/common"; + +/** + * Serializes a `TransactionPrototype` (`@meshsdk/common`) to transaction CBOR hex via whisky's + * `js_tx_prototype_to_hex` WASM entry point. + * + * Uses `JSONbig.stringify`, not `JSON.stringify`, and that is load-bearing rather than stylistic: + * `TransactionPrototype` carries `bigint` on every field whose Conway CDDL range exceeds + * `Number.MAX_SAFE_INTEGER` (`PlutusDataPrototype`'s `INTEGER.value`/`CONSTR.alternative`, + * `MetadatumPrototype`'s `INT.value`, `ScriptNOfKPrototype.n`, `PoolRetirementPrototype.epoch`, + * `CommitteeMemberPrototype.term_limit`). Plain `JSON.stringify` throws outright on those + * (`TypeError: Do not know how to serialize a BigInt`), and the obvious `(k, v) => Number(v)` + * replacer would reintroduce exactly the precision loss the `bigint` typing exists to prevent. + * `JSONbig.stringify` emits them as unquoted JSON number literals, which is what serde on the + * Rust side expects. Same reason `mesh-core-csl/src/core/serializer.ts` already uses it for the + * `js_serialize_tx_body` boundary. + * + * BLOCKING whisky BUG — `i128 is not supported` + * --------------------------------------------- + * whisky types both `PlutusData::Integer { value }` and `Metadatum::Int { value }` as Rust + * `i128`, and `serde_json` refuses to deserialize `i128` unless built with its + * `arbitrary_precision` feature (which whisky's WASM build is not). So `js_tx_prototype_to_hex` + * rejects, with `Invalid TransactionPrototype JSON: Error("i128 is not supported")`: + * - ANY inline/manual Plutus datum containing an integer, and + * - ANY transaction metadata containing an integer. + * This is not a large-value problem: it was verified to fail for `value: 0`, serialized with a + * plain `JSON.stringify` and a plain `number`. Nothing on this side can work around it — the + * failure is in whisky's deserialize step, before any of our encoding matters. + * + * WORKAROUND that does work today: use the `{ type: "CBOR", hex }` arm of `PlutusDataVariant` + * instead of `{ type: "MANUAL", data }`. That bypasses whisky's `PlutusData` enum (and hence the + * `i128` field) entirely, and is verified to serialize successfully. Non-integer `MANUAL` arms + * (`BYTES`, `LIST`, `MAP`, `CONSTR` with no integer inside) also work. + * + * Until whisky is patched, `mesh-core-cst/src/tx-prototype-to-cbor/` is the only backend that + * handles the full `TransactionPrototype` surface — it is pure TypeScript with no JSON/serde + * boundary, so none of this applies there. + * + * SET ENCODING: whisky always emits the Conway `#6.258`-tagged form for every CBOR set, with no + * option to disable it (verified: a body with inputs + collateral + reference inputs + required + * signers comes back with all four tagged). That matches the default of + * `mesh-core-cst`'s `transactionPrototypeToHex`, so the two backends agree out of the box. If you + * specifically need untagged sets, only the CST converter can produce them + * (`transactionPrototypeToHex(proto, { taggedSets: false })`). + * + * Separately, whisky's `ScriptNOfKPrototype.n`, `PoolRetirementPrototype.epoch` and + * `CommitteeMemberPrototype.term_limit` are `u32` where the ledger allows `int64`/`uint .size 8`, + * and `PlutusData::Integer` is `i128` where the ledger's `big_int` is effectively + * arbitrary-precision. CDDL-legal values beyond those bounds are representable in + * `TransactionPrototype` and handled correctly by the `mesh-core-cst` converter, but serde will + * reject them here rather than truncate. + */ +/** + * whisky's Rust structs carry a single, version-less `plutus_scripts: Vec` on both the + * witness set and the auxiliary data, whereas the Conway CDDL (and therefore + * `TransactionPrototype`) splits them across three keys — witness-set 3/6/7 and + * auxiliary-data 2/3/4 — because a Plutus script's language version is not recoverable from its + * bytes. There is no lossless mapping: sending only `plutus_v1_scripts` would silently drop V2/V3 + * scripts, so the three lists are concatenated into whisky's single field. That means whisky + * treats every script as though it were the version its own converter assumes, and a V2/V3 script + * routed through this backend will be mis-tagged in the resulting CBOR. + * + * Rather than let that corrupt a transaction silently, this throws when V2/V3 scripts are present. + * Use `mesh-core-cst/src/tx-prototype-to-cbor/` (which maps all three keys correctly) for those. + */ +const toWhiskyWireShape = (prototype: TransactionPrototype) => { + const ws = prototype.witness_set; + const aux = prototype.auxiliary_data; + + const misTagged = + (ws.plutus_v2_scripts?.length ?? 0) + + (ws.plutus_v3_scripts?.length ?? 0) + + (aux?.plutus_v2_scripts?.length ?? 0) + + (aux?.plutus_v3_scripts?.length ?? 0); + if (misTagged > 0) { + throw new Error( + "serializeTxPrototype error: whisky's tx_prototype has a single version-less " + + "`plutus_scripts` field and cannot represent PlutusV2/V3 scripts without mis-tagging " + + "them. Use the mesh-core-cst converter (tx-prototype-to-cbor) for these transactions.", + ); + } + + const flatten = (v1?: string[] | null) => (v1?.length ? v1 : undefined); + + return { + ...prototype, + witness_set: { + ...ws, + plutus_v1_scripts: undefined, + plutus_v2_scripts: undefined, + plutus_v3_scripts: undefined, + plutus_scripts: flatten(ws.plutus_v1_scripts), + }, + ...(aux + ? { + auxiliary_data: { + ...aux, + plutus_v1_scripts: undefined, + plutus_v2_scripts: undefined, + plutus_v3_scripts: undefined, + plutus_scripts: flatten(aux.plutus_v1_scripts), + }, + } + : {}), + }; +}; + +export const serializeTxPrototype = (prototype: TransactionPrototype): string => { + const result = js_tx_prototype_to_hex(JSONbig.stringify(toWhiskyWireShape(prototype))); + if (result.get_status() !== "success") { + throw new Error(`serializeTxPrototype error: ${result.get_error()}`); + } + return result.get_data(); +}; diff --git a/packages/mesh-core-csl/test/tx-prototype/serialize.test.ts b/packages/mesh-core-csl/test/tx-prototype/serialize.test.ts new file mode 100644 index 000000000..a980ce561 --- /dev/null +++ b/packages/mesh-core-csl/test/tx-prototype/serialize.test.ts @@ -0,0 +1,111 @@ +import JSONbig from "json-bigint"; + +import type { TransactionPrototype } from "@meshsdk/common"; + +import { serializeTxPrototype } from "../../src/tx-prototype"; + +const TX_HASH = "11".repeat(32); +const ADDRESS = + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9"; + +const minimal = (): TransactionPrototype => ({ + body: { + fee: "170000", + inputs: [{ transaction_id: TX_HASH, index: 0 }], + outputs: [{ address: ADDRESS, amount: { coin: "5000000" } }], + }, + is_valid: true, + witness_set: {}, +}); + +describe("serializeTxPrototype", () => { + it("serializes a minimal prototype to CBOR hex", () => { + const hex = serializeTxPrototype(minimal()); + expect(typeof hex).toEqual("string"); + expect(hex.length).toBeGreaterThan(0); + expect(/^[0-9a-f]+$/i.test(hex)).toBe(true); + }); + + const withDatum = (value: TransactionPrototype["body"]["outputs"][number]["plutus_data"]) => ({ + ...minimal(), + body: { + ...minimal().body, + outputs: [{ address: ADDRESS, amount: { coin: "5000000" }, plutus_data: value }], + }, + }); + + // Documents a BLOCKING whisky bug, not desired behaviour: whisky types PlutusData::Integer and + // Metadatum::Int as Rust i128, and serde_json cannot deserialize i128 without its + // `arbitrary_precision` feature. Verified to fail for value: 0 — it is not a large-value issue, + // and nothing on this side can work around it. If whisky is ever patched, these two tests + // should start failing and must be inverted. + it("REJECTS any manual integer datum — whisky i128/serde_json limitation", () => { + expect(() => + serializeTxPrototype( + withDatum({ type: "DATA", value: { type: "MANUAL", data: { type: "INTEGER", value: 0n } } }), + ), + ).toThrow(/i128 is not supported/); + }); + + it("REJECTS integer transaction metadata — same whisky i128 limitation", () => { + const proto: TransactionPrototype = { + ...minimal(), + auxiliary_data: { metadata: { "674": { type: "INT", value: 1n } }, prefer_alonzo_format: true }, + }; + expect(() => serializeTxPrototype(proto)).toThrow(/i128 is not supported/); + }); + + it("accepts a CBOR-variant datum — the working workaround for the i128 bug", () => { + const hex = serializeTxPrototype(withDatum({ type: "DATA", value: { type: "CBOR", hex: "00" } })); + expect(/^[0-9a-f]+$/i.test(hex)).toBe(true); + }); + + it("accepts non-integer MANUAL arms (BYTES), so only the integer arm is affected", () => { + const hex = serializeTxPrototype( + withDatum({ type: "DATA", value: { type: "MANUAL", data: { type: "BYTES", value: "cafe" } } }), + ); + expect(hex.toLowerCase()).toContain("cafe"); + }); + + // The prototype follows the CDDL's three version-specific plutus-script keys; whisky's wire + // shape has only one version-less `plutus_scripts`. V1-only is mappable; V2/V3 are not, and + // must fail loudly rather than be silently mis-tagged in the output CBOR. + it("maps a V1-only witness set onto whisky's single plutus_scripts field", () => { + const proto: TransactionPrototype = { + ...minimal(), + witness_set: { plutus_v1_scripts: ["4d01000033222220051200120011"] }, + }; + expect(/^[0-9a-f]+$/i.test(serializeTxPrototype(proto))).toBe(true); + }); + + it.each(["plutus_v2_scripts", "plutus_v3_scripts"] as const)( + "refuses to mis-tag %s through whisky's version-less field", + (field) => { + const proto: TransactionPrototype = { + ...minimal(), + witness_set: { [field]: ["4d01000033222220051200120011"] }, + }; + expect(() => serializeTxPrototype(proto)).toThrow(/cannot represent PlutusV2\/V3/); + }, + ); + + it("throws a descriptive error when whisky rejects the payload", () => { + const broken = { + body: { fee: "not-a-number", inputs: [], outputs: [] }, + is_valid: true, + witness_set: {}, + } as unknown as TransactionPrototype; + expect(() => serializeTxPrototype(broken)).toThrow(/serializeTxPrototype error/); + }); +}); + +describe("JSONbig vs JSON (the reason this module exists)", () => { + it("plain JSON.stringify cannot serialize the prototype's bigint fields at all", () => { + expect(() => JSON.stringify({ value: 1n })).toThrow(TypeError); + }); + + it("JSONbig emits bigints as unquoted JSON numbers, preserving full precision", () => { + const out = JSONbig.stringify({ value: 18446744073709551615n }); + expect(out).toEqual('{"value":18446744073709551615}'); + }); +}); diff --git a/packages/mesh-core-cst/src/index.ts b/packages/mesh-core-cst/src/index.ts index 7e1f4e40e..8aa351442 100644 --- a/packages/mesh-core-cst/src/index.ts +++ b/packages/mesh-core-cst/src/index.ts @@ -7,6 +7,8 @@ export * from "./serializer"; export * from "./utils"; export * from "./plutus-tools"; export * from "./offline-providers"; +export * from "./tx-prototype-to-cbor"; +export * from "./tx-prototype-from-cbor"; export * as CardanoSDKUtil from "@cardano-sdk/util"; export * as Crypto from "@cardano-sdk/crypto"; diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/auxiliary-data.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/auxiliary-data.ts new file mode 100644 index 000000000..6a11115db --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/auxiliary-data.ts @@ -0,0 +1,73 @@ +import { Serialization } from "@cardano-sdk/core"; + +import type { + AuxiliaryDataPrototype, + MetadatumPrototype, + TxMetadataPrototype, +} from "@meshsdk/common"; + +import { AuxiliaryData, TransactionMetadatum } from "../types"; +import { nativeScriptToPrototype } from "./native-script"; + +const { TransactionMetadatumKind } = Serialization; + +const metadatumToPrototype = (metadatum: TransactionMetadatum): MetadatumPrototype => { + switch (metadatum.getKind()) { + case TransactionMetadatumKind.Integer: + return { type: "INT", value: metadatum.asInteger()! }; + case TransactionMetadatumKind.Bytes: + return { type: "BYTES", value: [...metadatum.asBytes()!] }; + case TransactionMetadatumKind.Text: + return { type: "STRING", value: metadatum.asText()! }; + case TransactionMetadatumKind.List: { + const list = metadatum.asList()!; + const value: MetadatumPrototype[] = []; + for (let i = 0; i < list.getLength(); i++) value.push(metadatumToPrototype(list.get(i))); + return { type: "LIST", value }; + } + case TransactionMetadatumKind.Map: { + const map = metadatum.asMap()!; + const keys = map.getKeys(); + const value: [MetadatumPrototype, MetadatumPrototype][] = []; + for (let i = 0; i < keys.getLength(); i++) { + const key = keys.get(i); + value.push([metadatumToPrototype(key), metadatumToPrototype(map.get(key)!)]); + } + return { type: "MAP", value }; + } + default: + throw new Error(`Unsupported metadatum kind: ${metadatum.getKind()}`); + } +}; + +/** Inverse of `../tx-prototype-to-cbor/auxiliary-data.ts`. */ +export const auxiliaryDataToPrototype = (aux: AuxiliaryData): AuxiliaryDataPrototype => { + const result: AuxiliaryDataPrototype = { prefer_alonzo_format: true }; + + const metadata = aux.metadata(); + if (metadata) { + const entries = metadata.metadata(); + if (entries && entries.size > 0) { + const out: TxMetadataPrototype = {}; + for (const [label, value] of entries) { + out[label.toString()] = metadatumToPrototype(value); + } + result.metadata = out; + } + } + + const nativeScripts = aux.nativeScripts(); + if (nativeScripts?.length) { + result.native_scripts = nativeScripts.map(nativeScriptToPrototype); + } + + // Auxiliary-data map keys 2 / 3 / 4, one per Plutus language version. + const v1 = aux.plutusV1Scripts(); + if (v1?.length) result.plutus_v1_scripts = v1.map((s) => s.toCbor().toString()); + const v2 = aux.plutusV2Scripts(); + if (v2?.length) result.plutus_v2_scripts = v2.map((s) => s.toCbor().toString()); + const v3 = aux.plutusV3Scripts(); + if (v3?.length) result.plutus_v3_scripts = v3.map((s) => s.toCbor().toString()); + + return result; +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/body.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/body.ts new file mode 100644 index 000000000..a25ac33b7 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/body.ts @@ -0,0 +1,102 @@ +import type { + MintPrototype, + TransactionBodyPrototype, +} from "@meshsdk/common"; + +import { AssetId, TransactionBody } from "../types"; +import { certificateToPrototype } from "./certificates"; +import { proposalProcedureToPrototype, votingProceduresToPrototype } from "./governance"; +import { transactionInputToPrototype, transactionOutputToPrototype } from "./inputs-outputs"; +import { networkIdToPrototype } from "./primitives"; + +/** + * Inverse of `../tx-prototype-to-cbor/body.ts`. + * + * Absent CDDL keys decode to an absent TS field (not `null`), and empty collections are treated + * as absent — mirroring the encoder, which only emits a key when `?.length` is truthy. That makes + * `decode(encode(x))` idempotent on the second pass even where `x` used `null` or `[]` explicitly. + */ +export const transactionBodyToPrototype = (body: TransactionBody): TransactionBodyPrototype => { + const result: TransactionBodyPrototype = { + fee: body.fee().toString(), + inputs: [...body.inputs().values()].map(transactionInputToPrototype), + outputs: body.outputs().map(transactionOutputToPrototype), + }; + + const ttl = body.ttl(); + if (ttl !== undefined) result.ttl = ttl.toString(); + + const validityStart = body.validityStartInterval(); + if (validityStart !== undefined) result.validity_start_interval = validityStart.toString(); + + const certs = body.certs(); + if (certs?.size()) result.certs = [...certs.values()].map(certificateToPrototype); + + const withdrawals = body.withdrawals(); + if (withdrawals?.size) { + const out: Record = {}; + for (const [account, coin] of withdrawals) out[account.toString()] = coin.toString(); + result.withdrawals = out; + } + + const auxDataHash = body.auxiliaryDataHash(); + if (auxDataHash !== undefined) result.auxiliary_data_hash = auxDataHash.toString(); + + const mint = body.mint(); + if (mint?.size) { + const out: MintPrototype = {}; + for (const [assetId, quantity] of mint) { + const policyId = AssetId.getPolicyId(assetId).toString(); + const assetName = AssetId.getAssetName(assetId).toString(); + (out[policyId] ??= {})[assetName] = quantity.toString(); + } + result.mint = out; + } + + const scriptDataHash = body.scriptDataHash(); + if (scriptDataHash !== undefined) result.script_data_hash = scriptDataHash.toString(); + + const collateral = body.collateral(); + if (collateral?.size()) { + result.collateral = [...collateral.values()].map(transactionInputToPrototype); + } + + const requiredSigners = body.requiredSigners(); + if (requiredSigners?.size()) { + result.required_signers = [...requiredSigners.values()].map((s) => s.toCore().toString()); + } + + const networkId = body.networkId(); + if (networkId !== undefined) result.network_id = networkIdToPrototype(Number(networkId)); + + const collateralReturn = body.collateralReturn(); + if (collateralReturn) result.collateral_return = transactionOutputToPrototype(collateralReturn); + + const totalCollateral = body.totalCollateral(); + if (totalCollateral !== undefined) result.total_collateral = totalCollateral.toString(); + + const referenceInputs = body.referenceInputs(); + if (referenceInputs?.size()) { + result.reference_inputs = [...referenceInputs.values()].map(transactionInputToPrototype); + } + + const votingProcedures = body.votingProcedures(); + if (votingProcedures && votingProcedures.getVoters().length > 0) { + result.voting_procedures = votingProceduresToPrototype(votingProcedures); + } + + const proposals = body.proposalProcedures(); + if (proposals?.size()) { + result.voting_proposals = [...proposals.values()].map(proposalProcedureToPrototype); + } + + const currentTreasuryValue = body.currentTreasuryValue(); + if (currentTreasuryValue !== undefined) { + result.current_treasury_value = currentTreasuryValue.toString(); + } + + const donation = body.donation(); + if (donation !== undefined) result.donation = donation.toString(); + + return result; +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/certificates.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/certificates.ts new file mode 100644 index 000000000..1831e949c --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/certificates.ts @@ -0,0 +1,212 @@ +import { Cardano } from "@cardano-sdk/core"; + +import type { CertificatePrototype, PoolParamsPrototype, RelayPrototype } from "@meshsdk/common"; + +import { Certificate } from "../types"; +import { anchorToPrototype, credentialToPrototype, dRepToPrototype, fractionToPrototype } from "./primitives"; + +const relayToPrototype = (relay: Cardano.Relay): RelayPrototype => { + const r = relay as { + __typename: string; + ipv4?: string | null; + ipv6?: string | null; + port?: number | null; + hostname?: string; + dnsName?: string; + }; + switch (r.__typename) { + case "RelayByAddress": + return { + type: "SINGLE_HOST_ADDR", + value: { + ipv4: r.ipv4 ? (r.ipv4.split(".").map(Number) as [number, number, number, number]) : null, + ipv6: r.ipv6 ? (Buffer.from(r.ipv6.replace(/:/g, ""), "hex").toJSON().data as number[] as never) : null, + port: r.port ?? null, + }, + }; + case "RelayByName": + return { type: "SINGLE_HOST_NAME", value: { dns_name: r.hostname!, port: r.port ?? null } }; + case "RelayByNameMultihost": + return { type: "MULTI_HOST_NAME", value: { dns_name: r.dnsName ?? r.hostname! } }; + default: + throw new Error(`Unsupported relay typename: ${r.__typename}`); + } +}; + +/** + * `Cardano.PoolParameters` re-encodes two fields into bech32 that the prototype carries as raw + * hex, so they must be decoded back or the result cannot be re-encoded: + * - `id` is a `PoolId` ("pool1…"), built by `PoolParams.toCore()` via `PoolId.fromKeyHash`; + * the prototype's `operator` is the bare 28-byte key hash. + * - `owners` are `RewardAccount`s ("stake_test1…"), built via `createRewardAccount`; the + * prototype's `pool_owners` are bare key hashes. + * Passing the bech32 forms straight through made a decoded POOL_REGISTRATION un-re-encodable + * ("expected length '56', got 64"), which is now covered by a round-trip test. + */ +const poolParamsToPrototype = (p: Cardano.PoolParameters): PoolParamsPrototype => ({ + operator: Cardano.PoolId.toKeyHash(p.id).toString(), + vrf_keyhash: p.vrf.toString(), + pledge: p.pledge.toString(), + cost: p.cost.toString(), + margin: fractionToPrototype(p.margin), + reward_account: p.rewardAccount.toString(), + pool_owners: p.owners.map((o) => Cardano.RewardAccount.toHash(o).toString()), + relays: p.relays.map(relayToPrototype), + pool_metadata: p.metadataJson + ? { url: p.metadataJson.url, pool_metadata_hash: p.metadataJson.hash.toString() } + : null, +}); + +/** + * Inverse of `../tx-prototype-to-cbor/certificates.ts`, decoding straight from CST's core + * certificate union rather than through Mesh's own `CertificateType`. That makes this direction + * strictly less lossy than the encoder: the encoder must round-trip raw credentials through + * bech32 reward addresses to reuse `toCardanoCert`, whereas here the raw credential is available + * directly. + * + * Round-trip caveat: CDDL certs 0/1 (no deposit) and 7/8 (with deposit) are distinct, and Mesh's + * `CertificateType` has no deposit-carrying registration variant, so the encoder always emits the + * no-deposit form. A `STAKE_REGISTRATION` carrying a `coin` therefore comes back with `coin: null`. + */ +export const certificateToPrototype = (cert: Certificate): CertificatePrototype => { + const core = cert.toCore(); + switch (core.__typename) { + case Cardano.CertificateType.StakeRegistration: + return { + type: "STAKE_REGISTRATION", + value: { stake_credential: credentialToPrototype(core.stakeCredential), coin: null }, + }; + case Cardano.CertificateType.StakeDeregistration: + return { + type: "STAKE_DEREGISTRATION", + value: { stake_credential: credentialToPrototype(core.stakeCredential), coin: null }, + }; + case Cardano.CertificateType.Registration: + return { + type: "STAKE_REGISTRATION", + value: { + stake_credential: credentialToPrototype(core.stakeCredential), + coin: core.deposit.toString(), + }, + }; + case Cardano.CertificateType.Unregistration: + return { + type: "STAKE_DEREGISTRATION", + value: { + stake_credential: credentialToPrototype(core.stakeCredential), + coin: core.deposit.toString(), + }, + }; + case Cardano.CertificateType.StakeDelegation: + return { + type: "STAKE_DELEGATION", + value: { + stake_credential: credentialToPrototype(core.stakeCredential), + pool_keyhash: Cardano.PoolId.toKeyHash(core.poolId).toString(), + }, + }; + case Cardano.CertificateType.PoolRegistration: + return { + type: "POOL_REGISTRATION", + value: { pool_params: poolParamsToPrototype(core.poolParameters) }, + }; + case Cardano.CertificateType.PoolRetirement: + return { + type: "POOL_RETIREMENT", + value: { + pool_keyhash: Cardano.PoolId.toKeyHash(core.poolId).toString(), + epoch: BigInt(core.epoch), + }, + }; + case Cardano.CertificateType.VoteDelegation: + return { + type: "VOTE_DELEGATION", + value: { + stake_credential: credentialToPrototype(core.stakeCredential), + drep: dRepToPrototype(core.dRep), + }, + }; + case Cardano.CertificateType.StakeVoteDelegation: + return { + type: "STAKE_AND_VOTE_DELEGATION", + value: { + stake_credential: credentialToPrototype(core.stakeCredential), + pool_keyhash: Cardano.PoolId.toKeyHash(core.poolId).toString(), + drep: dRepToPrototype(core.dRep), + }, + }; + case Cardano.CertificateType.StakeRegistrationDelegation: + return { + type: "STAKE_REGISTRATION_AND_DELEGATION", + value: { + stake_credential: credentialToPrototype(core.stakeCredential), + pool_keyhash: Cardano.PoolId.toKeyHash(core.poolId).toString(), + coin: core.deposit.toString(), + }, + }; + case Cardano.CertificateType.VoteRegistrationDelegation: + return { + type: "VOTE_REGISTRATION_AND_DELEGATION", + value: { + stake_credential: credentialToPrototype(core.stakeCredential), + drep: dRepToPrototype(core.dRep), + coin: core.deposit.toString(), + }, + }; + case Cardano.CertificateType.StakeVoteRegistrationDelegation: + return { + type: "STAKE_VOTE_REGISTRATION_AND_DELEGATION", + value: { + stake_credential: credentialToPrototype(core.stakeCredential), + pool_keyhash: Cardano.PoolId.toKeyHash(core.poolId).toString(), + drep: dRepToPrototype(core.dRep), + coin: core.deposit.toString(), + }, + }; + case Cardano.CertificateType.AuthorizeCommitteeHot: + return { + type: "COMMITTEE_HOT_AUTH", + value: { + committee_cold_credential: credentialToPrototype(core.coldCredential), + committee_hot_credential: credentialToPrototype(core.hotCredential), + }, + }; + case Cardano.CertificateType.ResignCommitteeCold: + return { + type: "COMMITTEE_COLD_RESIGN", + value: { + committee_cold_credential: credentialToPrototype(core.coldCredential), + anchor: core.anchor ? anchorToPrototype(core.anchor) : null, + }, + }; + case Cardano.CertificateType.RegisterDelegateRepresentative: + return { + type: "DREP_REGISTRATION", + value: { + voting_credential: credentialToPrototype(core.dRepCredential), + coin: core.deposit.toString(), + anchor: core.anchor ? anchorToPrototype(core.anchor) : null, + }, + }; + case Cardano.CertificateType.UnregisterDelegateRepresentative: + return { + type: "DREP_DEREGISTRATION", + value: { + voting_credential: credentialToPrototype(core.dRepCredential), + coin: core.deposit.toString(), + }, + }; + case Cardano.CertificateType.UpdateDelegateRepresentative: + return { + type: "DREP_UPDATE", + value: { + voting_credential: credentialToPrototype(core.dRepCredential), + anchor: core.anchor ? anchorToPrototype(core.anchor) : null, + }, + }; + default: + throw new Error( + `Certificate type ${core.__typename} has no TransactionPrototype equivalent (pre-Conway, unsupported)`, + ); + } +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/governance.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/governance.ts new file mode 100644 index 000000000..42e00a3bb --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/governance.ts @@ -0,0 +1,161 @@ +import { Cardano, Serialization } from "@cardano-sdk/core"; + +import type { + GovernanceActionIdPrototype, + GovernanceActionPrototype, + VoterPrototype, + VoterVotesPrototype, + VotingProcedurePrototype, + VotingProposalPrototype, +} from "@meshsdk/common"; + +import { anchorToPrototype, credentialToPrototype, fractionToPrototype } from "./primitives"; + +const { VoterKind } = Serialization; + +const govActionIdToPrototype = ( + id: Cardano.GovernanceActionId, +): GovernanceActionIdPrototype => ({ + transaction_id: id.id.toString(), + index: Number(id.actionIndex), +}); + +export const voterToPrototype = (voter: Serialization.Voter): VoterPrototype => { + switch (voter.kind()) { + case VoterKind.ConstitutionalCommitteeKeyHash: + case VoterKind.ConstitutionalCommitteeScriptHash: + return { + type: "CONSTITUTIONAL_COMMITTEE_HOT_CRED", + value: credentialToPrototype(voter.toConstitutionalCommitteeHotCred()!), + }; + // Note the inconsistent casing in CST's own enum: `DrepKeyHash` but `DRepScriptHash`. + case VoterKind.DrepKeyHash: + case VoterKind.DRepScriptHash: + return { type: "DREP", value: credentialToPrototype(voter.toDrepCred()!) }; + case VoterKind.StakePoolKeyHash: + return { type: "STAKING_POOL", value: voter.toStakingPoolKeyHash()!.toString() }; + default: + throw new Error(`Unsupported voter kind: ${voter.kind()}`); + } +}; + +const votingProcedureToPrototype = ( + procedure: Serialization.VotingProcedure, +): VotingProcedurePrototype => { + const vote = procedure.vote(); + const anchor = procedure.anchor(); + return { + // Cardano.Vote: 0 = No, 1 = Yes, 2 = Abstain (see ../utils/vote.ts's toCardanoVoteKind). + vote: vote === 1 ? { type: "YES" } : vote === 0 ? { type: "NO" } : { type: "ABSTAIN" }, + anchor: anchor ? anchorToPrototype(anchor.toCore()) : null, + }; +}; + +export const votingProceduresToPrototype = ( + procedures: Serialization.VotingProcedures, +): VoterVotesPrototype[] => + procedures.getVoters().map((voter) => ({ + voter: voterToPrototype(voter), + votes: procedures.getGovernanceActionIdsByVoter(voter).map((actionId) => ({ + action_id: govActionIdToPrototype(actionId.toCore()), + voting_procedure: votingProcedureToPrototype(procedures.get(voter, actionId)!), + })), + })); + +/** + * Only the governance actions the encoder can produce are decoded. `PARAMETER_CHANGE_ACTION` is + * deliberately NOT implemented: `ProtocolParamUpdatePrototype` has ~34 fields whose CST core + * counterparts use different names, units (fractions vs decimal strings) and optionality, so a + * half-correct inverse would be worse than an explicit gap. Decode such a transaction with + * `transactionPrototypeFromCardano` and it throws rather than silently dropping the update. + */ +const govActionToPrototype = (action: Cardano.GovernanceAction): GovernanceActionPrototype => { + switch (action.__typename) { + case Cardano.GovernanceActionType.info_action: + return { type: "INFO_ACTION" }; + case Cardano.GovernanceActionType.no_confidence: + return { + type: "NO_CONFIDENCE_ACTION", + value: { + gov_action_id: action.governanceActionId + ? govActionIdToPrototype(action.governanceActionId) + : null, + }, + }; + case Cardano.GovernanceActionType.hard_fork_initiation_action: + return { + type: "HARD_FORK_INITIATION_ACTION", + value: { + gov_action_id: action.governanceActionId + ? govActionIdToPrototype(action.governanceActionId) + : null, + protocol_version: { + major: Number(action.protocolVersion.major), + minor: Number(action.protocolVersion.minor), + }, + }, + }; + case Cardano.GovernanceActionType.treasury_withdrawals_action: { + const withdrawals: Record = {}; + for (const w of action.withdrawals) { + withdrawals[w.rewardAccount.toString()] = w.coin.toString(); + } + return { + type: "TREASURY_WITHDRAWALS_ACTION", + value: { + withdrawals, + policy_hash: action.policyHash ? action.policyHash.toString() : null, + }, + }; + } + case Cardano.GovernanceActionType.new_constitution: + return { + type: "NEW_CONSTITUTION_ACTION", + value: { + gov_action_id: action.governanceActionId + ? govActionIdToPrototype(action.governanceActionId) + : null, + constitution: { + anchor: anchorToPrototype(action.constitution.anchor), + script_hash: action.constitution.scriptHash + ? action.constitution.scriptHash.toString() + : null, + }, + }, + }; + case Cardano.GovernanceActionType.update_committee: + return { + type: "UPDATE_COMMITTEE_ACTION", + value: { + gov_action_id: action.governanceActionId + ? govActionIdToPrototype(action.governanceActionId) + : null, + committee: { + members: [...action.membersToBeAdded].map((m) => ({ + stake_credential: credentialToPrototype(m.coldCredential), + term_limit: BigInt(m.epoch), + })), + quorum_threshold: fractionToPrototype(action.newQuorumThreshold), + }, + members_to_remove: [...action.membersToBeRemoved].map(credentialToPrototype), + }, + }; + default: + throw new Error( + `Governance action ${(action as { __typename: string }).__typename} is not supported by ` + + `the TransactionPrototype decoder (see the note on PARAMETER_CHANGE_ACTION)`, + ); + } +}; + +export const proposalProcedureToPrototype = ( + proposal: Serialization.ProposalProcedure, +): VotingProposalPrototype => { + const core = proposal.toCore(); + return { + deposit: core.deposit.toString(), + reward_account: core.rewardAccount.toString(), + governance_action: govActionToPrototype(core.governanceAction), + anchor: anchorToPrototype(core.anchor), + }; +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/index.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/index.ts new file mode 100644 index 000000000..dfb44e7d7 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/index.ts @@ -0,0 +1,4 @@ +export { + transactionPrototypeFromCardano, + transactionPrototypeFromHex, +} from "./transaction"; diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/inputs-outputs.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/inputs-outputs.ts new file mode 100644 index 000000000..6fde0ecd0 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/inputs-outputs.ts @@ -0,0 +1,63 @@ +import type { + MultiAssetPrototype, + TransactionInputPrototype, + TransactionOutputPrototype, + ValuePrototype, +} from "@meshsdk/common"; + +import { AssetId, TransactionInput, TransactionOutput, Value } from "../types"; +import { plutusDataToPrototype } from "./plutus-data"; + +export const transactionInputToPrototype = ( + input: TransactionInput, +): TransactionInputPrototype => ({ + transaction_id: input.transactionId().toString(), + index: Number(input.index()), +}); + +/** Inverse of `multiAssetPrototypeToAssets` — splits each `AssetId` (policyId ++ assetNameHex) + * back into the prototype's two-level `{ policyId: { assetNameHex: quantity } }` map. */ +export const valueToPrototype = (value: Value): ValuePrototype => { + const multiasset = value.multiasset(); + if (!multiasset || multiasset.size === 0) { + return { coin: value.coin().toString() }; + } + + const out: MultiAssetPrototype = {}; + for (const [assetId, quantity] of multiasset) { + const policyId = AssetId.getPolicyId(assetId).toString(); + const assetName = AssetId.getAssetName(assetId).toString(); + (out[policyId] ??= {})[assetName] = quantity.toString(); + } + return { coin: value.coin().toString(), multiasset: out }; +}; + +export const transactionOutputToPrototype = ( + output: TransactionOutput, +): TransactionOutputPrototype => { + const result: TransactionOutputPrototype = { + address: output.address().toBech32().toString(), + amount: valueToPrototype(output.amount()), + }; + + const datum = output.datum(); + if (datum) { + const dataHash = datum.asDataHash(); + const inline = datum.asInlineData(); + if (dataHash) { + result.plutus_data = { type: "DATA_HASH", value: dataHash.toString() }; + } else if (inline) { + result.plutus_data = { + type: "DATA", + value: { type: "MANUAL", data: plutusDataToPrototype(inline) }, + }; + } + } + + const scriptRef = output.scriptRef(); + if (scriptRef) { + result.script_ref = scriptRef.toCbor().toString(); + } + + return result; +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/native-script.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/native-script.ts new file mode 100644 index 000000000..f782b1ac1 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/native-script.ts @@ -0,0 +1,54 @@ +import type { NativeScriptPrototype } from "@meshsdk/common"; + +import { + NativeScript, + RequireAllOf, + RequireAnyOf, + RequireNOf, + RequireSignature, + RequireTimeAfter, + RequireTimeBefore, +} from "../types"; + +/** Inverse of `../tx-prototype-to-cbor/native-script.ts`. */ +export const nativeScriptToPrototype = (script: NativeScript): NativeScriptPrototype => { + switch (script.kind()) { + case RequireSignature: + return { + type: "SCRIPT_PUBKEY", + value: { addr_keyhash: script.asScriptPubkey()!.keyHash().toString() }, + }; + case RequireAllOf: + return { + type: "SCRIPT_ALL", + value: { native_scripts: script.asScriptAll()!.nativeScripts().map(nativeScriptToPrototype) }, + }; + case RequireAnyOf: + return { + type: "SCRIPT_ANY", + value: { native_scripts: script.asScriptAny()!.nativeScripts().map(nativeScriptToPrototype) }, + }; + case RequireNOf: { + const nOfK = script.asScriptNOfK()!; + return { + type: "SCRIPT_N_OF_K", + value: { + // Widened back to the CDDL's `int64` domain. The encoder narrowed to `number` at the + // Mesh-type boundary, so a value above 2^53 does not survive a full round trip — see + // the narrowing comment in ../tx-prototype-to-cbor/native-script.ts. + n: BigInt(nOfK.required()), + native_scripts: nOfK.nativeScripts().map(nativeScriptToPrototype), + }, + }; + } + case RequireTimeAfter: + return { type: "TIMELOCK_START", value: { slot: script.asTimelockStart()!.slot().toString() } }; + case RequireTimeBefore: + return { + type: "TIMELOCK_EXPIRY", + value: { slot: script.asTimelockExpiry()!.slot().toString() }, + }; + default: + throw new Error(`Unsupported native script kind: ${script.kind()}`); + } +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/plutus-data.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/plutus-data.ts new file mode 100644 index 000000000..6f8ec31fc --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/plutus-data.ts @@ -0,0 +1,52 @@ +import type { PlutusDataPrototype, PlutusDataVariant } from "@meshsdk/common"; + +import { PlutusData, PlutusDataKind } from "../types"; + +/** Inverse of `../tx-prototype-to-cbor/plutus-data.ts`. Walks CST's `PlutusData` tree back into + * the ledger-level tagged prototype shape. */ +export const plutusDataToPrototype = (data: PlutusData): PlutusDataPrototype => { + switch (data.getKind()) { + case PlutusDataKind.Integer: + return { type: "INTEGER", value: data.asInteger()! }; + case PlutusDataKind.Bytes: + return { type: "BYTES", value: Buffer.from(data.asBoundedBytes()!).toString("hex") }; + case PlutusDataKind.List: { + const list = data.asList()!; + const value: PlutusDataPrototype[] = []; + for (let i = 0; i < list.getLength(); i++) value.push(plutusDataToPrototype(list.get(i))); + return { type: "LIST", value }; + } + case PlutusDataKind.Map: { + const map = data.asMap()!; + const keys = map.getKeys(); + const value: [PlutusDataPrototype, PlutusDataPrototype][] = []; + for (let i = 0; i < keys.getLength(); i++) { + const key = keys.get(i); + value.push([plutusDataToPrototype(key), plutusDataToPrototype(map.get(key)!)]); + } + return { type: "MAP", value }; + } + case PlutusDataKind.ConstrPlutusData: { + const constr = data.asConstrPlutusData()!; + const fields = constr.getData(); + const out: PlutusDataPrototype[] = []; + for (let i = 0; i < fields.getLength(); i++) out.push(plutusDataToPrototype(fields.get(i))); + return { type: "CONSTR", alternative: constr.getAlternative(), fields: out }; + } + } +}; + +/** + * Decodes to the `MANUAL` arm by default, which is the round-trippable choice: `CBOR` would also + * be valid but would collapse every datum to an opaque hex string, so a `MANUAL` input would not + * survive an encode/decode cycle. Callers who want the opaque form can use `plutusDataToCborVariant`. + */ +export const plutusDataToVariant = (data: PlutusData): PlutusDataVariant => ({ + type: "MANUAL", + data: plutusDataToPrototype(data), +}); + +export const plutusDataToCborVariant = (data: PlutusData): PlutusDataVariant => ({ + type: "CBOR", + hex: data.toCbor(), +}); diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/primitives.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/primitives.ts new file mode 100644 index 000000000..33668b7be --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/primitives.ts @@ -0,0 +1,69 @@ +import { Serialization } from "@cardano-sdk/core"; + +import type { + AnchorPrototype, + CredTypePrototype, + DRepPrototype, + NetworkIdPrototype, + UnitIntervalPrototype, +} from "@meshsdk/common"; + +import { CredentialType } from "../types"; + +/** `Cardano.Credential` is `{ type: CredentialType, hash }` — the prototype's raw-hash form. */ +export const credentialToPrototype = (cred: { + type: CredentialType; + hash: string; +}): CredTypePrototype => + cred.type === CredentialType.ScriptHash + ? { type: "SCRIPT", value: cred.hash.toString() } + : { type: "KEY", value: cred.hash.toString() }; + +export const anchorToPrototype = (anchor: { + url: string; + dataHash: string; +}): AnchorPrototype => ({ + anchor_url: anchor.url, + anchor_data_hash: anchor.dataHash.toString(), +}); + +export const networkIdToPrototype = (networkId: number): NetworkIdPrototype => + networkId === 1 ? { type: "MAINNET" } : { type: "TESTNET" }; + +/** + * `Cardano.DelegateRepresentative` is `Credential | AlwaysAbstain | AlwaysNoConfidence`; the + * always-* arms are objects with boolean marker fields rather than a discriminant, so they are + * detected by property presence. + */ +export const dRepToPrototype = (drep: unknown): DRepPrototype => { + const d = drep as { + __typename?: string; + type?: CredentialType; + hash?: string; + }; + if (d.hash !== undefined && d.type !== undefined) { + return d.type === CredentialType.ScriptHash + ? { type: "SCRIPT_HASH", value: d.hash.toString() } + : { type: "KEY_HASH", value: d.hash.toString() }; + } + if (d.__typename === "AlwaysAbstain") return { type: "ALWAYS_ABSTAIN" }; + if (d.__typename === "AlwaysNoConfidence") return { type: "ALWAYS_NO_CONFIDENCE" }; + throw new Error(`Unrecognised DRep shape: ${JSON.stringify(drep)}`); +}; + +/** CST models unit intervals as `Cardano.Fraction` (`{ numerator, denominator }` numbers) in + * core form, but the prototype carries them as decimal strings. */ +export const fractionToPrototype = (f: { + numerator: number | bigint; + denominator: number | bigint; +}): UnitIntervalPrototype => ({ + numerator: f.numerator.toString(), + denominator: f.denominator.toString(), +}); + +export const unitIntervalToPrototype = ( + interval: Serialization.UnitInterval, +): UnitIntervalPrototype => ({ + numerator: interval.numerator().toString(), + denominator: interval.denominator().toString(), +}); diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/transaction.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/transaction.ts new file mode 100644 index 000000000..10d1ba790 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/transaction.ts @@ -0,0 +1,45 @@ +import { HexBlob } from "@cardano-sdk/util"; + +import type { TransactionPrototype } from "@meshsdk/common"; + +import { Transaction } from "../types"; +import { auxiliaryDataToPrototype } from "./auxiliary-data"; +import { transactionBodyToPrototype } from "./body"; +import { transactionWitnessSetToPrototype } from "./witness-set"; + +/** + * Decodes a `@cardano-sdk/core` `Transaction` back into a `TransactionPrototype` — the inverse of + * `../tx-prototype-to-cbor/transaction.ts`. + * + * Its main job is enabling round-trip verification of the encoder (`proto -> CBOR -> proto`), + * which pins far more than field-by-field assertions can. Known asymmetries, all documented at + * their source and covered by tests: + * + * - **Set encoding is not represented.** Whether the CBOR used `#6.258`-tagged sets or plain + * arrays is a serialization choice (`transactionPrototypeToHex`'s `taggedSets`), not prototype + * state, so it is lost on decode. Both forms decode to the same prototype. + * - **`null` vs absent is normalised.** Absent keys and empty collections both decode to an absent + * field, so a prototype written with explicit `null`/`[]` is not byte-identical after one round + * trip, but is stable from the second onwards. + * - **Values above 2^53 in `ScriptNOfKPrototype.n`, `PoolRetirementPrototype.epoch` and + * `CommitteeMemberPrototype.term_limit` do not survive**, because the *encoder* narrows them + * with `Number(...)` at the Mesh-type boundary. The decoder widens back to `bigint`. + * - **Datums decode to the `MANUAL` arm**, never `CBOR`; a `CBOR`-variant input therefore comes + * back structurally expanded (semantically identical, not textually). + * - **Stake registration deposits**: CDDL certs 0/1 (no deposit) and 7/8 (with deposit) are + * distinct, and the encoder can only emit the former, so a `coin` set on a `STAKE_REGISTRATION` + * is dropped in the encode direction. + */ +export const transactionPrototypeFromCardano = (tx: Transaction): TransactionPrototype => { + const auxiliaryData = tx.auxiliaryData(); + return { + body: transactionBodyToPrototype(tx.body()), + witness_set: transactionWitnessSetToPrototype(tx.witnessSet()), + is_valid: tx.isValid(), + ...(auxiliaryData ? { auxiliary_data: auxiliaryDataToPrototype(auxiliaryData) } : {}), + }; +}; + +/** Decodes transaction CBOR hex straight into a `TransactionPrototype`. */ +export const transactionPrototypeFromHex = (hex: string): TransactionPrototype => + transactionPrototypeFromCardano(Transaction.fromCbor(HexBlob(hex) as never)); diff --git a/packages/mesh-core-cst/src/tx-prototype-from-cbor/witness-set.ts b/packages/mesh-core-cst/src/tx-prototype-from-cbor/witness-set.ts new file mode 100644 index 000000000..8ec94cafd --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-from-cbor/witness-set.ts @@ -0,0 +1,86 @@ +import type { + RedeemerPrototype, + RedeemerTagPrototype, + TransactionWitnessSetPrototype, +} from "@meshsdk/common"; + +import { Redeemer, RedeemerTag, TransactionWitnessSet } from "../types"; +import { nativeScriptToPrototype } from "./native-script"; +import { plutusDataToPrototype } from "./plutus-data"; + +const TAGS: Record = { + [RedeemerTag.Spend]: "SPEND", + [RedeemerTag.Mint]: "MINT", + [RedeemerTag.Cert]: "CERT", + [RedeemerTag.Reward]: "REWARD", + [RedeemerTag.Voting]: "VOTE", + [RedeemerTag.Proposing]: "VOTING_PROPOSAL", +}; + +const redeemerToPrototype = (redeemer: Redeemer): RedeemerPrototype => { + const tag = TAGS[redeemer.tag()]; + if (!tag) throw new Error(`Unsupported redeemer tag: ${redeemer.tag()}`); + return { + tag: { type: tag }, + index: redeemer.index().toString(), + data: { type: "MANUAL", data: plutusDataToPrototype(redeemer.data()) }, + ex_units: { + mem: redeemer.exUnits().mem().toString(), + steps: redeemer.exUnits().steps().toString(), + }, + }; +}; + +/** Inverse of `../tx-prototype-to-cbor/witness-set.ts`. Empty CST collections decode to an absent + * field rather than an empty array, matching how the encoder treats `?.length` as key-absent. */ +export const transactionWitnessSetToPrototype = ( + ws: TransactionWitnessSet, +): TransactionWitnessSetPrototype => { + const result: TransactionWitnessSetPrototype = {}; + + const vkeys = ws.vkeys(); + if (vkeys?.size()) { + result.vkeys = [...vkeys.values()].map((v) => ({ + vkey: v.vkey().toString(), + signature: v.signature().toString(), + })); + } + + const nativeScripts = ws.nativeScripts(); + if (nativeScripts?.size()) { + result.native_scripts = [...nativeScripts.values()].map(nativeScriptToPrototype); + } + + const bootstraps = ws.bootstraps(); + if (bootstraps?.size()) { + result.bootstraps = [...bootstraps.values()].map((b) => ({ + vkey: b.vkey().toString(), + signature: b.signature().toString(), + chain_code: [...Buffer.from(b.chainCode().toString(), "hex")], + attributes: [...Buffer.from(b.attributes().toString(), "hex")], + })); + } + + // CDDL keys 3 / 6 / 7 — kept separate, since the language version is not recoverable from the + // script bytes and only the key records it. + const v1 = ws.plutusV1Scripts(); + if (v1?.size()) result.plutus_v1_scripts = [...v1.values()].map((s) => s.toCbor().toString()); + const v2 = ws.plutusV2Scripts(); + if (v2?.size()) result.plutus_v2_scripts = [...v2.values()].map((s) => s.toCbor().toString()); + const v3 = ws.plutusV3Scripts(); + if (v3?.size()) result.plutus_v3_scripts = [...v3.values()].map((s) => s.toCbor().toString()); + + const plutusData = ws.plutusData(); + if (plutusData?.size()) { + result.plutus_data = { + elems: [...plutusData.values()].map((d) => d.toCbor().toString()), + }; + } + + const redeemers = ws.redeemers(); + if (redeemers?.size()) { + result.redeemers = [...redeemers.values()].map(redeemerToPrototype); + } + + return result; +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/auxiliary-data.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/auxiliary-data.ts new file mode 100644 index 000000000..3caed6133 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/auxiliary-data.ts @@ -0,0 +1,88 @@ +import { Serialization } from "@cardano-sdk/core"; +import { HexBlob } from "@cardano-sdk/util"; + +import type { + AuxiliaryDataPrototype, + Metadatum, + MetadatumPrototype, + TxMetadata, +} from "@meshsdk/common"; + +import { + AuxilliaryData, + PlutusV1Script, + PlutusV2Script, + PlutusV3Script, + type AuxiliaryData, +} from "../types"; +import { toCardanoMetadataMap } from "../utils/metadata"; +import { nativeScriptPrototypeToCardano } from "./native-script"; + +const metadatumPrototypeToMesh = (m: MetadatumPrototype): Metadatum => { + switch (m.type) { + case "INT": + return BigInt(m.value); + case "BYTES": + return Uint8Array.from(m.value); + case "STRING": + return m.value; + case "LIST": + return m.value.map(metadatumPrototypeToMesh); + case "MAP": { + const map = new Map(); + m.value.forEach(([k, v]) => map.set(metadatumPrototypeToMesh(k), metadatumPrototypeToMesh(v))); + return map; + } + } +}; + +const txMetadataPrototypeToMesh = (metadata: Record): TxMetadata => { + const result: TxMetadata = new Map(); + for (const [label, value] of Object.entries(metadata)) { + result.set(BigInt(label), metadatumPrototypeToMesh(value)); + } + return result; +}; + +export const auxiliaryDataPrototypeToCardano = ( + proto: AuxiliaryDataPrototype, +): AuxiliaryData => { + // "AuxilliaryData" (extra "l") is `../types`'s own re-export name for the CST value/constructor + // (`Serialization.AuxiliaryData`); the correctly-spelled `AuxiliaryData` is that module's + // type-only export for the same class — not a typo introduced here. + const result = new AuxilliaryData(); + + if (proto.metadata) { + result.setMetadata( + new Serialization.GeneralTransactionMetadata( + toCardanoMetadataMap(txMetadataPrototypeToMesh(proto.metadata)), + ), + ); + } + + if (proto.native_scripts?.length) { + result.setNativeScripts(proto.native_scripts.map(nativeScriptPrototypeToCardano)); + } + + // CDDL `auxiliary_data_map` keys 2 / 3 / 4, one per Plutus language version — same 1:1 mapping + // as the witness set, no version guessing. + if (proto.plutus_v1_scripts?.length) { + result.setPlutusV1Scripts( + proto.plutus_v1_scripts.map((cbor) => PlutusV1Script.fromCbor(HexBlob(cbor))), + ); + } + + if (proto.plutus_v2_scripts?.length) { + result.setPlutusV2Scripts( + proto.plutus_v2_scripts.map((cbor) => PlutusV2Script.fromCbor(HexBlob(cbor))), + ); + } + + if (proto.plutus_v3_scripts?.length) { + result.setPlutusV3Scripts( + proto.plutus_v3_scripts.map((cbor) => PlutusV3Script.fromCbor(HexBlob(cbor))), + ); + } + + return result; +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/body.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/body.ts new file mode 100644 index 000000000..39b0c9ea1 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/body.ts @@ -0,0 +1,152 @@ +import { Serialization } from "@cardano-sdk/core"; + +import type { TransactionBodyPrototype } from "@meshsdk/common"; + +import { + AssetId, + AssetName, + CborSet, + Ed25519KeyHashHex, + Hash32ByteBase16, + PolicyId, + RewardAccount, + Slot, + TokenMap, + TransactionBody, + TransactionInput, +} from "../types"; +import { certificatePrototypeToCardano } from "./certificates"; +import { votingProceduresPrototypeToCardano, votingProposalPrototypeToCardano } from "./governance"; +import { transactionInputPrototypeToCardano, transactionOutputPrototypeToCardano } from "./inputs-outputs"; +import { networkIdToNumber } from "./primitives"; + +/** + * Converts the ledger-level fields of a `TransactionBodyPrototype` (inputs/outputs/certs/ + * withdrawals/mint/votes/proposals — everything except the witness set and auxiliary data, + * handled by `witness-set.ts`/`auxiliary-data.ts`) into a CST `TransactionBody`. + * + * Does not (re)compute `script_data_hash` or `auxiliary_data_hash` the way the existing + * `MeshTxBuilder` serializer does — a `TransactionPrototype` is the already-fully-decided final + * transaction, so if those hashes are present they're set verbatim; there is nothing left to + * derive them from at this layer. + * + * Not converted: `update` (genesis-key-signed protocol parameter update proposals) — a pre-Conway + * mechanism effectively superseded by governance actions and not expected in real usage; left + * unimplemented rather than guessed at. + */ +export const transactionBodyPrototypeToCardano = (body: TransactionBodyPrototype): TransactionBody => { + const networkId = networkIdToNumber(body.network_id); + + const inputs = CborSet.fromCore( + body.inputs.map((i) => transactionInputPrototypeToCardano(i).toCore()), + TransactionInput.fromCore, + ); + const outputs = body.outputs.map(transactionOutputPrototypeToCardano); + + const result = new TransactionBody(inputs, outputs, BigInt(body.fee)); + + if (body.certs?.length) { + result.setCerts( + CborSet.fromCore( + body.certs.map((c) => certificatePrototypeToCardano(c, networkId).toCore()), + Serialization.Certificate.fromCore, + ), + ); + } + + if (body.collateral?.length) { + result.setCollateral( + CborSet.fromCore( + body.collateral.map((i) => transactionInputPrototypeToCardano(i).toCore()), + TransactionInput.fromCore, + ), + ); + } + + if (body.collateral_return) { + result.setCollateralReturn(transactionOutputPrototypeToCardano(body.collateral_return)); + } + + if (body.current_treasury_value != null) { + result.setCurrentTreasuryValue(BigInt(body.current_treasury_value)); + } + + if (body.donation != null) { + result.setDonation(BigInt(body.donation)); + } + + if (body.mint) { + const mint: TokenMap = new Map(); + for (const [policyId, tokens] of Object.entries(body.mint)) { + for (const [assetNameHex, quantity] of Object.entries(tokens)) { + mint.set(AssetId.fromParts(PolicyId(policyId), AssetName(assetNameHex)), BigInt(quantity)); + } + } + result.setMint(mint); + } + + if (body.network_id) { + result.setNetworkId(networkId); + } + + if (body.reference_inputs?.length) { + result.setReferenceInputs( + CborSet.fromCore( + body.reference_inputs.map((i) => transactionInputPrototypeToCardano(i).toCore()), + TransactionInput.fromCore, + ), + ); + } + + if (body.required_signers?.length) { + result.setRequiredSigners( + CborSet.fromCore( + body.required_signers.map((s) => Ed25519KeyHashHex(s)), + Serialization.Hash.fromCore, + ), + ); + } + + if (body.script_data_hash) { + result.setScriptDataHash(Hash32ByteBase16(body.script_data_hash)); + } + + if (body.total_collateral != null) { + result.setTotalCollateral(BigInt(body.total_collateral)); + } + + if (body.ttl != null) { + result.setTtl(Slot(Number(body.ttl))); + } + + if (body.validity_start_interval != null) { + result.setValidityStartInterval(Slot(Number(body.validity_start_interval))); + } + + if (body.voting_procedures?.length) { + result.setVotingProcedures(votingProceduresPrototypeToCardano(body.voting_procedures)); + } + + if (body.voting_proposals?.length) { + result.setProposalProcedures( + CborSet.fromCore( + body.voting_proposals.map((p) => votingProposalPrototypeToCardano(p).toCore()), + Serialization.ProposalProcedure.fromCore, + ), + ); + } + + if (body.withdrawals) { + const withdrawals = new Map(); + for (const [address, amount] of Object.entries(body.withdrawals)) { + withdrawals.set(RewardAccount(address), BigInt(amount)); + } + result.setWithdrawals(withdrawals); + } + + if (body.auxiliary_data_hash) { + result.setAuxiliaryDataHash(Hash32ByteBase16(body.auxiliary_data_hash)); + } + + return result; +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/certificates.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/certificates.ts new file mode 100644 index 000000000..e6120c55a --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/certificates.ts @@ -0,0 +1,197 @@ +import type { + Anchor, + AnchorPrototype, + CertificatePrototype, + CertificateType, + CredTypePrototype, + PoolParams, + PoolParamsPrototype, + Relay, + RelayPrototype, +} from "@meshsdk/common"; + +import { Certificate as CardanoCert } from "../types"; +import { toCardanoCert } from "../utils/certificate"; +import { + credentialPrototypeToDRepIdBech32, + credentialPrototypeToRewardAddressBech32, + dRepPrototypeToMeshDRep, +} from "./primitives"; + +const anchorPrototypeToMesh = (anchor: AnchorPrototype): Anchor => ({ + anchorUrl: anchor.anchor_url, + anchorDataHash: anchor.anchor_data_hash, +}); + +/** IPv4/IPv6 are 4/16-byte tuples in the prototype; CST/Mesh relays want them as strings. */ +const ipv4ToString = (ip: [number, number, number, number]): string => ip.join("."); +const ipv6ToString = (ip: number[]): string => + Buffer.from(ip).toString("hex").replace(/(.{4})(?=.)/g, "$1:"); + +const relayPrototypeToMesh = (relay: RelayPrototype): Relay => { + switch (relay.type) { + case "SINGLE_HOST_ADDR": + return { + type: "SingleHostAddr", + IPV4: relay.value.ipv4 ? ipv4ToString(relay.value.ipv4) : undefined, + IPV6: relay.value.ipv6 ? ipv6ToString(relay.value.ipv6) : undefined, + port: relay.value.port ?? undefined, + }; + case "SINGLE_HOST_NAME": + return { + type: "SingleHostName", + domainName: relay.value.dns_name, + port: relay.value.port ?? undefined, + }; + case "MULTI_HOST_NAME": + return { type: "MultiHostName", domainName: relay.value.dns_name }; + } +}; + +const poolParamsPrototypeToMesh = (pool: PoolParamsPrototype): PoolParams => ({ + vrfKeyHash: pool.vrf_keyhash, + operator: pool.operator, + pledge: pool.pledge, + cost: pool.cost, + margin: [Number(pool.margin.numerator), Number(pool.margin.denominator)], + relays: pool.relays.map(relayPrototypeToMesh), + owners: pool.pool_owners, + rewardAddress: pool.reward_account, + metadata: pool.pool_metadata + ? { URL: pool.pool_metadata.url, hash: pool.pool_metadata.pool_metadata_hash } + : undefined, +}); + +/** + * `CertificatePrototype` carries only the raw ledger credential (a hash), while Mesh's own + * `CertificateType` (consumed by the already-tested `toCardanoCert`, `../utils/certificate.ts`) + * expects bech32 reward-account/committee-address strings and CIP-105 DRep ids — both of which it + * immediately re-derives the raw credential/hash from. Reconstructing those strings here (via + * `networkId`) is the price of reusing that conversion path for all 13 certificate kinds it + * supports, instead of re-deriving CST's ~13 certificate constructors independently. + * + * Note: when `STAKE_REGISTRATION`/`STAKE_DEREGISTRATION` carry an explicit `coin` + * (the post-Conway `reg_cert`/`unreg_cert` deposit form), it is silently dropped and the legacy + * no-deposit certificate is produced instead — `toCardanoCert`'s `RegisterStake`/`DeregisterStake` + * cases have no deposit-carrying equivalent either, so this matches Mesh's existing behavior + * rather than introducing a new loss, but it means a `TransactionPrototype` from a chain era that + * relies on the explicit-deposit form will round-trip incorrectly here. + */ +export const certificatePrototypeToCardano = ( + cert: CertificatePrototype, + networkId: 0 | 1, +): CardanoCert => { + const rewardAddr = (cred: CredTypePrototype) => + credentialPrototypeToRewardAddressBech32(cred, networkId); + + let certType: CertificateType; + switch (cert.type) { + case "STAKE_REGISTRATION": + certType = { type: "RegisterStake", stakeKeyAddress: rewardAddr(cert.value.stake_credential) }; + break; + case "STAKE_DEREGISTRATION": + certType = { type: "DeregisterStake", stakeKeyAddress: rewardAddr(cert.value.stake_credential) }; + break; + case "STAKE_DELEGATION": + certType = { + type: "DelegateStake", + stakeKeyAddress: rewardAddr(cert.value.stake_credential), + poolId: cert.value.pool_keyhash, + }; + break; + case "POOL_REGISTRATION": + certType = { + type: "RegisterPool", + poolParams: poolParamsPrototypeToMesh(cert.value.pool_params), + }; + break; + case "POOL_RETIREMENT": + certType = { + type: "RetirePool", + poolId: cert.value.pool_keyhash, + // Narrowing boundary: prototype carries the CDDL `epoch = uint .size 8` range as bigint; + // Mesh's own `CertificateType.RetirePool.epoch` is `number`. Lossy only above 2^53 — + // unreachable for a real epoch number (currently ~500). + epoch: Number(cert.value.epoch), + }; + break; + case "COMMITTEE_HOT_AUTH": + certType = { + type: "CommitteeHotAuth", + committeeColdKeyAddress: rewardAddr(cert.value.committee_cold_credential), + committeeHotKeyAddress: rewardAddr(cert.value.committee_hot_credential), + }; + break; + case "COMMITTEE_COLD_RESIGN": + certType = { + type: "CommitteeColdResign", + committeeColdKeyAddress: rewardAddr(cert.value.committee_cold_credential), + anchor: cert.value.anchor ? anchorPrototypeToMesh(cert.value.anchor) : undefined, + }; + break; + case "DREP_REGISTRATION": + certType = { + type: "DRepRegistration", + drepId: credentialPrototypeToDRepIdBech32(cert.value.voting_credential), + coin: Number(cert.value.coin), + anchor: cert.value.anchor ? anchorPrototypeToMesh(cert.value.anchor) : undefined, + }; + break; + case "DREP_DEREGISTRATION": + certType = { + type: "DRepDeregistration", + drepId: credentialPrototypeToDRepIdBech32(cert.value.voting_credential), + coin: Number(cert.value.coin), + }; + break; + case "DREP_UPDATE": + certType = { + type: "DRepUpdate", + drepId: credentialPrototypeToDRepIdBech32(cert.value.voting_credential), + anchor: cert.value.anchor ? anchorPrototypeToMesh(cert.value.anchor) : undefined, + }; + break; + case "VOTE_DELEGATION": + certType = { + type: "VoteDelegation", + stakeKeyAddress: rewardAddr(cert.value.stake_credential), + drep: dRepPrototypeToMeshDRep(cert.value.drep), + }; + break; + case "STAKE_AND_VOTE_DELEGATION": + certType = { + type: "StakeAndVoteDelegation", + stakeKeyAddress: rewardAddr(cert.value.stake_credential), + poolKeyHash: cert.value.pool_keyhash, + drep: dRepPrototypeToMeshDRep(cert.value.drep), + }; + break; + case "STAKE_REGISTRATION_AND_DELEGATION": + certType = { + type: "StakeRegistrationAndDelegation", + stakeKeyAddress: rewardAddr(cert.value.stake_credential), + poolKeyHash: cert.value.pool_keyhash, + coin: Number(cert.value.coin), + }; + break; + case "VOTE_REGISTRATION_AND_DELEGATION": + certType = { + type: "VoteRegistrationAndDelegation", + stakeKeyAddress: rewardAddr(cert.value.stake_credential), + drep: dRepPrototypeToMeshDRep(cert.value.drep), + coin: Number(cert.value.coin), + }; + break; + case "STAKE_VOTE_REGISTRATION_AND_DELEGATION": + certType = { + type: "StakeVoteRegistrationAndDelegation", + stakeKeyAddress: rewardAddr(cert.value.stake_credential), + poolKeyHash: cert.value.pool_keyhash, + drep: dRepPrototypeToMeshDRep(cert.value.drep), + coin: Number(cert.value.coin), + }; + break; + } + + return toCardanoCert(certType); +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/governance.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/governance.ts new file mode 100644 index 000000000..2a76237e3 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/governance.ts @@ -0,0 +1,285 @@ +import { Serialization } from "@cardano-sdk/core"; + +import { + scriptHash, + type Committee, + type Constitution, + type GovernanceAction, + type GovernanceActionId, + type GovernanceActionIdPrototype, + type GovernanceActionPrototype, + type ProtocolParamUpdate, + type ProtocolParamUpdatePrototype, + type RefTxIn, + type TreasuryWithdrawals, + type Voter, + type VoterPrototype, + type VotingProceduresPrototype, + type VotingProcedure, + type VotingProcedurePrototype, + type VotingProposalPrototype, +} from "@meshsdk/common"; + +import { toCardanoProposalProcedure } from "../utils/proposal"; +// NOTE: `../utils/proposal.ts` also exports a function named `toCardanoGovernanceActionId` with +// a different signature (`GovernanceActionId | undefined` -> `Cardano.GovernanceActionId | null`, +// used internally by `toCardanoProposalProcedure`) — importing from "../utils" instead of +// "../utils/vote" here would silently resolve to that one instead and fail type-wise. +import { toCardanoGovernanceActionId, toCardanoVoter, toCardanoVotingProcedure } from "../utils/vote"; +import { credentialPrototypeToDRepIdBech32, credentialPrototypeToMeshCredential } from "./primitives"; + +const governanceActionIdPrototypeToRefTxIn = ( + id: GovernanceActionIdPrototype, +): RefTxIn => ({ txHash: id.transaction_id, txIndex: id.index }); + +const governanceActionIdPrototypeToMesh = ( + id: GovernanceActionIdPrototype | null | undefined, +): GovernanceActionId | undefined => + id ? { transactionId: id.transaction_id, govActionIndex: id.index } : undefined; + +export const voterPrototypeToMesh = (voter: VoterPrototype): Voter => { + switch (voter.type) { + case "CONSTITUTIONAL_COMMITTEE_HOT_CRED": + return { + type: "ConstitutionalCommittee", + hotCred: credentialPrototypeToMeshCredential(voter.value), + }; + case "DREP": + return { type: "DRep", drepId: credentialPrototypeToDRepIdBech32(voter.value) }; + case "STAKING_POOL": + return { type: "StakingPool", keyHash: voter.value }; + } +}; + +const votingProcedurePrototypeToMesh = ( + vp: VotingProcedurePrototype, +): VotingProcedure => ({ + voteKind: vp.vote.type === "YES" ? "Yes" : vp.vote.type === "NO" ? "No" : "Abstain", + anchor: vp.anchor + ? { anchorUrl: vp.anchor.anchor_url, anchorDataHash: vp.anchor.anchor_data_hash } + : undefined, +}); + +export const votingProceduresPrototypeToCardano = ( + voterVotes: VotingProceduresPrototype, +): Serialization.VotingProcedures => { + const votingProcedures = Serialization.VotingProcedures.fromCore([]); + for (const entry of voterVotes) { + const cardanoVoter = toCardanoVoter(voterPrototypeToMesh(entry.voter)); + for (const vote of entry.votes) { + votingProcedures.insert( + cardanoVoter, + toCardanoGovernanceActionId(governanceActionIdPrototypeToRefTxIn(vote.action_id)), + toCardanoVotingProcedure(votingProcedurePrototypeToMesh(vote.voting_procedure)), + ); + } + } + return votingProcedures; +}; + +const rational = (r: { numerator: string; denominator: string }) => r; + +/** Confirmed against whisky's own `convert/governance.rs` (`proto_to_protocol_param_update`): + * cost-model map keys are the literal strings "PlutusV1"/"PlutusV2"/"PlutusV3" (matched via a + * Rust `match lang_str.as_str()`, `_ => continue` for anything else) — not numeric "0"/"1"/"2" as + * an earlier version of this function guessed. Mirrors that same silent-skip behavior for + * unrecognized keys rather than guessing a language for them. */ +const costModelKeyToLanguage = (key: string): "V1" | "V2" | "V3" | undefined => { + switch (key) { + case "PlutusV1": + return "V1"; + case "PlutusV2": + return "V2"; + case "PlutusV3": + return "V3"; + default: + return undefined; + } +}; + +const protocolParamUpdatePrototypeToMesh = ( + u: ProtocolParamUpdatePrototype, +): ProtocolParamUpdate => { + const result: ProtocolParamUpdate = {}; + if (u.minfee_a != null) result.minFeeA = u.minfee_a; + if (u.minfee_b != null) result.minFeeB = u.minfee_b; + if (u.max_block_body_size != null) result.maxBlockBodySize = u.max_block_body_size; + if (u.max_tx_size != null) result.maxTxSize = u.max_tx_size; + if (u.max_block_header_size != null) result.maxBlockHeaderSize = u.max_block_header_size; + if (u.key_deposit != null) result.keyDeposit = u.key_deposit; + if (u.pool_deposit != null) result.poolDeposit = u.pool_deposit; + if (u.max_epoch != null) result.maxEpoch = u.max_epoch; + if (u.n_opt != null) result.nOpt = u.n_opt; + if (u.pool_pledge_influence) result.poolPledgeInfluence = rational(u.pool_pledge_influence); + if (u.expansion_rate) result.expansionRate = rational(u.expansion_rate); + if (u.treasury_growth_rate) result.treasuryGrowthRate = rational(u.treasury_growth_rate); + if (u.min_pool_cost != null) result.minPoolCost = u.min_pool_cost; + if (u.ada_per_utxo_byte != null) result.adaPerUtxoByte = u.ada_per_utxo_byte; + if (u.cost_models) { + const costModels: Record = {}; + for (const [key, model] of Object.entries(u.cost_models)) { + const language = costModelKeyToLanguage(key); + if (!language) continue; // matches whisky's own `_ => continue` for unrecognized keys + // `model` entries are whisky's own string-wrapped i128 (see CostModelPrototype) — `Number()` + // here isn't this converter's choice, it's forced by Mesh's own `ProtocolParamUpdate.costModels: + // Record` (mesh-common/src/types/governance.ts), which has the same + // number-vs-bigint gap as the one just fixed in PlutusDataPrototype, just not fixed there yet. + costModels[language] = model.map(Number); + } + result.costModels = costModels; + } + if (u.execution_costs) { + result.executionCosts = { + memPrice: rational(u.execution_costs.mem_price), + stepPrice: rational(u.execution_costs.step_price), + }; + } + if (u.max_tx_ex_units) { + result.maxTxExUnits = { mem: u.max_tx_ex_units.mem, steps: u.max_tx_ex_units.steps }; + } + if (u.max_block_ex_units) { + result.maxBlockExUnits = { mem: u.max_block_ex_units.mem, steps: u.max_block_ex_units.steps }; + } + if (u.max_value_size != null) result.maxValueSize = u.max_value_size; + if (u.collateral_percentage != null) result.collateralPercentage = u.collateral_percentage; + if (u.max_collateral_inputs != null) result.maxCollateralInputs = u.max_collateral_inputs; + if (u.pool_voting_thresholds) { + const t = u.pool_voting_thresholds; + result.poolVotingThresholds = { + motionNoConfidence: rational(t.motion_no_confidence), + committeeNormal: rational(t.committee_normal), + committeeNoConfidence: rational(t.committee_no_confidence), + hardForkInitiation: rational(t.hard_fork_initiation), + ppSecurityGroup: rational(t.security_relevant_threshold), + }; + } + if (u.drep_voting_thresholds) { + const t = u.drep_voting_thresholds; + result.drepVotingThresholds = { + motionNoConfidence: rational(t.motion_no_confidence), + committeeNormal: rational(t.committee_normal), + committeeNoConfidence: rational(t.committee_no_confidence), + updateConstitution: rational(t.update_constitution), + hardForkInitiation: rational(t.hard_fork_initiation), + ppNetworkGroup: rational(t.pp_network_group), + ppEconomicGroup: rational(t.pp_economic_group), + ppTechnicalGroup: rational(t.pp_technical_group), + ppGovGroup: rational(t.pp_governance_group), + treasuryWithdrawal: rational(t.treasury_withdrawal), + }; + } + if (u.min_committee_size != null) result.minCommitteeSize = u.min_committee_size; + if (u.committee_term_limit != null) result.committeeTermLimit = u.committee_term_limit; + if (u.governance_action_validity_period != null) + result.govActionValidityPeriod = u.governance_action_validity_period; + if (u.governance_action_deposit != null) result.govActionDeposit = u.governance_action_deposit; + if (u.drep_deposit != null) result.drepDeposit = u.drep_deposit; + if (u.drep_inactivity_period != null) result.drepInactivityPeriod = u.drep_inactivity_period; + if (u.ref_script_coins_per_byte) + result.refScriptCostPerByte = rational(u.ref_script_coins_per_byte); + return result; +}; + +const governanceActionPrototypeToMesh = ( + action: GovernanceActionPrototype, +): GovernanceAction => { + switch (action.type) { + case "PARAMETER_CHANGE_ACTION": + return { + kind: "ParameterChangeAction", + action: { + govActionId: governanceActionIdPrototypeToMesh(action.value.gov_action_id), + protocolParamUpdates: protocolParamUpdatePrototypeToMesh( + action.value.protocol_param_updates, + ), + policyHash: action.value.policy_hash + ? scriptHash(action.value.policy_hash) + : undefined, + }, + }; + case "HARD_FORK_INITIATION_ACTION": + return { + kind: "HardForkInitiationAction", + action: { + govActionId: governanceActionIdPrototypeToMesh(action.value.gov_action_id), + protocolVersion: { + major: action.value.protocol_version.major, + minor: action.value.protocol_version.minor, + }, + }, + }; + case "TREASURY_WITHDRAWALS_ACTION": { + const withdrawals: TreasuryWithdrawals = {}; + for (const [address, amount] of Object.entries(action.value.withdrawals)) { + withdrawals[address] = amount; + } + return { + kind: "TreasuryWithdrawalsAction", + action: { + withdrawals, + policyHash: action.value.policy_hash + ? scriptHash(action.value.policy_hash) + : undefined, + }, + }; + } + case "NO_CONFIDENCE_ACTION": + return { + kind: "NoConfidenceAction", + action: { govActionId: governanceActionIdPrototypeToMesh(action.value.gov_action_id) }, + }; + case "UPDATE_COMMITTEE_ACTION": { + const committee: Committee = { + members: action.value.committee.members.map((m) => ({ + stakeCredential: credentialPrototypeToMeshCredential(m.stake_credential), + // Narrowing boundary: prototype carries the CDDL `epoch = uint .size 8` range as + // bigint (this is `update_committee`'s `=> epoch` map value); Mesh's own + // `CommitteeMember.termLimit` is `number`. Lossy only above 2^53. + termLimit: Number(m.term_limit), + })), + quorumThreshold: rational(action.value.committee.quorum_threshold), + }; + return { + kind: "UpdateCommitteeAction", + action: { + govActionId: governanceActionIdPrototypeToMesh(action.value.gov_action_id), + committee, + membersToRemove: action.value.members_to_remove.map(credentialPrototypeToMeshCredential), + }, + }; + } + case "NEW_CONSTITUTION_ACTION": { + const constitution: Constitution = { + anchor: { + anchorUrl: action.value.constitution.anchor.anchor_url, + anchorDataHash: action.value.constitution.anchor.anchor_data_hash, + }, + scriptHash: action.value.constitution.script_hash + ? scriptHash(action.value.constitution.script_hash) + : undefined, + }; + return { + kind: "NewConstitutionAction", + action: { + govActionId: governanceActionIdPrototypeToMesh(action.value.gov_action_id), + constitution, + }, + }; + } + case "INFO_ACTION": + return { kind: "InfoAction", action: {} }; + } +}; + +export const votingProposalPrototypeToCardano = ( + proposal: VotingProposalPrototype, +): Serialization.ProposalProcedure => + toCardanoProposalProcedure( + governanceActionPrototypeToMesh(proposal.governance_action), + { + anchorUrl: proposal.anchor.anchor_url, + anchorDataHash: proposal.anchor.anchor_data_hash, + }, + proposal.reward_account, + BigInt(proposal.deposit), + ); diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/index.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/index.ts new file mode 100644 index 000000000..e0ff0f315 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/index.ts @@ -0,0 +1,5 @@ +export { + transactionPrototypeToCardano, + transactionPrototypeToHex, + type TxPrototypeToCborOptions, +} from "./transaction"; diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/inputs-outputs.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/inputs-outputs.ts new file mode 100644 index 000000000..a16abc67f --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/inputs-outputs.ts @@ -0,0 +1,46 @@ +import { HexBlob } from "@cardano-sdk/util"; + +import type { + TransactionInputPrototype, + TransactionOutputPrototype, +} from "@meshsdk/common"; + +import { + Datum, + DatumHash, + Script, + TransactionId, + TransactionInput, + TransactionOutput, +} from "../types"; +import { toCardanoAddress } from "../utils"; +import { plutusDataVariantToCardano } from "./plutus-data"; +import { valuePrototypeToCardano } from "./value"; + +export const transactionInputPrototypeToCardano = ( + input: TransactionInputPrototype, +): TransactionInput => + new TransactionInput(TransactionId(input.transaction_id), BigInt(input.index)); + +export const transactionOutputPrototypeToCardano = ( + output: TransactionOutputPrototype, +): TransactionOutput => { + const cardanoOutput = new TransactionOutput( + toCardanoAddress(output.address), + valuePrototypeToCardano(output.amount), + ); + + if (output.plutus_data?.type === "DATA_HASH") { + cardanoOutput.setDatum(Datum.newDataHash(DatumHash(output.plutus_data.value))); + } else if (output.plutus_data?.type === "DATA") { + cardanoOutput.setDatum( + Datum.newInlineData(plutusDataVariantToCardano(output.plutus_data.value)), + ); + } + + if (output.script_ref) { + cardanoOutput.setScriptRef(Script.fromCbor(HexBlob(output.script_ref))); + } + + return cardanoOutput; +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/native-script.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/native-script.ts new file mode 100644 index 000000000..93f673a3c --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/native-script.ts @@ -0,0 +1,35 @@ +import type { + NativeScript, + NativeScriptPrototype, +} from "@meshsdk/common"; + +import type { NativeScript as CstNativeScript } from "../types"; +import { toNativeScript } from "../utils"; + +const meshNativeScript = (proto: NativeScriptPrototype): NativeScript => { + switch (proto.type) { + case "SCRIPT_PUBKEY": + return { type: "sig", keyHash: proto.value.addr_keyhash }; + case "SCRIPT_ALL": + return { type: "all", scripts: proto.value.native_scripts.map(meshNativeScript) }; + case "SCRIPT_ANY": + return { type: "any", scripts: proto.value.native_scripts.map(meshNativeScript) }; + case "SCRIPT_N_OF_K": + return { + type: "atLeast", + // Narrowing boundary: the prototype carries the CDDL-legal `int64` range as bigint, but + // Mesh's own `NativeScript`/`atLeast.required` is `number`. Lossy only above 2^53, which + // for "n of k signatures required" cannot occur in any real script. + required: Number(proto.value.n), + scripts: proto.value.native_scripts.map(meshNativeScript), + }; + case "TIMELOCK_START": + return { type: "after", slot: proto.value.slot }; + case "TIMELOCK_EXPIRY": + return { type: "before", slot: proto.value.slot }; + } +}; + +export const nativeScriptPrototypeToCardano = ( + proto: NativeScriptPrototype, +): CstNativeScript => toNativeScript(meshNativeScript(proto)); diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/plutus-data.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/plutus-data.ts new file mode 100644 index 000000000..deb917c67 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/plutus-data.ts @@ -0,0 +1,54 @@ +import { HexBlob } from "@cardano-sdk/util"; + +import type { + PlutusDataPrototype, + PlutusDataVariant, +} from "@meshsdk/common"; + +import { + ConstrPlutusData, + PlutusData, + PlutusList, + PlutusMap, +} from "../types"; + +/** Direct port of whisky's `tx_prototype/convert/plutus_data.rs` — `PlutusDataPrototype` is the + * raw ledger-level tagged datum shape (not Mesh's own convenience `Data`/`BuilderData`), so this + * can't reuse `toPlutusData`/`fromBuilderToPlutusData` (`../utils/data.ts`), which convert from + * that different, higher-level shape. */ +export const plutusDataPrototypeToCardano = ( + data: PlutusDataPrototype, +): PlutusData => { + switch (data.type) { + case "INTEGER": + return PlutusData.newInteger(BigInt(data.value)); + case "BYTES": + return PlutusData.newBytes(Buffer.from(data.value, "hex")); + case "LIST": { + const list = new PlutusList(); + data.value.forEach((el) => list.add(plutusDataPrototypeToCardano(el))); + return PlutusData.newList(list); + } + case "MAP": { + const map = new PlutusMap(); + data.value.forEach(([k, v]) => + map.insert(plutusDataPrototypeToCardano(k), plutusDataPrototypeToCardano(v)), + ); + return PlutusData.newMap(map); + } + case "CONSTR": { + const fields = new PlutusList(); + data.fields.forEach((el) => fields.add(plutusDataPrototypeToCardano(el))); + return PlutusData.newConstrPlutusData( + new ConstrPlutusData(BigInt(data.alternative), fields), + ); + } + } +}; + +export const plutusDataVariantToCardano = ( + data: PlutusDataVariant, +): PlutusData => + data.type === "CBOR" + ? PlutusData.fromCbor(HexBlob(data.hex)) + : plutusDataPrototypeToCardano(data.data); diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/primitives.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/primitives.ts new file mode 100644 index 000000000..c6c701130 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/primitives.ts @@ -0,0 +1,93 @@ +import { Serialization } from "@cardano-sdk/core"; +import base32 from "base32-encoding"; +import { bech32 } from "bech32"; + +import type { + AnchorPrototype, + CredTypePrototype, + DRepPrototype, + NetworkIdPrototype, +} from "@meshsdk/common"; + +import { Hash32ByteBase16 } from "../types"; +import { keyHashToRewardAddress, scriptHashToRewardAddress } from "../utils"; + +/** + * Mesh's own `Credential` name is ambiguous at the `@meshsdk/common` package boundary (both + * `types/transaction-builder/credential.ts` and `data/json/credentials.ts` export a type with + * this name, with different shapes) — `../utils/proposal.ts`'s own `toCardanoCommittee` sidesteps + * this the same way, with its own local `MeshCredential` type rather than importing `Credential`. + */ +export type MeshCredential = + | { type: "ScriptHash"; scriptHash: string } + | { type: "KeyHash"; keyHash: string }; + +/** Cardano's numeric network id convention: mainnet = 1, every testnet = 0. */ +export const networkIdToNumber = ( + networkId: NetworkIdPrototype | null | undefined, +): 0 | 1 => (networkId?.type === "MAINNET" ? 1 : 0); + +export const anchorPrototypeToCardano = ( + anchor: AnchorPrototype, +): Serialization.Anchor => + new Serialization.Anchor( + anchor.anchor_url, + Hash32ByteBase16(anchor.anchor_data_hash), + ); + +/** + * `IMeshTxSerializer`'s certificate/committee helpers (`toCardanoCert`) take a bech32 address + * string and re-derive the raw credential from it — so a raw `CredTypePrototype` credential has + * to be round-tripped through a reward address to reuse them. `RewardAddress.fromCredentials` + * accepts either credential type directly; which existing helper this calls only matters for + * readability. + */ +export const credentialPrototypeToRewardAddressBech32 = ( + cred: CredTypePrototype, + networkId: 0 | 1, +): string => + cred.type === "SCRIPT" + ? scriptHashToRewardAddress(cred.value, networkId) + : keyHashToRewardAddress(cred.value, networkId); + +export const credentialPrototypeToMeshCredential = ( + cred: CredTypePrototype, +): MeshCredential => + cred.type === "SCRIPT" + ? { type: "ScriptHash", scriptHash: cred.value } + : { type: "KeyHash", keyHash: cred.value }; + +/** CIP-105 DRep id, e.g. "drep1..." (key) / "drep_script1..." (script) — see below. */ +export const credentialPrototypeToDRepIdBech32 = (cred: CredTypePrototype): string => + bech32.encode( + cred.type === "SCRIPT" ? "drep_script" : "drep", + base32.encode(Buffer.from(cred.value, "hex")), + ); + +/** + * `Serialization.DRep` has direct `newKeyHash`/`newScriptHash`/`newAlwaysAbstain`/ + * `newAlwaysNoConfidence` constructors, but the certificate helpers this converter reuses + * (`toCardanoCert`) only accept Mesh's own `DRep` union, which for a key/script hash requires a + * CIP-105 bech32 "drep..."/"drep_script..." id string that it immediately decodes back into the + * same raw hash. Re-encoding here (rather than duplicating `toCardanoCert`'s per-variant + * certificate construction just to avoid it) keeps every certificate variant going through the + * one, already-correct conversion path. + */ +export const dRepPrototypeToMeshDRep = ( + drep: DRepPrototype, +): { dRepId: string } | { alwaysAbstain: null } | { alwaysNoConfidence: null } => { + switch (drep.type) { + case "ALWAYS_ABSTAIN": + return { alwaysAbstain: null }; + case "ALWAYS_NO_CONFIDENCE": + return { alwaysNoConfidence: null }; + case "KEY_HASH": + case "SCRIPT_HASH": + return { + dRepId: credentialPrototypeToDRepIdBech32({ + type: drep.type === "KEY_HASH" ? "KEY" : "SCRIPT", + value: drep.value, + }), + }; + } +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/transaction.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/transaction.ts new file mode 100644 index 000000000..e8d57cdbe --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/transaction.ts @@ -0,0 +1,74 @@ +import { inConwayEra, setInConwayEra } from "@cardano-sdk/core"; + +import type { TransactionPrototype } from "@meshsdk/common"; + +import { Transaction } from "../types"; +import { auxiliaryDataPrototypeToCardano } from "./auxiliary-data"; +import { transactionBodyPrototypeToCardano } from "./body"; +import { transactionWitnessSetPrototypeToCardano } from "./witness-set"; + +export type TxPrototypeToCborOptions = { + /** + * How to encode every CBOR set in the transaction (inputs, collateral, reference inputs, + * certificates, required signers, proposals, and the script/witness sets). + * + * The Conway CDDL accepts both forms — `set = #6.258([* a0]) / [* a0]` — but a transaction + * must pick one and use it throughout: the two encodings produce different bytes and therefore + * a different transaction hash, and a body that mixes them is at best surprising to verify. + * This flag is applied to the whole transaction at once, so mixing is not expressible. + * + * `true` (default) emits the Conway `#6.258`-tagged form; `false` emits plain arrays. + */ + taggedSets?: boolean; +}; + +/** + * CST equivalent of whisky's `proto_to_csl_transaction` — converts a `TransactionPrototype` + * (already-fully-decided: post coin-selection, post witness collection) into a + * `@cardano-sdk/core` `Transaction`. + * + * NOTE ON SET ENCODING: this function deliberately takes no `taggedSets` option, because it + * could not honour one. `@cardano-sdk/core` decides tagged-vs-plain inside `CborSet.toCbor()`, + * reading a module-global (`inConwayEra`, flipped by `setInConwayEra`) at *serialization* time — + * verified: building under `setInConwayEra(true)` and then serializing under `false` yields the + * untagged form. Since the returned `Transaction` is serialized later by the caller, the encoding + * is whatever the ambient global says at that moment — and that global defaults to `false` but is + * set to `true` as a side effect of constructing a `CardanoSDKSerializer`. If you need a + * deterministic result, use `transactionPrototypeToHex`, which pins the flag around the actual + * `toCbor()` call. + */ +export const transactionPrototypeToCardano = (proto: TransactionPrototype): Transaction => { + const body = transactionBodyPrototypeToCardano(proto.body); + const witnessSet = transactionWitnessSetPrototypeToCardano(proto.witness_set); + const auxiliaryData = proto.auxiliary_data + ? auxiliaryDataPrototypeToCardano(proto.auxiliary_data) + : undefined; + + const transaction = new Transaction(body, witnessSet, auxiliaryData); + // Constructor has no `isValid` param — must be set explicitly, or every phase-2-invalid + // (collateral-only, expected-to-fail-on-chain) transaction would silently round-trip as valid. + transaction.setIsValid(proto.is_valid); + return transaction; +}; + +/** + * CST equivalent of whisky's `proto_to_transaction_hex`, with deterministic set encoding. + * + * Pins `@cardano-sdk/core`'s `inConwayEra` global for the duration of the `toCbor()` call and + * restores it afterwards, so (a) every set in the transaction is encoded the same way and (b) + * this call neither depends on nor leaks ambient global state. The save/restore is the only way + * to control it — the flag is not exposed per-`CborSet`. + */ +export const transactionPrototypeToHex = ( + proto: TransactionPrototype, + { taggedSets = true }: TxPrototypeToCborOptions = {}, +): string => { + const transaction = transactionPrototypeToCardano(proto); + const previous = inConwayEra; + setInConwayEra(taggedSets); + try { + return transaction.toCbor(); + } finally { + setInConwayEra(previous); + } +}; diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/value.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/value.ts new file mode 100644 index 000000000..e575f0af6 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/value.ts @@ -0,0 +1,28 @@ +import type { + Asset, + MultiAssetPrototype, + ValuePrototype, +} from "@meshsdk/common"; + +import { Value } from "../types"; +import { toValue } from "../utils"; + +/** `MultiAssetPrototype` is `{ [policyId]: { [assetNameHex]: quantity } }`; flatten it into the + * `unit = policyId + assetNameHex` shape `toValue` (Mesh's own CST value builder) expects. */ +export const multiAssetPrototypeToAssets = ( + coin: string, + multiasset: MultiAssetPrototype | null | undefined, +): Asset[] => { + const assets: Asset[] = [{ unit: "lovelace", quantity: coin }]; + if (multiasset) { + for (const [policyId, tokens] of Object.entries(multiasset)) { + for (const [assetNameHex, quantity] of Object.entries(tokens)) { + assets.push({ unit: `${policyId}${assetNameHex}`, quantity }); + } + } + } + return assets; +}; + +export const valuePrototypeToCardano = (value: ValuePrototype): Value => + toValue(multiAssetPrototypeToAssets(value.coin, value.multiasset)); diff --git a/packages/mesh-core-cst/src/tx-prototype-to-cbor/witness-set.ts b/packages/mesh-core-cst/src/tx-prototype-to-cbor/witness-set.ts new file mode 100644 index 000000000..4d46241b9 --- /dev/null +++ b/packages/mesh-core-cst/src/tx-prototype-to-cbor/witness-set.ts @@ -0,0 +1,141 @@ +import { Serialization } from "@cardano-sdk/core"; +import { HexBlob } from "@cardano-sdk/util"; + +import type { + RedeemerPrototype, + TransactionWitnessSetPrototype, +} from "@meshsdk/common"; + +import { + BootstrapWitness, + CborSet, + Ed25519PublicKeyHex, + Ed25519SignatureHex, + ExUnits, + NativeScript, + PlutusData, + PlutusV1Script, + PlutusV2Script, + PlutusV3Script, + Redeemer, + RedeemerTag, + Redeemers, + TransactionWitnessSet, + VkeyWitness, +} from "../types"; +import { nativeScriptPrototypeToCardano } from "./native-script"; +import { plutusDataVariantToCardano } from "./plutus-data"; + +/** Every `TransactionWitnessSet` field except `redeemers` is a `CborSet`, not a + * plain array — build one from already-constructed class instances. */ +const toCborSet = ( + items: Cls[], + fromCore: (core: Core) => Cls, +): Serialization.CborSet => { + const set = CborSet.fromCore([], fromCore); + set.setValues(items); + return set; +}; + +const REDEEMER_TAGS: Record = { + SPEND: RedeemerTag.Spend, + MINT: RedeemerTag.Mint, + CERT: RedeemerTag.Cert, + REWARD: RedeemerTag.Reward, + VOTE: RedeemerTag.Voting, + VOTING_PROPOSAL: RedeemerTag.Proposing, +}; + +const redeemerPrototypeToCardano = (redeemer: RedeemerPrototype): Redeemer => + new Redeemer( + REDEEMER_TAGS[redeemer.tag.type], + BigInt(redeemer.index), + plutusDataVariantToCardano(redeemer.data), + new ExUnits(BigInt(redeemer.ex_units.mem), BigInt(redeemer.ex_units.steps)), + ); + +export const transactionWitnessSetPrototypeToCardano = ( + ws: TransactionWitnessSetPrototype, +): TransactionWitnessSet => { + const result = new TransactionWitnessSet(); + + if (ws.vkeys?.length) { + result.setVkeys( + toCborSet( + ws.vkeys.map( + (vkw) => new VkeyWitness(Ed25519PublicKeyHex(vkw.vkey), Ed25519SignatureHex(vkw.signature)), + ), + VkeyWitness.fromCore, + ), + ); + } + + if (ws.native_scripts?.length) { + result.setNativeScripts( + toCborSet(ws.native_scripts.map(nativeScriptPrototypeToCardano), NativeScript.fromCore), + ); + } + + if (ws.bootstraps?.length) { + result.setBootstraps( + toCborSet( + ws.bootstraps.map( + (b) => + new BootstrapWitness( + Ed25519PublicKeyHex(b.vkey), + Ed25519SignatureHex(b.signature), + HexBlob(Buffer.from(b.chain_code).toString("hex")), + HexBlob(Buffer.from(b.attributes).toString("hex")), + ), + ), + BootstrapWitness.fromCore, + ), + ); + } + + // CDDL keys 3 / 6 / 7 map 1:1 onto CST's three version-specific setters. (An earlier revision + // of the prototype had a single undifferentiated `plutus_scripts` list, forcing everything into + // `plutusV1Scripts` and silently mis-typing V2/V3 scripts; the prototype now carries the CDDL's + // three separate fields, so no version guessing is needed.) + if (ws.plutus_v1_scripts?.length) { + result.setPlutusV1Scripts( + toCborSet( + ws.plutus_v1_scripts.map((cbor) => PlutusV1Script.fromCbor(HexBlob(cbor))), + PlutusV1Script.fromCore, + ), + ); + } + + if (ws.plutus_v2_scripts?.length) { + result.setPlutusV2Scripts( + toCborSet( + ws.plutus_v2_scripts.map((cbor) => PlutusV2Script.fromCbor(HexBlob(cbor))), + PlutusV2Script.fromCore, + ), + ); + } + + if (ws.plutus_v3_scripts?.length) { + result.setPlutusV3Scripts( + toCborSet( + ws.plutus_v3_scripts.map((cbor) => PlutusV3Script.fromCbor(HexBlob(cbor))), + PlutusV3Script.fromCore, + ), + ); + } + + if (ws.plutus_data?.elems.length) { + result.setPlutusData( + toCborSet( + ws.plutus_data.elems.map((cbor) => PlutusData.fromCbor(HexBlob(cbor))), + PlutusData.fromCore, + ), + ); + } + + if (ws.redeemers?.length) { + result.setRedeemers(Redeemers.fromCore(ws.redeemers.map((r) => redeemerPrototypeToCardano(r).toCore()))); + } + + return result; +}; diff --git a/packages/mesh-core-cst/src/utils/certificate.ts b/packages/mesh-core-cst/src/utils/certificate.ts index 8e9581ece..74cb5679d 100644 --- a/packages/mesh-core-cst/src/utils/certificate.ts +++ b/packages/mesh-core-cst/src/utils/certificate.ts @@ -320,9 +320,10 @@ export const toCardanoCert = (cert: CertificateType): CardanoCert => { } if ((cert.drep as { dRepId: string }).dRepId !== undefined) { - return CardanoCert.newStakeVoteDelegationCert( - new Serialization.StakeVoteDelegation( + return CardanoCert.newStakeVoteRegistrationDelegationCert( + new Serialization.StakeVoteRegistrationDelegation( rewardAddress.getPaymentCredential(), + BigInt(cert.coin), toDRep((cert.drep as { dRepId: string }).dRepId), Ed25519KeyHashHex(cert.poolKeyHash), ), @@ -330,9 +331,10 @@ export const toCardanoCert = (cert: CertificateType): CardanoCert => { } else if ( (cert.drep as { alwaysAbstain: null }).alwaysAbstain !== undefined ) { - return CardanoCert.newStakeVoteDelegationCert( - new Serialization.StakeVoteDelegation( + return CardanoCert.newStakeVoteRegistrationDelegationCert( + new Serialization.StakeVoteRegistrationDelegation( rewardAddress.getPaymentCredential(), + BigInt(cert.coin), Serialization.DRep.newAlwaysAbstain(), Ed25519KeyHashHex(cert.poolKeyHash), ), @@ -341,9 +343,10 @@ export const toCardanoCert = (cert: CertificateType): CardanoCert => { (cert.drep as { alwaysNoConfidence: null }).alwaysNoConfidence !== undefined ) { - return CardanoCert.newStakeVoteDelegationCert( - new Serialization.StakeVoteDelegation( + return CardanoCert.newStakeVoteRegistrationDelegationCert( + new Serialization.StakeVoteRegistrationDelegation( rewardAddress.getPaymentCredential(), + BigInt(cert.coin), Serialization.DRep.newAlwaysNoConfidence(), Ed25519KeyHashHex(cert.poolKeyHash), ), diff --git a/packages/mesh-core-cst/test/tx-prototype-from-cbor/round-trip.test.ts b/packages/mesh-core-cst/test/tx-prototype-from-cbor/round-trip.test.ts new file mode 100644 index 000000000..e5d3a6bdb --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-from-cbor/round-trip.test.ts @@ -0,0 +1,721 @@ +import type { TransactionPrototype } from "@meshsdk/common"; + +import { transactionPrototypeFromHex } from "../../src/tx-prototype-from-cbor"; +import { transactionPrototypeToHex } from "../../src/tx-prototype-to-cbor"; + +const TX_HASH = "11".repeat(32); +const TX_HASH_2 = "22".repeat(32); +const KEY_HASH = "aa".repeat(28); +const POOL_KEY_HASH = "bb".repeat(28); +const ANCHOR_HASH = "cc".repeat(32); +const ADDRESS = + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9"; +const REWARD_ACCOUNT = "stake_test1uqdgagy7x7mtcta2qyyg244efgtr57wg5mxa2wwnvrx845s4sa2vp"; +const POLICY_ID = "aa".repeat(28); +const SCRIPT_CBOR = "4d01000033222220051200120011"; + +const base = (): TransactionPrototype => ({ + body: { + fee: "170000", + inputs: [{ transaction_id: TX_HASH, index: 0 }], + outputs: [{ address: ADDRESS, amount: { coin: "5000000" } }], + }, + is_valid: true, + witness_set: {}, +}); + +/** + * The core property: encoding a prototype and decoding it back yields the same prototype. + * This exercises far more of the encoder than field-by-field assertions can — every field must + * survive the trip through real CBOR. + */ +const roundTrip = (proto: TransactionPrototype): TransactionPrototype => + transactionPrototypeFromHex(transactionPrototypeToHex(proto)); + +describe("encode -> decode round trip", () => { + it("preserves a minimal transaction exactly", () => { + expect(roundTrip(base())).toEqual(base()); + }); + + it("preserves is_valid = false", () => { + const proto = { ...base(), is_valid: false }; + expect(roundTrip(proto).is_valid).toBe(false); + }); + + it("preserves every scalar body field", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + ttl: "100000000", + validity_start_interval: "99999000", + total_collateral: "2000000", + current_treasury_value: "123456789", + donation: "1000000", + auxiliary_data_hash: "33".repeat(32), + script_data_hash: "44".repeat(32), + network_id: { type: "TESTNET" }, + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves multi-asset values and negative mint quantities (burns)", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + outputs: [ + { + address: ADDRESS, + amount: { coin: "5000000", multiasset: { [POLICY_ID]: { "74657374": "10" } } }, + }, + ], + mint: { [POLICY_ID]: { "6d696e74": "5", "6275726e": "-3" } }, + }, + }; + const out = roundTrip(proto); + expect(out.body.mint![POLICY_ID]!["6275726e"]).toEqual("-3"); + expect(out).toEqual(proto); + }); + + it("preserves collateral, reference inputs and required signers", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + collateral: [{ transaction_id: TX_HASH_2, index: 1 }], + reference_inputs: [{ transaction_id: TX_HASH_2, index: 2 }], + required_signers: [KEY_HASH], + collateral_return: { address: ADDRESS, amount: { coin: "1000000" } }, + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves withdrawals", () => { + const proto: TransactionPrototype = { + ...base(), + body: { ...base().body, withdrawals: { [REWARD_ACCOUNT]: "2000000" } }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves an inline datum's full structure", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + outputs: [ + { + address: ADDRESS, + amount: { coin: "5000000" }, + plutus_data: { + type: "DATA", + value: { + type: "MANUAL", + data: { + type: "CONSTR", + alternative: 1n, + fields: [ + { type: "INTEGER", value: -42n }, + { type: "BYTES", value: "cafe" }, + { type: "LIST", value: [{ type: "INTEGER", value: 7n }] }, + { + type: "MAP", + value: [[{ type: "BYTES", value: "00" }, { type: "INTEGER", value: 1n }]], + }, + ], + }, + }, + }, + }, + ], + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves a datum hash", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + outputs: [ + { + address: ADDRESS, + amount: { coin: "5000000" }, + plutus_data: { type: "DATA_HASH", value: "55".repeat(32) }, + }, + ], + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves each plutus script version in its own field", () => { + const proto: TransactionPrototype = { + ...base(), + witness_set: { + plutus_v1_scripts: [SCRIPT_CBOR], + plutus_v2_scripts: [SCRIPT_CBOR], + plutus_v3_scripts: [SCRIPT_CBOR], + }, + }; + const out = roundTrip(proto); + expect(out.witness_set.plutus_v1_scripts).toHaveLength(1); + expect(out.witness_set.plutus_v2_scripts).toHaveLength(1); + expect(out.witness_set.plutus_v3_scripts).toHaveLength(1); + }); + + it("does not leak a V3 script into the V1 field across a round trip", () => { + const proto: TransactionPrototype = { + ...base(), + witness_set: { plutus_v3_scripts: [SCRIPT_CBOR] }, + }; + const out = roundTrip(proto); + expect(out.witness_set.plutus_v1_scripts).toBeUndefined(); + expect(out.witness_set.plutus_v2_scripts).toBeUndefined(); + expect(out.witness_set.plutus_v3_scripts).toHaveLength(1); + }); + + it("preserves vkey witnesses and native scripts", () => { + const proto: TransactionPrototype = { + ...base(), + witness_set: { + vkeys: [{ vkey: "dd".repeat(32), signature: "ee".repeat(64) }], + native_scripts: [ + { + type: "SCRIPT_N_OF_K", + value: { + n: 1n, + native_scripts: [ + { type: "SCRIPT_PUBKEY", value: { addr_keyhash: KEY_HASH } }, + { type: "TIMELOCK_START", value: { slot: "100" } }, + ], + }, + }, + ], + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves redeemers including their tag and ex units", () => { + const proto: TransactionPrototype = { + ...base(), + witness_set: { + redeemers: [ + { + tag: { type: "SPEND" }, + index: "0", + data: { type: "MANUAL", data: { type: "INTEGER", value: 1n } }, + ex_units: { mem: "1000", steps: "500" }, + }, + { + tag: { type: "VOTING_PROPOSAL" }, + index: "2", + data: { type: "MANUAL", data: { type: "BYTES", value: "ff" } }, + ex_units: { mem: "7", steps: "9" }, + }, + ], + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves transaction metadata", () => { + const proto: TransactionPrototype = { + ...base(), + auxiliary_data: { + prefer_alonzo_format: true, + metadata: { + "674": { + type: "MAP", + value: [ + [ + { type: "STRING", value: "msg" }, + { type: "LIST", value: [{ type: "STRING", value: "hello" }] }, + ], + [ + { type: "STRING", value: "n" }, + { type: "INT", value: -5n }, + ], + ], + }, + }, + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves certificates", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + certs: [ + { + type: "STAKE_DELEGATION", + value: { + stake_credential: { type: "KEY", value: KEY_HASH }, + pool_keyhash: POOL_KEY_HASH, + }, + }, + { type: "POOL_RETIREMENT", value: { pool_keyhash: POOL_KEY_HASH, epoch: 450n } }, + { + type: "VOTE_DELEGATION", + value: { + stake_credential: { type: "SCRIPT", value: KEY_HASH }, + drep: { type: "ALWAYS_ABSTAIN" }, + }, + }, + ], + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves voting procedures", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + voting_procedures: [ + { + voter: { type: "STAKING_POOL", value: KEY_HASH }, + votes: [ + { + action_id: { transaction_id: TX_HASH, index: 0 }, + voting_procedure: { vote: { type: "YES" }, anchor: null }, + }, + ], + }, + ], + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + it("preserves an INFO_ACTION governance proposal", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + voting_proposals: [ + { + deposit: "100000000000", + reward_account: REWARD_ACCOUNT, + governance_action: { type: "INFO_ACTION" }, + anchor: { anchor_url: "https://example.com", anchor_data_hash: ANCHOR_HASH }, + }, + ], + }, + }; + expect(roundTrip(proto)).toEqual(proto); + }); + + // These are all else-branch cases in the encoder (`=== "MAINNET" ? 1 : 0`, + // `=== "CBOR" ? … : manual`, `=== "YES" ? … : === "NO" ? … : "Abstain"`), i.e. exactly where an + // inverted comparison would go unnoticed without an explicit case. + it.each(["MAINNET", "TESTNET"] as const)("preserves network id %s", (type) => { + const proto: TransactionPrototype = { + ...base(), + body: { ...base().body, network_id: { type } }, + }; + expect(roundTrip(proto).body.network_id).toEqual({ type }); + }); + + it.each(["YES", "NO", "ABSTAIN"] as const)("preserves vote kind %s", (type) => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + voting_procedures: [ + { + voter: { type: "STAKING_POOL", value: KEY_HASH }, + votes: [ + { + action_id: { transaction_id: TX_HASH, index: 0 }, + voting_procedure: { vote: { type }, anchor: null }, + }, + ], + }, + ], + }, + }; + expect(roundTrip(proto).body.voting_procedures![0]!.votes[0]!.voting_procedure.vote).toEqual({ + type, + }); + }); + + it.each([ + ["CONSTITUTIONAL_COMMITTEE_HOT_CRED" as const, { type: "KEY" as const, value: KEY_HASH }], + ["DREP" as const, { type: "SCRIPT" as const, value: KEY_HASH }], + ])("preserves voter kind %s", (type, value) => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + voting_procedures: [ + { + voter: { type, value }, + votes: [ + { + action_id: { transaction_id: TX_HASH, index: 0 }, + voting_procedure: { vote: { type: "YES" }, anchor: null }, + }, + ], + }, + ], + }, + }; + expect(roundTrip(proto).body.voting_procedures![0]!.voter).toEqual({ type, value }); + }); + + it("preserves an anchor on a voting procedure", () => { + const anchor = { anchor_url: "https://example.com", anchor_data_hash: ANCHOR_HASH }; + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + voting_procedures: [ + { + voter: { type: "STAKING_POOL", value: KEY_HASH }, + votes: [ + { + action_id: { transaction_id: TX_HASH, index: 3 }, + voting_procedure: { vote: { type: "NO" }, anchor }, + }, + ], + }, + ], + }, + }; + expect(roundTrip(proto).body.voting_procedures![0]!.votes[0]!.voting_procedure.anchor).toEqual( + anchor, + ); + }); + + it("preserves a script_ref on an output", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + outputs: [ + { + address: ADDRESS, + amount: { coin: "5000000" }, + // Script-wrapped native script: [0, [0, keyhash]] + script_ref: `8200820058-1c${KEY_HASH}`.replace("-", ""), + }, + ], + }, + }; + expect(roundTrip(proto).body.outputs[0]!.script_ref).toBeDefined(); + }); + + // Before the `utils/certificate.ts` fix this came back as STAKE_AND_VOTE_DELEGATION with the + // deposit gone, because the encoder emitted CDDL cert 10 instead of 13. + it("preserves STAKE_VOTE_REGISTRATION_AND_DELEGATION including its deposit", () => { + const cert = { + type: "STAKE_VOTE_REGISTRATION_AND_DELEGATION" as const, + value: { + stake_credential: { type: "KEY" as const, value: KEY_HASH }, + pool_keyhash: POOL_KEY_HASH, + drep: { type: "ALWAYS_ABSTAIN" as const }, + coin: "2000000", + }, + }; + const out = roundTrip({ ...base(), body: { ...base().body, certs: [cert] } }); + expect(out.body.certs![0]).toEqual(cert); + }); + + it("preserves POOL_REGISTRATION and stays re-encodable", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + certs: [ + { + type: "POOL_REGISTRATION", + value: { + pool_params: { + operator: POOL_KEY_HASH, + vrf_keyhash: "cc".repeat(32), + pledge: "1000000", + cost: "340000000", + margin: { numerator: "3", denominator: "100" }, + reward_account: REWARD_ACCOUNT, + pool_owners: [KEY_HASH], + relays: [ + { type: "SINGLE_HOST_ADDR", value: { ipv4: [1, 2, 3, 4], ipv6: null, port: 3001 } }, + { type: "SINGLE_HOST_NAME", value: { dns_name: "relay.example.com", port: 3001 } }, + { type: "MULTI_HOST_NAME", value: { dns_name: "_relay._tcp.example.com" } }, + ], + pool_metadata: { url: "https://example.com/p.json", pool_metadata_hash: "dd".repeat(32) }, + }, + }, + }, + ], + }, + }; + const out = roundTrip(proto); + const params = (out.body.certs![0]!.value as { pool_params: { operator: string; pool_owners: string[] } }) + .pool_params; + // Regression: these came back bech32 ("pool1…" / "stake_test1…"), which the encoder rejects. + expect(params.operator).toEqual(POOL_KEY_HASH); + expect(params.pool_owners).toEqual([KEY_HASH]); + expect(() => transactionPrototypeToHex(out)).not.toThrow(); + }); + + // Closing the certificate variants the coverage audit found implemented-but-untested. + const certCases = { + COMMITTEE_HOT_AUTH: { + type: "COMMITTEE_HOT_AUTH" as const, + value: { + committee_cold_credential: { type: "KEY" as const, value: KEY_HASH }, + committee_hot_credential: { type: "SCRIPT" as const, value: POOL_KEY_HASH }, + }, + }, + COMMITTEE_COLD_RESIGN: { + type: "COMMITTEE_COLD_RESIGN" as const, + value: { + committee_cold_credential: { type: "KEY" as const, value: KEY_HASH }, + anchor: { anchor_url: "https://example.com", anchor_data_hash: ANCHOR_HASH }, + }, + }, + DREP_DEREGISTRATION: { + type: "DREP_DEREGISTRATION" as const, + value: { voting_credential: { type: "KEY" as const, value: KEY_HASH }, coin: "500000000" }, + }, + DREP_UPDATE: { + type: "DREP_UPDATE" as const, + value: { + voting_credential: { type: "SCRIPT" as const, value: KEY_HASH }, + anchor: { anchor_url: "https://example.com", anchor_data_hash: ANCHOR_HASH }, + }, + }, + STAKE_REGISTRATION_AND_DELEGATION: { + type: "STAKE_REGISTRATION_AND_DELEGATION" as const, + value: { + stake_credential: { type: "KEY" as const, value: KEY_HASH }, + pool_keyhash: POOL_KEY_HASH, + coin: "2000000", + }, + }, + VOTE_REGISTRATION_AND_DELEGATION: { + type: "VOTE_REGISTRATION_AND_DELEGATION" as const, + value: { + stake_credential: { type: "KEY" as const, value: KEY_HASH }, + drep: { type: "SCRIPT_HASH" as const, value: POOL_KEY_HASH }, + coin: "2000000", + }, + }, + }; + + it.each(Object.entries(certCases))("preserves certificate %s", (_name, cert) => { + const out = roundTrip({ ...base(), body: { ...base().body, certs: [cert] } }); + expect(out.body.certs![0]).toEqual(cert); + }); + + // Governance actions the audit flagged as implemented in both directions but never exercised. + const GOV_ACTION_ID = { transaction_id: TX_HASH_2, index: 7 }; + const anchor = { anchor_url: "https://example.com", anchor_data_hash: ANCHOR_HASH }; + + const govCases = { + NO_CONFIDENCE_ACTION: { + type: "NO_CONFIDENCE_ACTION" as const, + value: { gov_action_id: GOV_ACTION_ID }, + }, + HARD_FORK_INITIATION_ACTION: { + type: "HARD_FORK_INITIATION_ACTION" as const, + value: { gov_action_id: GOV_ACTION_ID, protocol_version: { major: 10, minor: 0 } }, + }, + NEW_CONSTITUTION_ACTION: { + type: "NEW_CONSTITUTION_ACTION" as const, + value: { + gov_action_id: GOV_ACTION_ID, + constitution: { anchor, script_hash: POOL_KEY_HASH }, + }, + }, + UPDATE_COMMITTEE_ACTION: { + type: "UPDATE_COMMITTEE_ACTION" as const, + value: { + gov_action_id: GOV_ACTION_ID, + committee: { + members: [ + { stake_credential: { type: "KEY" as const, value: KEY_HASH }, term_limit: 500n }, + ], + quorum_threshold: { numerator: "2", denominator: "3" }, + }, + members_to_remove: [{ type: "SCRIPT" as const, value: POOL_KEY_HASH }], + }, + }, + }; + + it.each(Object.entries(govCases))("preserves governance action %s", (_name, action) => { + const proposal = { + deposit: "100000000000", + reward_account: REWARD_ACCOUNT, + governance_action: action, + anchor, + }; + const out = roundTrip({ ...base(), body: { ...base().body, voting_proposals: [proposal] } }); + expect(out.body.voting_proposals![0]).toEqual(proposal); + }); + + // `gov_action_id` was set by no test at all — only its null branch ran. + it("preserves a non-null gov_action_id", () => { + const proposal = { + deposit: "100000000000", + reward_account: REWARD_ACCOUNT, + governance_action: { + type: "NO_CONFIDENCE_ACTION" as const, + value: { gov_action_id: GOV_ACTION_ID }, + }, + anchor, + }; + const out = roundTrip({ ...base(), body: { ...base().body, voting_proposals: [proposal] } }); + const value = (out.body.voting_proposals![0]!.governance_action as { value: { gov_action_id: unknown } }) + .value; + expect(value.gov_action_id).toEqual(GOV_ACTION_ID); + }); + + it("preserves a TREASURY_WITHDRAWALS_ACTION policy_hash (guardrails script)", () => { + const proposal = { + deposit: "100000000000", + reward_account: REWARD_ACCOUNT, + governance_action: { + type: "TREASURY_WITHDRAWALS_ACTION" as const, + value: { withdrawals: { [REWARD_ACCOUNT]: "5000000" }, policy_hash: POOL_KEY_HASH }, + }, + anchor, + }; + const out = roundTrip({ ...base(), body: { ...base().body, voting_proposals: [proposal] } }); + expect(out.body.voting_proposals![0]).toEqual(proposal); + }); + + // auxiliary_data_map keys 2/3/4 — the witness-set equivalents were tested, these were not, so + // an off-by-one in the aux setters would have gone unnoticed. + it("preserves auxiliary-data plutus scripts in their per-version fields", () => { + const proto: TransactionPrototype = { + ...base(), + auxiliary_data: { + prefer_alonzo_format: true, + plutus_v1_scripts: [SCRIPT_CBOR], + plutus_v2_scripts: [SCRIPT_CBOR], + plutus_v3_scripts: [SCRIPT_CBOR], + }, + }; + const aux = roundTrip(proto).auxiliary_data!; + expect(aux.plutus_v1_scripts).toHaveLength(1); + expect(aux.plutus_v2_scripts).toHaveLength(1); + expect(aux.plutus_v3_scripts).toHaveLength(1); + }); + + it("does not put an aux-data V3 script into the V1 field", () => { + const proto: TransactionPrototype = { + ...base(), + auxiliary_data: { prefer_alonzo_format: true, plutus_v3_scripts: [SCRIPT_CBOR] }, + }; + const aux = roundTrip(proto).auxiliary_data!; + expect(aux.plutus_v1_scripts).toBeUndefined(); + expect(aux.plutus_v3_scripts).toHaveLength(1); + }); + + // The only Metadatum variant with no coverage: pins the number[] <-> bytes conversion, which is + // shaped differently from PlutusData's hex-string BYTES. + it("preserves a BYTES metadatum (byte array, not hex string)", () => { + const proto: TransactionPrototype = { + ...base(), + auxiliary_data: { + prefer_alonzo_format: true, + metadata: { "674": { type: "BYTES", value: [0, 1, 254, 255] } }, + }, + }; + expect(roundTrip(proto).auxiliary_data!.metadata!["674"]).toEqual({ + type: "BYTES", + value: [0, 1, 254, 255], + }); + }); + + it("is stable — a second round trip is a fixed point", () => { + const once = roundTrip(base()); + expect(roundTrip(once)).toEqual(once); + }); + + it("decodes both set encodings to the same prototype", () => { + const proto = base(); + expect(transactionPrototypeFromHex(transactionPrototypeToHex(proto, { taggedSets: true }))).toEqual( + transactionPrototypeFromHex(transactionPrototypeToHex(proto, { taggedSets: false })), + ); + }); +}); + +describe("documented round-trip asymmetries", () => { + it("normalises explicit null/[] to absent (stable from the second pass)", () => { + const proto: TransactionPrototype = { + ...base(), + body: { ...base().body, certs: null, collateral: [], ttl: null }, + }; + const once = roundTrip(proto); + expect(once.body.certs).toBeUndefined(); + expect(once.body.collateral).toBeUndefined(); + expect(once.body.ttl).toBeUndefined(); + expect(roundTrip(once)).toEqual(once); + }); + + it("expands a CBOR-variant datum into its MANUAL structure", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + outputs: [ + { + address: ADDRESS, + amount: { coin: "5000000" }, + // "01" is CBOR for the integer 1. + plutus_data: { type: "DATA", value: { type: "CBOR", hex: "01" } }, + }, + ], + }, + }; + const datum = roundTrip(proto).body.outputs[0]!.plutus_data; + expect(datum).toEqual({ + type: "DATA", + value: { type: "MANUAL", data: { type: "INTEGER", value: 1n } }, + }); + }); + + it("loses a STAKE_REGISTRATION deposit — encoder emits the no-deposit cert form", () => { + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + certs: [ + { + type: "STAKE_REGISTRATION", + value: { stake_credential: { type: "KEY", value: KEY_HASH }, coin: "2000000" }, + }, + ], + }, + }; + const cert = roundTrip(proto).body.certs![0]!; + expect(cert.type).toEqual("STAKE_REGISTRATION"); + expect((cert.value as { coin: string | null }).coin).toBeNull(); + }); + + it("truncates an epoch above 2^53 — encoder narrows to number at the Mesh boundary", () => { + const huge = 9_007_199_254_740_993n; // MAX_SAFE_INTEGER + 2 + const proto: TransactionPrototype = { + ...base(), + body: { + ...base().body, + certs: [{ type: "POOL_RETIREMENT", value: { pool_keyhash: POOL_KEY_HASH, epoch: huge } }], + }, + }; + const cert = roundTrip(proto).body.certs![0]!; + expect((cert.value as { epoch: bigint }).epoch).not.toEqual(huge); + }); +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/auxiliary-data.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/auxiliary-data.test.ts new file mode 100644 index 000000000..3d5408bfa --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/auxiliary-data.test.ts @@ -0,0 +1,30 @@ +import { auxiliaryDataPrototypeToCardano } from "../../src/tx-prototype-to-cbor/auxiliary-data"; + +describe("auxiliaryDataPrototypeToCardano", () => { + it("converts metadata labels/values", () => { + const auxData = auxiliaryDataPrototypeToCardano({ + metadata: { + "674": { type: "MAP", value: [[{ type: "STRING", value: "msg" }, { type: "STRING", value: "hello" }]] }, + }, + prefer_alonzo_format: true, + }); + const entry = auxData.metadata()!.metadata()!.get(674n); + expect(entry).toBeDefined(); + }); + + it("converts native_scripts", () => { + const auxData = auxiliaryDataPrototypeToCardano({ + native_scripts: [{ type: "SCRIPT_PUBKEY", value: { addr_keyhash: "aa".repeat(28) } }], + prefer_alonzo_format: true, + }); + const scripts = auxData.nativeScripts()!; + expect(scripts).toHaveLength(1); + expect(scripts[0]!.asScriptPubkey()!.keyHash().toString()).toEqual("aa".repeat(28)); + }); + + it("returns an auxiliary data object with nothing set when the prototype is empty", () => { + const auxData = auxiliaryDataPrototypeToCardano({ prefer_alonzo_format: true }); + expect(auxData.metadata()).toBeUndefined(); + expect(auxData.nativeScripts()).toBeUndefined(); + }); +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/body.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/body.test.ts new file mode 100644 index 000000000..1626b6966 --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/body.test.ts @@ -0,0 +1,88 @@ +import { transactionBodyPrototypeToCardano } from "../../src/tx-prototype-to-cbor/body"; + +const TX_HASH = "11".repeat(32); +const ADDRESS = + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9"; +const REWARD_ACCOUNT = "stake_test1uqdgagy7x7mtcta2qyyg244efgtr57wg5mxa2wwnvrx845s4sa2vp"; +const POLICY_ID = "aa".repeat(28); + +const minimalBody = () => ({ + fee: "170000", + inputs: [{ transaction_id: TX_HASH, index: 0 }], + outputs: [{ address: ADDRESS, amount: { coin: "5000000" } }], +}); + +describe("transactionBodyPrototypeToCardano", () => { + it("converts the required fields", () => { + const body = transactionBodyPrototypeToCardano(minimalBody()); + expect(body.fee()).toEqual(170000n); + expect([...body.inputs().values()]).toHaveLength(1); + expect(body.outputs()).toHaveLength(1); + }); + + it("sets ttl/validity_start_interval", () => { + const body = transactionBodyPrototypeToCardano({ + ...minimalBody(), + ttl: "100000000", + validity_start_interval: "99999000", + }); + expect(body.ttl()!.toString()).toEqual("100000000"); + expect(body.validityStartInterval()!.toString()).toEqual("99999000"); + }); + + it("sets mint as an AssetId -> quantity map", () => { + const body = transactionBodyPrototypeToCardano({ + ...minimalBody(), + mint: { [POLICY_ID]: { "6d696e74": "5" } }, + }); + const mint = body.mint()!; + expect(mint.size).toEqual(1); + expect([...mint.values()][0]).toEqual(5n); + }); + + it("sets withdrawals keyed by reward account", () => { + const body = transactionBodyPrototypeToCardano({ + ...minimalBody(), + withdrawals: { [REWARD_ACCOUNT]: "2000000" }, + }); + const withdrawals = body.withdrawals()!; + expect(withdrawals.size).toEqual(1); + expect([...withdrawals.values()][0]).toEqual(2000000n); + }); + + it("sets already-computed script_data_hash/auxiliary_data_hash verbatim", () => { + const scriptDataHash = "22".repeat(32); + const auxDataHash = "33".repeat(32); + const body = transactionBodyPrototypeToCardano({ + ...minimalBody(), + auxiliary_data_hash: auxDataHash, + script_data_hash: scriptDataHash, + }); + expect(body.scriptDataHash()!.toString()).toEqual(scriptDataHash); + expect(body.auxiliaryDataHash()!.toString()).toEqual(auxDataHash); + }); + + it("sets certs using the given network_id", () => { + const body = transactionBodyPrototypeToCardano({ + ...minimalBody(), + certs: [ + { + type: "STAKE_REGISTRATION", + value: { stake_credential: { type: "KEY", value: "bb".repeat(28) } }, + }, + ], + network_id: { type: "TESTNET" }, + }); + expect([...body.certs()!.values()]).toHaveLength(1); + }); + + it("sets required_signers", () => { + const body = transactionBodyPrototypeToCardano({ + ...minimalBody(), + required_signers: ["cc".repeat(28)], + }); + const signers = [...body.requiredSigners()!.values()]; + expect(signers).toHaveLength(1); + expect(signers[0]!.toCore()).toEqual("cc".repeat(28)); + }); +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/certificates.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/certificates.test.ts new file mode 100644 index 000000000..150a4731c --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/certificates.test.ts @@ -0,0 +1,152 @@ +import { certificatePrototypeToCardano } from "../../src/tx-prototype-to-cbor/certificates"; + +const KEY_HASH = "aa".repeat(28); +const POOL_KEY_HASH = "bb".repeat(28); + +describe("certificatePrototypeToCardano", () => { + it("converts STAKE_REGISTRATION to a StakeRegistration cert carrying the raw credential", () => { + const cert = certificatePrototypeToCardano( + { type: "STAKE_REGISTRATION", value: { stake_credential: { type: "KEY", value: KEY_HASH } } }, + 0, + ); + const core = cert.toCore() as { stakeCredential: { hash: string } }; + expect(core.stakeCredential.hash.toString()).toEqual(KEY_HASH); + }); + + it("produces the same credential regardless of networkId (0 vs 1)", () => { + const build = (networkId: 0 | 1) => + ( + certificatePrototypeToCardano( + { + type: "STAKE_DEREGISTRATION", + value: { stake_credential: { type: "SCRIPT", value: KEY_HASH } }, + }, + networkId, + ).toCore() as { stakeCredential: { hash: string } } + ).stakeCredential.hash.toString(); + + expect(build(0)).toEqual(build(1)); + expect(build(0)).toEqual(KEY_HASH); + }); + + it("converts STAKE_DELEGATION with pool keyhash", () => { + const cert = certificatePrototypeToCardano( + { + type: "STAKE_DELEGATION", + value: { + stake_credential: { type: "KEY", value: KEY_HASH }, + pool_keyhash: POOL_KEY_HASH, + }, + }, + 0, + ); + const core = cert.toCore() as { stakeCredential: { hash: string }; poolId: string }; + expect(core.stakeCredential.hash.toString()).toEqual(KEY_HASH); + expect(core.poolId.toString()).toContain("pool1"); + }); + + it("converts POOL_RETIREMENT with epoch", () => { + const cert = certificatePrototypeToCardano( + { type: "POOL_RETIREMENT", value: { pool_keyhash: POOL_KEY_HASH, epoch: 450n } }, + 0, + ); + const core = cert.toCore() as { epoch: number }; + expect(core.epoch).toEqual(450); + }); + + it("converts VOTE_DELEGATION to always-abstain", () => { + const cert = certificatePrototypeToCardano( + { + type: "VOTE_DELEGATION", + value: { + stake_credential: { type: "KEY", value: KEY_HASH }, + drep: { type: "ALWAYS_ABSTAIN" }, + }, + }, + 0, + ); + const core = cert.toCore() as { stakeCredential: { hash: string }; dRep: unknown }; + expect(core.stakeCredential.hash.toString()).toEqual(KEY_HASH); + expect(core.dRep).toBeDefined(); + }); + + it("round-trips a DRep key hash through DREP_REGISTRATION", () => { + const cert = certificatePrototypeToCardano( + { + type: "DREP_REGISTRATION", + value: { voting_credential: { type: "KEY", value: KEY_HASH }, coin: "500000000" }, + }, + 0, + ); + const core = cert.toCore() as { deposit: bigint; dRepCredential: { hash: string } }; + expect(core.deposit).toEqual(500000000n); + expect(core.dRepCredential.hash.toString()).toEqual(KEY_HASH); + }); + + // Regression for a bug in `utils/certificate.ts` (shared with the v1 MeshTxBuilder): the + // `StakeVoteRegistrationAndDelegation` branch emitted `newStakeVoteDelegationCert` + // (CDDL certificate 10, no deposit) instead of `newStakeVoteRegistrationDelegationCert` + // (CDDL certificate 13). The two were byte-identical and the `coin` was silently dropped, while + // the builder's `getTotalDeposit()` still charged it — so the balance included a deposit the + // certificate never declared, and the stake credential was never registered. + describe("STAKE_VOTE_REGISTRATION_AND_DELEGATION (CDDL cert 13)", () => { + const build = (coin: string) => + certificatePrototypeToCardano( + { + type: "STAKE_VOTE_REGISTRATION_AND_DELEGATION", + value: { + stake_credential: { type: "KEY", value: KEY_HASH }, + pool_keyhash: POOL_KEY_HASH, + drep: { type: "ALWAYS_ABSTAIN" }, + coin, + }, + }, + 0, + ); + + it("emits a registration-delegation certificate, not a plain delegation", () => { + const core = build("2000000").toCore() as { __typename: string }; + expect(core.__typename).toEqual("StakeVoteRegistrationDelegateCertificate"); + }); + + it("carries the deposit", () => { + const core = build("2000000").toCore() as { deposit: bigint }; + expect(core.deposit).toEqual(2000000n); + }); + + it("is distinct from STAKE_AND_VOTE_DELEGATION (cert 10)", () => { + const cert10 = certificatePrototypeToCardano( + { + type: "STAKE_AND_VOTE_DELEGATION", + value: { + stake_credential: { type: "KEY", value: KEY_HASH }, + pool_keyhash: POOL_KEY_HASH, + drep: { type: "ALWAYS_ABSTAIN" }, + }, + }, + 0, + ); + expect(build("2000000").toCbor()).not.toEqual(cert10.toCbor()); + }); + + it.each(["ALWAYS_ABSTAIN", "ALWAYS_NO_CONFIDENCE"] as const)( + "carries the deposit for DRep variant %s", + (drepType) => { + const cert = certificatePrototypeToCardano( + { + type: "STAKE_VOTE_REGISTRATION_AND_DELEGATION", + value: { + stake_credential: { type: "KEY", value: KEY_HASH }, + pool_keyhash: POOL_KEY_HASH, + drep: { type: drepType }, + coin: "3000000", + }, + }, + 0, + ); + expect((cert.toCore() as { deposit: bigint }).deposit).toEqual(3000000n); + }, + ); + }); + +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/governance.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/governance.test.ts new file mode 100644 index 000000000..f95d2c7b1 --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/governance.test.ts @@ -0,0 +1,113 @@ +import { + voterPrototypeToMesh, + votingProceduresPrototypeToCardano, + votingProposalPrototypeToCardano, +} from "../../src/tx-prototype-to-cbor/governance"; +import { toDRep } from "../../src/utils/converter"; + +const KEY_HASH = "aa".repeat(28); +const TX_HASH = "11".repeat(32); +const ANCHOR_HASH = "cc".repeat(32); +const REWARD_ACCOUNT = "stake_test1uqdgagy7x7mtcta2qyyg244efgtr57wg5mxa2wwnvrx845s4sa2vp"; + +describe("voterPrototypeToMesh", () => { + it("converts a DRep voter to a CIP-105 drep id that decodes back to the same key hash", () => { + const voter = voterPrototypeToMesh({ type: "DREP", value: { type: "KEY", value: KEY_HASH } }); + expect(voter.type).toEqual("DRep"); + const decoded = toDRep((voter as { drepId: string }).drepId); + expect(decoded.toKeyHash()).toEqual(KEY_HASH); + }); + + it("passes through a StakingPool voter's key hash", () => { + const voter = voterPrototypeToMesh({ type: "STAKING_POOL", value: KEY_HASH }); + expect(voter).toEqual({ type: "StakingPool", keyHash: KEY_HASH }); + }); + + it("converts a ConstitutionalCommittee voter's hot credential", () => { + const voter = voterPrototypeToMesh({ + type: "CONSTITUTIONAL_COMMITTEE_HOT_CRED", + value: { type: "KEY", value: KEY_HASH }, + }); + expect(voter).toEqual({ + type: "ConstitutionalCommittee", + hotCred: { type: "KeyHash", keyHash: KEY_HASH }, + }); + }); +}); + +describe("votingProceduresPrototypeToCardano", () => { + it("inserts one vote per (voter, action) pair and preserves the vote kind", () => { + const votingProcedures = votingProceduresPrototypeToCardano([ + { + voter: { type: "STAKING_POOL", value: KEY_HASH }, + votes: [ + { + action_id: { transaction_id: TX_HASH, index: 0 }, + voting_procedure: { vote: { type: "YES" } }, + }, + ], + }, + ]); + + expect(votingProcedures.getVoters()).toHaveLength(1); + const [voter] = votingProcedures.getVoters(); + const [actionId] = votingProcedures.getGovernanceActionIdsByVoter(voter!); + expect(votingProcedures.get(voter!, actionId!)!.vote()).toEqual(1); // Yes + }); +}); + +describe("votingProposalPrototypeToCardano", () => { + it("converts an INFO_ACTION proposal with anchor/reward account/deposit", () => { + const proposal = votingProposalPrototypeToCardano({ + anchor: { anchor_url: "https://example.com", anchor_data_hash: ANCHOR_HASH }, + deposit: "100000000000", + governance_action: { type: "INFO_ACTION" }, + reward_account: REWARD_ACCOUNT, + }); + const core = proposal.toCore(); + expect(core.deposit).toEqual(100000000000n); + expect(core.anchor.url).toEqual("https://example.com"); + expect(core.anchor.dataHash.toString()).toEqual(ANCHOR_HASH); + }); + + it("converts a TREASURY_WITHDRAWALS_ACTION with withdrawals", () => { + const proposal = votingProposalPrototypeToCardano({ + anchor: { anchor_url: "https://example.com", anchor_data_hash: ANCHOR_HASH }, + deposit: "100000000000", + governance_action: { + type: "TREASURY_WITHDRAWALS_ACTION", + value: { withdrawals: { [REWARD_ACCOUNT]: "5000000" } }, + }, + reward_account: REWARD_ACCOUNT, + }); + const action = proposal.toCore().governanceAction as { withdrawals: Set }; + expect(action.withdrawals.size).toEqual(1); + }); + + it("maps PARAMETER_CHANGE_ACTION cost_models keyed by \"PlutusV1\"/\"PlutusV2\"/\"PlutusV3\" (per whisky's real convert/governance.rs), skipping unrecognized keys", () => { + const proposal = votingProposalPrototypeToCardano({ + anchor: { anchor_url: "https://example.com", anchor_data_hash: ANCHOR_HASH }, + deposit: "100000000000", + governance_action: { + type: "PARAMETER_CHANGE_ACTION", + value: { + protocol_param_updates: { + cost_models: { + PlutusV1: ["1", "2", "3"], + PlutusV2: ["4", "5"], + SomeFutureLanguage: ["999"], // must be silently skipped, not thrown on + }, + }, + }, + }, + reward_account: REWARD_ACCOUNT, + }); + const action = proposal.toCore().governanceAction as { + protocolParamUpdate: { costModels?: Map }; + }; + const costModels = action.protocolParamUpdate.costModels!; + expect(costModels.size).toEqual(2); // PlutusV1 + PlutusV2 only + expect([...costModels.values()]).toContainEqual([1, 2, 3]); + expect([...costModels.values()]).toContainEqual([4, 5]); + }); +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/inputs-outputs.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/inputs-outputs.test.ts new file mode 100644 index 000000000..ea6b92c7b --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/inputs-outputs.test.ts @@ -0,0 +1,76 @@ +import { + transactionInputPrototypeToCardano, + transactionOutputPrototypeToCardano, +} from "../../src/tx-prototype-to-cbor/inputs-outputs"; +import { nativeScriptPrototypeToCardano } from "../../src/tx-prototype-to-cbor/native-script"; +import { Script } from "../../src/types"; + +const TX_HASH = "11".repeat(32); +const ADDRESS = + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9"; + +describe("transactionInputPrototypeToCardano", () => { + it("converts transaction_id/index", () => { + const input = transactionInputPrototypeToCardano({ transaction_id: TX_HASH, index: 3 }); + expect(input.transactionId().toString()).toEqual(TX_HASH); + expect(input.index()).toEqual(3n); + }); +}); + +describe("transactionOutputPrototypeToCardano", () => { + it("converts a plain address + value output", () => { + const output = transactionOutputPrototypeToCardano({ + address: ADDRESS, + amount: { coin: "5000000" }, + }); + expect(output.address().toBech32()).toEqual(ADDRESS); + expect(output.amount().coin()).toEqual(5000000n); + expect(output.datum()).toBeUndefined(); + expect(output.scriptRef()).toBeUndefined(); + }); + + it("sets a datum hash from DATA_HASH", () => { + const hash = "22".repeat(32); + const output = transactionOutputPrototypeToCardano({ + address: ADDRESS, + amount: { coin: "5000000" }, + plutus_data: { type: "DATA_HASH", value: hash }, + }); + expect(output.datum()!.asDataHash()!.toString()).toEqual(hash); + }); + + it("sets an inline datum from DATA", () => { + const output = transactionOutputPrototypeToCardano({ + address: ADDRESS, + amount: { coin: "5000000" }, + plutus_data: { + type: "DATA", + value: { type: "MANUAL", data: { type: "INTEGER", value: 42n } }, + }, + }); + expect(output.datum()!.asInlineData()!.asInteger()).toEqual(42n); + }); + + it("sets a reference script from script_ref cbor", () => { + const nativeScript = nativeScriptPrototypeToCardano({ + type: "SCRIPT_PUBKEY", + value: { addr_keyhash: "aa".repeat(28) }, + }); + const scriptCbor = Script.newNativeScript(nativeScript).toCbor(); + const output = transactionOutputPrototypeToCardano({ + address: ADDRESS, + amount: { coin: "5000000" }, + script_ref: scriptCbor, + }); + expect(output.scriptRef()!.toCbor()).toEqual(scriptCbor); + }); + + it("converts amount with a multiasset", () => { + const policyId = "aa".repeat(28); + const output = transactionOutputPrototypeToCardano({ + address: ADDRESS, + amount: { coin: "2000000", multiasset: { [policyId]: { "74657374": "10" } } }, + }); + expect(output.amount().multiasset()!.size).toEqual(1); + }); +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/native-script.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/native-script.test.ts new file mode 100644 index 000000000..5c7a277c6 --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/native-script.test.ts @@ -0,0 +1,67 @@ +import { nativeScriptPrototypeToCardano } from "../../src/tx-prototype-to-cbor/native-script"; + +const KEY_HASH = "aa".repeat(28); + +describe("nativeScriptPrototypeToCardano", () => { + it("converts SCRIPT_PUBKEY", () => { + const script = nativeScriptPrototypeToCardano({ + type: "SCRIPT_PUBKEY", + value: { addr_keyhash: KEY_HASH }, + }); + expect(script.asScriptPubkey()!.keyHash().toString()).toEqual(KEY_HASH); + }); + + it("converts SCRIPT_ALL with nested scripts", () => { + const script = nativeScriptPrototypeToCardano({ + type: "SCRIPT_ALL", + value: { + native_scripts: [{ type: "SCRIPT_PUBKEY", value: { addr_keyhash: KEY_HASH } }], + }, + }); + const all = script.asScriptAll()!; + expect(all.nativeScripts()).toHaveLength(1); + expect(all.nativeScripts()[0]!.asScriptPubkey()!.keyHash().toString()).toEqual(KEY_HASH); + }); + + it("converts SCRIPT_ANY", () => { + const script = nativeScriptPrototypeToCardano({ + type: "SCRIPT_ANY", + value: { + native_scripts: [{ type: "SCRIPT_PUBKEY", value: { addr_keyhash: KEY_HASH } }], + }, + }); + expect(script.asScriptAny()!.nativeScripts()).toHaveLength(1); + }); + + it("converts SCRIPT_N_OF_K", () => { + const script = nativeScriptPrototypeToCardano({ + type: "SCRIPT_N_OF_K", + value: { + n: 1n, + native_scripts: [ + { type: "SCRIPT_PUBKEY", value: { addr_keyhash: KEY_HASH } }, + { type: "SCRIPT_PUBKEY", value: { addr_keyhash: "bb".repeat(28) } }, + ], + }, + }); + const nOfK = script.asScriptNOfK()!; + expect(nOfK.required()).toEqual(1); + expect(nOfK.nativeScripts()).toHaveLength(2); + }); + + it("converts TIMELOCK_START", () => { + const script = nativeScriptPrototypeToCardano({ + type: "TIMELOCK_START", + value: { slot: "100" }, + }); + expect(script.asTimelockStart()!.slot().toString()).toEqual("100"); + }); + + it("converts TIMELOCK_EXPIRY", () => { + const script = nativeScriptPrototypeToCardano({ + type: "TIMELOCK_EXPIRY", + value: { slot: "200" }, + }); + expect(script.asTimelockExpiry()!.slot().toString()).toEqual("200"); + }); +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/plutus-data.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/plutus-data.test.ts new file mode 100644 index 000000000..586a30545 --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/plutus-data.test.ts @@ -0,0 +1,101 @@ +import { + plutusDataPrototypeToCardano, + plutusDataVariantToCardano, +} from "../../src/tx-prototype-to-cbor/plutus-data"; +import { PlutusDataKind } from "../../src/types"; + +describe("plutusDataPrototypeToCardano", () => { + it("converts INTEGER", () => { + const data = plutusDataPrototypeToCardano({ type: "INTEGER", value: 42n }); + expect(data.getKind()).toEqual(PlutusDataKind.Integer); + expect(data.asInteger()).toEqual(42n); + }); + + it("converts a negative INTEGER", () => { + const data = plutusDataPrototypeToCardano({ type: "INTEGER", value: -7n }); + expect(data.asInteger()).toEqual(-7n); + }); + + it("converts BYTES from hex", () => { + const data = plutusDataPrototypeToCardano({ type: "BYTES", value: "deadbeef" }); + expect(data.getKind()).toEqual(PlutusDataKind.Bytes); + expect(Buffer.from(data.asBoundedBytes()!).toString("hex")).toEqual("deadbeef"); + }); + + it("converts a nested LIST", () => { + const data = plutusDataPrototypeToCardano({ + type: "LIST", + value: [ + { type: "INTEGER", value: 1n }, + { type: "BYTES", value: "ff" }, + ], + }); + expect(data.getKind()).toEqual(PlutusDataKind.List); + const list = data.asList()!; + expect(list.getLength()).toEqual(2); + expect(list.get(0).asInteger()).toEqual(1n); + expect(Buffer.from(list.get(1).asBoundedBytes()!).toString("hex")).toEqual("ff"); + }); + + it("converts a MAP", () => { + const data = plutusDataPrototypeToCardano({ + type: "MAP", + value: [[{ type: "INTEGER", value: 1n }, { type: "INTEGER", value: 2n }]], + }); + expect(data.getKind()).toEqual(PlutusDataKind.Map); + const map = data.asMap()!; + const keys = map.getKeys(); + expect(keys.getLength()).toEqual(1); + expect(map.get(keys.get(0))?.asInteger()).toEqual(2n); + }); + + it("converts a CONSTR with fields", () => { + const data = plutusDataPrototypeToCardano({ + type: "CONSTR", + alternative: 0n, + fields: [{ type: "INTEGER", value: 7n }], + }); + expect(data.getKind()).toEqual(PlutusDataKind.ConstrPlutusData); + const constr = data.asConstrPlutusData()!; + expect(constr.getAlternative()).toEqual(0n); + expect(constr.getData().get(0).asInteger()).toEqual(7n); + }); + + it("accepts a CONSTR alternative beyond Number.MAX_SAFE_INTEGER as a bigint", () => { + // whisky's own `alternative` field is Rust `u64` (general constr tag 102), not `u32` like + // e.g. epoch/n fields — a plain `number` would silently lose precision for values this size. + const huge = 9_007_199_254_740_993n; // MAX_SAFE_INTEGER + 2, unrepresentable exactly as number + const data = plutusDataPrototypeToCardano({ type: "CONSTR", alternative: huge, fields: [] }); + expect(data.asConstrPlutusData()!.getAlternative()).toEqual(huge); + }); + + it("round-trips through CBOR", () => { + const data = plutusDataPrototypeToCardano({ + type: "CONSTR", + alternative: 1n, + fields: [{ type: "BYTES", value: "cafe" }], + }); + const roundTripped = plutusDataPrototypeToCardano({ + type: "CONSTR", + alternative: 1n, + fields: [{ type: "BYTES", value: "cafe" }], + }); + expect(data.toCbor()).toEqual(roundTripped.toCbor()); + }); +}); + +describe("plutusDataVariantToCardano", () => { + it("decodes a CBOR variant directly", () => { + const rawInt = plutusDataPrototypeToCardano({ type: "INTEGER", value: 5n }); + const data = plutusDataVariantToCardano({ type: "CBOR", hex: rawInt.toCbor() }); + expect(data.asInteger()).toEqual(5n); + }); + + it("converts a MANUAL variant via plutusDataPrototypeToCardano", () => { + const data = plutusDataVariantToCardano({ + type: "MANUAL", + data: { type: "BYTES", value: "1234" }, + }); + expect(Buffer.from(data.asBoundedBytes()!).toString("hex")).toEqual("1234"); + }); +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/transaction.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/transaction.test.ts new file mode 100644 index 000000000..ae4f30d46 --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/transaction.test.ts @@ -0,0 +1,112 @@ +import { inConwayEra, setInConwayEra } from "@cardano-sdk/core"; + +import type { TransactionPrototype } from "@meshsdk/common"; + +import { + transactionPrototypeToCardano, + transactionPrototypeToHex, +} from "../../src/tx-prototype-to-cbor/transaction"; +import { Transaction } from "../../src/types"; + +const TX_HASH = "11".repeat(32); +const ADDRESS = + "addr_test1qpvx0sacufuypa2k4sngk7q40zc5c4npl337uusdh64kv0uafhxhu32dys6pvn6wlw8dav6cmp4pmtv7cc3yel9uu0nq93swx9"; + +const minimalPrototype = (isValid = true) => ({ + auxiliary_data: undefined, + body: { + fee: "170000", + inputs: [{ transaction_id: TX_HASH, index: 0 }], + outputs: [{ address: ADDRESS, amount: { coin: "5000000" } }], + }, + is_valid: isValid, + witness_set: {}, +}); + +describe("transactionPrototypeToCardano / transactionPrototypeToHex", () => { + it("produces a Transaction that round-trips through real CBOR encode/decode", () => { + const hex = transactionPrototypeToHex(minimalPrototype()); + const decoded = Transaction.fromCbor(hex as never); + + expect(decoded.body().fee()).toEqual(170000n); + expect(decoded.body().outputs()).toHaveLength(1); + expect(decoded.body().outputs()[0]!.address().toBech32()).toEqual(ADDRESS); + expect(decoded.isValid()).toEqual(true); + }); + + it("respects is_valid = false (phase-2-invalid / collateral-only transactions)", () => { + const hex = transactionPrototypeToHex(minimalPrototype(false)); + const decoded = Transaction.fromCbor(hex as never); + expect(decoded.isValid()).toEqual(false); + }); + + describe("taggedSets option", () => { + // CBOR tag 258 encodes as d90102. A transaction must use one set encoding throughout — the + // two forms hash differently — so the flag is transaction-wide by construction. + const SET_TAG = "d90102"; + + const withManySets = (): TransactionPrototype => ({ + ...minimalPrototype(), + body: { + ...minimalPrototype().body, + collateral: [{ transaction_id: TX_HASH, index: 1 }], + reference_inputs: [{ transaction_id: TX_HASH, index: 2 }], + required_signers: ["cc".repeat(28)], + }, + }); + + it("emits #6.258-tagged sets by default", () => { + expect(transactionPrototypeToHex(minimalPrototype())).toContain(SET_TAG); + }); + + it("emits plain arrays when taggedSets is false", () => { + expect(transactionPrototypeToHex(minimalPrototype(), { taggedSets: false })).not.toContain( + SET_TAG, + ); + }); + + it("applies the choice to EVERY set — all tagged or none, never mixed", () => { + // 4 sets present: inputs, collateral, reference_inputs, required_signers. + const tagged = transactionPrototypeToHex(withManySets(), { taggedSets: true }); + const plain = transactionPrototypeToHex(withManySets(), { taggedSets: false }); + const count = (hex: string) => hex.split(SET_TAG).length - 1; + + expect(count(tagged)).toEqual(4); + expect(count(plain)).toEqual(0); + }); + + it("produces different CBOR for the two encodings (i.e. a different tx hash)", () => { + expect(transactionPrototypeToHex(minimalPrototype(), { taggedSets: true })).not.toEqual( + transactionPrototypeToHex(minimalPrototype(), { taggedSets: false }), + ); + }); + + it("restores the ambient global, so it neither leaks nor is inherited", () => { + setInConwayEra(false); + transactionPrototypeToHex(minimalPrototype(), { taggedSets: true }); + expect(inConwayEra).toBe(false); + + setInConwayEra(true); + transactionPrototypeToHex(minimalPrototype(), { taggedSets: false }); + expect(inConwayEra).toBe(true); + }); + + it("ignores the ambient global — same input, same output regardless", () => { + setInConwayEra(false); + const a = transactionPrototypeToHex(minimalPrototype(), { taggedSets: true }); + setInConwayEra(true); + const b = transactionPrototypeToHex(minimalPrototype(), { taggedSets: true }); + expect(a).toEqual(b); + }); + }); + + it("carries a witness set through to the encoded transaction", () => { + const proto = minimalPrototype(); + const tx = transactionPrototypeToCardano({ + ...proto, + witness_set: { vkeys: [{ vkey: "dd".repeat(32), signature: "ee".repeat(64) }] }, + }); + const decoded = Transaction.fromCbor(tx.toCbor()); + expect([...decoded.witnessSet().vkeys()!.values()]).toHaveLength(1); + }); +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/value.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/value.test.ts new file mode 100644 index 000000000..21b9895d3 --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/value.test.ts @@ -0,0 +1,44 @@ +import { + multiAssetPrototypeToAssets, + valuePrototypeToCardano, +} from "../../src/tx-prototype-to-cbor/value"; + +const POLICY_ID = "aa".repeat(28); +const ASSET_NAME_HEX = "6d795f746f6b656e"; // "my_token" + +describe("multiAssetPrototypeToAssets", () => { + it("returns just lovelace when there is no multiasset", () => { + expect(multiAssetPrototypeToAssets("1000000", undefined)).toEqual([ + { unit: "lovelace", quantity: "1000000" }, + ]); + }); + + it("flattens policy/assetName/quantity into unit = policyId + assetNameHex", () => { + const assets = multiAssetPrototypeToAssets("1000000", { + [POLICY_ID]: { [ASSET_NAME_HEX]: "5" }, + }); + expect(assets).toEqual([ + { unit: "lovelace", quantity: "1000000" }, + { unit: `${POLICY_ID}${ASSET_NAME_HEX}`, quantity: "5" }, + ]); + }); +}); + +describe("valuePrototypeToCardano", () => { + it("converts lovelace-only value", () => { + const value = valuePrototypeToCardano({ coin: "1000000" }); + expect(value.coin()).toEqual(1000000n); + expect(value.multiasset()).toBeUndefined(); + }); + + it("converts a value with a multiasset", () => { + const value = valuePrototypeToCardano({ + coin: "2000000", + multiasset: { [POLICY_ID]: { [ASSET_NAME_HEX]: "5" } }, + }); + expect(value.coin()).toEqual(2000000n); + const multiasset = value.multiasset()!; + expect(multiasset.size).toEqual(1); + expect([...multiasset.values()][0]).toEqual(5n); + }); +}); diff --git a/packages/mesh-core-cst/test/tx-prototype-to-cbor/witness-set.test.ts b/packages/mesh-core-cst/test/tx-prototype-to-cbor/witness-set.test.ts new file mode 100644 index 000000000..ddd3d9ab1 --- /dev/null +++ b/packages/mesh-core-cst/test/tx-prototype-to-cbor/witness-set.test.ts @@ -0,0 +1,102 @@ +import { transactionWitnessSetPrototypeToCardano } from "../../src/tx-prototype-to-cbor/witness-set"; +import { RedeemerTag } from "../../src/types"; + +const PUBKEY = "dd".repeat(32); +const SIGNATURE = "ee".repeat(64); + +const redeemer = (tag: "SPEND" | "MINT" | "CERT" | "REWARD" | "VOTE" | "VOTING_PROPOSAL") => ({ + data: { type: "CBOR" as const, hex: "01" }, // CBOR for the integer 1 + ex_units: { mem: "1000", steps: "500" }, + index: "0", + tag: { type: tag }, +}); + +describe("transactionWitnessSetPrototypeToCardano", () => { + it("returns an empty witness set for an empty prototype", () => { + const ws = transactionWitnessSetPrototypeToCardano({}); + expect(ws.vkeys()).toBeUndefined(); + expect(ws.redeemers()).toBeUndefined(); + }); + + it("converts vkeys", () => { + const ws = transactionWitnessSetPrototypeToCardano({ + vkeys: [{ vkey: PUBKEY, signature: SIGNATURE }], + }); + const vkeys = [...ws.vkeys()!.values()]; + expect(vkeys).toHaveLength(1); + expect(vkeys[0]!.vkey().toString()).toEqual(PUBKEY); + expect(vkeys[0]!.signature().toString()).toEqual(SIGNATURE); + }); + + it.each([ + ["SPEND", RedeemerTag.Spend], + ["MINT", RedeemerTag.Mint], + ["CERT", RedeemerTag.Cert], + ["REWARD", RedeemerTag.Reward], + ["VOTE", RedeemerTag.Voting], + ["VOTING_PROPOSAL", RedeemerTag.Proposing], + ] as const)("maps redeemer tag %s to RedeemerTag.%s", (protoTag, expected) => { + const ws = transactionWitnessSetPrototypeToCardano({ redeemers: [redeemer(protoTag)] }); + const [red] = [...ws.redeemers()!.values()]; + expect(red!.tag()).toEqual(expected); + expect(red!.index()).toEqual(0n); + expect(red!.exUnits().mem()).toEqual(1000n); + expect(red!.exUnits().steps()).toEqual(500n); + expect(red!.data().asInteger()).toEqual(1n); + }); + + it("converts native_scripts via structured NativeScriptPrototype", () => { + const ws = transactionWitnessSetPrototypeToCardano({ + native_scripts: [{ type: "SCRIPT_PUBKEY", value: { addr_keyhash: "aa".repeat(28) } }], + }); + const scripts = [...ws.nativeScripts()!.values()]; + expect(scripts).toHaveLength(1); + expect(scripts[0]!.asScriptPubkey()!.keyHash().toString()).toEqual("aa".repeat(28)); + }); + + it("routes each plutus script version to its own CDDL key (3/6/7), no version guessing", () => { + // Any valid-looking script hex is fine — this checks witness-set wiring, not execution. + const scriptCbor = "4d01000033222220051200120011"; + const ws = transactionWitnessSetPrototypeToCardano({ + plutus_v1_scripts: [scriptCbor], + plutus_v2_scripts: [scriptCbor], + plutus_v3_scripts: [scriptCbor], + }); + expect([...ws.plutusV1Scripts()!.values()]).toHaveLength(1); + expect([...ws.plutusV2Scripts()!.values()]).toHaveLength(1); + expect([...ws.plutusV3Scripts()!.values()]).toHaveLength(1); + }); + + it("does not put a V3 script into the V1 bucket (regression: old single-field shape did)", () => { + const ws = transactionWitnessSetPrototypeToCardano({ + plutus_v3_scripts: ["4d01000033222220051200120011"], + }); + expect(ws.plutusV1Scripts()).toBeUndefined(); + expect([...ws.plutusV3Scripts()!.values()]).toHaveLength(1); + }); + + it("converts plutus_data as raw CBOR datum witnesses", () => { + const ws = transactionWitnessSetPrototypeToCardano({ + plutus_data: { elems: ["01"] }, // CBOR for the integer 1 + }); + const [datum] = [...ws.plutusData()!.values()]; + expect(datum!.asInteger()).toEqual(1n); + }); + + it("converts bootstraps", () => { + const ws = transactionWitnessSetPrototypeToCardano({ + bootstraps: [ + { + attributes: [1, 2, 3], + chain_code: [4, 5, 6, 7], + signature: SIGNATURE, + vkey: PUBKEY, + }, + ], + }); + const [bootstrap] = [...ws.bootstraps()!.values()]; + expect(bootstrap!.vkey().toString()).toEqual(PUBKEY); + expect(bootstrap!.chainCode().toString()).toEqual("04050607"); + expect(bootstrap!.attributes().toString()).toEqual("010203"); + }); +});