Skip to content

A craft that costs two currencies

Fifty gold and three shards — with the shards reserved the moment the craft is accepted and only actually taken when it lands.

Recipe

Systems required: Crafting, Currency. Package: Complete only — the systems above ship in different packages, so no single-system package can run this. Shape: one class you drop into a project that already exists. No scene, no prefab, no setup ritual. Public API only. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.

The part that is not obvious

A recipe asset carries exactly one price. RecipeCore.Currency is a single CurrencyCost — one id, one amount — so a second currency has nowhere to live on the asset, and the obvious workaround (charge it yourself when the craft finishes) gets the ordering wrong in a way players notice: the craft is accepted, the gold is taken, and forty seconds later the bench discovers there are no shards.

The composition is crafting's own lifecycle, mirrored onto escrow. Crafting already debits its price at accept and refunds it on cancel. This does the same for the second currency: hold on accept, commit on completion, release on cancel and on failure.

Miss OnJobFailed and the two prices disagree

A craft that finishes but cannot be delivered — a full bag — refunds the gold and raises OnJobFailed, not OnJobCancelled. Subscribe to cancellation alone and that craft hands back the gold while quietly keeping the shards. It is the exact shape of bug that makes a player stop trusting a crafting screen, and it is one missing line.

Why a hold rather than a debit and a credit

Both take the money at the same moment. The difference is at the other end.

A plain refund is a Credit, and a credit can be refused — by a balance cap, by a wallet at its ceiling, by an authority. ICraftingCurrencyAdapter.Credit returns void, so crafting's own refund cannot report that it failed; recovering that answer is the entire reason ICraftingCurrencyCreditReporter exists. ICurrencyEscrow.Release returns a result.

Choosing the seam that answers you is most of what this recipe is demonstrating.

A save and a reload during a craft voids the hold

A hard hold debits at hold time, so a wallet captured mid-craft saves the post-debit balance. Restoring it and then releasing would credit money the restored balance already accounts for — currency from nothing — so escrow refuses and answers EscrowOpCode.Invalidated instead.

On a commit that costs nothing: the money was always going to be spent. On a release it means the player's refund is now yours to issue or to decline, deliberately, with a plain Credit.

This is the same reason CraftingService's own escrow path is immediate-only and says so: long-running holds need persisting, and that is a feature rather than a line of code.

Enqueue returning a job no longer means the job is live

If the hold fails, the craft has already been accepted and its own price and inputs have already been taken — so the only way to put them back is CancelJob, called from inside the accepted event. The service is explicitly hardened for that (it removes jobs by identity, not by a cached index, precisely so a handler may cancel during dispatch).

The caller still gets its CraftJob back. Check job.state, not just non-null. The accepted event needs the same care and for the same reason: a subscriber ahead of this one can cancel the job from inside that dispatch, and the job is then already out of the service — so a hold placed on it afterwards could never be settled by any event. And read what CancelJob returns. It reports an attempt, not an outcome: an authority can refuse it.

It goes on the crafter, not on the bench. The service collects ICraftingValidator from the craft's owner and its parents, so a component on a workbench is never asked — and it only asks at all when validators are enabled on the service. With them off, the refusal half silently does nothing while the hold half carries on working, which is the first misconfiguration to check.

The two halves are not scoped alike

Validators are collected per owner. The job events are not: they are service-wide, and every component in the scene hears every crafter's jobs. Left unfiltered that is a double charge — two crafters each carrying one of these, one validated price, two holds — and a crafter carrying none is charged anyway, against a price list it was never given.

So the hold half tests the owner itself and ignores any job that is not on this transform or under it, which is the scope the validator half already had. One component per crafter is right; one on a shared parent covers everything beneath it.

It refuses to price both halves in the same currency. The validator reads the balance before crafting takes its own price, so one currency on both halves lets a craft pass a check against money it is about to spend. Note that CurrencyId lowercases on construction while the recipe asset only trims — so the comparison has to go through CurrencyId at both ends, or Gold and gold read as two different currencies and the check quietly stops working.

Drop it in

SecondCurrencyCost.cs
using System;
using System.Collections.Generic;

