A customer sells 2.5 ETH in November. Your system has to put a cost basis on that sale, and the answer depends on facts the sell order doesn't carry: which of the customer's units were sold, whether each was bought on your platform or transferred in from a wallet, whether it was acquired before or after January 1, 2026, and whether the customer told you which units to sell before the trade executed. Get one of those wrong and the number on the form is wrong. Under FIFO, it can also make the next sale wrong, and the one after that.

That is what makes 1099-DA cost basis reporting different from the 2025 cycle. Brokers reported gross proceeds for digital asset sales made in 2025. From 2026 they must also report basis on certain sales, and the IRS final regulations decide which ones by where each unit came from. The first forms carrying 2026 basis go to customers and to the IRS in early 2027. Proceeds are a fact about a trade. Basis is a fact about a lot's history, and most exchange ledgers weren't built to keep that history.

This article is for the team building that system. It covers the lot provenance table that decides what a sale must report, the order in which lots are chosen, the basis arithmetic that differs from ordinary securities, a production-shaped lot engine in C#, and how to correct basis after the forms have gone out. It's written for engineers and engineering leads, not taxpayers, and it isn't tax advice: your tax counsel owns the interpretation, and the system's job is to make that interpretation executable and provable.

1099-DA Cost Basis Reporting Starts With the Lot, Not the Sale

The 2026 Form 1099-DA instructions define a covered digital asset as one acquired after 2025, for cash, other digital assets, or other property or services, in an account where the broker provides custodial services. Everything else is noncovered, and two categories matter most in practice: units acquired before 2026, and units transferred in to the broker from somewhere else.

The consequence for design is that covered is a property of a lot, not of an asset or a customer. The same customer can hold 1 ETH they bought on your platform in March (covered), 1 ETH they bought on your platform in 2024 (noncovered), and 1 ETH they withdrew from a hardware wallet into their account in June (noncovered, whenever they originally bought it). One sell order can consume all three. The form has to reflect that, and the only way it can is if every lot carries its provenance from the moment it is created.

Three other facts shape the system around the engine. Generally, each sale goes on its own Form 1099-DA. A single form can't mix short-term and long-term gain. And Form 1099-DA can only be e-filed through IRIS, not the older FIRE system. FIRE is being retired anyway: the IRS says IRIS will be the only e-file system for information returns, corrections included, after January 1, 2027. If your 1099-B filing still runs through FIRE, plan one IRIS integration for both forms rather than two.

The Lot Provenance Table

This is the table we put in front of a team before any code is written. Each row is a way a lot can come into existence. The columns are what the rules require when units from that lot are sold. If your ledger can't tell these rows apart today, that is the first thing to fix.

How the lot entered the account Status Basis on the form What the engine must store
Bought for cash in the account, 2026 or later Covered Required (box 1g); box 2 checked Acquisition time, units, cost plus fees
Received in a digital-asset exchange in the account, 2026 or later Covered Required; basis is the fair market value used for the exchange The valuation used, its source and timestamp, and the linked disposal
Acquired in the account before 2026 Noncovered Optional. Box 9 checked; box 2 only if basis is reported anyway Whatever basis you hold, and whether you chose to report it
Transferred in from another wallet or broker Noncovered Optional. Box 9; units and transfer-in date in boxes 12a and 12b Transfer-in time and units. Customer-supplied history kept separately (see below)

Two details in that table cause most of the design mistakes. First, box 9 isn't decoration. The instructions say that if the broker checks box 9 for a noncovered sale, it isn't penalized under sections 6721 and 6722 for basis it chose to report voluntarily. If it reports basis and doesn't check box 9, that basis carries penalty exposure like any covered figure. So "noncovered" has to flow all the way to the form as a flag, not just live in the engine as a reason to skip a calculation.

Second, transfer-ins create a temptation. Customers will offer their purchase history for units they moved in, and it's tempting to treat that as basis. The instructions allow a broker to use customer-provided acquisition information to decide which units were sold, but not as the reported basis or acquisition date, and box 8 exists to flag that the broker relied on it. In the data model, customer-supplied acquisition data goes in its own field, labelled as such, and never overwrites the transfer-in record. A lot's Origin is set once, when the lot is created, and nothing downstream changes it.

Which Lot Was Sold: The Identification Order

Once lots carry provenance, the engine has to pick which ones a sale consumes. The rules in Treas. Reg. §1.1012-1(j) give a clear order for units held in a broker's custody:

  1. Specific identification. The customer names the units to sell, using an identifier the broker accepts (acquisition date and time, price, a lot id), no later than the date and time of the sale.
  2. Standing order. A rule the customer set up in advance, such as highest basis first or last-in first-out. If the broker offers only one identification method, that method is treated as the customer's standing order.
  3. FIFO. With no adequate identification, the earliest-acquired units of that asset held in the broker's custody are treated as sold first.

