Reviewed release and reorg-hold resolution
This commit is contained in:
@@ -7,7 +7,9 @@ import { allocateXmrSale } from '../../shared/xmr-pricing.ts';
|
||||
import { validateXmrAddress } from './xmr.ts';
|
||||
import { replayReservedSale } from './sale-rehearsal.ts';
|
||||
import { address, verifySignature } from '../testnet/consensus.ts';
|
||||
import { depositCreditApprovalMessage, type DepositCreditReviewSubject } from './monero-deposit-credit.ts';
|
||||
import { depositCreditApprovalMessage, depositCreditReleaseApprovalMessage,
|
||||
depositReorgResolutionMessage, type DepositCreditReleaseSubject, type DepositCreditReviewSubject,
|
||||
type DepositReorgResolutionSubject } from './monero-deposit-credit.ts';
|
||||
|
||||
/** Codes only: never attach customer IDs, payloads, SQL errors or addresses to operational diagnostics. */
|
||||
export class CustodyIntegrityError extends Error {
|
||||
@@ -177,7 +179,11 @@ type DepositCreditRow = { credit_id: string; wallet_fingerprint: string; observa
|
||||
beneficiary: string; amount_atomic_xmr: string; journal_id: string; last_observation_event_sequence: string;
|
||||
observation_digest: string; reviewer_owner: string; reviewer_public_key: string; review_nonce: string;
|
||||
review_expires_at: string; review_signature: string; review_subject: DepositCreditReviewSubject; credited_at: string;
|
||||
review_digest: string; minimum_confirmations: number; balance: string | null; normal: string | null };
|
||||
review_digest: string; minimum_confirmations: number; balance: string | null; normal: string | null;
|
||||
release_id: string | null; release_journal_id: string | null; release_reviewer_owner: string | null;
|
||||
release_reviewer_public_key: string | null; release_review_nonce: string | null; release_review_expires_at: string | null;
|
||||
release_review_signature: string | null; release_review_subject: DepositCreditReleaseSubject | null;
|
||||
release_review_digest: string | null; released_at: string | null };
|
||||
async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
let after: string | null = null;
|
||||
for (;;) {
|
||||
@@ -186,10 +192,15 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
c.allocation_id::text,c.beneficiary,c.amount_atomic_xmr::text,c.journal_id,c.last_observation_event_sequence::text,
|
||||
c.observation_digest,c.reviewer_owner,c.reviewer_public_key,c.review_nonce,c.review_expires_at::text,
|
||||
c.review_signature,c.review_subject,c.review_digest,c.credited_at::text,c.minimum_confirmations,
|
||||
a.balance::text,a.normal FROM wk_custody.monero_deposit_credit_events c
|
||||
a.balance::text,a.normal,r.release_id::text,r.journal_id AS release_journal_id,r.reviewer_owner AS release_reviewer_owner,
|
||||
r.reviewer_public_key AS release_reviewer_public_key,r.review_nonce::text AS release_review_nonce,
|
||||
r.review_expires_at::text AS release_review_expires_at,r.review_signature AS release_review_signature,
|
||||
r.review_subject AS release_review_subject,r.review_digest AS release_review_digest,r.released_at::text
|
||||
FROM wk_custody.monero_deposit_credit_events c
|
||||
LEFT JOIN wk_custody.accounts a ON a.id='customer:'||c.beneficiary||':deposit-pending:'||c.credit_id::text
|
||||
LEFT JOIN wk_custody.monero_deposit_release_events r ON r.credit_id=c.credit_id
|
||||
WHERE ($1::uuid IS NULL OR c.credit_id>$1::uuid) ORDER BY c.credit_id LIMIT 128`, [after]);
|
||||
const postings = await journals(client, result.rows.map(row => row.journal_id));
|
||||
const postings = await journals(client, result.rows.flatMap(row => [row.journal_id, ...(row.release_journal_id ? [row.release_journal_id] : [])]));
|
||||
for (const row of result.rows) {
|
||||
let approvalIsValid = false;
|
||||
let subjectIsValid = false;
|
||||
@@ -210,11 +221,78 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
row.review_subject.minimumConfirmations === row.minimum_confirmations;
|
||||
} catch { approvalIsValid = false; }
|
||||
if (!subjectIsValid || !approvalIsValid) fail('deposit-credit-observation-mismatch');
|
||||
if (row.normal !== 'credit' || row.balance !== row.amount_atomic_xmr) fail('deposit-pending-liability-mismatch');
|
||||
const released = row.release_id !== null;
|
||||
if (row.normal !== 'credit' || row.balance !== (released ? '0' : row.amount_atomic_xmr)) fail('deposit-pending-liability-mismatch');
|
||||
expectJournal(postings, row.journal_id, 'monero-deposit-pending-credit', [
|
||||
d('treasury:monero:pending', row.amount_atomic_xmr),
|
||||
c(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
||||
]);
|
||||
if (released) {
|
||||
let releaseValid = false;
|
||||
try {
|
||||
const releaseMessage = depositCreditReleaseApprovalMessage(row.release_review_subject!, row.release_reviewer_owner!,
|
||||
row.release_review_nonce!, Number(row.release_review_expires_at));
|
||||
const releaseSubject = row.release_review_subject!;
|
||||
releaseValid = address(row.release_reviewer_public_key!) === row.release_reviewer_owner &&
|
||||
createHash('sha256').update(releaseMessage).digest('hex') === row.release_review_digest &&
|
||||
Number(row.release_review_expires_at) >= Number(row.released_at) &&
|
||||
verifySignature(row.release_reviewer_public_key!, row.release_reviewer_owner!, releaseMessage, row.release_review_signature!) &&
|
||||
releaseSubject.creditId === row.credit_id && releaseSubject.walletFingerprint === row.wallet_fingerprint &&
|
||||
releaseSubject.observationId === row.observation_id && releaseSubject.beneficiary === row.beneficiary &&
|
||||
releaseSubject.amountAtomicXmr === row.amount_atomic_xmr && releaseSubject.allocationId === row.allocation_id;
|
||||
} catch { releaseValid = false; }
|
||||
if (!releaseValid) fail('deposit-release-review-invalid');
|
||||
expectJournal(postings, row.release_journal_id!, 'monero-deposit-release', [
|
||||
d(`customer:${row.beneficiary}:deposit-pending:${row.credit_id}`, row.amount_atomic_xmr),
|
||||
c(`customer:${row.beneficiary}:available`, row.amount_atomic_xmr),
|
||||
]);
|
||||
const resolutions = await client.query(`SELECT r.resolution_id::text,r.hold_id::text,r.release_id::text,r.resolved_by,r.resolved_at::text,
|
||||
r.disposition,r.resolution_kind,r.reviewer_public_key,r.review_nonce::text,r.review_expires_at::text,
|
||||
r.review_signature,r.review_subject,r.review_digest,h.observation_event_sequence::text AS hold_event_sequence,
|
||||
h.event_type AS hold_event_type
|
||||
FROM wk_custody.monero_deposit_reorg_resolutions r
|
||||
JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id WHERE r.credit_id=$1`, [row.credit_id]);
|
||||
const approvedHoldIds = [...(row.release_review_subject?.unresolvedHolds ?? [])].map(hold => hold.holdId).sort();
|
||||
const releaseResolutions = resolutions.rows.filter(item => item.resolution_kind === 'release-review');
|
||||
const releaseHolds = await client.query(`SELECT h.hold_id::text,h.observation_event_sequence::text,h.event_type
|
||||
FROM wk_custody.monero_deposit_reorg_holds h JOIN wk_custody.monero_deposit_reorg_resolutions r
|
||||
ON r.hold_id=h.hold_id AND r.resolution_kind='release-review'
|
||||
WHERE h.credit_id=$1 AND r.release_id=$2 ORDER BY h.observation_event_sequence`, [row.credit_id, row.release_id]);
|
||||
const signedHoldFacts = [...(row.release_review_subject?.unresolvedHolds ?? [])]
|
||||
.map(hold => JSON.stringify([hold.holdId, hold.eventSequence, hold.eventType])).sort();
|
||||
const persistedHoldFacts = releaseHolds.rows.map(hold => JSON.stringify([
|
||||
hold.hold_id, Number(hold.observation_event_sequence), hold.event_type])).sort();
|
||||
if (releaseResolutions.some(item => item.release_id !== row.release_id || item.resolved_by !== row.release_reviewer_owner ||
|
||||
item.reviewer_public_key !== row.release_reviewer_public_key || item.review_nonce !== row.release_review_nonce ||
|
||||
item.review_expires_at !== row.release_review_expires_at || item.review_signature !== row.release_review_signature ||
|
||||
item.review_digest !== row.release_review_digest || item.disposition !== 'revalidated-for-release') ||
|
||||
releaseResolutions.map(item => item.hold_id).sort().join(',') !== approvedHoldIds.join(',') ||
|
||||
releaseHolds.rows.length !== signedHoldFacts.length || signedHoldFacts.join(',') !== persistedHoldFacts.join(',')) {
|
||||
fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
for (const resolution of resolutions.rows.filter(item => item.resolution_kind === 'post-release-review')) {
|
||||
let resolutionValid = false;
|
||||
try {
|
||||
const subject = resolution.review_subject as DepositReorgResolutionSubject;
|
||||
const resolutionMessage = depositReorgResolutionMessage(subject, resolution.resolved_by,
|
||||
resolution.review_nonce, Number(resolution.review_expires_at));
|
||||
resolutionValid = resolution.release_id === row.release_id && resolution.disposition === 'revalidated-for-release' &&
|
||||
subject.creditId === row.credit_id && subject.releaseId === row.release_id && subject.holdId === resolution.hold_id &&
|
||||
subject.eventSequence === Number(resolution.hold_event_sequence) && subject.eventType === resolution.hold_event_type &&
|
||||
subject.walletFingerprint === row.wallet_fingerprint && subject.observationId === row.observation_id &&
|
||||
subject.currentObservation.beneficiary === row.beneficiary &&
|
||||
subject.currentObservation.amountAtomicXmr === row.amount_atomic_xmr &&
|
||||
address(resolution.reviewer_public_key) === resolution.resolved_by &&
|
||||
createHash('sha256').update(resolutionMessage).digest('hex') === resolution.review_digest &&
|
||||
Number(resolution.review_expires_at) >= Number(resolution.resolved_at) &&
|
||||
verifySignature(resolution.reviewer_public_key, resolution.resolved_by, resolutionMessage, resolution.review_signature);
|
||||
} catch { resolutionValid = false; }
|
||||
if (!resolutionValid) fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
if (resolutions.rows.some(item => !['release-review','post-release-review'].includes(item.resolution_kind))) {
|
||||
fail('deposit-reorg-resolution-invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.rows.length < 128) break;
|
||||
after = result.rows.at(-1)!.credit_id;
|
||||
@@ -227,6 +305,14 @@ async function checkDepositCredits(client: PoolClient, budget: () => void) {
|
||||
const orphanJournal = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-pending-credit'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_credit_events c WHERE c.journal_id=j.id) LIMIT 1`);
|
||||
if (orphanJournal.rowCount) fail('orphan-deposit-credit-journal');
|
||||
const orphanRelease = await client.query(`SELECT 1 FROM wk_custody.journals j WHERE j.kind='monero-deposit-release'
|
||||
AND NOT EXISTS(SELECT 1 FROM wk_custody.monero_deposit_release_events r WHERE r.journal_id=j.id) LIMIT 1`);
|
||||
if (orphanRelease.rowCount) fail('orphan-deposit-release-journal');
|
||||
const orphanResolution = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_resolutions r
|
||||
LEFT JOIN wk_custody.monero_deposit_reorg_holds h ON h.hold_id=r.hold_id
|
||||
LEFT JOIN wk_custody.monero_deposit_release_events e ON e.release_id=r.release_id
|
||||
WHERE h.hold_id IS NULL OR e.release_id IS NULL OR h.credit_id<>r.credit_id OR e.credit_id<>r.credit_id LIMIT 1`);
|
||||
if (orphanResolution.rowCount) fail('orphan-deposit-reorg-resolution');
|
||||
const invalidHold = await client.query(`SELECT 1 FROM wk_custody.monero_deposit_reorg_holds h
|
||||
JOIN wk_custody.monero_deposit_credit_events c ON c.credit_id=h.credit_id
|
||||
WHERE h.wallet_fingerprint<>c.wallet_fingerprint OR h.observation_id<>c.observation_id OR
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -208,6 +208,13 @@ export class PostgresCustody {
|
||||
check(!incident.rowCount, 'Custody integrity incident is unresolved; operator review required');
|
||||
check(!this.diagnostics().mutationHold, 'Custody financial reconciliation requires operator review; balances remain readable and original receipts can be replayed');
|
||||
check(permitsMarketAction(this.options, a.type), 'New market trading is disabled by this host; existing balances and resolution remain available');
|
||||
if (a.type === 'reserve' || a.type === 'withdraw') {
|
||||
const depositHold = await c.query(`SELECT 1 FROM wk_custody.monero_deposit_credit_events d
|
||||
JOIN wk_custody.monero_deposit_reorg_holds h ON h.credit_id=d.credit_id
|
||||
LEFT JOIN wk_custody.monero_deposit_reorg_resolutions r ON r.hold_id=h.hold_id
|
||||
WHERE d.beneficiary=$1 AND r.hold_id IS NULL LIMIT 1`, [cmd.actor]);
|
||||
check(!depositHold.rowCount, 'Customer deposit is under reorg review; new spending is held');
|
||||
}
|
||||
// Sandbox admission budget matches SQLite. Receipt/history are NEVER pruned; cancellation stays available.
|
||||
if (!['cancel-listing', 'cancel-reservation', 'cancel-withdrawal'].includes(a.type)) {
|
||||
const count = await c.query('SELECT count(*)::integer AS n FROM (SELECT 1 FROM wk_custody.receipts LIMIT 10000) b');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
|
||||
export const CUSTODY_PG_VERSION = 5;
|
||||
export const CUSTODY_PG_VERSION = 7;
|
||||
export const CUSTODY_WRITE_LOCK = '7140824361';
|
||||
export const CUSTODY_SCHEMA_V1 = `
|
||||
CREATE SCHEMA IF NOT EXISTS wk_custody;
|
||||
@@ -254,9 +254,61 @@ REVOKE ALL ON FUNCTION wk_custody.monero_deposit_credit_history_immutable() FROM
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V5_DIGEST = createHash('sha256')
|
||||
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}`).digest('hex');
|
||||
export const CUSTODY_MIGRATION_V6 = `
|
||||
CREATE TABLE wk_custody.monero_deposit_release_events(
|
||||
release_id uuid PRIMARY KEY,
|
||||
credit_id uuid NOT NULL UNIQUE REFERENCES wk_custody.monero_deposit_credit_events(credit_id),
|
||||
journal_id text NOT NULL UNIQUE REFERENCES wk_custody.journals(id),
|
||||
reviewer_owner text NOT NULL CHECK(reviewer_owner ~ '^[a-f0-9]{64}$'),
|
||||
reviewer_public_key text NOT NULL CHECK(length(reviewer_public_key) BETWEEN 1 AND 256),
|
||||
review_nonce uuid NOT NULL,
|
||||
review_expires_at bigint NOT NULL CHECK(review_expires_at>=0),
|
||||
review_signature text NOT NULL CHECK(length(review_signature) BETWEEN 1 AND 256),
|
||||
review_subject jsonb NOT NULL,
|
||||
review_digest text NOT NULL CHECK(review_digest ~ '^[a-f0-9]{64}$'),
|
||||
released_at bigint NOT NULL CHECK(released_at>=0),
|
||||
UNIQUE(reviewer_owner,review_nonce)
|
||||
);
|
||||
CREATE TABLE wk_custody.monero_deposit_reorg_resolutions(
|
||||
resolution_id uuid PRIMARY KEY,
|
||||
hold_id uuid NOT NULL UNIQUE REFERENCES wk_custody.monero_deposit_reorg_holds(hold_id),
|
||||
credit_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_credit_events(credit_id),
|
||||
release_id uuid NOT NULL REFERENCES wk_custody.monero_deposit_release_events(release_id),
|
||||
resolved_by text NOT NULL CHECK(resolved_by ~ '^[a-f0-9]{64}$'),
|
||||
resolved_at bigint NOT NULL CHECK(resolved_at>=0),
|
||||
disposition text NOT NULL CHECK(disposition='revalidated-for-release'),
|
||||
resolution_kind text NOT NULL CHECK(resolution_kind IN ('release-review','post-release-review')),
|
||||
reviewer_public_key text NOT NULL CHECK(length(reviewer_public_key) BETWEEN 1 AND 256),
|
||||
review_nonce uuid NOT NULL,
|
||||
review_expires_at bigint NOT NULL CHECK(review_expires_at>=0),
|
||||
review_signature text NOT NULL CHECK(length(review_signature) BETWEEN 1 AND 256),
|
||||
review_subject jsonb NOT NULL,
|
||||
review_digest text NOT NULL CHECK(review_digest ~ '^[a-f0-9]{64}$'),
|
||||
UNIQUE(hold_id,release_id),
|
||||
UNIQUE(resolved_by,review_nonce)
|
||||
);
|
||||
CREATE INDEX monero_deposit_resolutions_credit ON wk_custody.monero_deposit_reorg_resolutions(credit_id,hold_id);
|
||||
CREATE FUNCTION wk_custody.monero_deposit_release_history_immutable() RETURNS trigger LANGUAGE plpgsql
|
||||
SET search_path=wk_custody,pg_catalog AS $$
|
||||
BEGIN RAISE EXCEPTION 'Monero deposit release/resolution history is append-only'; END $$;
|
||||
CREATE TRIGGER monero_deposit_release_events_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_release_events
|
||||
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_release_history_immutable();
|
||||
CREATE TRIGGER monero_deposit_reorg_resolutions_no_update BEFORE UPDATE OR DELETE ON wk_custody.monero_deposit_reorg_resolutions
|
||||
FOR EACH ROW EXECUTE FUNCTION wk_custody.monero_deposit_release_history_immutable();
|
||||
REVOKE ALL ON wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION wk_custody.monero_deposit_release_history_immutable() FROM PUBLIC;
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V6_DIGEST = createHash('sha256')
|
||||
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}`).digest('hex');
|
||||
export const CUSTODY_MIGRATION_V7 = `
|
||||
ALTER TABLE wk_custody.monero_deposit_reorg_resolutions
|
||||
DROP CONSTRAINT monero_deposit_reorg_resolutions_resolved_by_review_nonce_key;
|
||||
`;
|
||||
export const CUSTODY_SCHEMA_V7_DIGEST = createHash('sha256')
|
||||
.update(`${CUSTODY_SCHEMA_V1}\n-- custody migration v2 --\n${CUSTODY_MIGRATION_V2}\n-- custody migration v3 --\n${CUSTODY_MIGRATION_V3}\n-- custody migration v4 --\n${CUSTODY_MIGRATION_V4}\n-- custody migration v5 --\n${CUSTODY_MIGRATION_V5}\n-- custody migration v6 --\n${CUSTODY_MIGRATION_V6}\n-- custody migration v7 --\n${CUSTODY_MIGRATION_V7}`).digest('hex');
|
||||
/** Retained alias for callers that need the initial schema definition in disposable fixtures. */
|
||||
export const CUSTODY_SCHEMA = CUSTODY_SCHEMA_V1;
|
||||
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V5_DIGEST;
|
||||
export const CUSTODY_SCHEMA_DIGEST = CUSTODY_SCHEMA_V7_DIGEST;
|
||||
|
||||
/** Explicit operator migration; the HTTP runtime never creates or upgrades schema. */
|
||||
export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
@@ -289,6 +341,16 @@ export async function migrateCustody(pool: Pool): Promise<void> {
|
||||
if (current === 4) {
|
||||
await c.query(CUSTODY_MIGRATION_V5);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(5,$1)', [CUSTODY_SCHEMA_V5_DIGEST]);
|
||||
current = 5;
|
||||
}
|
||||
if (current === 5) {
|
||||
await c.query(CUSTODY_MIGRATION_V6);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(6,$1)', [CUSTODY_SCHEMA_V6_DIGEST]);
|
||||
current = 6;
|
||||
}
|
||||
if (current === 6) {
|
||||
await c.query(CUSTODY_MIGRATION_V7);
|
||||
await c.query('INSERT INTO wk_custody.schema_versions VALUES(7,$1)', [CUSTODY_SCHEMA_V7_DIGEST]);
|
||||
}
|
||||
if (current > CUSTODY_PG_VERSION) throw new Error('Custody PostgreSQL schema is newer than this application');
|
||||
await verifyCustodySchema(c);
|
||||
@@ -303,7 +365,7 @@ export async function verifyCustodySchema(c: PoolClient): Promise<void> {
|
||||
async function verifyCustodySchemaHistory(c: PoolClient): Promise<number> {
|
||||
const rows = await c.query('SELECT version,digest FROM wk_custody.schema_versions ORDER BY version');
|
||||
const expected = [CUSTODY_SCHEMA_V1_DIGEST, CUSTODY_SCHEMA_V2_DIGEST, CUSTODY_SCHEMA_V3_DIGEST,
|
||||
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST];
|
||||
CUSTODY_SCHEMA_V4_DIGEST, CUSTODY_SCHEMA_V5_DIGEST, CUSTODY_SCHEMA_V6_DIGEST, CUSTODY_SCHEMA_V7_DIGEST];
|
||||
if (!rows.rowCount || rows.rowCount > expected.length || rows.rows.some((row, index) =>
|
||||
Number(row.version) !== index + 1 || row.digest !== expected[index])) {
|
||||
throw new Error('Custody PostgreSQL schema version/digest history mismatch; explicit migration required');
|
||||
@@ -386,11 +448,13 @@ export async function grantMoneroDepositCreditRuntime(pool: Pool, role: string,
|
||||
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA wk_custody FROM ${name};
|
||||
GRANT SELECT ON wk_custody.custody_meta,wk_custody.monero_observer_state,wk_custody.monero_transfer_observations,
|
||||
wk_custody.monero_observation_events,wk_custody.monero_deposit_allocations,
|
||||
wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds TO ${name};
|
||||
wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds,
|
||||
wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions TO ${name};
|
||||
GRANT SELECT(id,normal) ON wk_custody.accounts TO ${name};
|
||||
GRANT INSERT(id,normal) ON wk_custody.accounts TO ${name};
|
||||
GRANT INSERT(id,kind,created) ON wk_custody.journals TO ${name};
|
||||
GRANT INSERT ON wk_custody.entries,wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds TO ${name};`);
|
||||
GRANT INSERT ON wk_custody.entries,wk_custody.monero_deposit_credit_events,wk_custody.monero_deposit_reorg_holds,
|
||||
wk_custody.monero_deposit_release_events,wk_custody.monero_deposit_reorg_resolutions TO ${name};`);
|
||||
await c.query('COMMIT');
|
||||
} catch (error) { await c.query('ROLLBACK').catch(() => {}); throw error; }
|
||||
finally { c.release(); }
|
||||
|
||||
Reference in New Issue
Block a user