using RevGaming.RevFramework.Crafting.Abstractions;
using RevGaming.RevFramework.Crafting.Core;
using RevGaming.RevFramework.Crafting.UnityIntegration;
using RevGaming.RevFramework.Currency;
using RevGaming.RevFramework.Currency.Abstractions;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.SecondCurrencyCost
{
    /// <summary>
    /// A craft that costs two currencies — gold on the recipe asset and, say, shards on top — with the
    /// second price reserved while the job runs and only taken when it finishes.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Crafting</b>, <b>Currency</b>. Public API only.</para>
    ///
    /// <para><b>Why this cannot be authored.</b> <see cref="RecipeCore"/> carries exactly one
    /// <c>CurrencyCost</c> — one id, one amount — so "50 gold and 3 shards" has nowhere to live on the
    /// asset. The second price therefore lives on this component, and the honest cost of that is a
    /// split: half the price is authored on the recipe and half is authored here. Nothing in the
    /// framework keeps the two lists in step, so treat this as part of the recipe's authoring rather
    /// than as a scene setting.</para>
    ///
    /// <para><b>Where it goes: on the crafter, not on the bench.</b> The service collects
    /// <see cref="ICraftingValidator"/> from the craft's owner and its parents, so a component sitting
    /// on a workbench is never asked. It also only asks at all when validators are enabled on the
    /// service — with them off, the refusal half of this recipe silently does nothing while the hold
    /// half carries on working, which is the one misconfiguration worth checking first.</para>
    ///
    /// <para><b>The two halves are not scoped alike, so the hold half scopes itself.</b> Validators
    /// are collected per owner; the job events are service-wide, and every component in the scene
    /// hears every crafter's jobs. Left unfiltered, two crafters each carrying one of these charge one
    /// validated price twice over, and a crafter carrying none is charged by somebody else's list. So
    /// the hold half ignores any job whose owner is not this transform or under it — the same scope
    /// the validator half already has. One per crafter is right, and one on a shared parent covers
    /// everything beneath it.</para>
    ///
    /// <para><b>It rides <see cref="CraftingService.Enqueue"/>'s job lifecycle, and only that.</b>
    /// <see cref="CraftingService.TryCraftImmediateEscrow"/> runs validators but creates no
    /// <see cref="CraftJob"/> and raises no accepted event, so on that path the refusal fires and the
    /// charge never does: a craft gated on a price it does not pay. It is the validators-disabled
    /// hazard with the halves swapped and the worse ending, and nothing in the public surface lets a
    /// validator tell the two paths apart — if your project uses that call, charge the second price
    /// where you make it.</para>
    ///
    /// <para><b>The composition is the crafting lifecycle mirrored onto escrow.</b> Crafting already
    /// debits its own currency at accept and refunds it on cancel; this does the same for the second
    /// currency with <see cref="ICurrencyEscrow"/> — hold on accept, commit on completion, release on
    /// cancel <i>and</i> on failure. Miss <see cref="CraftingService.OnJobFailed"/> and a craft that
    /// cannot be delivered hands back the gold and keeps the shards, which is the exact shape of the
    /// bug that makes players stop trusting a crafting screen.</para>
    ///
    /// <para><b>Escrow rather than debit-and-credit, for one specific reason.</b> Both would take the
    /// money at the same moment. The difference is at the other end: a plain credit can be refused by a
    /// balance cap, and <see cref="ICraftingCurrencyAdapter.Credit"/> returns <c>void</c>, so crafting's
    /// own refund cannot report that it failed — that is what
    /// <see cref="ICraftingCurrencyCreditReporter"/> exists to recover. <see cref="ICurrencyEscrow.Release"/>
    /// returns a result. Choosing the seam that answers you is most of what this recipe is
    /// demonstrating.</para>
    ///
    /// <para><b>A save and a reload during a craft voids the hold.</b> A hard hold debits at hold time,
    /// so a wallet captured mid-craft saves the post-debit balance; restoring it and then releasing
    /// would credit money the restored balance already accounts for. Escrow refuses to do that and
    /// answers <see cref="EscrowOpCode.Invalidated"/> instead. On a <i>commit</i> that costs nothing —
    /// the money was always going to be spent. On a <i>release</i> it means the player's refund is
    /// yours to issue or to decline, deliberately, with a plain
    /// <see cref="ICurrencyService.Credit"/>. This is the same reason <c>CraftingService</c>'s own
    /// escrow path is immediate-only and says so: long-running holds need persisting, and that is a
    /// feature rather than a line of code.</para>
    ///
    /// <para><b>The job side of that reload is quieter, and this component cannot fix it.</b>
    /// Restoring saved jobs replaces the live ones without raising anything and rebuilds them under
    /// fresh ids, so a reservation made before the load can never be settled and the restored craft
    /// carries no record of its second price at all. Cancel that craft and crafting refunds its own
    /// currency while this component has nothing to give back — it says so once, rather than losing
    /// the player's shards in silence. Settle or disable before restoring if that matters to you.</para>
    ///
    /// <para><b>Enqueue returning a job no longer means the job is live.</b> If the hold fails the
    /// craft has already been accepted and the recipe's own price and inputs have already been taken,
    /// so the only way to put them back is <see cref="CraftingService.CancelJob"/> — from inside the
    /// accepted event, which the service is explicitly hardened for. The caller still gets its
    /// <see cref="CraftJob"/> back. Check <see cref="CraftJob.state"/>, not just non-null.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class SecondCurrencyCost : MonoBehaviour, ICraftingValidator
    {
        /// <summary>One recipe and the extra currency it costs on top of its own price.</summary>
        [Serializable]
        public struct ExtraPrice
        {
            [Tooltip("The recipe that costs a second currency.")]
            public RecipeCore recipe;

            [Tooltip("Currency id for the EXTRA price -- not the one authored on the recipe asset.")]
            public string currencyId;

            [Tooltip("How much of it one craft costs. Batches multiply it.")]
            [Min(1)] public int amountPerCraft;
        }

        [Tooltip("Crafting service to listen to. Leave empty to find one in the scene on enable.")]
        [SerializeField] private CraftingService crafting;

        [Tooltip("Recipes that cost a second currency.")]
        [SerializeField] private List<ExtraPrice> prices = new();

        [Tooltip("Seconds before an abandoned hold is refunded by the escrow pump. 0 = never, and 0 is " +
                 "the safe default. It is measured in UNSCALED realtime while the craft it is meant to " +
                 "outlive runs on scaled time, and an expired hold refuses BOTH commit and release: too " +
                 "short and the craft delivers while the price is refunded by the pump, or -- with no " +
                 "CurrencyEscrowExpiryPump in the scene -- is stuck for good. Set it well above the " +
                 "longest craft plus any time the game can sit paused.")]
        [SerializeField, Min(0f)] private float holdTtlSeconds = 0f;

        // Keyed by job id, which is the only identity a job has that survives the trip from the accepted
        // event to the completed one. Two jobs for the same recipe and owner are otherwise identical.
        private readonly Dictionary<int, Guid> _holds = new();

        private ICurrencyService _wallet;
        private ICurrencyEscrow _escrow;
        private int _walletEpoch = int.MinValue;

        // Cancelling a job this component never reserved for is the ordinary failure path, not the
        // save-restore one, so it must not spend the one warning that reports the restore case.
        private bool _cancellingUnheld;
        private bool _warnedUnreserved;

        private void OnEnable()
        {
            if (!crafting) crafting = FindAnyObjectByType<CraftingService>();
            if (!crafting)
            {
                Debug.LogWarning($"[{nameof(SecondCurrencyCost)}] No CraftingService found, so no second " +
                                 "price will be charged.", this);
                return;
            }

            WarnOnPriceListProblems();

            crafting.OnJobAccepted += OnAccepted;
            crafting.OnJobCompleted += OnCompleted;
            crafting.OnJobCancelled += OnCancelled;
            crafting.OnJobFailed += OnFailed;
        }

        private void OnDisable()
        {
            if (crafting)
            {
                crafting.OnJobAccepted -= OnAccepted;
                crafting.OnJobCompleted -= OnCompleted;
                crafting.OnJobCancelled -= OnCancelled;
                crafting.OnJobFailed -= OnFailed;
            }

            // Release rather than commit, and release rather than nothing. Nothing is the worst of the
            // three: the money is already debited and the only token that could return it dies with this
            // component. Releasing means a job still running finishes without paying its second price,
            // which is a bug the player never notices in their favour -- the honest lesser evil.
            ReleaseOutstanding("SecondCurrencyCost:Disabled");
        }

        // =================================================================================================
        // Refusal, before acceptance
        // =================================================================================================

        /// <summary>
        /// Refuses a craft whose second price the owner cannot pay, and lowers the count it will allow.
        /// </summary>
        /// <remarks>
        /// <para>Collected from the owner and its parents by the service before acceptance, when
        /// validators are enabled. Returning the proposal untouched is how a validator says "no
        /// opinion".</para>
        /// <para>A lowered count is an answer, not a trimmed order. <see cref="CraftingService.CanCraftCount"/>
        /// and <c>Probe</c> report it to the UI, and an unbatched multi-count request stops early at it;
        /// but <see cref="CraftingService.EnqueueBatch"/> is all-or-nothing and refuses the whole request
        /// — with a reason derived from its own core bounds rather than from the shortfall here, because
        /// <see cref="CraftCheck.reason"/> only carries meaning at <c>maxCrafts == 0</c>. A UI that offers
        /// "craft 10" should ask <see cref="CraftingService.CanCraftCount"/> what it can offer first.</para>
        /// </remarks>
        public CraftCheck Validate(ref CraftContext ctx, in CraftCheck proposed)
        {
            if (proposed.maxCrafts <= 0 || !ctx.owner)
                return proposed;

            if (!TryGetPrice(ctx.recipe, out var id, out int perCraft))
                return proposed;

            // Refuse rather than allow when the wallet cannot be reached. Allowing is a craft that
            // silently costs nothing, and a free craft is discovered by players long before it is
            // discovered by whoever configured the scene.
            var wallet = ResolveWallet();
            if (wallet == null || _escrow == null)
                return new CraftCheck { maxCrafts = 0, reason = CraftFailReason.NoCurrency };

            long balance = wallet.GetBalance(ctx.owner, id).amount;
            long affordable = balance / perCraft;

            if (affordable <= 0)
                return new CraftCheck { maxCrafts = 0, reason = CraftFailReason.NoCurrency };

            // Lowering maxCrafts is the seam doing something better than a boolean could -- but it is an
            // answer, not a cap on the order. Probe and CanCraftCount report it; EnqueueBatch refuses the
            // whole batch rather than trimming it to fit. See the remarks above.
            return affordable >= proposed.maxCrafts
                ? proposed
                : new CraftCheck { maxCrafts = (int)affordable, reason = proposed.reason };
        }

        // =================================================================================================
        // The hold, and its two endings
        // =================================================================================================

        // The owner test the job events do not do. IsChildOf is true for the transform itself, so this
        // is exactly the scope the service uses to collect validators, read from the other end.
        private bool IsMine(CraftJob job) =>
            job != null && job.owner && job.owner.transform.IsChildOf(transform);

        private void OnAccepted(CraftJob job)
        {
            if (!IsMine(job) || !job.recipe)
                return;

            // Accepted does not mean live. A subscriber ahead of this one may have cancelled the job from
            // inside this same dispatch -- the service is hardened for exactly that, and the failure path
            // below does it. The job is then already out of the service, so no Completed, Cancelled or
            // Failed event will ever carry its id again and a hold placed now could never be settled.
            if (job.state is CraftJobState.Completed or CraftJobState.Cancelled or CraftJobState.Failed)
                return;

            if (!TryGetPrice(job.recipe, out var id, out int perCraft))
                return;

            long total = (long)perCraft * Mathf.Max(1, job.batchCount);

            var escrow = ResolveEscrow();
            var hold = escrow?.TryHold(job.owner, id, total, "SecondCurrencyCost", name, holdTtlSeconds)
                       ?? EscrowOpResult.Fail(EscrowOpCode.ServiceMissing);

            if (hold.Success)
            {
                _holds[job.id] = hold.Token;
                return;
            }

            // The validator already checked the balance, so reaching here means the world moved between
            // the check and the accept -- or that no escrow is composed at all. Either way the craft has
            // been accepted and paid for in its own currency, so cancelling with refunds is what undoes
            // it. The service removes jobs by identity precisely so a handler may do this.
            Debug.LogWarning($"[{nameof(SecondCurrencyCost)}] Could not reserve {total} '{id}' for job " +
                             $"{job.id} ({hold.Code}). Cancelling the craft to refund its own price.", this);

            // CancelJob reports an attempt, not an outcome: false when an authority denies the cancel, or
            // when the job has already left the service. Announcing the cancellation without reading the
            // answer is how a console line ends up asserting the opposite of what happened.
            _cancellingUnheld = true;
            bool cancelled = crafting.CancelJob(job.id);
            _cancellingUnheld = false;

            if (!cancelled)
            {
                Debug.LogError($"[{nameof(SecondCurrencyCost)}] Job {job.id} could not be cancelled, so it " +
                               "keeps its inputs and its own price and will never pay its second one.", this);
            }
        }

        private void OnCompleted(CraftJob job) => Settle(job, commit: true);

        private void OnCancelled(CraftJob job) => Settle(job, commit: false);

        private void OnFailed(CraftJob job, CraftFailReason reason) => Settle(job, commit: false);

        private void Settle(CraftJob job, bool commit)
        {
            if (job == null)
                return;

            // The dictionary is the ownership test on this side -- it only ever contains jobs this
            // component reserved for, so another crafter's job is simply not in it. Deliberately not the
            // transform test OnAccepted uses: an owner destroyed mid-craft is fake-null, and refusing to
            // settle a hold on that basis would strand the money this component is holding.
            if (!_holds.TryGetValue(job.id, out var token))
            {
                WarnUnreservedOnce(job, commit);
                return;
            }

            _holds.Remove(job.id);

            var escrow = ResolveEscrow();
            if (escrow == null)
            {
                Debug.LogWarning($"[{nameof(SecondCurrencyCost)}] Escrow disappeared before job {job.id} " +
                                 "could be settled. The reserved amount is still held.", this);
                return;
            }

            var result = commit
                ? escrow.Commit(token, "SecondCurrencyCost:Commit", name)
                : escrow.Release(token, "SecondCurrencyCost:Release", name);

            if (result.Success)
                return;

            // Invalidated means a save was restored while this hold was live. On a commit that is
            // harmless -- the restored balance already has the money gone, which is what committing
            // wanted. On a release it is the interesting case: escrow will not credit the refund,
            // because from where it stands that credit would be currency from nothing.
            if (result.Code == EscrowOpCode.Invalidated)
            {
                if (!commit)
                {
                    Debug.LogWarning(
                        $"[{nameof(SecondCurrencyCost)}] Job {job.id} was cancelled after a save was " +
                        "restored, so its reservation is void and the escrow will not refund it. Issue " +
                        "the credit yourself if your game means to give it back.", this);
                }

                return;
            }

            // Expired refuses commit AND release and leaves the hold standing, so the TTL has decided the
            // outcome instead of the craft: with an expiry pump in the scene the pump credits the amount
            // back to a player who already has the item, and with no pump nothing can ever recover it.
            // The TTL runs on unscaled realtime; the craft it was meant to outlive does not.
            if (result.Code == EscrowOpCode.Expired)
            {
                Debug.LogError($"[{nameof(SecondCurrencyCost)}] The reservation for job {job.id} expired " +
                               $"before it could be {(commit ? "committed" : "released")}. Raise " +
                               "holdTtlSeconds well above the longest craft, or set it to 0.", this);
                return;
            }

            Debug.LogWarning($"[{nameof(SecondCurrencyCost)}] Job {job.id} could not be " +
                             $"{(commit ? "committed" : "released")} ({result.Code}).", this);
        }

        // Said once, because the ordinary causes are benign: the hold was refused, or another handler
        // cancelled the craft before this one saw it. The case worth the line is a save restore, which
        // replaces the live jobs without an event and rebuilds them under fresh ids -- the reservation
        // this component made is then unreachable and the restored craft has no second price on record,
        // so cancelling it hands back crafting's own currency and quietly keeps the rest.
        private void WarnUnreservedOnce(CraftJob job, bool commit)
        {
            if (commit || _warnedUnreserved || _cancellingUnheld || !IsMine(job))
                return;

            if (!TryGetPrice(job.recipe, out var id, out _))
                return;

            _warnedUnreserved = true;
            Debug.LogWarning($"[{nameof(SecondCurrencyCost)}] Job {job.id} ended without a reservation this " +
                             $"component knows about, so no '{id}' was returned for it. Restoring a save " +
                             "replaces live jobs with new ones and raises nothing, which is the usual " +
                             "cause. Reported once.", this);
        }

        private void ReleaseOutstanding(string reason)
        {
            if (_holds.Count == 0)
                return;

            var escrow = ResolveEscrow();
            if (escrow == null)
            {
                Debug.LogWarning($"[{nameof(SecondCurrencyCost)}] {_holds.Count} reservation(s) could not " +
                                 "be released. A hold TTL is the only thing that will return them.", this);
                _holds.Clear();
                return;
            }

            int failed = 0;
            var firstCode = EscrowOpCode.Ok;

            foreach (var token in _holds.Values)
            {
                var result = escrow.Release(token, reason, name);
                if (result.Success)
                    continue;

                if (failed++ == 0)
                    firstCode = result.Code;
            }

            // Reading the answer here too, for the same reason the rest of the class does. Escrow keeps a
            // hold it could not refund precisely so it can be released again later -- and this is the last
            // moment anyone holds the token that would do it, because the next line throws them all away.
            if (failed > 0)
            {
                Debug.LogWarning($"[{nameof(SecondCurrencyCost)}] {failed} of {_holds.Count} reservation(s) " +
                                 $"were not released (first: {firstCode}). A save restore voids holds and " +
                                 "reports here harmlessly; anything genuinely still held is now stuck, " +
                                 "because its token dies with this component.", this);
            }

            _holds.Clear();
        }

        // =================================================================================================
        // Lookups and resolution
        // =================================================================================================

        private bool TryGetPrice(RecipeCore recipe, out CurrencyId id, out int perCraft)
        {
            id = CurrencyId.Empty;
            perCraft = 0;

            if (!recipe)
                return false;

            for (int i = 0; i < prices.Count; i++)
            {
                var p = prices[i];

                if (p.recipe != recipe || p.amountPerCraft <= 0 || string.IsNullOrWhiteSpace(p.currencyId))
                    continue;

                var candidate = new CurrencyId(p.currencyId);
                if (!candidate.IsValid || IsRecipesOwnCurrency(recipe, candidate))
                    continue;

                id = candidate;
                perCraft = p.amountPerCraft;
                return true;
            }

            return false;
        }

        /// <summary>
        /// Whether an extra price names the same currency the recipe already charges.
        /// </summary>
        /// <remarks>
        /// It must not. The validator reads the balance <i>before</i> crafting takes its own price, so
        /// pricing both halves in one currency lets a craft pass a check against money it is about to
        /// spend. <see cref="CurrencyId"/> lowercases on construction and the recipe asset only trims,
        /// so the comparison has to go through <see cref="CurrencyId"/> at both ends or "Gold" and
        /// "gold" read as two different currencies — which is how this check would quietly stop working.
        /// </remarks>
        private static bool IsRecipesOwnCurrency(RecipeCore recipe, CurrencyId candidate)
        {
            var own = recipe.Currency;
            return own.amountPerCraft > 0
                && !string.IsNullOrWhiteSpace(own.currencyId)
                && new CurrencyId(own.currencyId) == candidate;
        }

        private void WarnOnPriceListProblems()
        {
            for (int i = 0; i < prices.Count; i++)
            {
                var p = prices[i];

                if (!p.recipe || p.amountPerCraft <= 0 || string.IsNullOrWhiteSpace(p.currencyId))
                    continue;

                // One extra price per recipe. The lookup returns on the first usable entry and never reads
                // the rest, so a second row for the same recipe is a currency nobody is ever charged --
                // silently, which is the only part of that worth fixing. "Usable" has to mean exactly what
                // it means in TryGetPrice, including the same-currency rejection below: an earlier row the
                // lookup skips does not win, and naming it as the winner would be a false report.
                for (int j = 0; j < i; j++)
                {
                    var earlier = prices[j];

                    if (earlier.recipe != p.recipe || earlier.amountPerCraft <= 0
                        || string.IsNullOrWhiteSpace(earlier.currencyId)
                        || IsRecipesOwnCurrency(earlier.recipe, new CurrencyId(earlier.currencyId)))
                        continue;

                    Debug.LogWarning(
                        $"[{nameof(SecondCurrencyCost)}] '{p.recipe.name}' is listed more than once. Only " +
                        $"one extra currency per recipe is charged -- '{earlier.currencyId}' wins and " +
                        $"'{p.currencyId}' is ignored.", this);
                    break;
                }

                if (IsRecipesOwnCurrency(p.recipe, new CurrencyId(p.currencyId)))
                {
                    Debug.LogWarning(
                        $"[{nameof(SecondCurrencyCost)}] '{p.recipe.name}' already charges " +
                        $"'{p.currencyId}' on the asset, so the extra price is ignored. Raise the amount " +
                        "on the recipe instead, or price this one in a different currency.", this);
                }
            }
        }

        private ICurrencyService ResolveWallet()
        {
            // Liveness first: the field is interface-typed, so a destroyed scene service is fake-null and
            // `_wallet == null` would stay false forever while every call went to a dead component.
            if (_wallet is UnityEngine.Object uo && !uo)
            {
                _wallet = null;
                _escrow = null;
            }

            // The epoch, because a bootstrap publishes its composed stack from Start and anything cached
            // before that is the raw wallet with no escrow on it at all -- which would make every hold
            // fail with ServiceMissing and every craft cancel itself.
            if (_wallet == null || _walletEpoch != CurrencyResolve.PublishEpoch)
            {
                _wallet = CurrencyResolve.ServiceFrom(this);
                _walletEpoch = CurrencyResolve.PublishEpoch;

                if (_wallet == null || !CurrencyFactories.TryGetEscrow(_wallet, out _escrow))
                    _escrow = null;
            }

            return _wallet;
        }

        private ICurrencyEscrow ResolveEscrow()
        {
            ResolveWallet();
            return _escrow;
        }
    }
}

