Cosmic Bull

depreciation

gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation on onyx-1 testnet

pure packageprimitiveaccountinglive on onyx-1

Know what a capital asset is worth after a given number of periods.

Spreads an asset's cost over its useful life so the carrying amount is a calculation rather than a judgement call.

In detail curated

The carrying amount of a capital asset as a PURE FUNCTION of how many whole periods it has been in service -- not an accumulator, and that distinction is the whole design. The obvious implementation stores a carrying amount, subtracts a per-period charge and advances an anchor; because the charge is an integer division it truncates on every call and forfeits the part-period the call lands in, so RUNNING THE SCHEDULE MORE OFTEN DEPRECIATES LESS -- a misstatement of asset value, not a rounding preference. Here CarryingAt(n) is a pure function of the period count and Charge(from,to) is its difference at two points, so charges TELESCOPE exactly -- Charge(a,b)+Charge(b,c) == Charge(a,c) -- by construction rather than by careful anchor arithmetic, and there is no stored carrying amount that could drift at all. The read path cannot disagree with the write path because there is one function and AccumulatedAt and Charge are both defined in terms of it. Two methods: StraightLine (closed form) and DecliningBalance (a bounded loop -- integer declining balance has NO closed form, because period n is the rate applied n times with the floor taken each period, and taking the floor once at the end is a different and wrong number). The loop is bounded by the asset's own declared useful life, validated against MaxLife=1200 at construction, so the cost of a call is bounded by a number the caller declared and this package checked, never by how long ago the asset was bought. Overflow is excluded at CONSTRUCTION rather than per call: check() establishes Basis <= maxInt64/Life and Cost <= maxInt64/RateDen, which is what makes CarryingAt and Charge total functions on a constructed Schedule. Holds no coins, reads no chain API, owns no package-level state, and imports only `errors`.

Use it

A pure package has no state and no transactions: you use it by importing it.

Import it

import "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation"

The path is the address: there is no package manager, nothing to add to a manifest and no version to pin, so the package you compile against is the one the chain runs. It differs per chain because the namespace in it is the deploying account's.

Called from real code

Not an example written for this page. These lines are quoted from assets, a deployed package in this portfolio that imports this one as depreciation, out of the committed file whose sha256 onyx-1 testnet attested. They compile because they are compiled.

s, err := depreciation.New(cost, salvage, life)
onyx/r/assets/assets.gno line 396, onyx-1 testnet
s, err := depreciation.NewDeclining(cost, salvage, life, num, den)
onyx/r/assets/assets.gno line 420, onyx-1 testnet
n, err := depreciation.Periods(a.placedAt, runtime.ChainHeight(), a.period)
onyx/r/assets/assets.gno line 567, onyx-1 testnet

Deployments

onyx-1 testnet onyx-1

RenderedSourceCall builder

chain-attested
Import pathgno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation
Namespaceg1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3
Deployed at height332,075
Deploy transaction5148670fd8a24b673ff0b30f95aeecb852e0c98c7f31e01958a942f81b85e439 look it up on the RPC
Deployerg1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3
Gas used28,059,876
Storagenot attested per package on this chain — see the limitations below
Files on chaingnomod.toml depreciation.gno
Deployed bytesdepreciation.gno — 19,983 bytes
sha2566f0b9c40246de49c47307e839b7cb76996e14b4cea61c476f4c0ec75ee30bbe2
Realm addressnone — A /p/ package is not a realm: it holds no state, custodies no coins and is never a transaction sender. The pkgPath derivation would still produce a value; recording one would name nothing.
Used byr/assets

Do not take the hash above on trust. $download returns the bytes onyx-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:

curl -sS 'https://onyx.testnets.gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation$download&file=depreciation.gno' | shasum -a 256

Expected: 6f0b9c40246de49c47307e839b7cb76996e14b4cea61c476f4c0ec75ee30bbe2 — 19,983 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.

API

chain-derived 3 exported functions, 2 types, 8 methods.

This is a /p/ package: you import it, you do not call it in a transaction. gnoweb's $help shows only exported top-level functions, so the types and methods below do not appear there at all — which is why this reference exists.

Overview

Package depreciation is the carrying amount of a capital asset as a pure function of how many whole periods it has been in service.

A realm stores the plan and the date the asset was placed in service, and asks this package what it is worth now and what to charge for the span since it last asked. Nothing here reads the chain, holds state, or knows what a ledger is, so a realm can test a decade of its own depreciation without a node.

