Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/node-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Run npm audit on production dependencies (HIGH and CRITICAL only)
# Development tooling has an independent, non-blocking full-tree report
# in dependency-audit.yml; production dependencies block this workflow.
run: npm audit --omit=dev --audit-level=high
- name: Run blocking production dependency audit
run: npm run audit:production
continue-on-error: false
Expand Down
72 changes: 72 additions & 0 deletions docs/ACCOUNT_RECOVERY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Guardian-based account access recovery (#535)

This flow recovers the account's **sign-in key**, not custodial wallet key
material. It is deliberately fail-closed: there is no operator or platform
administrator override.

## Setup

An owner nominates between two and five other active platform users. Each
nominee must have a verified email address and must explicitly accept while
signed in before they count toward the policy. The owner chooses a quorum from
two to five; recovery stays unavailable until at least that many eligible
guardians have accepted. The recovery delay is fixed at 72 hours and cannot be
shortened or disabled.

Version 1 supports platform-user guardians only. External email/phone contacts
are not accepted as guardians because the platform has no independent external
guardian identity and approval credential flow. Users should nominate reliable
people who can sign in and respond; if the accepted quorum is unreachable,
recovery cannot complete and the platform cannot bypass it.

## Recovery lifecycle

1. A claimant obtains a nonce for a new Stellar public signing key through
`/auth/challenge`, signs it, and submits the registered, verified account
email, key, and signature. The public endpoint returns the same response
for eligible and ineligible requests to avoid account enumeration.
2. The registered email receives an immediate alert and one-time cancellation
token. The token remains valid only while the request is pending, so the owner
can cancel at any time before completion. Every accepted guardian receives an email and must separately sign
in and approve. Initiation is also emitted to the account's durable/live
security event stream.
3. Reaching the configured quorum starts a mandatory 72-hour cooling-off
period. No recovery action is effective before it ends.
4. Until completion, the owner can cancel immediately with any active session
or with the one-time token sent to the registered email. Guardian consensus
is not required to cancel.
5. At expiry, the new public key replaces only the account's sign-in key (the
asset wallet address is unchanged), all existing sessions and refresh
credentials are revoked, API keys are revoked, and TOTP/passkey credentials
are reset. The claimant signs in using the normal wallet-signature flow and
re-enrolls any second factors.

Nomination, acceptance, rejection, revocation, initiation, each approval,
cancellation, and completion are recorded in `AdminAuditLog`. Notifications
are queued through the durable outbound email queue; initiation also emits a
real-time account event.

## Threat model and residual risk

The quorum prevents a single guardian or the platform from unilaterally
recovering an account. The delay plus a registered-email cancellation path
provides the original owner time to notice and stop a takeover. Approval is an
individual action; no bulk or automatic approval exists. A request also fails
closed if it lacks a policy, accepted quorum, a verified registered email, or
an available new signing key.

Guardian collusion or successful social engineering of enough guardians
remains a material risk. Multi-channel alerts and the fixed delay reduce, but
cannot eliminate, that risk. Users should select independent, reachable
guardians and keep their registered email secure. If the registered email is
compromised along with a sufficient guardian quorum, the owner may lose the
ability to cancel in time.

Recovery is for a primary account only. It does not recover a custodial
wallet's private key and does not implement sub-account recovery.

