diff --git a/apollo.go b/apollo.go index a6d3e9a..86d385e 100644 --- a/apollo.go +++ b/apollo.go @@ -54,6 +54,20 @@ type Apollo struct { totalCollateral int64 referenceInputs []shelley.ShelleyTransactionInput collateralReturn *babbage.BabbageTransactionOutput + // collateralOverlapRef holds the ref of an auto-selected collateral UTxO + // that is also allowed to serve as a regular spending input. It is set only + // when no dedicated (separate) collateral UTxO was available, so wallets + // with a single UTxO can still build script transactions. The Cardano ledger + // permits this overlap because collateral is consumed only on phase-2 script + // failure and regular inputs only on success - the two paths are mutually + // exclusive. When empty, collateral is reserved out of the coin-selection + // pool as usual. + collateralOverlapRef string + // collateralAutoSelected is true when setCollateral() chose the collateral + // inputs itself (rather than the caller pinning them via AddCollateral). + // Only auto-selected collateral is resized by finalizeCollateral(), so + // caller-pinned collateral is never silently rewritten. + collateralAutoSelected bool nativescripts []common.NativeScript usedUtxos map[string]bool wallet Wallet @@ -934,9 +948,11 @@ func (a *Apollo) Clone() *Apollo { forceFee: a.forceFee, Ttl: a.Ttl, ValidityStart: a.ValidityStart, - totalCollateral: a.totalCollateral, - collateralAmount: a.collateralAmount, - currentTreasury: a.currentTreasury, + totalCollateral: a.totalCollateral, + collateralAmount: a.collateralAmount, + collateralOverlapRef: a.collateralOverlapRef, + collateralAutoSelected: a.collateralAutoSelected, + currentTreasury: a.currentTreasury, treasuryDonation: a.treasuryDonation, estimateExUnits: a.estimateExUnits, wallet: a.wallet, @@ -1180,10 +1196,25 @@ func (a *Apollo) Complete() (*Apollo, error) { } } - // Coin selection + // Coin selection. setCollateral() reserves an auto-selected collateral UTxO + // out of the pool so multi-UTxO wallets keep a separate collateral and an + // unchanged tx shape. If that reservation starves selection (e.g. a wallet + // with a single UTxO), release the collateral for overlap - the ledger lets + // one UTxO be both a spending input and collateral - and retry once. selectedUtxos, err := a.selectCoins(selectionTarget, totalInput) if err != nil { - return a, fmt.Errorf("coin selection failed: %w", err) + if a.releaseCollateralForOverlap() { + selectedUtxos, err = a.selectCoins(selectionTarget, totalInput) + if err != nil { + // The overlap retry also failed. Restore the collateral + // reservation and clear the overlap flag so a subsequent + // Complete() on this builder starts from consistent state. + a.restoreCollateralReservation() + } + } + if err != nil { + return a, fmt.Errorf("coin selection failed: %w", err) + } } // Build inputs (explicit allocation to avoid slice aliasing) @@ -1191,7 +1222,7 @@ func (a *Apollo) Complete() (*Apollo, error) { allInputUtxos = append(allInputUtxos, a.preselectedUtxos...) allInputUtxos = append(allInputUtxos, selectedUtxos...) allInputUtxos = SortInputs(allInputUtxos) - if err := a.validateCollateralDistinctFromInputs(allInputUtxos); err != nil { + if err := a.validateCollateral(); err != nil { return a, err } @@ -1355,6 +1386,13 @@ func (a *Apollo) Complete() (*Apollo, error) { converged = true break } + // Size the collateral against the current fee BEFORE re-estimating, so + // the fee accounts for the final collateral total/return footprint in + // the body. A collateral_return that only materializes after sizing + // would otherwise grow the tx past the frozen estimate. + if err := a.finalizeCollateral(fee); err != nil { + return a, err + } newFee, err := a.estimateFee(allInputUtxos, outputs) if err != nil { return a, fmt.Errorf("fee re-estimation failed: %w", err) @@ -1380,6 +1418,16 @@ func (a *Apollo) Complete() (*Apollo, error) { } } + // Recompute total collateral and the collateral return from the FINAL fee. + // setCollateral() runs before coin selection and fee estimation, so it can + // only size collateral from a preliminary (max-by-size) fee. The ledger + // requires total collateral >= ceil(fee * collateralPercent / 100) against + // the ACTUAL fee, so a stale preliminary value triggers + // InsufficientCollateral. Resize here now that the fee is final. + if err := a.finalizeCollateral(fee); err != nil { + return a, err + } + // Build transaction body body, err := a.buildBody(allInputUtxos, outputs, uint64(fee)) if err != nil { @@ -1592,8 +1640,18 @@ func (a *Apollo) estimateFee(inputs []common.Utxo, outputs []babbage.BabbageTran return 0, err } - // Build a dummy transaction to estimate size - body, err := a.buildBody(inputs, outputs, 0) + // Build a dummy transaction to estimate size. The fee field (body key 2) is + // `omitempty`, so a zero fee is dropped entirely and a non-trivial fee is + // encoded as a multi-byte integer. Sizing against a zero fee therefore + // undercounts the body by the width of the real fee field (up to ~5 bytes), + // producing a fee that is a few hundred lovelace short and a FeeTooSmallUTxO + // rejection. Use a placeholder fee whose CBOR width matches (or exceeds) the + // final fee so the size — and thus the fee — is not underestimated. + placeholderFee, feeErr := a.Context.MaxTxFee() + if feeErr != nil || placeholderFee == 0 { + placeholderFee = 2_000_000 + } + body, err := a.buildBody(inputs, outputs, placeholderFee) if err != nil { return 0, err } @@ -1653,15 +1711,102 @@ func (a *Apollo) estimateFee(inputs []common.Utxo, outputs []babbage.BabbageTran fee += int64(exUnitFeeFloat) } + // Add the Conway tiered reference-script fee. Reference scripts (native and + // Plutus, carried by spending inputs or supplied via reference inputs) are + // priced per byte on a growing tier, and the ledger includes this in minfee, + // so omitting it produces FeeTooSmallUTxO. Compute it over the resolved input + // union; totalReferenceScriptSize returns 0 when no reference scripts are + // present, so transactions that use none are unaffected. + refScriptSize, err := a.totalReferenceScriptSize(inputs) + if err != nil { + return 0, err + } + if refScriptSize > 0 { + refFeePerByte := pp.RefScriptFeePerByte() + if refFeePerByte <= 0 { + return 0, fmt.Errorf( + "transaction references %d bytes of scripts but the protocol parameters carry no reference-script price; cannot compute the reference-script fee", + refScriptSize, + ) + } + fee += backend.TierRefScriptFee( + refScriptSize, + refFeePerByte, + pp.RefScriptSizeIncrement(), + pp.RefScriptMultiplier(), + ) + } + return fee, nil } +// totalReferenceScriptSize resolves the combined byte size of all reference +// scripts (native and Plutus) that the ledger prices into the reference-script fee. +// The ledger counts the size of every script attached (via script_ref) to the +// outputs of the transaction's reference inputs AND its spending inputs, so we +// resolve both. A reference input that fails to resolve is a hard error: an +// undercounted size silently underprices the fee and gets the tx rejected. +func (a *Apollo) totalReferenceScriptSize(inputs []common.Utxo) (int, error) { + seen := make(map[string]struct{}) + total := 0 + + addScript := func(script common.Script) { + if script == nil { + return + } + // The ledger prices every reference script (native and Plutus) by its + // raw byte size, so do not filter by language here. + total += len(script.RawScriptBytes()) + } + + // Spending inputs already resolved by the caller carry their outputs. + for _, utxo := range inputs { + ref := utxoRef(utxo) + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + addScript(utxo.Output.ScriptRef()) + } + + // Reference inputs must be resolved against the chain context. + for _, refInput := range a.referenceInputs { + ref := hex.EncodeToString(refInput.TxId.Bytes()) + "#" + strconv.Itoa(int(refInput.OutputIndex)) + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + utxo, err := a.Context.UtxoByRef(refInput.TxId, refInput.OutputIndex) + if err != nil { + return 0, fmt.Errorf( + "failed to resolve reference input %s for reference-script fee: %w", + ref, err, + ) + } + if utxo == nil { + return 0, fmt.Errorf("reference input %s not found for reference-script fee", ref) + } + addScript(utxo.Output.ScriptRef()) + } + + return total, nil +} + // estimateExecutionUnits builds a preliminary transaction and evaluates it // against the chain to get actual execution units for script redeemers. // The returned ExUnits include a buffer for safety. func (a *Apollo) estimateExecutionUnits(inputs []common.Utxo, outputs []babbage.BabbageTransactionOutput) error { - // Build preliminary tx with current (possibly zero) ExUnits - body, err := a.buildBody(inputs, outputs, 0) + // Build preliminary tx with current (possibly zero) ExUnits. The fee field + // is `omitempty`, so a zero fee is dropped from the CBOR and the body has no + // fee (key 2). Strict evaluators (Ogmios, and Blockfrost which proxies it) + // reject such a body with "field fee with key 2, not decoded". Use a + // non-zero placeholder fee so the field is always present; its value does + // not affect script evaluation. + placeholderFee, feeErr := a.Context.MaxTxFee() + if feeErr != nil || placeholderFee == 0 { + placeholderFee = 2_000_000 + } + body, err := a.buildBody(inputs, outputs, placeholderFee) if err != nil { return fmt.Errorf("failed to build preliminary tx body: %w", err) } @@ -2249,7 +2394,14 @@ func (a *Apollo) isUsed(ref string) bool { } } for _, utxo := range a.collaterals { - if utxoRef(utxo) == ref { + // A collateral UTxO flagged for overlap is intentionally left available + // to coin selection so it can ALSO be picked as a regular spending input + // (see collateralOverlapRef). Treat it as not-used here. + cref := utxoRef(utxo) + if cref == a.collateralOverlapRef { + continue + } + if cref == ref { return true } } @@ -2284,6 +2436,19 @@ func (a *Apollo) hasScripts() bool { } // setCollateral auto-selects collateral from UTxOs if needed. +// +// Selection prefers a SEPARATE eligible vkey UTxO that is reserved out of the +// coin-selection pool (the common multi-UTxO case, where the tx shape is +// unchanged). When no dedicated UTxO is free, it falls back to a UTxO that may +// ALSO be used as a regular spending input: the candidate is recorded in +// collateralOverlapRef and is NOT reserved, so coin selection can still pick +// it. This lets a wallet with a single UTxO build a script transaction. The +// ledger permits the overlap because collateral is consumed only on phase-2 +// script failure and regular inputs only on success. +// +// totalCollateral and collateralReturn are sized here from a preliminary +// (max-by-size) fee; finalizeCollateral() resizes them once the final fee is +// known. func (a *Apollo) setCollateral() error { if len(a.collaterals) > 0 || !a.hasScripts() { return nil @@ -2312,80 +2477,307 @@ func (a *Apollo) setCollateral() error { candidates = loaded } - // First pass: prefer pure-lovelace UTxOs (no assets) - for _, utxo := range candidates { - ref := utxoRef(utxo) - if a.isUsed(ref) { - continue - } - if utxo.Output.Assets() != nil { - continue + // collateralEligible reports whether a UTxO can back collateral: it must be + // vkey-locked (never a script address), hold a representable lovelace amount + // of at least minCollateral, and -- if it carries native assets -- leave a + // positive ADA remainder so the assets can be returned via collateral_return. + collateralEligible := func(utxo common.Utxo, requirePureLovelace bool) bool { + assets := utxo.Output.Assets() + if requirePureLovelace && assets != nil { + return false } addr := utxo.Output.Address() if addr.Type() != common.AddressTypeKeyKey && addr.Type() != common.AddressTypeKeyNone { - continue + return false } amt := utxo.Output.Amount() if amt == nil || !amt.IsInt64() { - continue + return false } lovelace := amt.Int64() if lovelace < minCollateral { - continue + return false } + // An asset-bearing UTxO needs a positive remainder to carry the assets + // forward in the collateral return. + if assets != nil && lovelace-minCollateral == 0 { + return false + } + return true + } + + // selectCollateral records the chosen UTxO as collateral, reserves it out of + // the coin-selection pool (markUsed), and sizes the preliminary total and + // return. The reservation is provisional: if coin selection cannot then meet + // its target (a wallet with no other UTxO to spare), Complete() releases it + // for overlap via releaseCollateralForOverlap(). + selectCollateral := func(utxo common.Utxo) { + ref := utxoRef(utxo) a.collaterals = append(a.collaterals, utxo) + a.collateralAutoSelected = true a.markUsed(ref) a.totalCollateral = minCollateral + lovelace := utxo.Output.Amount().Int64() remainder := lovelace - minCollateral - if remainder > 0 { - returnVal := Value{Coin: uint64(remainder)} + assets := utxo.Output.Assets() + if remainder > 0 || assets != nil { + returnVal := Value{Coin: uint64(remainder)} //nolint:gosec // remainder >= 0 (eligibility checked) + if assets != nil { + returnVal.Assets = CloneMultiAsset(assets) + } ret := NewBabbageOutput(a.getChangeAddress(), returnVal, nil, nil) a.collateralReturn = &ret } - return nil } - // Fallback: allow UTxOs with some assets + + // First pass: prefer a pure-lovelace UTxO (no assets). Selection keeps the + // first-eligible candidate so a multi-UTxO wallet produces the same tx shape + // as before this change; the overlap fallback in Complete() handles the case + // where reserving a dedicated collateral starves coin selection. for _, utxo := range candidates { - ref := utxoRef(utxo) - if a.isUsed(ref) { + if a.isUsed(utxoRef(utxo)) { continue } - addr := utxo.Output.Address() - if addr.Type() != common.AddressTypeKeyKey && addr.Type() != common.AddressTypeKeyNone { + if collateralEligible(utxo, true) { + selectCollateral(utxo) + return nil + } + } + // Second pass: a UTxO that may carry assets. + for _, utxo := range candidates { + if a.isUsed(utxoRef(utxo)) { continue } + if collateralEligible(utxo, false) { + selectCollateral(utxo) + return nil + } + } + return errors.New("script transaction requires collateral, but no eligible collateral UTxO was found") +} + +// releaseCollateralForOverlap un-reserves an auto-selected collateral UTxO so +// coin selection can also pick it as a regular spending input. The ledger +// permits a UTxO to be both a spending input and collateral because the two are +// consumed on mutually exclusive paths (success vs phase-2 script failure). +// +// It only acts on a single auto-selected collateral input that has not already +// been flagged for overlap, and reports whether anything was released so the +// caller knows a retry is worthwhile. Caller-pinned collateral is never touched. +func (a *Apollo) releaseCollateralForOverlap() bool { + if !a.collateralAutoSelected || a.collateralOverlapRef != "" || len(a.collaterals) != 1 { + return false + } + ref := utxoRef(a.collaterals[0]) + delete(a.usedUtxos, ref) + a.collateralOverlapRef = ref + return true +} + +// restoreCollateralReservation reverses releaseCollateralForOverlap: it re-marks +// the released collateral UTxO as used and clears the overlap flag. It is called +// when the overlap retry still fails, so the builder is left in the same state +// it had before the release and a subsequent Complete() is not skewed by a +// half-applied overlap. +func (a *Apollo) restoreCollateralReservation() { + if a.collateralOverlapRef == "" { + return + } + a.markUsed(a.collateralOverlapRef) + a.collateralOverlapRef = "" +} + +// finalizeCollateral sizes and validates the total collateral and the +// collateral-return output against the final transaction fee. The ledger +// requires +// +// totalCollateral >= ceil(fee * collateralPercent / 100) +// +// computed against the ACTUAL fee. setCollateral() only had a preliminary +// (max-by-size) fee available, so its sizing is stale once coin selection and +// fee estimation have run. +// +// The computation uses the collateral inputs' value ALONE: on phase-2 script +// failure only the collateral is consumed, so collateral_return = (collateral +// input ADA - total_collateral) plus all native assets on the collateral. This +// never touches the success-path input/output/fee balance. +// +// Three modes: +// - auto-selected, no explicit amount: total/return are (re)computed from the +// final fee. +// - explicit SetCollateralAmount: total_collateral is pinned to the requested +// amount (raised to the ledger minimum if the caller asked for too little is +// rejected rather than silently bumped), and the return is recomputed so the +// requested amount is actually emitted in the body. +// - fully manual AddCollateral with no amount: the sizing is left to the +// ledger's implicit "all collateral inputs" rule, but it is still validated +// so an under-funded or asset-stranding collateral set is rejected locally +// rather than built into an invalid tx. +func (a *Apollo) finalizeCollateral(fee int64) error { + if len(a.collaterals) == 0 { + return nil + } + pp, err := a.Context.ProtocolParams() + if err != nil { + return fmt.Errorf("failed to get protocol params for collateral sizing: %w", err) + } + if pp.CollateralPercent <= 0 || fee <= 0 { + return nil + } + if fee > (math.MaxInt64-99)/int64(pp.CollateralPercent) { + return fmt.Errorf("collateral sizing overflows: fee=%d collateralPercent=%d", fee, pp.CollateralPercent) + } + // Ceil division: ceil(fee * percent / 100). + required := (fee*int64(pp.CollateralPercent) + 99) / 100 + if required <= 0 { + return nil + } + + // Sum the lovelace and assets across the collateral inputs so the collateral + // return can carry the remainder (and any tokens) forward. + var totalLovelace int64 + var collateralAssets *common.MultiAsset[common.MultiAssetTypeOutput] + hasAssets := false + for _, utxo := range a.collaterals { amt := utxo.Output.Amount() if amt == nil || !amt.IsInt64() { - continue - } - lovelace := amt.Int64() - if lovelace < minCollateral { - continue + return fmt.Errorf("collateral UTxO %s has an invalid lovelace amount", utxoRef(utxo)) } - remainder := lovelace - minCollateral - assets := utxo.Output.Assets() - if remainder == 0 && assets != nil { - // We can't select an asset-bearing collateral UTxO unless we can - // produce a collateral return that carries those assets forward. - continue + sum := totalLovelace + amt.Int64() + if sum < totalLovelace { + return errors.New("collateral lovelace total overflows int64") } - a.collaterals = append(a.collaterals, utxo) - a.markUsed(ref) - a.totalCollateral = minCollateral - if remainder > 0 { - returnVal := Value{Coin: uint64(remainder)} - if assets != nil { - returnVal.Assets = CloneMultiAsset(assets) + totalLovelace = sum + if assets := utxo.Output.Assets(); assets != nil { + hasAssets = true + if collateralAssets == nil { + collateralAssets = CloneMultiAsset(assets) + } else { + collateralAssets.Add(assets) } - ret := NewBabbageOutput(a.getChangeAddress(), returnVal, nil, nil) + } + } + + if required > totalLovelace { + return fmt.Errorf( + "insufficient collateral: need %d lovelace (ceil(fee %d * %d%%)), collateral inputs hold %d", + required, fee, pp.CollateralPercent, totalLovelace, + ) + } + + // Fully manual collateral with no explicit amount: the caller pinned the + // inputs and did not ask for a specific total_collateral, so leave the body + // untouched (the ledger consumes the whole collateral set on failure). Still + // validate: the implicit collateral cannot carry assets forward without a + // collateral return, so asset-bearing manual collateral must be rejected. + if !a.collateralAutoSelected && a.collateralAmount == 0 { + if hasAssets { + return errors.New( + "manual collateral carries native assets but no collateral return is set; " + + "set a collateral amount/return or use ADA-only collateral", + ) + } + return nil + } + + // Explicit SetCollateralAmount: honor the requested total_collateral (it must + // still meet the ledger minimum and fit within the collateral inputs). This + // raises the effective total above `required` when the caller wants a larger + // margin; a request below the minimum is rejected rather than silently bumped. + if a.collateralAmount > 0 { + if a.collateralAmount < required { + return fmt.Errorf( + "insufficient collateral: requested amount %d is below the required %d (ceil(fee %d * %d%%))", + a.collateralAmount, required, fee, pp.CollateralPercent, + ) + } + if a.collateralAmount > totalLovelace { + return fmt.Errorf( + "requested collateral amount %d exceeds collateral inputs %d", + a.collateralAmount, totalLovelace, + ) + } + required = a.collateralAmount + } + + remainder := totalLovelace - required + + // Asset-bearing collateral mandates a collateral_return to carry the tokens + // forward, and that return must meet min-ADA. If the ADA remainder cannot + // cover min-ADA, lowering total_collateral to free more ADA could break the + // >= required invariant, so report a clear error rather than build an + // invalid transaction. + if hasAssets { + returnVal := Value{Coin: uint64(remainder), Assets: collateralAssets} //nolint:gosec // remainder >= 0 + ret := NewBabbageOutput(a.getChangeAddress(), returnVal, nil, nil) + minReturn, mErr := MinLovelacePostAlonzo(&ret, pp.CoinsPerUtxoByteValue()) + if mErr != nil { + return fmt.Errorf("failed to compute min UTxO for collateral return: %w", mErr) + } + if minReturn < 0 { + return fmt.Errorf("invalid min UTxO for collateral return: %d", minReturn) + } + if remainder < minReturn { + return fmt.Errorf( + "collateral return for native assets needs %d lovelace but only %d is available after total collateral %d; supply a larger or additional collateral UTxO", + minReturn, remainder, required, + ) + } + a.totalCollateral = required + a.collateralReturn = &ret + return nil + } + + // ADA-only collateral. If the remainder is too small to form a valid return + // output (below min-ADA), absorb it into total_collateral and omit the + // return rather than emit a sub-min-ADA output. + if remainder > 0 { + returnVal := Value{Coin: uint64(remainder)} //nolint:gosec // remainder > 0 + ret := NewBabbageOutput(a.getChangeAddress(), returnVal, nil, nil) + minReturn, mErr := MinLovelacePostAlonzo(&ret, pp.CoinsPerUtxoByteValue()) + if mErr != nil { + return fmt.Errorf("failed to compute min UTxO for collateral return: %w", mErr) + } + if minReturn < 0 { + return fmt.Errorf("invalid min UTxO for collateral return: %d", minReturn) + } + if remainder >= minReturn { + a.totalCollateral = required a.collateralReturn = &ret + return nil + } + // Dust remainder below min-ADA. For an explicitly requested amount we may + // not silently raise total_collateral (that would forfeit the dust on + // failure and exceed what the caller asked for); reject instead. Only the + // auto-sized path is free to absorb the dust into total_collateral. + if a.collateralAmount > 0 { + return fmt.Errorf( + "requested collateral amount %d leaves a %d lovelace return below the %d min-ADA; choose an amount that leaves no return or at least min-ADA", + required, remainder, minReturn, + ) } + // Auto-sized dust remainder: absorb into total_collateral, no return. + a.totalCollateral = totalLovelace + a.collateralReturn = nil return nil } - return errors.New("script transaction requires collateral, but no eligible collateral UTxO was found") + + // Exact match: total collateral consumes the whole input, no return. + a.totalCollateral = required + a.collateralReturn = nil + return nil } -func (a *Apollo) validateCollateralDistinctFromInputs(inputs []common.Utxo) error { +// validateCollateral checks the collateral input set against the ledger rules +// that apollo can enforce locally: no duplicate collateral inputs and no more +// than MaxCollateralInputs of them. +// +// It deliberately does NOT reject a UTxO that is also a regular spending input. +// The Cardano ledger permits that overlap because collateral is consumed only +// on phase-2 script failure and regular inputs only on success; the two paths +// are mutually exclusive. This matches mesh, lucid, and lucid-evolution and +// lets a single-UTxO wallet build a script transaction. +func (a *Apollo) validateCollateral() error { if len(a.collaterals) == 0 { return nil } @@ -2396,13 +2788,25 @@ func (a *Apollo) validateCollateralDistinctFromInputs(inputs []common.Utxo) erro return fmt.Errorf("duplicate collateral input %s", ref) } seen[ref] = struct{}{} - } - for _, input := range inputs { - ref := utxoRef(input) - if _, ok := seen[ref]; ok { - return fmt.Errorf("utxo %s cannot be used as both input and collateral", ref) + // Collateral must be vkey-locked: the ledger rejects collateral at a + // script address. Auto-selection already filters on this, but + // caller-pinned collateral (AddCollateral) is validated here so a + // script-address UTxO never reaches the body. + if out := collateral.Output; out != nil { + if addr := out.Address(); addr.Type() != common.AddressTypeKeyKey && + addr.Type() != common.AddressTypeKeyNone { + return fmt.Errorf("collateral input %s is not vkey-locked (script-address collateral is rejected by the ledger)", ref) + } } } + // Enforce the protocol max on collateral inputs when known. + if pp, err := a.Context.ProtocolParams(); err == nil && pp.MaxCollateralInputs > 0 && + len(a.collaterals) > pp.MaxCollateralInputs { + return fmt.Errorf( + "too many collateral inputs: %d exceeds protocol maximum of %d", + len(a.collaterals), pp.MaxCollateralInputs, + ) + } return nil } diff --git a/apollo_test.go b/apollo_test.go index 18bf47f..011642f 100644 --- a/apollo_test.go +++ b/apollo_test.go @@ -1234,22 +1234,10 @@ func TestCompleteDoesNotReuseCollateralAsInput(t *testing.T) { func TestCompleteFailsWhenScriptTxHasNoEligibleCollateral(t *testing.T) { cc := setupFixedContext() addr := testAddress(t) - var txHash common.Blake2b256 - txHash[0] = 0x01 - output := babbage.BabbageTransactionOutput{ - OutputAddress: addr, - OutputAmount: mary.MaryTransactionOutputValue{ - Amount: 10_000_000, - Assets: testMultiAsset(1, "token", 1), - }, - } - cc.AddUtxo(addr, common.Utxo{ - Id: shelley.ShelleyTransactionInput{ - TxId: txHash, - OutputIndex: 0, - }, - Output: &output, - }) + // The only UTxO is at a SCRIPT address: it can never back collateral + // (collateral must be vkey-locked) and there is no other UTxO to fall back + // to, so collateral selection must fail. + cc.AddUtxo(addr, scriptAddressUtxo(t, 0x01, 10_000_000)) datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} script := common.PlutusV2Script([]byte{0x01, 0x02}) @@ -1485,3 +1473,464 @@ func TestResolveCredentialInvalidBech32(t *testing.T) { t.Error("expected error for invalid bech32") } } + +// --- Collateral input / spending input overlap --- + +// bodyInputRefs collects the regular spending input refs from a built tx body. +func bodyInputRefs(t *testing.T, a *Apollo) []string { + t.Helper() + items := a.tx.Body.TxInputs.Items() + refs := make([]string, 0, len(items)) + for _, in := range items { + refs = append(refs, hex.EncodeToString(in.TxId.Bytes())+"#"+strconv.Itoa(int(in.OutputIndex))) + } + return refs +} + +// bodyCollateralRefs collects the collateral input refs from a built tx body. +func bodyCollateralRefs(t *testing.T, a *Apollo) []string { + t.Helper() + items := a.tx.Body.TxCollateral.Items() + refs := make([]string, 0, len(items)) + for _, in := range items { + refs = append(refs, hex.EncodeToString(in.TxId.Bytes())+"#"+strconv.Itoa(int(in.OutputIndex))) + } + return refs +} + +// scriptAddressUtxo builds a UTxO locked at a script payment address (type +// AddressTypeScriptKey), used to verify script-address collateral is rejected. +func scriptAddressUtxo(t *testing.T, txHashByte byte, lovelace uint64) common.Utxo { + t.Helper() + var raw [57]byte + raw[0] = byte(common.AddressTypeScriptKey) << 4 // network 0, script payment credential + raw[1] = 0xCC // script hash bytes + raw[29] = 0xDD // stake key hash + addr, err := common.NewAddressFromBytes(raw[:]) + if err != nil { + t.Fatal(err) + } + var txHash common.Blake2b256 + txHash[0] = txHashByte + output := babbage.BabbageTransactionOutput{ + OutputAddress: addr, + OutputAmount: mary.MaryTransactionOutputValue{Amount: lovelace}, + } + return common.Utxo{ + Id: shelley.ShelleyTransactionInput{TxId: txHash, OutputIndex: 0}, + Output: &output, + } +} + +// TestSingleUtxoIsBothInputAndCollateral verifies that a wallet with a single +// vkey UTxO can build a Plutus script transaction where that UTxO is used as +// BOTH a regular spending input and the collateral input. +func TestSingleUtxoIsBothInputAndCollateral(t *testing.T) { + cc := setupFixedContext() + addr := testAddress(t) + const utxoLovelace = 20_000_000 + addTestUtxo(cc, addr, utxoLovelace, 0x01, 0) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err != nil { + t.Fatalf("Complete failed: %v", err) + } + + if len(a.collaterals) != 1 { + t.Fatalf("expected 1 collateral, got %d", len(a.collaterals)) + } + collRef := utxoRef(a.collaterals[0]) + + inputRefs := bodyInputRefs(t, a) + collRefs := bodyCollateralRefs(t, a) + + // The same UTxO ref must appear in both inputs and collateral. + if len(collRefs) != 1 || collRefs[0] != collRef { + t.Fatalf("collateral ref mismatch: body collateral %v, want [%s]", collRefs, collRef) + } + count := 0 + for _, r := range inputRefs { + if r == collRef { + count++ + } + } + if count != 1 { + t.Fatalf("overlapped UTxO must appear exactly once in inputs, found %d times in %v", count, inputRefs) + } + + // total_collateral >= ceil(fee * collateralPercent / 100) and <= input ADA. + pp, _ := cc.ProtocolParams() + fee := a.tx.Body.TxFee + required := (fee*uint64(pp.CollateralPercent) + 99) / 100 //nolint:gosec // CollateralPercent is a positive protocol parameter + totalColl := a.tx.Body.TxTotalCollateral + if totalColl < required { + t.Fatalf("total_collateral %d below required %d (fee %d)", totalColl, required, fee) + } + if totalColl > utxoLovelace { + t.Fatalf("total_collateral %d exceeds collateral input ADA %d", totalColl, utxoLovelace) + } + + // collateral_return = collateral_input_value - total_collateral (ADA-only here). + if a.tx.Body.TxCollateralReturn == nil { + t.Fatal("expected a collateral return for the ADA remainder") + } + gotReturn := a.tx.Body.TxCollateralReturn.Amount() + wantReturn := new(big.Int).SetUint64(utxoLovelace - totalColl) + if gotReturn == nil || gotReturn.Cmp(wantReturn) != 0 { + t.Fatalf("collateral_return = %v, want %v", gotReturn, wantReturn) + } + + // Success-path balance must EXCLUDE collateral accounting: + // sum(inputs) == sum(outputs) + fee. The wallet has a single UTxO, so the + // only spending input is that UTxO. Collateral return / total collateral + // must not appear in this equation. + if len(inputRefs) != 1 || inputRefs[0] != collRef { + t.Fatalf("expected exactly the single UTxO as input, got %v", inputRefs) + } + inSum := new(big.Int).SetUint64(utxoLovelace) + outSum := new(big.Int) + for i := range a.tx.Body.TxOutputs { + amt := a.tx.Body.TxOutputs[i].Amount() + if amt == nil { + t.Fatalf("output %d has nil amount", i) + } + outSum.Add(outSum, amt) + } + outPlusFee := new(big.Int).Add(outSum, new(big.Int).SetUint64(fee)) + if inSum.Cmp(outPlusFee) != 0 { + t.Fatalf("success-path balance broken: inputs %s != outputs+fee %s (fee %d)", inSum, outPlusFee, fee) + } +} + +// TestTwoUtxoWalletPicksSeparateCollateral verifies the regression case: a +// wallet with two vkey UTxOs still reserves a SEPARATE ADA-only collateral and +// does not overlap, keeping the transaction shape identical to before. +func TestTwoUtxoWalletPicksSeparateCollateral(t *testing.T) { + cc := setupFixedContext() + addr := testAddress(t) + addTestUtxo(cc, addr, 30_000_000, 0x01, 0) + addTestUtxo(cc, addr, 5_000_000, 0x02, 0) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err != nil { + t.Fatalf("Complete failed: %v", err) + } + + if a.collateralOverlapRef != "" { + t.Fatalf("expected no overlap for a multi-UTxO wallet, got overlap ref %s", a.collateralOverlapRef) + } + if len(a.collaterals) != 1 { + t.Fatalf("expected 1 collateral, got %d", len(a.collaterals)) + } + collRef := utxoRef(a.collaterals[0]) + for _, r := range bodyInputRefs(t, a) { + if r == collRef { + t.Fatalf("collateral %s was reused as a regular input on a multi-UTxO wallet", collRef) + } + } +} + +// TestScriptAddressCollateralRejected verifies an auto-selected collateral is +// never taken from a script address: only vkey-locked UTxOs are eligible. +func TestScriptAddressCollateralRejected(t *testing.T) { + cc := setupFixedContext() + addr := testAddress(t) + // Only available UTxO is at a script address. + cc.AddUtxo(addr, scriptAddressUtxo(t, 0x01, 20_000_000)) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err == nil { + t.Fatal("expected error: script-address UTxO must not be eligible as collateral") + } +} + +// TestDuplicateCollateralRejected verifies that adding the same UTxO twice as +// collateral is rejected by validateCollateral. +func TestDuplicateCollateralRejected(t *testing.T) { + cc := setupFixedContext() + addr := testAddress(t) + addTestUtxo(cc, addr, 30_000_000, 0x01, 0) + + var collHash common.Blake2b256 + collHash[0] = 0x02 + coll := makeTestUtxo(t, collHash, 0, 10_000_000) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + AddCollateral(coll). + AddCollateral(coll). // duplicate + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err == nil { + t.Fatal("expected duplicate collateral error") + } +} + +// TestMaxCollateralInputsEnforced verifies that exceeding MaxCollateralInputs +// is rejected. +func TestMaxCollateralInputsEnforced(t *testing.T) { + pp := backend.ProtocolParameters{ + MinFeeConstant: 155381, + MinFeeCoefficient: 44, + MaxTxSize: 16384, + CoinsPerUtxoByte: "4310", + CollateralPercent: 150, + MaxCollateralInputs: 2, // small cap for the test + MaxValSize: "5000", + PriceMem: 0.0577, + PriceStep: 0.0000721, + MaxTxExMem: "14000000", + MaxTxExSteps: "10000000000", + KeyDeposits: "2000000", + PoolDeposits: "500000000", + } + gp := backend.GenesisParameters{NetworkMagic: 1} + cc := fixed.NewFixedChainContext(pp, gp, 0) + addr := testAddress(t) + addTestUtxo(cc, addr, 30_000_000, 0x01, 0) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + // Three caller-pinned collateral inputs exceed the cap of 2. + for i := byte(2); i <= 4; i++ { + var h common.Blake2b256 + h[0] = i + a.AddCollateral(makeTestUtxo(t, h, 0, 6_000_000)) + } + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err == nil { + t.Fatal("expected too-many-collateral-inputs error") + } +} + +// TestManualScriptAddressCollateralRejected verifies that caller-pinned +// (AddCollateral) collateral at a script address is rejected by +// validateCollateral, matching the ledger requirement that collateral be +// vkey-locked. +func TestManualScriptAddressCollateralRejected(t *testing.T) { + cc := setupFixedContext() + addr := testAddress(t) + addTestUtxo(cc, addr, 30_000_000, 0x01, 0) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + AddCollateral(scriptAddressUtxo(t, 0x02, 10_000_000)). + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err == nil { + t.Fatal("expected script-address collateral to be rejected") + } +} + +// TestExplicitCollateralAmountBelowRequiredRejected verifies that an explicit +// SetCollateralAmount below ceil(fee * collateralPercent / 100) is reported as +// an error rather than building a ledger-invalid transaction. +func TestExplicitCollateralAmountBelowRequiredRejected(t *testing.T) { + cc := setupFixedContext() + addr := testAddress(t) + addTestUtxo(cc, addr, 30_000_000, 0x01, 0) + addTestUtxo(cc, addr, 10_000_000, 0x02, 0) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + SetCollateralAmount(1). // far below ceil(fee * collateralPercent / 100) + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err == nil { + t.Fatal("expected insufficient explicit collateral error") + } +} + +// TestExplicitCollateralAmountEmittedInBody verifies that an explicit +// SetCollateralAmount combined with a caller-pinned AddCollateral actually +// emits total_collateral (body key 18) equal to the requested amount, with a +// collateral return for the remainder. +func TestExplicitCollateralAmountEmittedInBody(t *testing.T) { + cc := setupFixedContext() + addr := testAddress(t) + addTestUtxo(cc, addr, 30_000_000, 0x01, 0) + + var collHash common.Blake2b256 + collHash[0] = 0x02 + coll := makeTestUtxo(t, collHash, 0, 10_000_000) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + const wantAmount = 5_000_000 + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + AddCollateral(coll). + SetCollateralAmount(wantAmount). + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err != nil { + t.Fatalf("Complete failed: %v", err) + } + if a.tx.Body.TxTotalCollateral != wantAmount { + t.Fatalf("total_collateral = %d, want %d", a.tx.Body.TxTotalCollateral, wantAmount) + } + if a.tx.Body.TxCollateralReturn == nil { + t.Fatal("expected a collateral return for the remainder") + } + gotReturn := a.tx.Body.TxCollateralReturn.Amount() + wantReturn := big.NewInt(10_000_000 - wantAmount) + if gotReturn == nil || gotReturn.Cmp(wantReturn) != 0 { + t.Fatalf("collateral_return = %v, want %v", gotReturn, wantReturn) + } +} + +// TestManualAssetCollateralRejected verifies that fully manual collateral +// carrying native assets (with no collateral return) is rejected, because the +// implicit full-input collateral path cannot return the assets on failure. +func TestManualAssetCollateralRejected(t *testing.T) { + cc := setupFixedContext() + addr := testAddress(t) + addTestUtxo(cc, addr, 30_000_000, 0x01, 0) + + var collHash common.Blake2b256 + collHash[0] = 0x02 + coll := makeAssetTestUtxo(t, collHash, 0, 10_000_000, testMultiAsset(1, "token", 1)) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + AddCollateral(coll). + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err == nil { + t.Fatal("expected manual asset-bearing collateral to be rejected") + } +} + +// TestExplicitCollateralAmountLeavingDustRejected verifies that an explicit +// collateral amount that would leave a sub-min-ADA collateral return is +// rejected rather than silently raising total_collateral (which would forfeit +// the dust on phase-2 failure and exceed the requested amount). +func TestExplicitCollateralAmountLeavingDustRejected(t *testing.T) { + cc := setupFixedContext() + addr := testAddress(t) + addTestUtxo(cc, addr, 30_000_000, 0x01, 0) + + const collLovelace = 10_000_000 + var collHash common.Blake2b256 + collHash[0] = 0x02 + coll := makeTestUtxo(t, collHash, 0, collLovelace) + + datum := common.Datum{Data: plutigoData.NewInteger(big.NewInt(1))} + script := common.PlutusV2Script([]byte{0x01, 0x02}) + unit := NewUnit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "746f6b656e", 1) + + // Request an amount that leaves only 1 lovelace of return, far below min-ADA. + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(script). + DisableExecutionUnitsEstimation(). + AddCollateral(coll). + SetCollateralAmount(collLovelace - 1). + Mint(unit, &datum, &common.ExUnits{Memory: 1, Steps: 1}) + payment, err := NewPayment(validTestAddrBech32, 2_000_000, nil) + if err != nil { + t.Fatal(err) + } + a.AddPayment(payment) + if _, err := a.Complete(); err == nil { + t.Fatal("expected dust collateral return to be rejected for an explicit amount") + } +} diff --git a/backend/base.go b/backend/base.go index 86adf4b..460d303 100644 --- a/backend/base.go +++ b/backend/base.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "fmt" "math" + "math/big" "strconv" "strings" @@ -85,6 +86,103 @@ type ProtocolParameters struct { MinFeeReferenceScriptsRange int `json:"min_fee_reference_scripts_range"` MinFeeReferenceScriptsBase int `json:"min_fee_reference_scripts_base"` MinFeeReferenceScriptsMultiplier int `json:"min_fee_reference_scripts_multiplier"` + // MinFeeRefScriptCostPerByte is the BlockFrost/ledger flat name for the + // reference-script base price (lovelace per byte for the first tier). Some + // providers (e.g. BlockFrost) expose only this field and not the structured + // MinFeeReferenceScripts{Base,Range,Multiplier} triple. RefScriptFeePerByte() + // reconciles the two representations. + MinFeeRefScriptCostPerByte float64 `json:"min_fee_ref_script_cost_per_byte"` +} + +// Conway reference-script fee tier constants. The ledger prices reference +// scripts on a growing tier: the first SizeIncrement bytes cost the base +// price per byte, and each subsequent tier of SizeIncrement bytes is priced +// at the previous tier's price multiplied by Multiplier. These two values are +// ledger constants (not surfaced by every provider), so they are defaulted +// when a provider does not supply them. +const ( + DefaultRefScriptSizeIncrement = 25600 + DefaultRefScriptMultiplier = 1.2 +) + +// RefScriptFeePerByte returns the base reference-script price (lovelace per +// byte for the first tier), preferring the structured MinFeeReferenceScriptsBase +// when present and falling back to the flat MinFeeRefScriptCostPerByte. +func (p ProtocolParameters) RefScriptFeePerByte() float64 { + if p.MinFeeReferenceScriptsBase > 0 { + return float64(p.MinFeeReferenceScriptsBase) + } + return p.MinFeeRefScriptCostPerByte +} + +// RefScriptSizeIncrement returns the per-tier size increment, defaulting to the +// Conway ledger constant when a provider does not supply it. +func (p ProtocolParameters) RefScriptSizeIncrement() int { + if p.MinFeeReferenceScriptsRange > 0 { + return p.MinFeeReferenceScriptsRange + } + return DefaultRefScriptSizeIncrement +} + +// RefScriptMultiplier returns the per-tier price multiplier, defaulting to the +// Conway ledger constant when a provider does not supply it. +func (p ProtocolParameters) RefScriptMultiplier() float64 { + if p.MinFeeReferenceScriptsMultiplier > 0 { + return float64(p.MinFeeReferenceScriptsMultiplier) + } + return DefaultRefScriptMultiplier +} + +// TierRefScriptFee computes the Conway tiered reference-script fee for a total +// reference-script byte size, matching the ledger's tierRefScriptFee function: +// +// go acc curTierPrice n +// | n < sizeIncrement = floor(acc + n*curTierPrice) +// | otherwise = go (acc + sizeIncrement*curTierPrice) +// (curTierPrice*multiplier) (n - sizeIncrement) +// +// with the first-tier price = baseFeePerByte. A zero base price yields a zero +// fee (pre-Conway / provider that does not charge for reference scripts). +// +// The accumulation uses exact rational arithmetic and floors once at the end, +// matching the ledger (which accumulates a Rational and applies floor). Using +// float64 here would let the repeated multiplier product (1.2 is not exactly +// representable in binary floating point) drift across an integer boundary on +// multi-tier reference scripts, producing a fee 1 lovelace below the ledger +// minimum and a FeeTooSmall rejection. +func TierRefScriptFee(totalRefScriptSize int, baseFeePerByte float64, sizeIncrement int, multiplier float64) int64 { + if totalRefScriptSize <= 0 || baseFeePerByte <= 0 { + return 0 + } + if sizeIncrement <= 0 { + sizeIncrement = DefaultRefScriptSizeIncrement + } + if multiplier <= 0 { + multiplier = DefaultRefScriptMultiplier + } + // Exact rationals. baseFeePerByte is an integer lovelace/byte in practice + // (15 at current params); the multiplier is the ledger constant 6/5. Recover + // it exactly from the float (1.2 -> 1200/1000 -> 6/5) so there is no drift. + base := new(big.Rat).SetFloat64(baseFeePerByte) + if base == nil { // NaN/Inf guard + return 0 + } + m := big.NewRat(int64(math.Round(multiplier*1000)), 1000) + acc := new(big.Rat) + price := new(big.Rat).Set(base) + incr := new(big.Rat).SetInt64(int64(sizeIncrement)) + n := totalRefScriptSize + for n >= sizeIncrement { + acc.Add(acc, new(big.Rat).Mul(incr, price)) + price.Mul(price, m) + n -= sizeIncrement + } + if n > 0 { + acc.Add(acc, new(big.Rat).Mul(new(big.Rat).SetInt64(int64(n)), price)) + } + // floor(acc): acc is non-negative, so integer division of num/denom truncates + // toward zero, i.e. floors. + return new(big.Int).Quo(acc.Num(), acc.Denom()).Int64() } // CoinsPerUtxoByteValue returns the coins per UTxO byte value parsed from the string field. diff --git a/backend/tier_ref_script_fee_test.go b/backend/tier_ref_script_fee_test.go new file mode 100644 index 0000000..0e096ff --- /dev/null +++ b/backend/tier_ref_script_fee_test.go @@ -0,0 +1,69 @@ +package backend + +import ( + "math/big" + "testing" +) + +// exactTierRefScriptFee is an independent exact-rational implementation of the +// ledger's tierRefScriptFee (multiplier as the exact rational mulNum/mulDen), +// used as the oracle for TierRefScriptFee. +func exactTierRefScriptFee(size int, base float64, incr int, mulNum, mulDen int64) int64 { + if size <= 0 || base <= 0 { + return 0 + } + b := new(big.Rat).SetFloat64(base) + m := big.NewRat(mulNum, mulDen) + acc := new(big.Rat) + price := new(big.Rat).Set(b) + n := size + for n >= incr { + acc.Add(acc, new(big.Rat).Mul(new(big.Rat).SetInt64(int64(incr)), price)) + price.Mul(price, m) + n -= incr + } + if n > 0 { + acc.Add(acc, new(big.Rat).Mul(new(big.Rat).SetInt64(int64(n)), price)) + } + return new(big.Int).Quo(acc.Num(), acc.Denom()).Int64() +} + +// TierRefScriptFee must equal the exact ledger value (floor of the exact +// rational accumulation) for all sizes, including multi-tier. +func TestTierRefScriptFeeIsExact(t *testing.T) { + const base = 15.0 + const incr = DefaultRefScriptSizeIncrement // 25600 + const mul = DefaultRefScriptMultiplier // 1.2 + for _, s := range []int{1, 100, 5000, 25599, 25600, 25601, 51200, 76800, 80000, 128000, 200000, 250880, 333333} { + got := TierRefScriptFee(s, base, incr, mul) + want := exactTierRefScriptFee(s, base, incr, 6, 5) + if got != want { + t.Errorf("TierRefScriptFee(%d) = %d, want exact %d", s, got, want) + } + } +} + +// For a single tier (size < increment) with an integer base price, the fee is +// exactly size*base. +func TestTierRefScriptFeeSingleTierLinear(t *testing.T) { + const base = 15.0 + for _, s := range []int{1, 1000, 5000, 25599} { + if got, want := TierRefScriptFee(s, base, DefaultRefScriptSizeIncrement, DefaultRefScriptMultiplier), int64(s)*15; got != want { + t.Errorf("single-tier TierRefScriptFee(%d) = %d, want %d", s, got, want) + } + } +} + +// Dense sweep across the multi-tier regime (1..50 tiers): the rational +// implementation must equal the exact ledger oracle for every size, with no +// float drift able to push the floor below the ledger minimum. +func TestTierRefScriptFeeExactAcrossTiers(t *testing.T) { + const base = 15.0 + const incr = DefaultRefScriptSizeIncrement + const mul = DefaultRefScriptMultiplier + for s := 1; s <= 50*incr; s += 97 { + if got, want := TierRefScriptFee(s, base, incr, mul), exactTierRefScriptFee(s, base, incr, 6, 5); got != want { + t.Fatalf("TierRefScriptFee(%d) = %d, want exact %d", s, got, want) + } + } +} diff --git a/finalization_test.go b/finalization_test.go new file mode 100644 index 0000000..5c7b306 --- /dev/null +++ b/finalization_test.go @@ -0,0 +1,215 @@ +package apollo + +import ( + "math/big" + "testing" + + "github.com/blinklabs-io/gouroboros/cbor" + "github.com/blinklabs-io/gouroboros/ledger/common" + plutigoData "github.com/blinklabs-io/plutigo/data" + + "github.com/Salvionied/apollo/v2/backend" + "github.com/Salvionied/apollo/v2/backend/fixed" +) + +// TestComputeScriptDataHashOmitsEmptyDatums locks the fix for the +// ScriptIntegrityHashMismatch that the Cardano node raised on datum-less +// script transactions (e.g. reward-withdrawal redeemers). The ledger +// (Conway UtxoValidateScriptDataHash) concatenates redeemers || datums || +// langViews and appends NOTHING for datums when the witness set has none. +// Encoding an empty array (0x80) would shift the hash by one byte. This test +// reconstructs the ledger's exact hash input and asserts ComputeScriptDataHash +// matches it byte-for-byte. +func TestComputeScriptDataHashOmitsEmptyDatums(t *testing.T) { + redeemers := map[common.RedeemerKey]common.RedeemerValue{ + {Tag: common.RedeemerTagReward, Index: 0}: { + Data: common.Datum{Data: plutigoData.NewInteger(big.NewInt(42))}, + ExUnits: common.ExUnits{Memory: 1000, Steps: 2000}, + }, + } + v3 := []int64{100788, 420, 1, 1, 1000, 173, 0, 1} + costModels := map[string][]int64{"PlutusV3": v3} + + got, err := ComputeScriptDataHash(redeemers, nil, costModels) + if err != nil { + t.Fatalf("ComputeScriptDataHash: %v", err) + } + if got == nil { + t.Fatal("expected a hash") + } + + // Reconstruct exactly as the ledger does: redeemers || langViews, with no + // datum bytes at all when there are no datums. + redeemerCbor, err := cbor.Encode(redeemers) + if err != nil { + t.Fatal(err) + } + langViews, err := common.EncodeLangViews( + map[uint]struct{}{2: {}}, + map[uint][]int64{2: v3}, + ) + if err != nil { + t.Fatal(err) + } + expectedInput := make([]byte, 0, len(redeemerCbor)+len(langViews)) + expectedInput = append(expectedInput, redeemerCbor...) + expectedInput = append(expectedInput, langViews...) + want := common.Blake2b256Hash(expectedInput) + if *got != want { + t.Fatalf("script data hash mismatch with ledger formula:\n got %x\n want %x", got.Bytes(), want.Bytes()) + } + + // Guard against a regression to the old behaviour: appending an empty-array + // (0x80) datum must NOT produce the same hash. + wrongInput := make([]byte, 0, len(redeemerCbor)+1+len(langViews)) + wrongInput = append(wrongInput, redeemerCbor...) + wrongInput = append(wrongInput, 0x80) + wrongInput = append(wrongInput, langViews...) + wrong := common.Blake2b256Hash(wrongInput) + if *got == wrong { + t.Fatal("ComputeScriptDataHash still encodes an empty-array datum (0x80); this reintroduces the ScriptIntegrityHashMismatch") + } +} + +// TestTierRefScriptFee locks the Conway tiered reference-script fee, whose +// omission caused FeeTooSmallUTxO. It checks the flat single-tier case, the +// multi-tier growth, and the base-price fallbacks. +func TestTierRefScriptFee(t *testing.T) { + // Single tier (size below the increment): floor(size * base). + if got := backend.TierRefScriptFee(13892, 15, 25600, 1.2); got != 13892*15 { + t.Fatalf("single-tier fee = %d, want %d", got, 13892*15) + } + // Zero base price => zero fee (pre-Conway / provider without ref pricing). + if got := backend.TierRefScriptFee(13892, 0, 25600, 1.2); got != 0 { + t.Fatalf("zero-base fee = %d, want 0", got) + } + // Zero size => zero fee. + if got := backend.TierRefScriptFee(0, 15, 25600, 1.2); got != 0 { + t.Fatalf("zero-size fee = %d, want 0", got) + } + // Multi-tier: first 25600 bytes at 15, next 4400 at 15*1.2=18. + // floor(25600*15 + 4400*18) = floor(384000 + 79200) = 463200. + if got := backend.TierRefScriptFee(30000, 15, 25600, 1.2); got != 463200 { + t.Fatalf("multi-tier fee = %d, want %d", got, 463200) + } + + // Protocol-parameter accessors fall back to ledger constants and prefer the + // structured base when present. + ppFlat := backend.ProtocolParameters{MinFeeRefScriptCostPerByte: 15} + if ppFlat.RefScriptFeePerByte() != 15 { + t.Fatalf("flat RefScriptFeePerByte = %v, want 15", ppFlat.RefScriptFeePerByte()) + } + if ppFlat.RefScriptSizeIncrement() != backend.DefaultRefScriptSizeIncrement { + t.Fatalf("default size increment = %d, want %d", ppFlat.RefScriptSizeIncrement(), backend.DefaultRefScriptSizeIncrement) + } + if ppFlat.RefScriptMultiplier() != backend.DefaultRefScriptMultiplier { + t.Fatalf("default multiplier = %v, want %v", ppFlat.RefScriptMultiplier(), backend.DefaultRefScriptMultiplier) + } + ppStruct := backend.ProtocolParameters{ + MinFeeReferenceScriptsBase: 44, + MinFeeReferenceScriptsRange: 30000, + MinFeeReferenceScriptsMultiplier: 2, + } + if ppStruct.RefScriptFeePerByte() != 44 { + t.Fatalf("structured RefScriptFeePerByte = %v, want 44", ppStruct.RefScriptFeePerByte()) + } + if ppStruct.RefScriptSizeIncrement() != 30000 { + t.Fatalf("structured size increment = %d, want 30000", ppStruct.RefScriptSizeIncrement()) + } + if ppStruct.RefScriptMultiplier() != 2 { + t.Fatalf("structured multiplier = %v, want 2", ppStruct.RefScriptMultiplier()) + } +} + +// TestFinalizeCollateralUsesFinalFee locks the InsufficientCollateral fix: +// total collateral and the collateral return must be sized from the FINAL fee +// (ceil(fee * collateralPercent / 100)), not the preliminary max-by-size fee +// that setCollateral() had available. +func TestFinalizeCollateralUsesFinalFee(t *testing.T) { + pp := backend.ProtocolParameters{ + MinFeeConstant: 155381, + MinFeeCoefficient: 44, + MaxTxSize: 16384, + CoinsPerUtxoByte: "4310", + CollateralPercent: 150, + } + gp := backend.GenesisParameters{NetworkMagic: 1} + cc := fixed.NewFixedChainContext(pp, gp, 0) + addr := testAddress(t) + + var collHash common.Blake2b256 + collHash[0] = 0x42 + collateralUtxo := makeTestUtxo(t, collHash, 0, 5_000_000) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + AttachScript(common.PlutusV2Script([]byte{0x01, 0x02})). + AddLoadedUTxOs(collateralUtxo) + + if err := a.setCollateral(); err != nil { + t.Fatalf("setCollateral: %v", err) + } + // setCollateral sizes from the max-by-size fee (16384*44+155381 = 876277): + // ceil-ish 876277*150/100 = 1314415. Confirm it is NOT yet the final value. + prelim := a.totalCollateral + if prelim == 0 { + t.Fatal("expected setCollateral to record a preliminary total collateral") + } + + const finalFee = 1_255_870 + if err := a.finalizeCollateral(finalFee); err != nil { + t.Fatalf("finalizeCollateral: %v", err) + } + + wantRequired := int64((finalFee*150 + 99) / 100) // ceil(fee*percent/100) + if a.totalCollateral != wantRequired { + t.Fatalf("total collateral = %d, want %d (ceil(%d*150/100))", a.totalCollateral, wantRequired, finalFee) + } + if wantRequired <= prelim { + t.Fatalf("test fixture invalid: final required %d should exceed preliminary %d", wantRequired, prelim) + } + // Collateral return must carry the remainder forward. + if a.collateralReturn == nil { + t.Fatal("expected a collateral return output") + } + wantRemainder := uint64(5_000_000 - wantRequired) + if a.collateralReturn.OutputAmount.Amount != wantRemainder { + t.Fatalf("collateral return = %d, want %d", a.collateralReturn.OutputAmount.Amount, wantRemainder) + } +} + +// TestFinalizeCollateralRespectsExplicitAmount ensures a user-pinned collateral +// amount is left untouched by the fee-driven resize. +func TestFinalizeCollateralRespectsExplicitAmount(t *testing.T) { + pp := backend.ProtocolParameters{ + MinFeeConstant: 155381, + MinFeeCoefficient: 44, + MaxTxSize: 16384, + CoinsPerUtxoByte: "4310", + CollateralPercent: 150, + } + gp := backend.GenesisParameters{NetworkMagic: 1} + cc := fixed.NewFixedChainContext(pp, gp, 0) + addr := testAddress(t) + + var collHash common.Blake2b256 + collHash[0] = 0x43 + collateralUtxo := makeTestUtxo(t, collHash, 0, 5_000_000) + + a := New(cc). + SetWallet(NewExternalWallet(addr)). + SetCollateralAmount(5_000_000). + AttachScript(common.PlutusV2Script([]byte{0x01, 0x02})). + AddLoadedUTxOs(collateralUtxo) + + if err := a.setCollateral(); err != nil { + t.Fatalf("setCollateral: %v", err) + } + before := a.totalCollateral + if err := a.finalizeCollateral(1_255_870); err != nil { + t.Fatalf("finalizeCollateral: %v", err) + } + if a.totalCollateral != before { + t.Fatalf("explicit collateral amount was resized: before %d after %d", before, a.totalCollateral) + } +} diff --git a/helpers.go b/helpers.go index 1a3de78..9634e57 100644 --- a/helpers.go +++ b/helpers.go @@ -511,14 +511,18 @@ func ComputeScriptDataHash( return nil, fmt.Errorf("failed to encode redeemers: %w", err) } + // Datums contribute to the hash only when present. The Cardano ledger + // (Conway UtxoValidateScriptDataHash) appends the datum CBOR to the hash + // input only if the witness set carries datums; when there are none it + // appends nothing. Encoding an empty array (0x80) here would shift every + // hash by one byte and produce a ScriptIntegrityHashMismatch for any + // datum-less script transaction (e.g. reward-withdrawal redeemers). var datumBytes []byte if len(datums) > 0 { datumBytes, err = cbor.Encode(datums) - } else { - datumBytes, err = cbor.Encode([]common.Datum{}) - } - if err != nil { - return nil, fmt.Errorf("failed to encode datums: %w", err) + if err != nil { + return nil, fmt.Errorf("failed to encode datums: %w", err) + } } // Encode cost models as language views using gouroboros, which