Wiring it up

  1. Compose a currency stack that exposes escrow — CurrencyFactories.WithEscrow(...) or one of the ...Escrow combinations — and publish it. Without it every craft of a listed recipe is refused with NoCurrency: loudly, and before anything has been taken. (With validators disabled instead, the refusal does nothing and the craft is accepted and then cancelled by the failed hold.)
  2. Put the component on the crafter (or a parent), and enable validators on the CraftingService. One component per crafter — it charges only crafts by the owner it sits on or above.
  3. List the recipes that cost a second currency, with the id and the per-craft amount. One extra currency per recipe: list a recipe twice and only the first entry is ever charged, which the component says on enable.
  4. Leave the hold TTL at 0 unless you have a reason. It is a foot-gun in both directions, and the reason it exists is holds this component never gets to settle at all.

A hold TTL runs on a different clock from the craft it is meant to outlive

Escrow measures a TTL in unscaled realtime. A CraftingService measures craft progress on scaled time, so a pause menu at timeScale = 0, a slow-motion effect or a PauseJob moves the two apart — and the TTL clock never stops.

An expired hold then refuses both Commit and Release, and escrow keeps it. With a CurrencyEscrowExpiryPump in the scene the pump credits the amount back to a player who already has the item; with no pump — which is what WithEscrow(...) alone gives you — nothing can recover it at all. The component names the job either way: as an error when it meets the expiry itself, and as an UnknownToken warning when the pump swept the hold away first — which is the refunded case, so that quieter line is the one to watch for.

