Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ func (d *OperationsProcessor) Process() ProcessedOperations {
}

// https://identity.foundation/sidetree/spec/#value-locking
// The valueLockFn is the integration seam the Bitcoin layer (ion-node)
// implements: it resolves the writerLockId to a ValueTimeLock, looks up the
// block's normalized fee, identifies the transaction writer, and decides via
// the ported policy VerifyLockAmount (see valuelock.go). It returns true iff
// the anchor is permitted.
//
// NOTE: the opCount passed here is the writer-DECLARED anchor-string count
// (the "paid" count). It is an upper bound and may exceed the operations
// actually anchored (ION permits paying/locking for more than are used), so a
Expand Down
115 changes: 115 additions & 0 deletions valuelock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package sidetree

import (
"fmt"
"math"
)

// Value-time-lock verifier — a direct port of the Sidetree reference
// ValueTimeLockVerifier (decentralized-identity/sidetree v1.0.6). It is the
// protocol policy that decides how many operations an anchor may carry given a
// resolved on-chain value-time-lock and the block's normalized fee.
//
// sidetree-go owns this policy; the Bitcoin layer (ion-node) owns the data it
// needs — resolving the writerLockId to a ValueTimeLock (#55), tracking the
// per-block normalized fee (#54), and identifying the anchoring transaction's
// writer. ion-node plugs the policy in by implementing the ValueLocking callback
// (see sidetree.go) as a thin adapter that resolves those inputs and calls
// VerifyLockAmount. Until that resolver exists, the reader default-rejects
// over-quota locked anchors (see OperationsProcessor.checkOperationLimit), and
// ION mainnet runs with value-locking disabled so no canonical anchor needs it.

var (
// ErrValueLockInvalidOwner: the lock's funds owner is not the anchoring
// transaction's writer, so the writer may not use it.
ErrValueLockInvalidOwner = fmt.Errorf("value-time-lock owner does not match the transaction writer")

// ErrValueLockTimeOutOfRange: the anchor's block time is outside the lock's
// active window [lockTransactionTime, unlockTransactionTime).
ErrValueLockTimeOutOfRange = fmt.Errorf("anchor time is outside the value-time-lock window")

// ErrValueLockInsufficientForOps: the anchor declares more operations than the
// locked value (at the block's normalized fee) permits.
ErrValueLockInsufficientForOps = fmt.Errorf("anchor operation count exceeds the value-time-lock allowance")
)

// ValueTimeLock is a resolved on-chain value-time-lock — the Bitcoin layer
// resolves a writerLockId to one of these (mirrors the reference
// ValueTimeLockModel). The lock is active for block times in
// [LockTransactionTime, UnlockTransactionTime).
type ValueTimeLock struct {
// AmountLocked is the locked value in satoshis.
AmountLocked int64
// Owner identifies the locked-funds owner; it must equal the anchoring
// transaction's writer for the lock to apply to that writer's anchors.
Owner string
// LockTransactionTime is the block height at which the lock starts (inclusive).
LockTransactionTime int
// UnlockTransactionTime is the block height at which the lock ends (exclusive).
UnlockTransactionTime int
}

// CalculateMaxNumberOfOperationsAllowed returns the maximum number of operations
// an anchor may carry given a (possibly absent) value-time-lock and the block's
// normalized fee, porting the reference function of the same name:
//
// allowed = floor(amountLocked / (normalizedFee * 0.001 * 60000)), floored to 100.
//
// With no lock the allowance is the free quota (MaxNumberOfOperationsForNoValue
// TimeLock = 100). The absolute ceiling MaxOperationsPerBatch (10000) is NOT
// applied here — it is enforced separately by the reader's op-count gate.
func CalculateMaxNumberOfOperationsAllowed(lock *ValueTimeLock, normalizedFee float64) int {
if lock == nil {
return MaxNumberOfOperationsForNoValueTimeLock
}

feePerOperation := normalizedFee * NormalizedFeeToPerOperationFeeMultiplier
lockAmountPerOperation := feePerOperation * float64(ValueTimeLockAmountMultiplier)
if lockAmountPerOperation <= 0 {
// Defensive: a non-positive fee would make the division meaningless.
// normalizedFee is always > 0 in practice (initialNormalizedFee = 1000),
// so this only guards against invalid input; fall back to the free quota.
return MaxNumberOfOperationsForNoValueTimeLock
}

numberOfOpsAllowed := int(math.Floor(float64(lock.AmountLocked) / lockAmountPerOperation))
if numberOfOpsAllowed < MaxNumberOfOperationsForNoValueTimeLock {
return MaxNumberOfOperationsForNoValueTimeLock
}
return numberOfOpsAllowed
}