s, err := New(120000, 0, 120) // 120000 over 120 months n, _ := Periods(placedAt, now, Monthly) charge, _ := s.Charge(booked, n) // what to post carrying, _ := s.CarryingAt(n) // what it is worth

Why this is a function of n and not an accumulator

The obvious implementation stores the carrying amount, subtracts a per-period charge, and advances an anchor to now. It is wrong in a way that is specific and quiet: the per-period charge is an integer division, so each call truncates, and the part-period the call lands in is forfeited. Run twice as often and you forfeit twice as much — which for an asset register means *running the schedule more often depreciates less*. That is a misstatement of asset value, not a rounding preference.

p/moul/x/games/accrual names this bug and fixes it by advancing the anchor only by the whole periods it actually paid for, leaving the remainder on the clock, and it states the invariant that rules the bug out:

Advance(s, a, c) == Advance(Advance(s, a, b), b, c) for a <= b <= c

That invariant is adopted here, and the way it is obtained is different and stronger. Nothing in this package is an accumulator: Schedule.CarryingAt is a pure function of the period count, and Schedule.Charge is the difference of it at two points. So charges **telescope** —

Charge(a, b) + Charge(b, c) == Charge(a, c)

— by construction rather than by careful anchor arithmetic, and there is no stored carrying amount that could drift from the schedule at all. A realm that loses track of what it has booked can recompute it from the dates. [TestChargesTelescope] asserts it over pseudo-random partitions, because an implementation that truncates per call passes every round span and fails only on the ones that land mid-period.

The read path cannot disagree with the write path for the same reason: there is one function, and `Charge` is defined in terms of it.

Why a bounded loop is the right answer here and O(1) is not available

accrual must be closed form because elapsed time is unbounded: the player who returns after a month is exactly the one whose Render times out. Depreciation has no such exposure, and declining balance has no integer closed form — period n is the rate applied n times, with the floor taken each period, and taking the floor once at the end is a different and wrong number.

What bounds the loop instead is the asset's own **declared useful life**: Schedule.CarryingAt clamps at `Life` periods, `Life` is validated against MaxLife at construction, and the loop exits early once the salvage floor is reached. So the cost of a call is bounded by a number the caller declared and this package checked, not by how long ago the asset was bought. That is a different safety argument from closed-form and it is the one that makes declining balance implementable at all. Straight line *is* closed form and takes no loop.

What this package does not claim

p/moul/x/games/accrual: /p/moul/x/games/accrual/v0

Imports

Constants and variables

Period lengths in seconds, for a caller that keeps time in unix seconds. They are conveniences and not a calendar: Monthly is 30 days and Yearly is 365, so neither tracks month lengths or leap years. A caller that needs a real calendar must compute its own period count and pass it to the CarryingAt/Charge pair directly, which take period counts and not times.

const (
	Daily   int64 = 86400
	Monthly int64 = 30 * Daily
	Yearly  int64 = 365 * Daily
)

MaxLife bounds the declared useful life in whole periods, and so bounds the declining-balance loop in Schedule.CarryingAt. 1200 is a century of monthly periods, which is past the point where any real schedule stops being meaningful.

What the bound costs is stated here rather than waved at, because the earlier wording claimed the loop "is not a gas hazard even in a Render" and no gas was ever measured. The figures that were: CarryingAt runs the loop n times with n < Life, so at most MaxLife-1 = 1199 iterations per call, and Schedule.Charge evaluates CarryingAt twice, so one Charge can run 2398. The early exit at the salvage floor is an optimisation and not part of the bound — a schedule whose per-period charge truncates to zero (Cost*RateNum < RateDen) never moves off Cost, never reaches the floor, and so runs the full 1199 on every call for the asset's whole life. A realm that walks many assets in one transaction, or renders them, must size that against the gas it has. This package bounds the work; it does not price it.