The timing condition is the part to enforce in code. An instruction that arrives after the sale executed doesn't count, however quickly it follows. The engine needs the instruction's receipt time and the trade's execution time from clocks it can defend, and it has to compare them rather than assume the order in which messages were processed.

There is a 2026 wrinkle that surprises teams. Many brokers weren't ready to accept per-trade instructions, so the IRS gave customers temporary relief: during the relief period, a customer can identify units, or record a standing order, in their own books and records without telling the broker. Notice 2026-20 extended that relief through December 31, 2026, and says plainly that it doesn't apply to broker reporting. The result: for 2026 sales, the basis a broker reports can differ from the customer's basis, and both can be correct. Build for that. Record which rule selected each lot, and expect support tickets asking why a form disagrees with the customer's spreadsheet. The answer should come out of the data, not out of an engineer's memory.

Basis Arithmetic That Trips Engineers

Most of the arithmetic is ordinary cost-basis accounting. Four rules aren't, and each one has a specific way of going wrong:

  • Exchanges create a new lot at fair market value. When a customer swaps one digital asset for a materially different one, the asset received takes a basis equal to the fair market value used to compute the amount realized on the asset given up. The valuation becomes part of two records: proceeds on the disposal and basis on the new lot. Store it once, with its source and timestamp, and have both records refer to it. Two lookups at slightly different times will disagree.
  • Exchange fees go to the disposal side. Under §1.1012-1(h), transaction costs for a digital-asset-for-digital-asset exchange are allocated entirely to the disposal: they reduce proceeds on the asset given up and add nothing to the new lot's basis. For a purchase with cash, fees are added to the basis of what was bought. An engine with one fee rule for every trade gets one of these wrong.
  • Units withheld to pay fees come from the new units. If the broker withholds some of the received units to pay for the exchange, the regulations treat those units as coming from the units just received, regardless of the customer's identification method. A FIFO engine that pays the fee out of the oldest lot changes the basis of every later sale.
  • Partial fills leak cents. A 1.0-unit lot sold in three pieces, each with its basis computed as cost × units / lotUnits and rounded, rarely adds back up to the lot's cost. The last piece of a lot should take all of the remaining basis, so the lot's total always equals what was paid.

One more rule to keep straight: the wash sale rules on Form 1099-DA apply only to tokenized securities, meaning digital assets that are also stock or securities. For other digital assets, box 1i stays empty. If your securities engine applies wash-sale adjustments by default, turn that off per asset class, not for the whole form.

Building the Lot Engine in .NET

Here is the shape we usually find. It works in the demo and fails most of the rules above:

// NAIVE: one FIFO queue per customer and asset, decremented in place.
public async Task<decimal> BasisForSaleAsync(
    Guid customerId, string asset, decimal units, CancellationToken ct)
{
    var lots = await _db.Lots
        .Where(l => l.CustomerId == customerId && l.Asset == asset && l.Remaining > 0)
        .OrderBy(l => l.AcquiredAt)                // ignores any instruction; ties are random
        .ToListAsync(ct);

    decimal basis = 0;
    foreach (var lot in lots)
    {
        var take = Math.Min(units, lot.Remaining);
        basis += Math.Round(lot.CostPerUnit * take, 2); // drifts on every partial fill
        lot.Remaining -= take;                       // overwrites history: no replay
        units -= take;
        if (units == 0) break;
    }

    await _db.SaveChangesAsync(ct);
    return basis;   // one number: covered? term? which lots? a shortfall? unknowable
}

It keys lots by customer rather than by account, so it mixes accounts. It ignores specific identification and standing orders. It returns a single figure for a sale that may need to be split into covered and noncovered, short-term and long-term. If the customer doesn't have enough units, it quietly returns a partial basis. And because it decrements Remaining in place, a correction to one early lot can't be replayed: the history that later sales depended on has been overwritten.

The fix is to make allocation a deterministic function of the account's lots and the instruction in force, and to return allocations rather than a total:

public enum LotOrigin { AcquiredInAccount, TransferredIn }
public enum IdSource { SpecificId, StandingOrder, Fifo }

/// <summary>One slice of one lot consumed by one disposal. Everything the form
/// needs is on the slice, including why this lot was chosen.</summary>
public sealed record Allocation(Guid LotId, decimal Units, decimal Basis,
    bool Covered, bool LongTerm, IdSource Source);

