@degenerate/shareblock

v0.7.1

Published

ShareBlock specializations (Clearing/Funding/Distribution) of the ShareData Adaptive Framework Ecosystem (SAFE) network.

Downloads

866

Readme

@degenerate/shareblock

ShareBlock specializations layered on top of @degenerate/sharedata. Each factory in src/creation/ builds a ShareData pre-wired with the right state schema, standard functions, and rules for a specific money-movement role in a loan's lifecycle. Use these factories instead of constructing a ShareData directly when you need one of the three documented variants — they encode the right rule set, the right state shape, and the function-level gates the rule engine can't express on its own.

Variants at a glance

| Variant | Factory | Lock flag | Use case | |---|---|---|---| | Clearing | createClearingShareBlock | (none) | Neutral holding spot funds pass through. Free deposits and withdrawals until the block is closed. | | Funding | createFundingShareBlock | committed: boolean | Lender-funded pool that backs a specific loan. Once committed, deposits/withdrawals become no-ops. Requires metadata.relatedLoanApplicationId. | | Distribution | createDistributionShareBlock | distributed: boolean | Pays principal/returns/interest out to recipients. Once distributed, no further movement. Requires metadata.relatedLoanApplicationId. |

All three accept a proofFor(purpose, shareData) callback so crypto is caller-supplied. Production server-side wiring uses buildEllipticalCurvesProofFor (signs on demand with a held private key). The wire-format / multi-org trust path uses buildLookupProofFor (looks up client-pre-signed proofs from a bundle). Tests use generateTestSigner from @degenerate/sharedata to mint real keypairs and proofs — stub proofs are no longer supported because every addFunction / addRule call runs verifyStructuralProof, which rejects forged hashes and signatures.

Each factory also has a createLocked*ShareBlock sibling that auto-calls initialize() after construction; use those when you don't need to layer extra rules/functions before locking.

Quick start

import {
    buildEllipticalCurvesProofFor,
    createLockedClearingShareBlock,
    safeSubmitTransaction,
} from '@degenerate/shareblock';
import {
    EllipticalCurvesSigning,
    ShareBlockType,
    ShareBlockParticipantRole,
    createKeyPairFromPrivateKey,
} from '@degenerate/generic';

// 1. Build a production proofFor closure. The signer establishes the
//    `creationHash` on its first call, then chains every subsequent proof.
const proofFor = buildEllipticalCurvesProofFor({
    privateKey: process.env.SHAREBLOCK_SIGNING_KEY!,
    // curve defaults to Curve25519
});
const ownerPublicKey = createKeyPairFromPrivateKey(
    EllipticalCurvesSigning.Curve25519,
    process.env.SHAREBLOCK_SIGNING_KEY!,
).getPublic('hex');

// 2. Build the block (locked variant initializes immediately).
const block = createLockedClearingShareBlock({
    metadata: {
        shareBlockId: 'block-id',
        blockType: ShareBlockType.Clearing,
        createdAt: Date.now(),
        createdBy: 'creator-uid',
    },
    creationPublicKey: ownerPublicKey,
    initialState: {
        currency: 'USD',
        participants: {
            'alice': {
                userId: 'alice',
                role: ShareBlockParticipantRole.Borrower,
                committedAmount: 1000,
                settledAmount: 0,
            },
        },
    },
    proofFor,
});

// 3. Submit transactions through the safe helper. It handles the
//    no-op-throws contract and rule-engine semantics for you.
const depositFn = block.functions[1]; // registration order documented per factory
const result = safeSubmitTransaction(block, depositFn, {
    userId: 'alice',
    amount: 500,
});

if (result.noOp) {
    // Function returned current unchanged — block was locked / role gate
    // fired / etc. No transaction was recorded.
} else if (!result.accepted) {
    // Rule engine rejected the proposal. result.transactionId points at the
    // history record (recorded with ruleOutcome: false).
} else {
    // Committed. result.transactionId is the new history entry.
}

For a sequence of related submissions (e.g. "deposit then close"), use safeSubmitMany:

import { safeSubmitMany } from '@degenerate/shareblock';

const { allAccepted, stoppedAt, results } = safeSubmitMany(block, [
    { fn: depositFn, input: { userId: 'alice', amount: 500 } },
    { fn: depositFn, input: { userId: 'bob', amount: 300 } },
    { fn: closeFn, input: { userId: 'alice' } },
]);
// Default stopOn: 'rejected' — halts on rule-engine rejection, continues
// past no-ops. Pass 'rejected-or-noop' to also stop on no-ops, or 'never'
// for diagnostics. ShareData has no rollback — earlier accepted steps stay
// recorded even when a later step short-circuits the batch.

Crypto proofs

Two helpers produce a ShareBlockProofFor callback, one per trust model:

buildEllipticalCurvesProofFor — signer holds the private key

For server-side / single-org use where one identity signs everything. Establishes initial crypto state on its first call (signs the empty ShareData and writes creationHash/creationSignature/etc. via initializeCrypto). For every subsequent factory call, signs the canonical ShareData string + the prior proof's hash + a timestamp, producing a chained IShareDataCryptoProof.