So: 0, or comfortably longer than the longest craft plus every second the game can spend paused.

What it deliberately does not do

It does not put the second price on the recipe asset. It cannot — that is the gap it exists to fill. The honest cost is that half the price is authored on the asset and half here, with nothing keeping the two in step. Treat the component as part of the recipe's authoring, not as a scene setting.

It does not invent a fail reason. CraftFailReason is a closed enum, so a refusal borrows NoCurrency — which is at least honest, and your UI will need to know it can mean "not enough shards".

It does not decide what a voided hold means for your game. While the job survives the load — a project that restores the wallet but not the jobs — the release comes back Invalidated, the component says so, and issuing the refund or declining it is yours to decide.

It does not reconcile after a job restore. With both save participants in play, the usual setup, restoring the crafting snapshot replaces the live jobs without raising anything and rebuilds them under fresh ids. The reservation this component made is then unreachable, and the restored craft carries no record of a second price: cancel it and crafting hands back its own currency while this component has nothing to give. It says that once rather than losing the shards in silence, but it cannot put them back — nothing tells it a restore happened. Settle or disable before restoring if that matters, and treat the abandoned entries as cleared when the component is disabled.

It does not cover TryCraftImmediateEscrow. This is two hooks on Enqueue's job lifecycle, and that call runs validators but creates no job and raises no accepted event. On that path the refusal fires and the charge never does — a craft gated on a price it does not pay. It is the validators-disabled hazard with the halves swapped and the worse ending. If your project crafts that way, charge the second price where you make the call.

It does not trim a batch. A shortfall lowers the count the validator will allow, which is what CanCraftCount and Probe report to a UI and where an unbatched multi-count request stops. But EnqueueBatch is all-or-nothing: it refuses the whole request, and the reason it reports comes from its own core bounds rather than from the shard shortfall. A screen that offers "craft 10" should ask CanCraftCount what it can offer first.

  • Crafting — jobs, validators, and the accept/refund lifecycle this mirrors.
  • Currency — the service stack, escrow, and what a hold is.
  • Recipes that cost blood — the same two hooks against Health, where the price cannot be held and has to be re-checked at delivery instead.
  • A pickup you have to pay for — the same ordering problem across a sequence rather than across time.