public sealed class LotAllocator(ILogger<LotAllocator> logger)
{
    private static readonly DateTimeOffset CoveredFrom = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);

    /// <summary>Allocates one disposal against the open lots of one account and asset.
    /// Deterministic: the same lots and instruction always give the same result, which is
    /// what makes a later correction replayable.</summary>
    public IReadOnlyList<Allocation> Allocate(
        IReadOnlyList<OpenLot> open, Disposal sale, LotInstruction? instruction)
    {
        var (ordered, source) = Order(open, sale, instruction);
        var result = new List<Allocation>();
        var remaining = sale.Units;

        foreach (var lot in ordered)
        {
            if (remaining == 0) break;
            var take = Math.Min(remaining, lot.UnitsLeft);
            // The last slice of a lot takes all remaining basis, so slices sum to the cost.
            var basis = take == lot.UnitsLeft
                ? lot.BasisLeft
                : Math.Round(lot.BasisLeft * take / lot.UnitsLeft, 2, MidpointRounding.ToEven);

            lot.Consume(take, basis);
            remaining -= take;
            result.Add(new Allocation(lot.LotId, take, basis,
                Covered: lot.Origin == LotOrigin.AcquiredInAccount && lot.AcquiredAt >= CoveredFrom,
                LongTerm: sale.ExecutedAt.Date > lot.AcquiredAt.Date.AddYears(1),
                source));
        }

        // Fail closed: selling units we can't find is a ledger defect, not a zero-basis sale.
        if (remaining != 0)
            throw new LotShortfallException(sale.DisposalId, remaining);

        return result;
    }

    private (IEnumerable<OpenLot>, IdSource) Order(
        IReadOnlyList<OpenLot> open, Disposal sale, LotInstruction? ins)
    {
        // Only instructions received no later than execution count.
        if (ins is not null && ins.ReceivedAt <= sale.ExecutedAt)
            return ins.Kind switch
            {
                InstructionKind.SpecificLots =>
                    (ins.LotIds.Select(id => open.Single(l => l.LotId == id)), IdSource.SpecificId),
                InstructionKind.HighestBasisFirst =>
                    (open.OrderByDescending(l => l.BasisPerUnit).ThenBy(l => l.AcquiredAt)
                         .ThenBy(l => l.LotId), IdSource.StandingOrder),
                _ => throw new NotSupportedException($"Instruction {ins.Kind}")
            };

        if (ins is not null)
            logger.LogWarning("Instruction for {DisposalId} received after execution; using FIFO.",
                sale.DisposalId);

        // FIFO with a stable tiebreak: same-timestamp lots must order identically on replay.
        return (open.OrderBy(l => l.AcquiredAt).ThenBy(l => l.LotId), IdSource.Fifo);
    }
}

A few decisions in that code matter more than they look. open is scoped to one account and one asset before it reaches the allocator, because the ordering rules apply to units in the broker's custody, not to everything a customer owns everywhere. Every ordering ends with ThenBy(l => l.LotId), because two lots acquired in the same millisecond must come out in the same order every time the history is replayed. A shortfall throws instead of returning a partial basis. And the IdSource on each slice is what answers the support ticket in the previous section.

The allocations then become form lines. Group them by (Covered, LongTerm), because covered and noncovered units report differently and a form can't mix short-term and long-term gain. Each group becomes its own 1099-DA with its share of proceeds. Split proceeds by units and give the last group the remainder, the same way basis handles the last slice, so the forms add back up to the trade. One sale that consumes a transferred-in 2024 lot and a March 2026 purchase produces two forms. That isn't an edge case. It is the normal result for any customer who moved coins onto the platform and later bought more.

Two storage notes. Keep quantities as the chain's integer base units (wei, satoshis) and convert once. decimal carries 28 to 29 significant digits, which is enough for an 18-decimal token balance but gets tight in intermediate products. And store lots and disposals as append-only events, with open-lot state computed from them. That is what makes the next section possible.

Fixing Basis After Filing

Basis gets corrected after filing more often than proceeds do, because it depends on more history. A fee reversal arrives in March. A deposit booked as a purchase turns out to have been a transfer-in. A valuation feed had a bad minute. Each is a fix to one early event, and under FIFO one early event affects every later sale that consumed units after it.

The mechanism that handles this is replay and diff, and it depends on the event log being immutable:

  1. Record the fix as a new event with both its effective time (when the acquisition happened) and its knowledge time (when you learned about it). Never edit the original.
  2. Replay the account from its first event with the fix included, through the same deterministic allocator.
  3. Diff the new projection against what was filed, form by form, on the reported fields. Store the filed figures separately, as filed. Recomputing them isn't enough.
  4. Correct only the forms that changed, and file those as soon as possible, as the IRS General Instructions require, through IRIS, with a corrected statement to the customer.

Keeping the two timestamps is what lets you answer both of the questions an examiner, or your own support team, will ask: what is the right basis now, and what did we believe when we filed. That is bitemporal modelling, and we go through it in CQRS for regulatory compliance. The general case, a reporting store that can prove what it held at filing time, is covered in the 1099 compliance automation playbook, including filing each return exactly once. The same idempotency key discipline applies to corrections: a correction for a given form and version is filed once, however many times the replay job runs.

