- Contract: Rust, Soroban SDK, targeting Stellar testnet first, mainnet only after a real security review (not yet scheduled — see ROADMAP.md).
- SDK: TypeScript, wraps
@stellar/stellar-sdk+ generated contract bindings, published as@docforum/escrow-sdkon npm. - No database. No server. This repo ships a contract + a client library — nothing here runs as a persistent service.
__constructor(allowed_tokens: Vec<Address>, pause_admin: Address) // immutable token allowlist (empty = allow all) + the one pause custodian; no setter, no upgrade
pause(caller) -> result // IMPLEMENTED (Phase E5, ADR 0011) — custodian only; blocks every deposit path, never release/refund
unpause(caller) -> result // IMPLEMENTED (Phase E5, ADR 0011) — custodian only; re-opens new deposits
is_paused() -> bool // IMPLEMENTED (Phase E5, ADR 0011) — read-only pause flag
get_pause_admin() -> Option<Address> // IMPLEMENTED (Phase E5, ADR 0011) — read-only custodian address
version() -> String // semantic contract version; source revision and Soroban SDK version are embedded as WASM metadata
create_escrow(payer, payee, token, amount, condition_ref, releaser, refund_after, notify_payee, refund_to, fee_recipient, fee_bps) -> escrow_id // Phase E5 callback + sponsored refund destination (ADR 0009) + optional protocol fee (ADR 0010); the fee is not yet on testnet
create_escrow_release_time(..., fee_recipient, fee_bps) -> escrow_id // single-releaser creation with an earliest release time; accepts the same optional fee pair
create_escrow_with_acceptance(..., requires_acceptance, refund_to) -> escrow_id // optional payee acceptance gate; deployed on testnet
accept(escrow_id, payee) -> () // payee-authorized, one-time acceptance before release; deployed on testnet
cancel(escrow_id, payer) -> () // payer-authorized, only while Funded + requires_acceptance + not yet accepted; funds to refund_to, status Cancelled (ADR 0010); not deployed yet
create_escrow_with_releasers(payer, payee, token, amount, condition_ref, releasers, threshold, refund_after, notify_payee, refund_to) -> escrow_id // IMPLEMENTED (Phase E5, issue #119, ADR 0007) — M-of-N, deployed on testnet
get_status(escrow_id) -> EscrowStatus // enum: Funded | Released | Refunded | Cancelled — IMPLEMENTED + LIVE ON TESTNET (Phase E1)
get_escrow(escrow_id) -> EscrowData // read-only: payer, payee, token, amount, condition_ref, status, releasers, threshold, refund_after, refund_to, fee_recipient, fee_bps — IMPLEMENTED (Phase E4 + E5; `releaser` became `releasers`+`threshold` in #119)
release(escrow_id, caller) -> result // IMPLEMENTED (Phase E2) — a releaser, threshold must be 1, only while Funded
release_with_approvals(escrow_id, approvers) -> result // IMPLEMENTED (Phase E5, issue #119, ADR 0007) — >= threshold distinct releasers, each authorizing this invocation
refund(escrow_id, caller) -> result // releaser (threshold 1), stored payee voluntarily while Funded, or anyone after timeout; always pays stored `refund_to` (ADR 0009/0010)
refund_with_approvals(escrow_id, approvers) -> result // IMPLEMENTED (Phase E5, issue #119) — same approval rules as release_with_approvals
settle_many(caller, ops: Vec<(escrow_id, Release|Refund)>) -> Vec<Result<(), Error>> // IMPLEMENTED (Phase E5, issue #29, ADR 0012) — caller authorizes once; each op gets exactly the checks of release/refund against that escrow's own releasers; best-effort, one result per op in input order (Err = untouched; same escrow twice → second NotFunded); a post-transfer failure (payee callback) reverts the whole batch; at most MAX_SETTLE_OPS = 20 (BatchTooLarge); not deployed on testnet yet
top_up(escrow_id, payer, amount) -> result // IMPLEMENTED (issue #26) — original payer only, funded state only; emits `top_up`
transfer_payee(escrow_id, current_payee, new_payee) -> result // IMPLEMENTED (Phase E5, issue #45) — current payee only, funded state only; new_payee may not be the contract address or the payer; rotation tool, not key-loss recovery
create_pooled_escrow(creator, payee, token, funding_target, condition_ref, releaser, funding_deadline) -> escrow_id // IMPLEMENTED (Phase E5, issue #113, ADR 0011) — pooled escrow; amount starts at 0, `funding_target` > 0 and a strictly-future `funding_deadline` are fixed at creation; not deployed on testnet yet
contribute(escrow_id, payer, amount) -> () // IMPLEMENTED (issue #113) — permissionless before the deadline, up to the target only; balance-delta verified (`transfer_in_exact`) and recorded per payer, accumulating across repeats
claim_refund(escrow_id, contributor) -> () // IMPLEMENTED (issue #113) — pull-based refund, contributor's own auth, only from a failed round (deadline passed, target unmet), pays exactly the recorded amount once (`AlreadyRefunded` after)
get_pooled(escrow_id) -> PooledData // read-only: funding_target, funding_deadline — errors NotPooled (26) on non-pooled ids
get_contribution(escrow_id, contributor) -> Option<Contribution> // read-only: one contributor's record (amount, refunded); None if never contributed
rotate_releaser(escrow_id, caller, new_releaser) -> result // IMPLEMENTED (issue #23) — single-releaser escrow only; current releaser and payer authorize; funded only
solvency(token) -> (balance, liabilities) // IMPLEMENTED (Phase E5, issue #39) — read-only; liabilities = sum of `amount` over Funded escrows in `token`, kept in persistent `Liability(token)` (TTL re-extended on every write) and updated in the same invocation as every create/top_up/contribute (+) and release/refund/cancel/claim_refund (−); not deployed on testnet yet
The contract emits lifecycle events on every successful create_escrow*,
release*, and refund* call (issue #9, addresses threat-model T7).
Error paths emit nothing — an event is a signal that a state change
actually committed.
Lifecycle events share the same full-state snapshot shape used by the indexer:
Topic: ("ESCROW", action, escrow_id)
action ∈ { "created", "released", "refunded", "cancelled" }
escrow_id : u64
Payload: (payer, payee, token, amount, condition_ref, releasers, threshold,
refund_after, notify_payee, requires_acceptance, accepted_at,
status, (fee_recipient, fee_bps))
The status value is the matching Funded, Released, Refunded, or
Cancelled symbol. The fee fields are nested because Soroban event tuples
support at most 13 values. condition_ref remains opaque: callers must not
put sensitive data in it (threat model T9).
Other emitted events (not lifecycle):
top_up— topic("top_up", escrow_id), payload(added, new_total)rotate— topic("rotate", escrow_id), payload(old_releaser, new_releaser)accepted— topic("accepted", escrow_id), payload(payee, accepted_at)
No new contract-level role came in with the M-of-N work: releasers is
escrow data chosen by the payer at creation and immutable afterwards. There is
still no admin over funds, no owner and no upgrade path — OpenZeppelin's
upgradeable/access modules were evaluated and rejected in docs/adr/0006
precisely because they would add one. The one contract-level role is the
emergency-pause custodian (pause_admin, ADR 0011), whose only power is
refusing new deposits; it cannot release, refund, edit an escrow,
rotate a releaser or upgrade anything, and it never gates an outbound path.
Emergency pause (Phase E5, docs/adr/0011): one address fixed at
deployment by __constructor may set a flag that makes every deposit path
(create_escrow and its variants, plus top_up) fail with Paused
(error 26). That is its only power. release, refund (both the
releaser and timeout paths), get_status and get_escrow do not read the
flag, so a pause can never lock funds that are already escrowed — the
contract-side tests state that explicitly
(contracts/escrow/tests/pause.rs). pause/unpause require the
custodian's signature, are not idempotent (AlreadyPaused/NotPaused),
and emit Paused/Unpaused events. There is no timelock on either
direction and no unpause backdoor. Any future deposit path must apply
the same check in the same PR.
escrow data chosen by the payer at creation and immutable afterwards.There is still no admin, no owner, no pause and no upgrade path — OpenZeppelin's
pausable/upgradeable/access modules were evaluated and rejected in
docs/adr/0006 precisely because they would add one.
Pooled escrows (Phase E5, issue #113, docs/adr/0011) are additive: they
live behind create_pooled_escrow and two appended storage keys
(Pooled(id), Contribution(id, address)), so EscrowData and every
existing entry point keep their exact meaning. Rules, all tested in
contracts/escrow/tests/pooled.rs: an explicit funding_target and
funding_deadline are fixed at creation; contribute is open to anyone
until the deadline and rejects anything past the target (overfunding is
rejected, not refunded); release (both paths) additionally requires
funded == target (FundingIncomplete = 29), so partial pools never
release — a successful pool releases its whole total to the payee; if
the deadline passes with the target unmet, the round is failed for good
and every contributor pulls back exactly their recorded amount via
claim_refund, independently and once each (AlreadyRefunded = 31),
with no contributor iteration anywhere (no cap by design — see ADR
0011 "Resource limits"). The push paths refund/refund_with_approvals/
cancel/top_up reject pooled escrows (NotSupportedForPooled = 33)
because they would route the pool to one address or grow it without a
record; pooled escrows have no refund_after. get_status keeps its
four-value settlement vocabulary — a failed round stays Funded; read
get_pooled + the ledger clock for funding state. New errors 26–34 are
append-only (ADR 0011 lists them).
The allowlist is fixed when the contract is deployed. create_escrow
rejects any token outside a non-empty list with DisallowedToken (error
code 8); an empty list preserves the prior open-token behavior. The
current testnet deployment includes the native XLM SAC and the
BalanceMismatch test token in its immutable allowlist; see
docs/testnet-deployments.md.
create_escrow's param order in code is (payer, payee, token, amount, condition_ref, releaser, refund_after, notify_payee, refund_to, fee_recipient, fee_bps).
notify_payee opts a contract payee into on_escrow_released(escrow_id, amount); when true, the payee must authorize creation as well as the payer.
The quorum creation entry point has the same final callback flag. The SDK
option defaults to false. Callback failure rolls release back atomically;
see ADR 0005 for failure, gas, and stored-record compatibility.
The current contract surface (create_escrow, get_status, get_escrow,
release, refund, payee acceptance, M-of-N quorum, callbacks, and
sponsored refund destinations) is deployed on testnet as of 2026-10-20
(docs/testnet-deployments.md, latest entry) — including
a real timeout refund triggered by a non-releaser after its deadline, a
real release after the deadline, and real rejections of a
pre-deadline stranger refund (Error(Contract, #3)), a deadline already
in the past at creation (Error(Contract, #5)), and a second refund on
an already-settled escrow (Error(Contract, #4)). The Phase E1 and E2
deployments are superseded (their contract IDs predate the
refund_after param and EscrowData layout) and are kept only as
historical record.
refund_after is an optional unix timestamp in seconds set once at
creation (docs/adr/0004): after it passes anyone may call
refund, but the destination is unchanged — always the stored
refund_to, which is the payer unless set otherwise at creation —
and release is never gated by it, so the releaser can still release
after the deadline (whoever lands first wins). No refund_after means
behaviour is exactly what it was before: releaser-only refunds, forever.
The timeout is the answer to "what if the releaser's key is lost?" (threat
model T2): instead of locking funds permanently, a deadline lets anyone
return them.
refund_to (Phase E5, docs/adr/0009) is an optional address, set at
creation under the funder's (payer's) auth, that every refund path pays
instead of payer. It is stored, immutable, never supplied at payout, and
may not be the contract itself (InvalidRefundTo); omitted, it equals
payer and behaviour is exactly as before. Invariant I1 is therefore
"release → stored payee, refund → stored refund_to". Make sure the
destination can hold the token: if the transfer to it fails, so does the
refund.
The optional protocol fee (fee_recipient/fee_bps, docs/adr/0010):
set once at creation, taken off the top at release —
⌊amount × fee_bps ÷ 10 000⌋ to fee_recipient, the exact remainder to
payee — and never on refund, which always returns the full amount
to refund_to. fee_bps is capped at MAX_FEE_BPS = 1 000 (10 %) with
floor rounding; fee_recipient must not be the contract itself; both
params must be given together or both omitted, or creation fails with
InvalidFee (error 35). create_escrow and create_escrow_release_time
take the pair; the acceptance and M-of-N entry points record no fee. A
top_up grows the stored amount, so it grows the bps fee (the fee math
is re-checked before the top-up transfer). Both fields are readable from
get_escrow, and the ESCROW event snapshot appends them as one nested
(fee_recipient, fee_bps) pair, so payer and payee can check the exact
deal before relying on the escrow.
release_with_approvals/refund_with_approvals check that at least
threshold distinct releasers each authorized this exact invocation.
Approvals are never stored, so nothing can be replayed, and both work
only while the escrow is Funded. The payee-acceptance gate
(docs/adr/0008) only blocks release: refunds never need acceptance.
The contract decides who may call release/refund, never why:
condition_ref is opaque to it (docs/adr/0001).
contracts/releaser-account/ provides an optional custom account to use as
the per-escrow releaser: an immutable escrow target plus an M-of-N set of
Ed25519 keys. Soroban authorization signatures cover the host-generated
payload, including the nonce and invocation context. The account rejects
authorization contexts other than one release or refund call on its
configured escrow. Signer additions/removals and threshold changes use a
separate signed management digest bound to the account and a monotonic
management nonce. See ADR 0005.
import { EscrowClient, Keypair } from "@docforum/escrow-sdk";
const client = new EscrowClient({ contractId }); // defaults: testnet RPC + passphrase
const client2 = new EscrowClient({ contractId, network: networks.testnet }); // named preset: RPC URL + passphrase + known contract ids
// networks.futurenet and networks.local are also available; custom networks (private quickstart) still work
// by passing explicit rpcUrl + networkPassphrase. Constructing with the mainnet passphrase throws
// unless allowMainnet: true is passed. With a preset (or verifyNetwork: true) the RPC's getNetwork
// passphrase is verified lazily and once (cached) before the first network call, never in the
// constructor; pass verifyNetwork: true with a custom rpcUrl so it cannot silently point at mainnet
// (verifyNetwork: false turns the check off). Plain-HTTP RPC needs allowHttp (networks.local sets it).
const { escrowId, txHash } = await client.createEscrow({ payer, payee, token, amount, conditionRef, releaser, refundAfter, notifyPayee: true, refundTo, feeRecipient, feeBps });
await client.createQuorumEscrow({ payer, payee, token, amount, conditionRef, releasers, threshold, refundAfter, notifyPayee: true, refundTo }); // M-of-N, ADR 0007
await client.getStatus(escrowId); // "Funded" | "Released" | "Refunded"
await client.getEscrow(escrowId); // full stored record — incl. releasers + threshold + refundAfter, refundTo, so a payee can check the deal (docs/adr/0004/0007/0009, threat model T8)
await client.release({ escrowId, caller }); // { txHash } — caller: Keypair or SEP-43 Signer, must be a releaser and threshold must be 1
await client.releaseWithApprovals({ escrowId, approvers }); // { txHash } — one Keypair/Signer per approver (>= threshold, distinct)
await client.refund({ escrowId, caller }); // { txHash } — releaser when threshold is 1; anyone after refundAfter has passed
await client.isPaused(); // boolean — are new deposits blocked? release/refund are unaffected
await client.getPauseAdmin(); // custodian address set at deploy (docs/adr/0011)
await client.pause(caller); // { txHash } — caller: Keypair/Signer, must be the pause custodian
await client.unpause(caller); // { txHash } — same authority; re-opens new deposits
await client.refund({ escrowId, caller }); // { txHash } — releaser (threshold 1), the stored payee voluntarily, or anyone after refundAfter
await client.refundWithApprovals({ escrowId, approvers }); // { txHash } — M-of-N refund
await client.getTokenInfo(token); // { decimals, symbol, name } — cached token metadata read from the SEP-41 contract
await client.getEvents({ fromLedger }); // { events, nextCursor } — decoded lifecycle event page
const stop = client.subscribe(handler, { cursor, pollIntervalMs }); // serial polling; await handler for back-pressure
await client.createMilestoneEscrow({ payer, payee, token, amount, conditionRef, releaser, milestones }); // milestones: bigint[] tranches, validated client-side (sum == amount, all > 0); one escrow per tranche
await client.releaseMilestone({ escrow: group, milestoneIndex, caller }); // releases that tranche's escrow, once
await client.refundMilestone({ escrow: group, milestoneIndex, caller }); // refunds that tranche's escrow, same rules as refund
await client.getMilestones(group); // [{ index, amount, escrowId, status: "unreleased" | "released" | "refunded" }]
await client.estimate({ source, method, args }); // { resourceFee, inclusionFee, total, minResourceFee } — simulation only, 20% resource buffer
client.guardSponsorship(signedXdr); // { ok, reasons, invokedContracts } — every op + contract auth entry must target this escrow
client.feeBump(signedXdr, sponsor, { maxFee, baseFee? }); // { xdr, fee, innerTxHash } — throws MaxFeeExceededError, never overshoots the capMilestones are an SDK-level grouping, not a contract entry point: the
deployed contract is single-tranche, so createMilestoneEscrow creates one
ordinary escrow per tranche, tags its conditionRef as
<conditionRef>#m<i+1>/<n>, and returns the ids. Each tranche therefore
settles independently, release/refund act per tranche, and creation is one
transaction per tranche. validateMilestoneSchedule checks the schedule
(positive tranches summing exactly to amount, i128-safe) before the first
RPC call and throws MilestoneScheduleError; a tag that would exceed the
64-byte condition_ref limit is rejected the same way. A failure partway
through throws MilestoneEscrowPartialError, which carries the ids of the
tranches already funded so the caller can release or refund them.
refundTo on createEscrow and createQuorumEscrow is optional too (default: the payer) and
getEscrow returns it as refundTo.
feeRecipient/feeBps on createEscrow are optional as one atomic pair
(both or neither; feeBps is an integer 0..=MAX_FEE_BPS, i.e. at most
10 %), validated client-side before any transaction is built. At release
the floored bps share goes to feeRecipient and the remainder to the
payee; refunds never take a fee. getEscrow returns
feeRecipient/feeBps (undefined = no fee), and MAX_FEE_BPS mirrors
the contract's cap (docs/adr/0010).
Amounts are raw bigint in the token's smallest unit. sdk/src/amounts.ts
exports parseAmount(value, decimals) and formatAmount(amount, decimals)
plus getTokenInfo(token) (cached decimals/symbol/name read from the
token contract). Parsing is locale-independent and strict: no floats, no
exponent notation, no thousands separators, no negatives, and more
fractional digits than decimals is rejected rather than rounded. Never
assume 7 decimals — read them from the token, since arbitrary SEP-41 tokens
may differ.
estimate never signs or submits; it simulates against the client's RPC
and reports stroops. guardSponsorship is the pre-sponsorship check for
transactions that come from someone else: it rejects foreign contract
invocations, non-invocation operations, contract-address auth entries
pointing elsewhere, and already-bumped envelopes, while allowing the
account auth entries (payer/releaser) a legitimate escrow call carries.
feeBump charges baseFee × (inner operations + 1) and validates the
cap before building the envelope, so a fee spike fails loudly instead of
silently costing more than the sponsor agreed to.
Event results are a discriminated union: known lifecycle events expose
their event type and escrow id; unrecognized topics are returned as
type: "unknown" with decoded topic values and payload. Persist the
last handled event's cursor to resume; an expired RPC history cursor throws
EventCursorExpiredError. These helpers require the contract lifecycle
events tracked by issue #9; the current deployment does not emit them.
refundAfter on createEscrow is optional (unix seconds, must be strictly
in the future or the contract rejects with InvalidRefundAfter); omit it
for no timeout, which behaves exactly as this contract did before the
param existed.
MAX_RELEASERS (10) and MAX_CONDITION_REF_LENGTH (64) mirror the contract's
bounds client-side so callers fail fast. getEscrow() returns
releasers: string[] and threshold: number (releaser: string was replaced
in SDK 0.5.0 when the contract's EscrowData changed).
On the pause custodian: it is the one contract-level role in the design, and
it is deliberately weaker than the contract-level admin ADR 0002 rejected —
it cannot release, refund, edit an escrow, rotate a releaser, or upgrade
anything, so its worst case is "no new deposits on this contract id"
(threat model T12), never "someone's money is stuck". For mainnet, pass a
Stellar multisig account (e.g. 2-of-3) as pause_admin; require_auth
honours that account's own thresholds, so no contract change is needed.
Because the constructor is the only place the address is set, changing
custodians means deploying a new contract id.
sdk/src/generated/contract-client.ts is machine-generated
(stellar contract bindings typescript --wasm ...) — never hand-edit it;
regenerate it whenever the contract's public signatures/types change. CI now
enforces that: .github/workflows/ci.yml's sdk-bindings job rebuilds the
WASM, regenerates with the CLI version pinned in scripts/stellar-cli-version,
and diffs the result (scripts/check-generated-bindings.sh, issue #121). See
sdk/src/generated/README.md for the regeneration steps.
sdk/src/index.ts is the actual public surface (hand-written, ergonomic
wrapper) — consumers import from the package root, never from
src/generated/ directly. The package publishes ESM and CommonJS entry
points with declarations for both; browser-aware bundlers use its ESM entry.
Known contract ids per network come from the
repo-root deployments.json (written by scripts/deploy), bundled as
src/deployments.generated.ts and exposed as knownContractId /
knownNetworkConfig; unknown networks and mainnet throw.
condition_ref is an opaque string/id the contract does not interpret, bounded to 64 bytes at creation (error ConditionRefTooLong = 7; SDK constant MAX_CONDITION_REF_LENGTH) — it's the caller's job (e.g. docforum-core) to decide when release is warranted and call release(). releaser is the one address permitted
to call release/refund for that specific escrow, set once at
create_escrow time — see docs/adr/0002 for why a per-escrow releaser
was chosen over a single contract-level admin. The one exception is the
timeout: once refund_after has passed, anyone may call refund for
that escrow, still only back to payer — see docs/adr/0004 for why
that removes a permanent lock without adding a privileged role. The
contract enforces who can call release/refund, not why — keep it that
way. Baking healthcare-specific logic into the contract is exactly the
scope creep this repo exists to avoid (see README "why this repo exists,
honestly").
payer is an Address, so a Soroban contract account (for example a
passkey-backed smart wallet) is a valid payer and the escrow contract needs no
change. what differs is authorization: a C... account cannot sign a
transaction envelope, so it authorizes the invocation with a signed auth
entry that its own __check_auth verifies (secp256r1/WebAuthn for a passkey
wallet).
The SDK's write path signs envelopes, so it covers G... payers only; it does
not build or accept auth entries for C... payers yet. The flow, the missing
two-step API and the test contract the follow-up needs are written up in
docs/smart-wallet-auth-flow.md. The SDK read path (getEscrow,
getStatus) needs no signer and works for any payer type today.
EscrowClient methods accept either a local Keypair or a SEP-43 Signer
(address + signTransaction), so the same createEscrow/release/refund
surface works in Node and in a browser wallet. sdk/src/signers/wallets-kit.ts
ships WalletsKitSigner, which adapts Stellar Wallets Kit to that interface:
- It is exported on the
@docforum/escrow-sdk/signers/wallets-kitsubpath and has no runtime import of@creit.tech/stellar-wallets-kit(optional peer dependency, injected kit). Server imports of the package root therefore never pull wallet code;sideEffects: falsekeeps it tree-shakeable. - A wallet on the wrong network throws
WalletNetworkMismatchErrorbefore signing. - A user rejection is a normal SEP-43 outcome (
error.code === -4), not an unhandled throw. - 12 offline tests in
sdk/tests/signers.test.tswith a fake kit.
packages/escrow-react (@docforum/escrow-react) is framework glue only: no
UI components or styling. It depends on TanStack Query v5 as a peer and
requires EscrowProvider (client) inside the app's QueryClientProvider.
useEscrow(id)pollsgetEscrowand stops at terminal statuses (Released/Refunded).useEscrowActions(signer)wraps creation/settlement, invalidates the matching query, and returns user rejection as{ status: "rejected" }.useEscrowTimeline(escrow)counts down torefundAfter. Documented caveat:refund_afteris a ledger timestamp, so a local countdown is advisory; passledgerTimeOffsetMsto align it. The contract, not the countdown, authorizes a timeout refund.
EscrowClientConfig.instrumentation / onEvent add optional tracing for the
simulate, sign, submit and confirm phases. @opentelemetry/api is an
optional peer dependency; with no tracer and no onEvent the helpers return
before reading a clock or allocating a span. Attributes are contract id,
function, escrow id, transaction hash and retry count. Account addresses are
never included unless includeAddresses: true (threat model T9). Tests in
sdk/tests/instrumentation.test.ts assert every phase is produced and the
disabled path does no timing work.
sdk-go/ is a separate Go module (github.com/DocForum/docforum-escrow/sdk-go)
with CreateEscrow, GetStatus, GetEscrow, Release and Refund, typed
errors matching the contract's codes, and big.Int for i128 amounts. It is an
additive client — the contract and TypeScript SDK are unchanged by it. Offline
unit tests run in CI; integration tests are opt-in and run against a local
quickstart deployment.
- No PHI, ever, anywhere in this repo. Not in tests, not in example fixtures, not in comments. This repo should be safely public and understandable with zero healthcare context.
- Contract logic changes require updated tests beside the affected
contract (
contracts/escrow/tests/orcontracts/releaser-account/tests/) before merge — payment logic bugs are not "fix in a follow-up" territory. - SDK version bumps that change the public API are breaking changes —
semver strictly, since
docforum-corepins a version rather than tracking latest. - Testnet only until a security review has happened. Do not wire mainnet contract addresses into the SDK's defaults.
See ROADMAP.md in this repo. Live testnet deployment record:
docs/testnet-deployments.md.
A failed outbound token transfer during release returns Undeliverable and preserves the funded record, allowing the releaser to retry or use the refund path.