From 48ef331946c077cc5e59654e445a43d3bd4d346f Mon Sep 17 00:00:00 2001 From: Shishir Pai <64056966+mgpai22@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:02:55 +0000 Subject: [PATCH 1/7] fix: finalize script tx fee, collateral, and script data hash consistently Several Complete() finalization steps used stale or zero values that the ledger rejects for script transactions: - estimateFee/estimateExecutionUnits built the sizing/eval body with a zero fee; since the fee field is omitempty a zero value is dropped, undercounting the body size (FeeTooSmallUTxO) and producing a fee-less body that strict evaluators reject. Use a non-zero placeholder fee. - minfee omitted the Conway tiered reference-script fee for script txs. Add TierRefScriptFee plus protocol-param helpers, and a new MinFeeRefScriptCostPerByte field for providers (e.g. BlockFrost) that only expose the flat name. - collateral was sized from the preliminary fee; resize total collateral and the collateral return against the final fee (finalizeCollateral), leaving explicit/manual collateral untouched. - ComputeScriptDataHash encoded an empty datum array (0x80) when there were no datums, shifting the hash and causing ScriptIntegrityHashMismatch for datum-less redeemer txs; append nothing when there are no datums. --- apollo.go | 193 +++++++++++++++++++++++++++++++++++++- backend/base.go | 77 ++++++++++++++++ finalization_test.go | 215 +++++++++++++++++++++++++++++++++++++++++++ helpers.go | 14 ++- 4 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 finalization_test.go diff --git a/apollo.go b/apollo.go index a6d3e9a..0c687e3 100644 --- a/apollo.go +++ b/apollo.go @@ -1380,6 +1380,16 @@ func (a *Apollo) Complete() (*Apollo, error) { } } + // Recompute total collateral and the collateral return from the FINAL fee. + // setCollateral() runs before execution units and the reference-script fee + // are known, 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 +1602,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 +1673,102 @@ func (a *Apollo) estimateFee(inputs []common.Utxo, outputs []babbage.BabbageTran fee += int64(exUnitFeeFloat) } + // Add the Conway tiered reference-script fee. Scripts supplied via reference + // inputs (rather than attached to the witness set) are priced per byte on a + // growing tier. The ledger includes this in minfee, so omitting it produces + // FeeTooSmallUTxO. This only applies to script transactions: a tx with no + // redeemers/scripts executes nothing, so its reference inputs (which may be + // used purely to read datums) contribute no reference-script fee and need + // not be resolved. + refScriptSize := 0 + if a.hasScripts() { + refScriptSize, err = a.totalReferenceScriptSize(inputs) + if err != nil { + return 0, err + } + } + if refScriptSize > 0 { + fee += backend.TierRefScriptFee( + refScriptSize, + pp.RefScriptFeePerByte(), + pp.RefScriptSizeIncrement(), + pp.RefScriptMultiplier(), + ) + } + return fee, nil } +// totalReferenceScriptSize resolves the combined byte size of all Plutus +// reference scripts 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 + } + switch script.(type) { + case common.PlutusV1Script, *common.PlutusV1Script, + common.PlutusV2Script, *common.PlutusV2Script, + common.PlutusV3Script, *common.PlutusV3Script: + 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) } @@ -2385,6 +2492,84 @@ func (a *Apollo) setCollateral() error { return errors.New("script transaction requires collateral, but no eligible collateral UTxO was found") } +// finalizeCollateral recomputes the total collateral and the collateral-return +// output from 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 execution units and +// the reference-script fee are known. We keep the collateral UTxO it selected +// (so coin selection still excluded it) and only resize the total/return here. +// +// If the user pinned an explicit collateral amount (collateralAmount) or set +// the collateral inputs manually, the early sizing is intentional and left +// untouched. +func (a *Apollo) finalizeCollateral(fee int64) error { + if len(a.collaterals) == 0 || a.collateralAmount > 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 selected collateral inputs so the + // collateral return can carry the remainder (and any tokens) forward. + var totalLovelace int64 + var collateralAssets *common.MultiAsset[common.MultiAssetTypeOutput] + for _, utxo := range a.collaterals { + amt := utxo.Output.Amount() + if amt == nil || !amt.IsInt64() { + return fmt.Errorf("collateral UTxO %s has an invalid lovelace amount", utxoRef(utxo)) + } + sum := totalLovelace + amt.Int64() + if sum < totalLovelace { + return errors.New("collateral lovelace total overflows int64") + } + totalLovelace = sum + if assets := utxo.Output.Assets(); assets != nil { + if collateralAssets == nil { + collateralAssets = CloneMultiAsset(assets) + } else { + collateralAssets.Add(assets) + } + } + } + + if required > totalLovelace { + return fmt.Errorf( + "insufficient collateral: need %d lovelace (ceil(fee %d * %d%%)), selected collateral holds %d", + required, fee, pp.CollateralPercent, totalLovelace, + ) + } + + a.totalCollateral = required + remainder := totalLovelace - required + if remainder > 0 || collateralAssets != nil { + returnVal := Value{Coin: uint64(remainder)} //nolint:gosec // remainder >= 0 + if collateralAssets != nil { + returnVal.Assets = collateralAssets + } + ret := NewBabbageOutput(a.getChangeAddress(), returnVal, nil, nil) + a.collateralReturn = &ret + } else { + a.collateralReturn = nil + } + return nil +} + func (a *Apollo) validateCollateralDistinctFromInputs(inputs []common.Utxo) error { if len(a.collaterals) == 0 { return nil diff --git a/backend/base.go b/backend/base.go index 86adf4b..f5fb262 100644 --- a/backend/base.go +++ b/backend/base.go @@ -85,6 +85,83 @@ 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). +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 + } + acc := 0.0 + curTierPrice := baseFeePerByte + n := totalRefScriptSize + for n >= sizeIncrement { + acc += float64(sizeIncrement) * curTierPrice + curTierPrice *= multiplier + n -= sizeIncrement + } + acc += float64(n) * curTierPrice + return int64(math.Floor(acc)) } // CoinsPerUtxoByteValue returns the coins per UTxO byte value parsed from the string field. 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 From 019a4cda8c35d872f1d730bd8d12c4efc70a4c43 Mon Sep 17 00:00:00 2001 From: Shishir Pai <64056966+mgpai22@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:57:53 +0000 Subject: [PATCH 2/7] refactor(backend): compute tiered ref-script fee in exact rationals TierRefScriptFee accumulated the Conway tiered reference-script fee in float64 and floored at the end. The direction matches the ledger (which floors an exact Rational), and for the integer base price the drift is far below a lovelace, so this was correct in practice. Switch to big.Rat with the multiplier recovered exactly as 6/5 so the result provably equals the ledger minimum for any size and base, matching the reference implementations (CML, mesh) and removing any float-boundary risk on large multi-tier reference scripts. Add oracle tests (single-tier linear, exact match across 1..50 tiers). --- backend/base.go | 33 +++++++++++--- backend/tier_ref_script_fee_test.go | 69 +++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 backend/tier_ref_script_fee_test.go diff --git a/backend/base.go b/backend/base.go index f5fb262..460d303 100644 --- a/backend/base.go +++ b/backend/base.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "fmt" "math" + "math/big" "strconv" "strings" @@ -142,6 +143,13 @@ func (p ProtocolParameters) RefScriptMultiplier() float64 { // // 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 @@ -152,16 +160,29 @@ func TierRefScriptFee(totalRefScriptSize int, baseFeePerByte float64, sizeIncrem if multiplier <= 0 { multiplier = DefaultRefScriptMultiplier } - acc := 0.0 - curTierPrice := baseFeePerByte + // 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 += float64(sizeIncrement) * curTierPrice - curTierPrice *= multiplier + acc.Add(acc, new(big.Rat).Mul(incr, price)) + price.Mul(price, m) n -= sizeIncrement } - acc += float64(n) * curTierPrice - return int64(math.Floor(acc)) + 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) + } + } +} From 2c52d33948ddbeee7da1e1eeb2f139575cfd11c6 Mon Sep 17 00:00:00 2001 From: Shishir Pai <64056966+mgpai22@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:24:27 +0000 Subject: [PATCH 3/7] fix(fee): price all reference scripts and fail on a missing ref-script price Harden the Conway reference-script fee in estimateFee to match the ledger: - count every reference script (native and Plutus), not only Plutus, since the ledger prices all reference scripts by raw byte size - compute the size over the resolved input union unconditionally instead of gating on hasScripts(); totalReferenceScriptSize returns 0 when no reference scripts are present, so non-reference-script txs are unaffected - return a hard error when reference scripts are present but the protocol parameters carry no per-byte price, instead of silently charging zero and building an underpriced transaction --- apollo.go | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/apollo.go b/apollo.go index 0c687e3..c051f5a 100644 --- a/apollo.go +++ b/apollo.go @@ -1673,24 +1673,27 @@ func (a *Apollo) estimateFee(inputs []common.Utxo, outputs []babbage.BabbageTran fee += int64(exUnitFeeFloat) } - // Add the Conway tiered reference-script fee. Scripts supplied via reference - // inputs (rather than attached to the witness set) are priced per byte on a - // growing tier. The ledger includes this in minfee, so omitting it produces - // FeeTooSmallUTxO. This only applies to script transactions: a tx with no - // redeemers/scripts executes nothing, so its reference inputs (which may be - // used purely to read datums) contribute no reference-script fee and need - // not be resolved. - refScriptSize := 0 - if a.hasScripts() { - refScriptSize, err = a.totalReferenceScriptSize(inputs) - if err != nil { - return 0, err - } + // 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, - pp.RefScriptFeePerByte(), + refFeePerByte, pp.RefScriptSizeIncrement(), pp.RefScriptMultiplier(), ) @@ -1699,8 +1702,8 @@ func (a *Apollo) estimateFee(inputs []common.Utxo, outputs []babbage.BabbageTran return fee, nil } -// totalReferenceScriptSize resolves the combined byte size of all Plutus -// reference scripts that the ledger prices into the reference-script fee. +// 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 @@ -1713,12 +1716,9 @@ func (a *Apollo) totalReferenceScriptSize(inputs []common.Utxo) (int, error) { if script == nil { return } - switch script.(type) { - case common.PlutusV1Script, *common.PlutusV1Script, - common.PlutusV2Script, *common.PlutusV2Script, - common.PlutusV3Script, *common.PlutusV3Script: - total += len(script.RawScriptBytes()) - } + // 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. From 0450aed4e704dfb91d2216a49f20569068eae49a Mon Sep 17 00:00:00 2001 From: Shishir Pai <64056966+mgpai22@users.noreply.github.com> Date: Wed, 24 Jun 2026 03:49:17 +0000 Subject: [PATCH 4/7] feat: allow a wallet UTxO to be both spending input and collateral A wallet with a single vkey UTxO previously could not build a script transaction: setCollateral reserved a collateral UTxO out of the coin-selection pool and validateCollateralDistinctFromInputs rejected any overlap between inputs and collateral. This was stricter than the Cardano ledger (and than mesh, lucid, and lucid-evolution), which allow a UTxO to serve as both because collateral is consumed only on phase-2 script failure and regular inputs only on success. setCollateral still prefers a separate eligible vkey UTxO and reserves it, so multi-UTxO wallets keep an identical transaction shape. When that reservation starves coin selection, Complete releases the auto-selected collateral for overlap and retries selection once, so the lone UTxO appears exactly once in the inputs and once in the collateral. validateCollateral keeps duplicate detection and enforces MaxCollateralInputs but no longer rejects input/collateral overlap. finalizeCollateral sizes total_collateral to ceil(fee * collateralPercent / 100) from the final fee and builds the collateral return from the collateral inputs' value alone (excess ADA plus any native assets), never touching the success-path balance. Script-address collateral stays forbidden and caller-pinned collateral is never rewritten. --- apollo.go | 319 +++++++++++++++++++++++++++++++++++++++++-------- apollo_test.go | 309 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 560 insertions(+), 68 deletions(-) diff --git a/apollo.go b/apollo.go index b9bce1e..c05bc68 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,19 @@ 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 { + return a, fmt.Errorf("coin selection failed: %w", err) + } } // Build inputs (explicit allocation to avoid slice aliasing) @@ -1191,7 +1216,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 } @@ -1380,6 +1405,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 { @@ -2249,7 +2284,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 +2326,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 +2367,238 @@ 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). 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 +} + +// finalizeCollateral recomputes the total collateral and the collateral-return +// output from 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. We keep the collateral UTxO(s) it selected and only +// resize the total and the return here. +// +// 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. +// +// Only auto-selected collateral is resized. If the caller pinned the collateral +// inputs (AddCollateral) or an explicit amount (SetCollateralAmount), the +// sizing is intentional and left untouched. +func (a *Apollo) finalizeCollateral(fee int64) error { + if len(a.collaterals) == 0 || !a.collateralAutoSelected || a.collateralAmount > 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 selected 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 + return fmt.Errorf("collateral UTxO %s has an invalid lovelace amount", utxoRef(utxo)) } - lovelace := amt.Int64() - if lovelace < minCollateral { - continue - } - 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%%)), selected collateral holds %d", + required, fee, pp.CollateralPercent, totalLovelace, + ) + } + + 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: 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 } @@ -2397,11 +2610,13 @@ func (a *Apollo) validateCollateralDistinctFromInputs(inputs []common.Utxo) erro } 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) - } + // 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..53fdf24 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,292 @@ 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") + } +} From c6375165c0454ef9e3f219614f05b5a744ad0b5d Mon Sep 17 00:00:00 2001 From: Shishir Pai <64056966+mgpai22@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:00:44 +0000 Subject: [PATCH 5/7] fix(collateral): validate pinned collateral and stabilize sizing - validateCollateral now rejects script-address (non-vkey) collateral for caller-pinned AddCollateral, matching the ledger requirement. - finalizeCollateral validates explicit total_collateral (SetCollateralAmount) against ceil(fee*collateralPercent/100) instead of skipping the check. - size collateral inside the fee-convergence loop so the fee accounts for the final collateral total/return footprint. - restore the collateral reservation when the overlap retry fails so a later Complete() starts from consistent state. - prefer the smallest eligible dedicated collateral to reduce unnecessary overlap on multi-UTxO wallets. Adds tests for manual script-address collateral rejection and below-required explicit collateral amount. --- apollo.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++---- apollo_test.go | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/apollo.go b/apollo.go index c05bc68..4bd0647 100644 --- a/apollo.go +++ b/apollo.go @@ -1205,6 +1205,12 @@ func (a *Apollo) Complete() (*Apollo, error) { if err != nil { 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) @@ -1380,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) @@ -2420,16 +2433,30 @@ func (a *Apollo) setCollateral() error { } } - // First pass: prefer a pure-lovelace UTxO (no assets). - for _, utxo := range candidates { + // First pass: prefer a pure-lovelace UTxO (no assets). Among eligible + // candidates, choose the SMALLEST: reserving the smallest dedicated + // collateral leaves the larger UTxOs available to coin selection, which + // minimizes the chance the reservation starves spending and forces an + // overlap. (On starvation Complete() still falls back to overlap.) + var bestIdx = -1 + var bestLovelace int64 + for i, utxo := range candidates { if a.isUsed(utxoRef(utxo)) { continue } - if collateralEligible(utxo, true) { - selectCollateral(utxo) - return nil + if !collateralEligible(utxo, true) { + continue + } + lovelace := utxo.Output.Amount().Int64() + if bestIdx == -1 || lovelace < bestLovelace { + bestIdx = i + bestLovelace = lovelace } } + if bestIdx != -1 { + selectCollateral(candidates[bestIdx]) + return nil + } // Second pass: a UTxO that may carry assets. for _, utxo := range candidates { if a.isUsed(utxoRef(utxo)) { @@ -2461,6 +2488,19 @@ func (a *Apollo) releaseCollateralForOverlap() bool { 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 recomputes the total collateral and the collateral-return // output from the final transaction fee. The ledger requires // @@ -2480,7 +2520,7 @@ func (a *Apollo) releaseCollateralForOverlap() bool { // inputs (AddCollateral) or an explicit amount (SetCollateralAmount), the // sizing is intentional and left untouched. func (a *Apollo) finalizeCollateral(fee int64) error { - if len(a.collaterals) == 0 || !a.collateralAutoSelected || a.collateralAmount > 0 { + if len(a.collaterals) == 0 { return nil } pp, err := a.Context.ProtocolParams() @@ -2499,6 +2539,21 @@ func (a *Apollo) finalizeCollateral(fee int64) error { return nil } + // Caller-pinned sizing (explicit SetCollateralAmount, or a fully manual + // AddCollateral with an explicit total_collateral) is not rewritten - the + // caller asked for a specific value. But it is still ledger-validated: an + // explicit total below the required minimum would build an invalid tx, so + // surface that as a clear error instead of silently shipping it. + if !a.collateralAutoSelected || a.collateralAmount > 0 { + if a.totalCollateral > 0 && a.totalCollateral < required { + return fmt.Errorf( + "insufficient collateral: total_collateral %d is below the required %d (ceil(fee %d * %d%%))", + a.totalCollateral, required, fee, pp.CollateralPercent, + ) + } + return nil + } + // Sum the lovelace and assets across the selected collateral inputs so the // collateral return can carry the remainder (and any tokens) forward. var totalLovelace int64 @@ -2609,6 +2664,16 @@ func (a *Apollo) validateCollateral() error { return fmt.Errorf("duplicate collateral input %s", ref) } seen[ref] = struct{}{} + // 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 && diff --git a/apollo_test.go b/apollo_test.go index 53fdf24..7c1b9be 100644 --- a/apollo_test.go +++ b/apollo_test.go @@ -1762,3 +1762,61 @@ func TestMaxCollateralInputsEnforced(t *testing.T) { 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") + } +} From efffe1af443cfdd0ab2ca79f4f5b9f6172e82ad3 Mon Sep 17 00:00:00 2001 From: Shishir Pai <64056966+mgpai22@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:07:27 +0000 Subject: [PATCH 6/7] fix(collateral): honor explicit amount and validate manual collateral - finalizeCollateral now emits total_collateral (body key 18) for an explicit SetCollateralAmount even when combined with AddCollateral, and recomputes the collateral return from the requested amount; previously the requested amount was silently dropped. - reject an explicit collateral amount below the ledger minimum or above the collateral inputs instead of building an invalid tx. - validate fully manual AddCollateral: reject asset-bearing manual collateral that has no return (implicit collateral cannot return assets) and under-funded collateral inputs, while keeping prior ADA-only implicit-collateral behavior. - revert to first-eligible dedicated collateral selection so multi-UTxO wallets keep an identical tx shape. - add a final fee verification after collateral finalization to cover the non-converged fee path. Adds tests for explicit-amount emission, manual asset collateral rejection. --- apollo.go | 131 ++++++++++++++++++++++++++++++++----------------- apollo_test.go | 78 +++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 44 deletions(-) diff --git a/apollo.go b/apollo.go index 4bd0647..9bf2c05 100644 --- a/apollo.go +++ b/apollo.go @@ -1428,6 +1428,32 @@ func (a *Apollo) Complete() (*Apollo, error) { return a, err } + // Guard the non-converged escape: if the loop exhausted its iterations the + // fee was frozen before collateral was finalized against it, so the final + // collateral footprint could nudge the real min fee above the frozen value. + // Re-estimate once against the finalized body; if it is higher, raise the + // fee, rebuild outputs and re-finalize collateral so the body is consistent. + if !a.forceFee && a.Fee == 0 { + checkFee, err := a.estimateFee(allInputUtxos, outputs) + if err != nil { + return a, fmt.Errorf("final fee verification failed: %w", err) + } + checkFee += a.FeePadding + if checkFee < 0 { + checkFee = 0 + } + if checkFee > fee { + fee = checkFee + outputs, err = buildOutputsWithChange(fee) + if err != nil { + return a, err + } + if err := a.finalizeCollateral(fee); err != nil { + return a, err + } + } + } + // Build transaction body body, err := a.buildBody(allInputUtxos, outputs, uint64(fee)) if err != nil { @@ -2433,30 +2459,19 @@ func (a *Apollo) setCollateral() error { } } - // First pass: prefer a pure-lovelace UTxO (no assets). Among eligible - // candidates, choose the SMALLEST: reserving the smallest dedicated - // collateral leaves the larger UTxOs available to coin selection, which - // minimizes the chance the reservation starves spending and forces an - // overlap. (On starvation Complete() still falls back to overlap.) - var bestIdx = -1 - var bestLovelace int64 - for i, utxo := range candidates { + // 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 { if a.isUsed(utxoRef(utxo)) { continue } - if !collateralEligible(utxo, true) { - continue - } - lovelace := utxo.Output.Amount().Int64() - if bestIdx == -1 || lovelace < bestLovelace { - bestIdx = i - bestLovelace = lovelace + if collateralEligible(utxo, true) { + selectCollateral(utxo) + return nil } } - if bestIdx != -1 { - selectCollateral(candidates[bestIdx]) - return nil - } // Second pass: a UTxO that may carry assets. for _, utxo := range candidates { if a.isUsed(utxoRef(utxo)) { @@ -2501,24 +2516,32 @@ func (a *Apollo) restoreCollateralReservation() { a.collateralOverlapRef = "" } -// finalizeCollateral recomputes the total collateral and the collateral-return -// output from the final transaction fee. The ledger requires +// 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. We keep the collateral UTxO(s) it selected and only -// resize the total and the return here. +// 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. // -// Only auto-selected collateral is resized. If the caller pinned the collateral -// inputs (AddCollateral) or an explicit amount (SetCollateralAmount), the -// sizing is intentional and left untouched. +// 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 @@ -2539,23 +2562,8 @@ func (a *Apollo) finalizeCollateral(fee int64) error { return nil } - // Caller-pinned sizing (explicit SetCollateralAmount, or a fully manual - // AddCollateral with an explicit total_collateral) is not rewritten - the - // caller asked for a specific value. But it is still ledger-validated: an - // explicit total below the required minimum would build an invalid tx, so - // surface that as a clear error instead of silently shipping it. - if !a.collateralAutoSelected || a.collateralAmount > 0 { - if a.totalCollateral > 0 && a.totalCollateral < required { - return fmt.Errorf( - "insufficient collateral: total_collateral %d is below the required %d (ceil(fee %d * %d%%))", - a.totalCollateral, required, fee, pp.CollateralPercent, - ) - } - return nil - } - - // Sum the lovelace and assets across the selected collateral inputs so the - // collateral return can carry the remainder (and any tokens) forward. + // 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 @@ -2581,11 +2589,46 @@ func (a *Apollo) finalizeCollateral(fee int64) error { if required > totalLovelace { return fmt.Errorf( - "insufficient collateral: need %d lovelace (ceil(fee %d * %d%%)), selected collateral holds %d", + "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 diff --git a/apollo_test.go b/apollo_test.go index 7c1b9be..4c8d58f 100644 --- a/apollo_test.go +++ b/apollo_test.go @@ -1820,3 +1820,81 @@ func TestExplicitCollateralAmountBelowRequiredRejected(t *testing.T) { 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") + } +} From 5e53411d661e9917e4797345df7dedd734e639b4 Mon Sep 17 00:00:00 2001 From: Shishir Pai <64056966+mgpai22@users.noreply.github.com> Date: Wed, 24 Jun 2026 04:13:56 +0000 Subject: [PATCH 7/7] fix(collateral): reject dust return for explicit amount; drop fee re-check - finalizeCollateral now rejects an explicit collateral amount that leaves a sub-min-ADA return instead of silently raising total_collateral above the requested value (which would forfeit the dust on phase-2 failure). Only the auto-sized path absorbs ADA dust. - remove the post-loop fee re-verification: it relied on the same fee=0 size estimate as the main loop so it could not soundly detect underpay, and it added fee-adjacent logic outside the collateral scope. Collateral is sized inside the convergence loop before each estimate, so the converged fee already covers the finalized collateral footprint. Adds a test for explicit-amount dust rejection. --- apollo.go | 38 +++++++++++--------------------------- apollo_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/apollo.go b/apollo.go index 9bf2c05..8c09e88 100644 --- a/apollo.go +++ b/apollo.go @@ -1428,32 +1428,6 @@ func (a *Apollo) Complete() (*Apollo, error) { return a, err } - // Guard the non-converged escape: if the loop exhausted its iterations the - // fee was frozen before collateral was finalized against it, so the final - // collateral footprint could nudge the real min fee above the frozen value. - // Re-estimate once against the finalized body; if it is higher, raise the - // fee, rebuild outputs and re-finalize collateral so the body is consistent. - if !a.forceFee && a.Fee == 0 { - checkFee, err := a.estimateFee(allInputUtxos, outputs) - if err != nil { - return a, fmt.Errorf("final fee verification failed: %w", err) - } - checkFee += a.FeePadding - if checkFee < 0 { - checkFee = 0 - } - if checkFee > fee { - fee = checkFee - outputs, err = buildOutputsWithChange(fee) - if err != nil { - return a, err - } - if err := a.finalizeCollateral(fee); err != nil { - return a, err - } - } - } - // Build transaction body body, err := a.buildBody(allInputUtxos, outputs, uint64(fee)) if err != nil { @@ -2675,7 +2649,17 @@ func (a *Apollo) finalizeCollateral(fee int64) error { a.collateralReturn = &ret return nil } - // Dust remainder: absorb into total_collateral, no return. + // 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 diff --git a/apollo_test.go b/apollo_test.go index 4c8d58f..011642f 100644 --- a/apollo_test.go +++ b/apollo_test.go @@ -1898,3 +1898,39 @@ func TestManualAssetCollateralRejected(t *testing.T) { 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") + } +}