Martin Kleppmann makes the underlying argument in Designing Data-Intensive Applications (2017): derived data should be reproducible from the log of events that produced it. For a basis engine this isn't a matter of style. If basis can't be recomputed from history, a correction to one lot is a manual investigation into how many forms it touched.

What This Design Doesn't Solve

  • The customer's own basis. Noncovered lots, and the Notice 2026-20 books-and-records relief, mean the broker's figure and the customer's can legitimately differ. The engine reports what the rules require of the broker. It doesn't reconcile the customer's tax return.
  • Pre-2026 history. Basis for lots acquired before 2026 is optional to report. For a customer's own records, Rev. Proc. 2024-28 set out how to allocate pre-2025 basis to specific wallets and accounts. If you decide to report legacy basis voluntarily, treat it as a separate, lower-confidence data source and keep checking box 9.
  • Tokenized securities. Digital assets that are also securities go on Form 1099-DA, not 1099-B, and bring wash sales, accrued market discount, and CUSIP reporting with them. That is a securities cost-basis engine inside the digital asset one. Scope it separately.
  • Non-custodial activity. The final regulations cover brokers that take possession of the assets. This design assumes custody. If you don't hold the assets, you don't have the lot history this engine depends on.
  • Transactions under temporary exceptions. Notice 2024-57 exempts wrapping, liquidity provision, staking, lending, and some other transactions from reporting for now. Those events still move units between lots, so the engine has to track them even though no form reports them.
Common Questions

Frequently Asked Questions

Which digital assets require cost basis on Form 1099-DA?
Only covered securities. From 2026, a digital asset is covered if it was acquired after 2025, for cash, other digital assets, or other property or services, in an account where the broker provides custodial services. Units acquired before 2026 and units transferred in from another wallet or broker are noncovered. For noncovered units the broker isn't required to report basis. It can report basis voluntarily, and checking box 9 protects it from penalties if that voluntary figure is wrong. Gross proceeds are reported for every sale either way.
Why does a 1099-DA show no cost basis for crypto transferred into an exchange?
Because transferred-in units are noncovered securities. The receiving broker never saw the acquisition, so the rules don't make it responsible for the basis. It reports the number of units and the date they were transferred in (boxes 12a and 12b), checks box 9, and may leave basis blank. A customer can give the broker their acquisition details, but the broker may use that customer-provided information only to decide which units were sold, not as reported basis or acquisition date; box 8 flags that it did. The customer reports the basis from their own records.
Which lot does a broker report as sold on Form 1099-DA?
The units the customer adequately identified no later than the date and time of the sale: specific units, or a standing order such as highest basis first. If the broker offers only one identification method, that method counts as a standing order. Without a timely identification, the broker reports the earliest-acquired units held in its custody first (FIFO). For 2026, Notice 2026-20 also lets customers identify units in their own books and records without telling the broker, so the basis a broker reports for a 2026 sale can legitimately differ from the customer's own figure.
How are fees handled in a crypto-for-crypto exchange?
When one digital asset is exchanged for a different one, the transaction costs are allocated entirely to the disposal of the asset given up: they reduce its proceeds and don't increase the basis of the asset received. The asset received takes a basis equal to the fair market value used to compute the amount realized on the exchange. If the broker withholds some of the received units to pay the fee, those withheld units are treated as coming from the units just received, not from older lots. A purchase for cash works differently: its fees are added to the basis of what was bought.
How do you correct cost basis on a 1099-DA after it has been filed?
File a corrected return with the IRS and furnish a corrected statement to the customer as soon as the error is found. Form 1099-DA can only be e-filed through IRIS, so the correction goes through IRIS too. The engineering problem is finding which forms changed. Under FIFO, fixing one early lot can change the basis of every later sale that consumed units after it, sometimes across several forms. Rebuild the account's allocations from the event history, compare the result with what was filed, and correct only the forms whose reported values differ.

When to Bring in External Help

The proceeds cycle mostly tested whether a broker could produce forms at all. We saw that first-year pressure directly in our 1099 reporting automation work, where the first 1099-DA cycle was filed through a deliberate manual fallback while a partner's new digital-asset path was finished. The basis cycle tests something harder: whether the ledger underneath kept enough history to explain every number. That usually can't be fixed in January. Provenance has to be captured when a lot is created, and a lot created in March without it stays without it.

If your backend shows any of the patterns above, our Discovery Sprint is a two-week diagnostic that tells you exactly what to fix and in what order. For a basis engine that means checking lot provenance, identification timing, fee allocation, and whether a correction can be replayed, while there is still time to fix them before 2026's forms are built. Our compliance reporting automation work picks up from there.

Free Resource Before a broader conversation, run your system against our free 12-Point Backend Health Checklist. Its data-integrity and audit-trail checks are the ones a basis engine depends on.
Back to Insights