The creationPublicKey you pass to the factory must match the public key derived from the signer's private key — initializeCrypto rejects a mismatch. Derive both from the same private key (see Quick start above) to avoid the trap.

buildLookupProofFor — verify pre-signed proofs from a wire payload

For multi-org / client-signed flows where the server verifies but does not sign. Pair with extractShareBlockProofPayload(block) on the client side to capture the proofs + initialCrypto to ship over the wire.

// Client side — has private key, simulates the factory locally:
const block = createClearingShareBlock({
    metadata, creationPublicKey, initialState,
    proofFor: buildEllipticalCurvesProofFor({ privateKey: clientPrivateKey }),
});
const wirePayload = extractShareBlockProofPayload(block);
// Ship wirePayload.initialCrypto + wirePayload.proofs to the server.

// Server side — no private key, reconstructs and verifies:
const reconstructed = createClearingShareBlock({
    metadata,
    creationPublicKey: wirePayload.initialCrypto.publicKey,
    initialState,
    proofFor: buildLookupProofFor({
        proofs: wirePayload.proofs,
        initialCrypto: wirePayload.initialCrypto,
        blockType: ShareBlockType.Clearing,
    }),
});
// Each addFunction/addRule runs verifyStructuralProof against server-side
// state — the client's proofs only verify if the simulated state matches
// what the server actually constructs.

A required-purpose coverage list is exported per variant (CLEARING_REQUIRED_PROOF_PURPOSES, FUNDING_REQUIRED_PROOF_PURPOSES, DISTRIBUTION_REQUIRED_PROOF_PURPOSES) so a wire-format caller can validate the bundle covers everything the factory will request, up front, before invoking it.

Tests

Test setup uses generateTestSigner() from @degenerate/sharedata — generates a fresh keypair plus a matching proofFor callback. Real signing happens in every test, real verification happens at every addFunction / addRule. Stub proofs no longer work because the verifier rejects forged hashes.

Determinism note

metadata.createdAt is used as the seed transaction's timestamp — required for the wire-format flow because the transaction time is folded into the share-data hash, so non-deterministic Date.now() would make client and server proofs diverge. Pick a stable createdAt for any block whose proofs need to round-trip across machines.

Engine constraints worth knowing (load-bearing)

The factories encode several non-obvious decisions that exist because of how @degenerate/rules evaluates proposals. Reading these once saves a few hours of head-scratching when extending the package.

1. Every changed root property must have at least one passing rule

The rule engine's checkIfProposalIsValid requires totalRulesValid === totalRulesToBeValid, where totalRulesToBeValid is the count of root properties that differ between current and proposed state. A property that changes but has zero matching rules → the whole proposal is rejected.

That's why each factory wires buildAllowedMutationRule(...) rules for properties like participants, committedBy, committedAt, distributedBy, distributedAt. They're permissive rules whose semantic value is "this property is allowed to change" — without them, deposits/withdraws/commits would silently fail because they touch unguarded properties.

When you add a new mutating function, check every root property the function might change and confirm at least one rule targets it.

2. Rules only fire on changed root properties

getRootPropertyResults filters rules by the property-level diff: a rule keyed to targettedProperty: 'X' only runs when X differs between current and proposed. Practical consequence: "block must be open" can NOT be expressed as a rule on isOpen, because deposits don't change isOpen — the rule never fires.

That's why every mutating function carries a function-level if (!current.isOpen) return current; gate. The rule engine catches re-open attempts via buildMonotonicBooleanRule(id, 'isOpen', false); the function-level gate catches operations on an already-closed block.

Rule of thumb: rules catch transitions of a specific property; function-level checks catch operations conditioned on the current value of a property.

3. No-op proposals throw

startRuleEvaluation throws ShareDataCurrentAndProposalDataMatchesError when current === proposed. That means submitting a "function-level no-op" (function returns current unchanged) directly to addTransactionNotValidated blows up.

Always submit via safeSubmitTransaction. It detects reference-identity no-ops up front and catches the no-change error defensively (if your function returns a deep-equal-but-different reference).

4. The monotonic rule's condition shape is subtle

buildMonotonicBooleanRule(id, prop, lockedValue) asserts:

  • currentDataConditions: prop is a boolean (always satisfied; needed because the engine fails any rule with zero current conditions)
  • proposedDataConditions: prop === lockedValue

This catches transitions away from lockedValue while permitting transitions into it. Asserting prop === lockedValue on the current side instead (a tempting "mirror" shape) would incorrectly reject the legitimate first-time lock transition. Don't refactor the rule symmetrically without re-reading evaluate-rule.ts.

Function-level gates vs. rule-level gates — quick reference

| Need to enforce | Where it lives | Example | |---|---|---| | Property value must satisfy an invariant when changed | Rule | buildBalanceNonNegativeRule — balance ≥ 0 | | Property is one-way (once flipped, stays flipped) | Rule | buildMonotonicBooleanRulecommitted: false → true only | | Property is allowed to change (permissive) | Rule | buildAllowedMutationRule — satisfies "every changed property needs a rule" | | Operation depends on the current value of a property | Function-level | if (!current.isOpen) return current;, if (current.committed) return current; | | Operation depends on caller identity / role | Function-level | if (participant.role !== Lender) return current; |