const MaxLife = 1200
var (
	// ErrBadCost is returned when cost is not positive. A zero-cost asset
	// has nothing to depreciate and makes Basis degenerate.
	ErrBadCost = errors.New("depreciation: cost must be positive")

	// ErrBadSalvage is returned when salvage is negative, or is not less
	// than cost. Salvage equal to cost is a non-depreciating asset, which
	// is legitimate bookkeeping but is not a schedule: there is no charge
	// to post, ever, and silently accepting it would have a realm open
	// accounts and post nothing.
	ErrBadSalvage = errors.New("depreciation: salvage must be in [0, cost)")

	// ErrBadLife is returned when the useful life is not positive or
	// exceeds MaxLife.
	ErrBadLife = errors.New("depreciation: life must be in [1, MaxLife]")

	// ErrBadMethod is returned for a method outside the declared set.
	ErrBadMethod = errors.New("depreciation: unknown method")

	// ErrBadRate is returned when a declining-balance factor is not a
	// proper fraction in (0, 1). A rate of 1 writes the asset off in one
	// period and a rate of 0 never charges anything; both are better said
	// another way, so neither is accepted here.
	ErrBadRate = errors.New("depreciation: rate must satisfy 0 < num < den")

	// ErrBadPeriod is returned when a period length is not positive.
	ErrBadPeriod = errors.New("depreciation: period must be positive")

	// ErrBackwards is returned when now falls before the placed-in-service
	// time. Time moving backwards is a caller bug — a stored timestamp from
	// another clock, a test that rewound — and treating it as zero elapsed
	// would hide it.
	ErrBackwards = errors.New("depreciation: now falls before placedAt")

	// ErrBadSpan is returned by Charge when the span runs backwards, which
	// would make a depreciation charge negative and credit an expense.
	ErrBadSpan = errors.New("depreciation: charge span must not run backwards")

	// ErrOverflow is returned when the arithmetic would wrap. Most
	// overflow is excluded at construction instead; see New.
	ErrOverflow = errors.New("depreciation: int64 overflow")
)
const (
	// StraightLine charges the basis evenly across the life. Closed form.
	StraightLine Method = 1

	// DecliningBalance charges a fixed fraction of the remaining carrying
	// amount each period, so early periods charge more. Not closed form
	// over integers; see the package doc on why the loop is bounded.
	DecliningBalance Method = 2
)

Types

type Method

type Method uint8

Method is how the basis is spread across the useful life.

Method.String

func (m Method) String() string

String names the method. The zero Method is "invalid" rather than empty, so a caller that renders it without checking Valid prints something that reads as wrong instead of something that reads as missing.

Method.Valid

func (m Method) Valid() bool

Valid reports whether m is a method this package implements.

type Schedule

type Schedule struct {
	// Cost is the depreciable basis at placed-in-service.
	Cost int64

	// Salvage is the floor. The carrying amount never falls below it and
	// reaches it exactly at Life.
	Salvage int64

	// Life is the useful life in whole periods.
	Life int32

	// Method is how the basis is spread.
	Method Method

	// RateNum and RateDen are the declining-balance factor as a proper
	// fraction. Both are zero for StraightLine.
	RateNum int64
	RateDen int64
}

Schedule is the depreciation plan for one asset. It is a value, carries no state about what has been booked, and is safe to copy. Construct with New or NewDeclining so the invariants — including the overflow preconditions — are checked once rather than on every call.

Exported fieldTypeDoc
Costint64Cost is the depreciable basis at placed-in-service.
Salvageint64Salvage is the floor. The carrying amount never falls below it and reaches it exactly at Life.
Lifeint32Life is the useful life in whole periods.
MethodMethodMethod is how the basis is spread.
RateNumint64RateNum and RateDen are the declining-balance factor as a proper fraction. Both are zero for StraightLine.
RateDenint64

Schedule.AccumulatedAt

func (s Schedule) AccumulatedAt(n int32) (int64, error)

AccumulatedAt returns accumulated depreciation after n whole periods: the contra-asset balance, Cost - CarryingAt(n). It is defined in terms of CarryingAt so the two can never disagree, which is the same reason Charge is.

Schedule.Basis

func (s Schedule) Basis() int64

Basis is Cost - Salvage: the total that will ever be charged across the whole life, under either method.

It does not validate, and it is the only exported method here that does not. That is forced rather than chosen: check() calls Basis to establish one of its own overflow preconditions, so Basis cannot call check(). It is therefore a bare subtraction over whatever fields it was handed, and on an ill-formed schedule it returns an arbitrary number — Schedule{Cost: maxInt64, Salvage: -1} wraps to minInt64. A zero result means "invalid, or zero cost", never "fully depreciated"; depreciation booked to date is Schedule.AccumulatedAt, which does validate. Guard a caller-supplied schedule with Schedule.Valid before quoting Basis from it.

Schedule.CarryingAt

func (s Schedule) CarryingAt(n int32) (int64, error)

CarryingAt returns the carrying amount after n whole periods in service.