The migration rollback intentionally retains `users.authPublicKey`: copying a
recovered sign-in key back into `walletAddress` would change the user's asset
destination. Because completed credential recoveries cannot be reversed
safely, deployments must not roll back to an application version that only
authenticates against `walletAddress`.
1 change: 1 addition & 0 deletions docs/API_KEYS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ implies every admin scope, and the legacy `agent` implies `agent:read` and
| `fiat:write` | money | enforced | — | Create fiat on/off-ramp orders. | — | `POST /api/v1/fiat/orders` |
| `loans:write` | money | enforced | — | Originate and repay collateral loans. | Origination and repayment still pass the compliance freeze and sub-account permission checks. | `POST /api/v1/loans`, `POST /api/v1/loans/:loanId/repay` |
| `recurring_deposits:write` | money | enforced | — | Manage recurring deposit plans. | — | `DELETE /api/v1/deposit/recurring/:id`, `PATCH /api/v1/deposit/recurring/:id`, `POST /api/v1/deposit/recurring` |
| `transfer:write` | money | enforced | — | Transfer assets to another platform user. | Transfers still pass compliance, frozen-account, balance, and sub-account permission checks. | `POST /api/v1/transfer` |
| `vault:write` | money | enforced | — | Build vault transactions. | — | `POST /api/v1/vault/build-transaction` |
| `withdraw:write` | money | enforced | — | Create withdrawals. | Requires allowWithdrawals on the key AND the platform kill-switch USER_API_KEY_WITHDRAWALS_ENABLED to be on. | `DELETE /api/v1/recurring-withdrawals/:id`, `PATCH /api/v1/recurring-withdrawals/:id`, `POST /api/v1/recurring-withdrawals`, `POST /api/v1/withdraw` |
<!-- END GENERATED: user-scope-catalog -->
Expand Down
39 changes: 39 additions & 0 deletions docs/INTERNAL_TRANSFERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Platform-Internal Transfers (#546)

`POST /api/v1/transfer` sends one asset between two users on the same network.
The recipient is resolved from their eight-character public referral code; raw
user IDs and wallet addresses are not accepted. Users can retrieve their code
from the existing referral-code endpoint.

## Settlement and limits

Settlement atomically reassigns the sender's existing active, unlocked Position
balance to the recipient under the same protocol. It creates paired
`INTERNAL_TRANSFER` transaction history and one durable transfer record, but
does not submit a Stellar transaction. This is a transfer of platform-managed
position ownership, not an on-chain withdrawal and not a separate cash balance.
The v1 path rejects collateral-locked positions, frozen accounts, cross-network
transfers, self-transfers, and transfers while a withdrawal is pending. An
active savings goal is also enforced unless the sender explicitly acknowledges
the impact.

Transfers are immediate and final when the database transaction commits. V1
does not provide a recall or reversal window. The API requires an
`Idempotency-Key`, and recipient-handle resolution is protected by the
authenticated sensitive-operation rate limit. A parent's transfer into a
linked sub-account requires the existing `DEPOSIT` permission.

The compliance scorer evaluates each transfer, including velocity,
structuring, account age, amount anomaly, and a new-destination signal for a
first-time recipient. Scores at or above the existing withdrawal hold threshold
of 70 reject the transfer. Both transfer legs and audit entries are committed
with the position changes. Transaction responses expose the other party's
public handle only, not their internal user ID or wallet address.

## Tax treatment

An internal transfer is an ownership change, not a disposal. No tax disposal
is recorded; open cost-basis lots are split and carried to the recipient with
their original acquisition date and price. Amounts not represented by an open
lot (such as yield or legacy balance) are transferred without a new lot. See
[TAX_REPORT.md](./TAX_REPORT.md) for details.
17 changes: 17 additions & 0 deletions docs/TAX_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ methods" below). The lot/disposal schema itself is unchanged from #284 — the
method only decides _which_ open lots a withdrawal consumes, never the shape
of what gets written.

## Platform-internal transfers (#546)

An `INTERNAL_TRANSFER` is an ownership change between two platform users, not
a sale or withdrawal. It creates no `LotDisposal` and does not realize a gain
or loss. In the same database transaction as the paired transfer records, open
sender cost-basis lots are reduced and corresponding recipient lots are created
with the original acquisition price, price source, and acquisition date. Thus
later withdrawals by either user continue to use the original basis and
accounting-method rules. The source lot's consumed amount is not treated as a
taxable disposal.

Transfer amounts without a corresponding open cost-basis lot (for example,
legacy balances or amounts attributable to yield) are credited without a new
lot and remain unpriced for lot reporting. This transfer path changes ledger
ownership only: it does not liquidate or move the underlying protocol assets
on-chain. Consult a tax professional for jurisdiction-specific treatment.

## Accounting methods (#317)

`src/tax/methods/` is a small interface (`CostBasisMethod.consumeLots`) with
Expand Down
Loading
Loading