The rule engine compares static conditions on the state shape, not function arguments and not cross-field deltas. Anything fitting one of those last two patterns must live in the function.

The no-op contract for function authors

When a mutating function decides it has nothing to propose (block locked, role gate fired, etc.), it must return current directly — the same reference. safeSubmitTransaction uses reference identity (proposed === current) as the primary no-op signal and only falls back to the no-change exception as a defensive catch.

const buildDepositFunction = () => ({
    // ...
    func: ({ current, input }) => {
        if (!current.isOpen) return current;       // ✓ correct
        if (current.committed) return current;     // ✓ correct
        // const next = JSON.parse(JSON.stringify(current));
        // return next;                            // ✗ defensive catch will absorb,
        //                                          but reference-identity is cleaner
        const next = JSON.parse(JSON.stringify(current));
        next.balance += input.amount;
        return next;
    },
});

Custom errors

Construction failures throw typed errors from @degenerate/generic that callers can pattern-match:

import {
    ShareBlockMissingLoanApplicationIdError,
    ShareBlockTypeMismatchError,
} from '@degenerate/generic';

try {
    createFundingShareBlock({...});
} catch (err) {
    if (err instanceof ShareBlockTypeMismatchError) {
        // err.expected, err.received, err.shareBlockId
    } else if (err instanceof ShareBlockMissingLoanApplicationIdError) {
        // err.shareBlockId, err.blockType
    } else {
        throw err;
    }
}

Extending: adding a fourth ShareBlock variant

If you need a new variant:

  1. Add an IXyzShareBlockState model in @degenerate/generic under models/shareblock/. Extend IShareBlockStateSchema and narrow blockType with z.literal(...).
  2. Add a ShareBlockType.Xyz enum value to keep the discriminator in step.
  3. Create src/creation/create-xyz-shareblock.ts:
    • Build a new ShareData(...) with the right id and public key.
    • Use seedInitialState from helpers/ to plant the initial state through the canonical transaction path.
    • Define the variant-specific mutating functions inline (deposit, withdraw, custom transitions). Each must:
      • Carry the function-level isOpen gate (if (!current.isOpen) return current;).
      • Return current (same reference) for any no-op case.
    • Wire buildCloseFunction<TState>() from standard-functions/.
    • Wire buildBalanceNonNegativeRule and buildMonotonicBooleanRule(id, 'isOpen', false) from standard-rules/.
    • For every other property your functions touch, wire buildAllowedMutationRule(id, propName) so the engine doesn't reject the proposal.
    • Export a XYZ_FUNCTION_IDS constant so callers can match functions by id rather than registration order.
  4. Add the variant's typed errors if it has new construction guards (mirror ShareBlockTypeMismatchError).
  5. Write two test files:
    • Mocked tests under src/creation/__tests__/ — fast wiring checks against a mocked startRuleEvaluation.
    • One e2e block under src/__tests__/e2e-real-rule-engine.test.ts — happy path, rule-engine rejection, lock-stamps-attribution, monotonic-rule rejection, function-level lock no-op. Use the existing Distribution describe block as a template.

Test-script note

The test script in package.json uses jest --runTestsByPath src/creation/__tests__/*.test.ts src/__tests__/*.test.ts instead of relying on default test discovery. Jest 30's haste map in this workspace consistently misses a test file in some configurations for reasons that didn't surface through --no-watchman, --no-cache, custom --haste options, alternative testMatch patterns, or --clearCache. Running by explicit path is deterministic. Revert to the standard form when the underlying issue is fixed in tooling.

Package layout

src/
├── creation/                            — public factories (one per variant)
│   ├── create-clearing-shareblock.ts
│   ├── create-funding-shareblock.ts
│   ├── create-distribution-shareblock.ts
│   ├── create-locked-shareblock.ts      — auto-initialize() wrappers
│   └── __tests__/                       — fast mocked tests per factory
├── helpers/
│   ├── build-elliptical-curves-proof-for.ts — production proofFor closure
│   ├── safe-submit-many.ts              — batch helper around safeSubmitTransaction
│   ├── safe-submit-transaction.ts       — submit dance + no-op detection
│   └── seed-initial-state.ts            — plant initial state through canonical path
├── standard-functions/
│   └── build-close-function.ts          — shared across all variants
├── standard-rules/
│   ├── build-allowed-mutation-rule.ts   — permissive rule for changed properties
│   ├── build-balance-non-negative-rule.ts
│   └── build-monotonic-boolean-rule.ts
├── __tests__/                           — cross-variant suites
│   ├── e2e-real-rule-engine.test.ts     — real rule engine, all three variants
│   └── safe-submit-transaction.test.ts
└── index.ts                             — barrel; exports the public API

Anything not exported from src/index.ts is internal; the package.json exports field restricts external consumers to the barrel.