It is monotone non-increasing in n, equals Cost at n <= 0, and equals Salvage exactly at every n >= Life. That last clamp is a declared convention and is doing real work under DecliningBalance, which approaches the floor asymptotically and would otherwise never reach it: the final period absorbs whatever remains. The consequence is worth stating because it is visible in the numbers — a declining-balance schedule's last charge can be much larger than the one before it. Textbooks usually avoid that by crossing over to straight line partway; this package does not, because the crossover rule is a policy choice that belongs to the caller and burying it here would make the schedule unexplainable from its own fields.

A declining-balance schedule whose per-period charge truncates to zero never depreciates at all. The charge is (c*RateNum)/RateDen in integers, so whenever Cost*RateNum < RateDen it is 0, c never moves off Cost, and CarryingAt returns Cost at every n below Life and Salvage at Life — the whole of the basis falls in the last period. That is integer truncation behaving correctly and it is accepted rather than guarded, because the alternative is a minimum-charge rule, which is a policy a caller may reasonably want either way and cannot be inferred from the fields. It is documented because it is invisible in the ordinary numbers and is the one shape that also defeats the loop's early exit; see MaxLife on the cost.

Under StraightLine the result is closed form. Under DecliningBalance it costs O(min(n, Life)) iterations, bounded by MaxLife and exiting early at the floor; see the package doc.

Schedule.Charge

func (s Schedule) Charge(from, to int32) (int64, error)

Charge returns the depreciation expense for moving a schedule from `from` whole periods booked to `to`.

It is the difference of CarryingAt at the two points, which makes charges telescope exactly:

Charge(a, b) + Charge(b, c) == Charge(a, c) for a <= b <= c

So how often a realm runs the schedule cannot change the total it books, and a realm that has booked through period `from` can post Charge(from, now) with no stored carrying amount to drift. The result is never negative; a span that runs backwards is ErrBadSpan rather than a credit to an expense account.

Schedule.FullyDepreciatedAt

func (s Schedule) FullyDepreciatedAt(placedAt, period int64) (int64, error)

FullyDepreciatedAt returns the time at which the asset first carries only its salvage value, for a realm that wants to tell a reader when the charges stop. It is placedAt + Life*period.

Under DecliningBalance the carrying amount may sit very close to salvage well before this time, and under both methods it is not at salvage until this time, because Life is what the clamp in CarryingAt uses.

Schedule.Valid

func (s Schedule) Valid() bool

Valid reports whether the schedule is well-formed. A zero Schedule is not.

Functions

New

func New(cost, salvage int64, life int32) (Schedule, error)

New validates and returns a straight-line schedule.

NewDeclining

func NewDeclining(cost, salvage int64, life int32, num, den int64) (Schedule, error)

NewDeclining validates and returns a declining-balance schedule charging num/den of the remaining carrying amount each period. Double-declining over a 10-period life is num=2, den=10.

Periods

func Periods(placedAt, now, period int64) (int32, error)

Periods returns the whole periods elapsed between placedAt and now.

A partial period in progress is not counted and is not lost: it is counted by a later call, once it completes. That is what makes the telescoping property above reach the right total however the caller slices time.

The span is measured in uint64 on purpose, and the reason is the whole correctness argument. `now >= placedAt` is already enforced above, so the true elapsed span is non-negative; its largest value is maxInt64 - minInt64 = 2^64 - 1, which does not fit in an int64 and fits *exactly* in a uint64. int64 subtraction wraps two's-complement, so the bit pattern of now - placedAt is already the low 64 bits of the true difference, and reading that pattern as a uint64 recovers the difference with no loss, no clamp and no reachable wrap. ErrOverflow therefore means exactly one thing here — the quotient does not fit in an int32 — and the subtraction needs no guard at all.

The guard it replaces, `placedAt < 0 && now > 0 && elapsed < 0`, was wrong in *both* directions, measured by exhaustive differential sweep against exact arithmetic rather than reasoned about: it returned (0, nil) for Periods(minInt64, 0, 1) and even a negative count for Periods(minInt64, 0, 2^62), and it rejected representable answers such as Periods(-1, maxInt64, maxInt64), whose count is 1. See § 5 of AUDIT.md.


Doc text is reproduced as vm/qdoc returns it. The node markdown-escapes doc comments, so a bracket or angle bracket may carry a backslash the committed source does not have. The source itself is at source and in this repository.

Dependencies

chain-attested
Importserrors
First-party dependenciesnone

Known limitations

Recorded by the people who built and deployed it. This list is deliberately not empty where honesty costs something.

curated

Source

Source fileonyx/p/depreciation/depreciation.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository)
Matches the deployed bytesyes — byte-identical