// VerifyLockAmount verifies that an anchor declaring opCount operations is
// permitted, porting the reference verifyLockAmountAndThrowOnError. It returns
// nil when the anchor is allowed and a classified-free error otherwise (the
// caller wraps it with classifyMalformed):
//
// - opCount <= 100: always allowed (the free quota needs no lock).
// - opCount > 100 with a lock: the lock's owner must equal txWriter, the anchor
// block time must fall in [LockTransactionTime, UnlockTransactionTime), and
// opCount must not exceed CalculateMaxNumberOfOperationsAllowed.
// - opCount > 100 with no lock: rejected (allowance is 100).
//
// anchorTime is the block height of the anchoring transaction. As in the
// reference, the owner/window checks are skipped when lock is nil; the final
// allowance check then rejects the over-quota anchor.
func VerifyLockAmount(lock *ValueTimeLock, opCount int, normalizedFee float64, txWriter string, anchorTime int) error {
if opCount <= MaxNumberOfOperationsForNoValueTimeLock {
return nil
}

if lock != nil {
if lock.Owner != txWriter {
return fmt.Errorf("%w: owner %q, writer %q", ErrValueLockInvalidOwner, lock.Owner, txWriter)
}
if anchorTime < lock.LockTransactionTime || anchorTime >= lock.UnlockTransactionTime {
return fmt.Errorf("%w: anchor time %d not in [%d,%d)", ErrValueLockTimeOutOfRange, anchorTime, lock.LockTransactionTime, lock.UnlockTransactionTime)
}
}

maxOps := CalculateMaxNumberOfOperationsAllowed(lock, normalizedFee)
if opCount > maxOps {
return fmt.Errorf("%w: %d > %d", ErrValueLockInsufficientForOps, opCount, maxOps)
}
return nil
}
158 changes: 158 additions & 0 deletions valuelock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package sidetree

import (
"errors"
"testing"
)

// feePerOpAt1000 documents the canonical mainnet-ish arithmetic used below:
// normalizedFee = 1000 sat -> feePerOp = 1000 * 0.001 = 1 sat ->
// lockAmountPerOp = 1 * 60000 = 60000 sat. So a lock of N*60000 sat permits N ops
// (floored to 100).
const normalizedFee1000 = 1000.0

func TestCalculateMaxNumberOfOperationsAllowed(t *testing.T) {
tests := map[string]struct {
lock *ValueTimeLock
normalizedFee float64
want int
}{
"no lock returns the free quota": {
lock: nil,
normalizedFee: normalizedFee1000,
want: 100,
},
"lock smaller than the free quota still returns 100": {
// 60000 sat permits exactly 1 op, floored up to the 100 free quota.
lock: &ValueTimeLock{AmountLocked: 60000},
normalizedFee: normalizedFee1000,
want: 100,
},
"lock for exactly 100 ops returns 100": {
lock: &ValueTimeLock{AmountLocked: 100 * 60000},
normalizedFee: normalizedFee1000,
want: 100,
},
"lock for 200 ops returns 200": {
lock: &ValueTimeLock{AmountLocked: 200 * 60000},
normalizedFee: normalizedFee1000,
want: 200,
},
"allowance floors toward zero": {
// 200*60000 + 59999 -> still only 200 whole ops.
lock: &ValueTimeLock{AmountLocked: 200*60000 + 59999},
normalizedFee: normalizedFee1000,
want: 200,
},
"higher normalized fee reduces the allowance": {
// fee 2000 -> feePerOp 2 -> lockPerOp 120000; 200*60000=12,000,000 ->
// 12,000,000/120000 = 100 ops.
lock: &ValueTimeLock{AmountLocked: 200 * 60000},
normalizedFee: 2000,
want: 100,
},
"non-positive fee falls back to the free quota": {
lock: &ValueTimeLock{AmountLocked: 1_000_000_000},
normalizedFee: 0,
want: 100,
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
if got := CalculateMaxNumberOfOperationsAllowed(test.lock, test.normalizedFee); got != test.want {
t.Errorf("CalculateMaxNumberOfOperationsAllowed = %d, want %d", got, test.want)
}
})
}
}

func TestVerifyLockAmount(t *testing.T) {
// A lock owned by "writer" that permits 200 ops and is active for [100, 200).
lock200 := &ValueTimeLock{
AmountLocked: 200 * 60000,
Owner: "writer",
LockTransactionTime: 100,
UnlockTransactionTime: 200,
}

tests := map[string]struct {
lock *ValueTimeLock
opCount int
txWriter string
anchorTime int
wantErr error
}{
"at the free quota needs no lock": {
lock: nil,
opCount: 100,
txWriter: "writer",
anchorTime: 150,
wantErr: nil,
},
"over the free quota with no lock is rejected": {
lock: nil,
opCount: 101,
txWriter: "writer",
anchorTime: 150,
wantErr: ErrValueLockInsufficientForOps,
},
"over the free quota within a sufficient lock is allowed": {
lock: lock200,
opCount: 200,
txWriter: "writer",
anchorTime: 150,
wantErr: nil,
},
"over the lock allowance is rejected": {
lock: lock200,
opCount: 201,
txWriter: "writer",
anchorTime: 150,
wantErr: ErrValueLockInsufficientForOps,
},
"wrong owner is rejected": {
lock: lock200,
opCount: 200,
txWriter: "someone-else",
anchorTime: 150,
wantErr: ErrValueLockInvalidOwner,
},
"anchor before the lock window is rejected": {
lock: lock200,
opCount: 200,
txWriter: "writer",
anchorTime: 99,
wantErr: ErrValueLockTimeOutOfRange,
},
"anchor at the unlock height (exclusive) is rejected": {
lock: lock200,
opCount: 200,
txWriter: "writer",
anchorTime: 200,
wantErr: ErrValueLockTimeOutOfRange,
},
"anchor at the lock start (inclusive) is allowed": {
lock: lock200,
opCount: 200,
txWriter: "writer",
anchorTime: 100,
wantErr: nil,
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
err := VerifyLockAmount(test.lock, test.opCount, normalizedFee1000, test.txWriter, test.anchorTime)
if test.wantErr == nil {
if err != nil {
t.Errorf("expected nil, got %v", err)
}
return
}
if !errors.Is(err, test.wantErr) {
t.Errorf("expected %v, got %v", test.wantErr, err)
}
})
}
}
Loading