Reviewed release and reorg-hold resolution

This commit is contained in:
2026-10-21 22:24:42 -04:00
parent 9f82f050b4
commit 05aead39ae
8 changed files with 504 additions and 21 deletions
+262 -2
View File
@@ -48,6 +48,40 @@ export type DepositCreditApproval = {
signature: string;
};
export type DepositCreditResult = { creditId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean };
export type DepositCreditReleaseSubject = DepositCreditReviewSubject & {
creditId: string;
pendingJournalId: string;
unresolvedHolds: { holdId: string; eventSequence: number; eventType: 'changed' | 'not-seen' | 'reappeared' }[];
};
export type DepositCreditReleaseApproval = {
subject: DepositCreditReleaseSubject;
reviewerOwner: string;
reviewerPublicKey: string;
nonce: string;
expiresAt: number;
signature: string;
};
export type DepositCreditReleaseResult = { creditId: string; releaseId: string; journalId: string; beneficiary: string; amountAtomicXmr: string; replayed: boolean };
export type DepositReorgResolutionSubject = {
policyVersion: 'monero-reorg-resolution-v1';
creditId: string;
releaseId: string;
holdId: string;
walletFingerprint: string;
observationId: string;
eventSequence: number;
eventType: 'changed' | 'not-seen' | 'reappeared';
currentObservation: DepositCreditReviewSubject;
};
export type DepositReorgResolutionApproval = {
subject: DepositReorgResolutionSubject;
reviewerOwner: string;
reviewerPublicKey: string;
nonce: string;
expiresAt: number;
signature: string;
};
export type DepositReorgResolutionResult = { resolutionId: string; holdId: string; replayed: boolean };
const subjectSchema = z.object({
policyVersion: z.literal(POLICY_VERSION), walletFingerprint: fingerprint, observationId: z.string().min(129).max(160),
@@ -60,6 +94,17 @@ const subjectSchema = z.object({
}).strict();
const approvalSchema = z.object({ subject: subjectSchema, reviewerOwner: hex, reviewerPublicKey: publicKey,
nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
const releaseSubjectSchema = subjectSchema.extend({ creditId: uuid, pendingJournalId: z.string().min(1).max(256),
unresolvedHolds: z.array(z.object({ holdId: uuid, eventSequence: z.number().int().positive().safe(),
eventType: z.enum(['changed', 'not-seen', 'reappeared']) }).strict()).max(1024) }).strict();
const releaseApprovalSchema = z.object({ subject: releaseSubjectSchema, reviewerOwner: hex, reviewerPublicKey: publicKey,
nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
const resolutionSubjectSchema = z.object({ policyVersion: z.literal('monero-reorg-resolution-v1'), creditId: uuid,
releaseId: uuid, holdId: uuid, walletFingerprint: fingerprint, observationId: z.string().min(129).max(160),
eventSequence: z.number().int().positive().safe(), eventType: z.enum(['changed','not-seen','reappeared']),
currentObservation: subjectSchema }).strict();
const resolutionApprovalSchema = z.object({ subject: resolutionSubjectSchema, reviewerOwner: hex,
reviewerPublicKey: publicKey, nonce: uuid, expiresAt: z.number().int().nonnegative().safe(), signature }).strict();
function digest(value: string): string { return createHash('sha256').update(value).digest('hex'); }
function canonicalReviewMessage(subject: DepositCreditReviewSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
@@ -70,6 +115,24 @@ function canonicalReviewMessage(subject: DepositCreditReviewSubject, reviewerOwn
subject.observationDigest, subject.observationEventSequence, subject.observationEventHistoryDigest,
subject.observerUpdatedAt]);
}
function canonicalReleaseMessage(subject: DepositCreditReleaseSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
return JSON.stringify(['wikideck-monero-pending-release-v1', reviewerOwner, nonce, expiresAt,
subject.policyVersion, subject.creditId, subject.pendingJournalId, subject.walletFingerprint, subject.observationId,
subject.outputPublicKey, subject.amountAtomicXmr, subject.blockHeight, subject.confirmations,
subject.minimumConfirmations, subject.locked, subject.doubleSpendSeen, subject.allocationId, subject.beneficiary,
subject.observationDigest, subject.observationEventSequence, subject.observationEventHistoryDigest,
subject.observerUpdatedAt, subject.unresolvedHolds.map(hold => [hold.holdId, hold.eventSequence, hold.eventType])]);
}
function canonicalResolutionMessage(subject: DepositReorgResolutionSubject, reviewerOwner: string, nonce: string, expiresAt: number): string {
const observation = subject.currentObservation;
return JSON.stringify(['wikideck-monero-reorg-resolution-v1', reviewerOwner, nonce, expiresAt,
subject.policyVersion, subject.creditId, subject.releaseId, subject.holdId, subject.walletFingerprint,
subject.observationId, subject.eventSequence, subject.eventType, observation.outputPublicKey,
observation.amountAtomicXmr, observation.blockHeight, observation.confirmations, observation.minimumConfirmations,
observation.locked, observation.doubleSpendSeen, observation.allocationId, observation.beneficiary,
observation.observationDigest, observation.observationEventSequence, observation.observationEventHistoryDigest,
observation.observerUpdatedAt]);
}
function validatePolicy(policy: DepositCreditPolicy): void {
if (!Array.isArray(policy.approvedReviewerOwners) || !policy.approvedReviewerOwners.length ||
@@ -81,7 +144,7 @@ function validatePolicy(policy: DepositCreditPolicy): void {
}
async function readReviewSubject(client: PoolClient, walletFingerprint: string, observationId: string,
policy: DepositCreditPolicy, now: number): Promise<DepositCreditReviewSubject> {
policy: DepositCreditPolicy, now: number, allowReorgHistory = false): Promise<DepositCreditReviewSubject> {
const observation = await client.query(`SELECT o.wallet_fingerprint,o.observation_id,o.txid,o.monero_address,o.account_index,
o.address_index,o.output_public_key,o.amount_atomic_xmr::text,o.block_height,o.confirmations,o.locked,
o.double_spend_seen,o.payload_digest,o.currently_present,s.updated_at AS observer_updated_at
@@ -117,7 +180,7 @@ async function readReviewSubject(client: PoolClient, walletFingerprint: string,
FROM wk_custody.monero_observation_events WHERE wallet_fingerprint=$1 AND observation_id=$2
ORDER BY sequence LIMIT $3`, [walletFingerprint, observationId, MAX_EVENT_HISTORY + 1]);
if ((events.rowCount ?? events.rows.length) > MAX_EVENT_HISTORY) throw new Error('Deposit observation event history exceeds review bound');
if (events.rows.some(event => event.event_type === 'not-seen' || event.event_type === 'reappeared')) {
if (!allowReorgHistory && events.rows.some(event => event.event_type === 'not-seen' || event.event_type === 'reappeared')) {
throw new Error('Deposit observation has prior disappearance/reappearance and requires separate review');
}
const sequence = events.rows.length ? Number(events.rows.at(-1)!.sequence) : 0;
@@ -226,6 +289,203 @@ export async function postReviewedDepositPendingCredit(pool: Pool, rawApproval:
finally { client.release(); }
}
async function readReleaseSubject(client: PoolClient, creditId: string, policy: DepositCreditPolicy,
now: number): Promise<DepositCreditReleaseSubject> {
const credit = await client.query(`SELECT credit_id::text,wallet_fingerprint,observation_id,beneficiary,
amount_atomic_xmr::text,journal_id FROM wk_custody.monero_deposit_credit_events WHERE credit_id=$1`, [creditId]);
if (credit.rowCount !== 1) throw new Error('Pending deposit credit not found');
const row = credit.rows[0];
if (await client.query('SELECT 1 FROM wk_custody.monero_deposit_release_events WHERE credit_id=$1', [creditId]).then(r => r.rowCount)) {
throw new Error('Pending deposit credit was already released');
}
const observation = await readReviewSubject(client, row.wallet_fingerprint, row.observation_id, policy, now, true);
if (observation.beneficiary !== row.beneficiary || observation.amountAtomicXmr !== row.amount_atomic_xmr) {
throw new Error('Pending credit no longer matches the current observed output');
}
const holds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type
FROM wk_custody.monero_deposit_reorg_holds h
WHERE h.credit_id=$1 AND NOT EXISTS (
SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)
ORDER BY h.observation_event_sequence LIMIT 1025`, [creditId]);
if (holds.rows.length > 1024) throw new Error('Deposit reorg hold set exceeds review bound');
return { ...observation, creditId, pendingJournalId: row.journal_id,
unresolvedHolds: holds.rows.map(hold => ({ holdId: hold.hold_id,
eventSequence: Number(hold.observation_event_sequence), eventType: hold.event_type })) };
}
/** Return the current pending liability and unresolved reorg evidence for explicit reviewer approval. */
export async function prepareDepositCreditReleaseReview(pool: Pool, creditId: string,
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditReleaseSubject> {
validatePolicy(policy); uuid.parse(creditId);
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
const subject = await readReleaseSubject(client, creditId, policy, now);
await client.query('COMMIT'); return subject;
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
/** Canonical reviewer message for releasing a pending liability and resolving its included scoped holds. */
export function depositCreditReleaseApprovalMessage(subject: DepositCreditReleaseSubject, reviewerOwner: string,
nonce: string, expiresAt: number): string {
return canonicalReleaseMessage(releaseSubjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt);
}
/** Explicit reviewer-authorized, idempotent move from pending to spendable available balance. Not HTTP-wired. */
export async function releaseReviewedDepositCredit(pool: Pool, rawApproval: DepositCreditReleaseApproval,
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositCreditReleaseResult> {
validatePolicy(policy);
const approval = releaseApprovalSchema.parse(rawApproval);
const message = canonicalReleaseMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt);
if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) ||
address(approval.reviewerPublicKey) !== approval.reviewerOwner ||
!verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, message, approval.signature)) {
throw new Error('Deposit release review signature is invalid');
}
const reviewDigest = digest(message);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'; SET LOCAL synchronous_commit=on");
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
const existing = await client.query(`SELECT release_id,credit_id::text,journal_id,reviewer_owner,review_nonce,
review_signature,review_digest,review_subject,released_at FROM wk_custody.monero_deposit_release_events
WHERE credit_id=$1`, [approval.subject.creditId]);
if (existing.rowCount) {
const saved = existing.rows[0];
if (saved.reviewer_owner !== approval.reviewerOwner || saved.review_nonce !== approval.nonce ||
saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) {
throw new Error('Deposit release was already committed under different reviewed terms');
}
const credit = await client.query(`SELECT beneficiary,amount_atomic_xmr::text FROM wk_custody.monero_deposit_credit_events
WHERE credit_id=$1`, [saved.credit_id]);
await client.query('COMMIT');
return { creditId: saved.credit_id, releaseId: saved.release_id, journalId: saved.journal_id,
beneficiary: credit.rows[0].beneficiary, amountAtomicXmr: credit.rows[0].amount_atomic_xmr, replayed: true };
}
if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) {
throw new Error('Deposit release review signature is expired or outside the allowed window');
}
const current = await readReleaseSubject(client, approval.subject.creditId, policy, now);
if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit release evidence changed after reviewer approval');
const pendingAccount = `customer:${current.beneficiary}:deposit-pending:${current.creditId}`;
const availableAccount = `customer:${current.beneficiary}:available`;
const pending = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [pendingAccount]);
if (pending.rowCount !== 1 || pending.rows[0].normal !== 'credit') {
throw new Error('Pending deposit liability does not match reviewed release amount');
}
await client.query('INSERT INTO wk_custody.accounts(id,normal) VALUES($1,\'credit\') ON CONFLICT(id) DO NOTHING', [availableAccount]);
const available = await client.query('SELECT normal FROM wk_custody.accounts WHERE id=$1', [availableAccount]);
if (available.rows[0]?.normal !== 'credit') throw new Error('Available customer account normal mismatch');
const releaseId = randomUUID(), journalId = `monero-deposit-release:${current.creditId}`;
await client.query('INSERT INTO wk_custody.journals(id,kind,created) VALUES($1,$2,$3)',
[journalId, 'monero-deposit-release', now]);
await client.query('INSERT INTO wk_custody.entries VALUES($1,0,$2,$3,0),($1,1,$4,0,$3)',
[journalId, pendingAccount, current.amountAtomicXmr, availableAccount]);
await client.query(`INSERT INTO wk_custody.monero_deposit_release_events(
release_id,credit_id,journal_id,reviewer_owner,reviewer_public_key,review_nonce,review_expires_at,
review_signature,review_subject,review_digest,released_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
[releaseId, current.creditId, journalId, approval.reviewerOwner, approval.reviewerPublicKey, approval.nonce,
approval.expiresAt, approval.signature, JSON.stringify(current), reviewDigest, now]);
for (const hold of current.unresolvedHolds) {
await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_resolutions(
resolution_id,hold_id,credit_id,release_id,resolved_by,resolved_at,disposition,resolution_kind,
reviewer_public_key,review_nonce,review_expires_at,review_signature,review_subject,review_digest)
VALUES($1,$2,$3,$4,$5,$6,'revalidated-for-release','release-review',$7,$8,$9,$10,$11,$12)`,
[randomUUID(), hold.holdId, current.creditId, releaseId, approval.reviewerOwner, now,
approval.reviewerPublicKey, approval.nonce, approval.expiresAt, approval.signature,
JSON.stringify({ subject: current, holdId: hold.holdId }), reviewDigest]);
}
await client.query('COMMIT');
return { creditId: current.creditId, releaseId, journalId, beneficiary: current.beneficiary,
amountAtomicXmr: current.amountAtomicXmr, replayed: false };
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
async function readReorgResolutionSubject(client: PoolClient, creditId: string, holdId: string,
policy: DepositCreditPolicy, now: number): Promise<DepositReorgResolutionSubject> {
const row = await client.query(`SELECT h.hold_id::text,h.credit_id::text,h.wallet_fingerprint,h.observation_id,
h.observation_event_sequence::text,h.event_type,c.release_id::text
FROM wk_custody.monero_deposit_reorg_holds h
JOIN wk_custody.monero_deposit_release_events c ON c.credit_id=h.credit_id
WHERE h.hold_id=$1 AND h.credit_id=$2 AND NOT EXISTS(
SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r WHERE r.hold_id=h.hold_id)`, [holdId, creditId]);
if (row.rowCount !== 1) throw new Error('Unresolved released-deposit reorg hold not found');
const currentObservation = await readReviewSubject(client, row.rows[0].wallet_fingerprint,
row.rows[0].observation_id, policy, now, true);
return resolutionSubjectSchema.parse({ policyVersion: 'monero-reorg-resolution-v1', creditId,
releaseId: row.rows[0].release_id, holdId, walletFingerprint: row.rows[0].wallet_fingerprint,
observationId: row.rows[0].observation_id, eventSequence: Number(row.rows[0].observation_event_sequence),
eventType: row.rows[0].event_type, currentObservation });
}
/** Prepare an exact current snapshot to resolve one scoped reorg hold after the output reappears. */
export async function prepareDepositReorgResolution(pool: Pool, creditId: string, holdId: string,
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositReorgResolutionSubject> {
validatePolicy(policy); uuid.parse(creditId); uuid.parse(holdId);
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL idle_in_transaction_session_timeout='10s'");
const subject = await readReorgResolutionSubject(client, creditId, holdId, policy, now);
await client.query('COMMIT'); return subject;
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
export function depositReorgResolutionMessage(subject: DepositReorgResolutionSubject, reviewerOwner: string,
nonce: string, expiresAt: number): string {
return canonicalResolutionMessage(resolutionSubjectSchema.parse(subject), hex.parse(reviewerOwner), uuid.parse(nonce), expiresAt);
}
/** Resolve a reorg hold only after the output is again present, unlocked, sufficiently confirmed, and signed off. */
export async function resolveReleasedDepositReorgHold(pool: Pool, rawApproval: DepositReorgResolutionApproval,
policy: DepositCreditPolicy, now = Date.now()): Promise<DepositReorgResolutionResult> {
validatePolicy(policy);
const approval = resolutionApprovalSchema.parse(rawApproval);
const message = canonicalResolutionMessage(approval.subject, approval.reviewerOwner, approval.nonce, approval.expiresAt);
if (!policy.approvedReviewerOwners.includes(approval.reviewerOwner) ||
address(approval.reviewerPublicKey) !== approval.reviewerOwner ||
!verifySignature(approval.reviewerPublicKey, approval.reviewerOwner, message, approval.signature)) {
throw new Error('Deposit reorg resolution signature is invalid');
}
const reviewDigest = digest(message), client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL statement_timeout='10s'; SET LOCAL lock_timeout='3s'; SET LOCAL synchronous_commit=on");
await client.query('SELECT pg_advisory_xact_lock($1::bigint)', [CUSTODY_WRITE_LOCK]);
const existing = await client.query(`SELECT resolution_id::text,review_nonce,review_signature,review_digest
FROM wk_custody.monero_deposit_reorg_resolutions WHERE hold_id=$1`, [approval.subject.holdId]);
if (existing.rowCount) {
const saved = existing.rows[0];
if (saved.review_nonce !== approval.nonce || saved.review_signature !== approval.signature || saved.review_digest !== reviewDigest) {
throw new Error('Deposit reorg hold was already resolved under different reviewed terms');
}
await client.query('COMMIT'); return { resolutionId: saved.resolution_id, holdId: approval.subject.holdId, replayed: true };
}
if (approval.expiresAt < now || approval.expiresAt > now + 5 * 60_000) {
throw new Error('Deposit reorg resolution signature is expired or outside the allowed window');
}
const current = await readReorgResolutionSubject(client, approval.subject.creditId, approval.subject.holdId, policy, now);
if (JSON.stringify(current) !== JSON.stringify(approval.subject)) throw new Error('Deposit reorg evidence changed after review');
const resolutionId = randomUUID();
await client.query(`INSERT INTO wk_custody.monero_deposit_reorg_resolutions(
resolution_id,hold_id,credit_id,release_id,resolved_by,resolved_at,disposition,resolution_kind,
reviewer_public_key,review_nonce,review_expires_at,review_signature,review_subject,review_digest)
VALUES($1,$2,$3,$4,$5,$6,'revalidated-for-release','post-release-review',$7,$8,$9,$10,$11,$12)`,
[resolutionId, approval.subject.holdId, approval.subject.creditId, approval.subject.releaseId,
approval.reviewerOwner, now, approval.reviewerPublicKey, approval.nonce, approval.expiresAt,
approval.signature, JSON.stringify(approval.subject), reviewDigest]);
await client.query('COMMIT'); return { resolutionId, holdId: approval.subject.holdId, replayed: false };
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; }
finally { client.release(); }
}
/** Append review holds for post-credit Monero reorg signals; this never reverses a journal or releases pending funds. */
export async function reconcileDepositCreditReorgHolds(pool: Pool, walletFingerprint: string, now = Date.now()): Promise<number> {
fingerprint.parse(walletFingerprint);