Validation-only pilot contract
This commit is contained in:
@@ -31,6 +31,8 @@ After an operator creates/provisions a distinct empty database and its schema-ow
|
||||
|
||||
The schema migration does not grant runtime roles. The identity command is one-time and replay-idempotent only for the same pilot chain and custodian. The CLI refuses other custody actions on the pilot profile; mainnet keeps its existing separate controls. This is groundwork only: no customer deposits, financial posting, withdrawal intent or send is enabled by these commands.
|
||||
|
||||
The signed pilot deposit-request contract is also implemented as a validation-only library boundary. It explicitly signs both the pilot market identity and Monero Mainnet identity, then verifies the initialized identity and wallet-generation/fingerprint/account context. It returns an allocation intent digest only; it does not reserve an index, call Wallet-RPC, return an address, persist an allocation, or expose an HTTP route. Existing mainnet enrollment remains unchanged. Do not treat a valid signature or this validator as approval to accept funds.
|
||||
|
||||
### Reusing the operator-hosted daemon
|
||||
|
||||
The next integration should point the observer/gateway to the existing daemon rather than deploy or synchronize another one. An operator-triggered, read-only `get_info` probe is now available as a first connectivity check. It checks the expected Monero network, synchronized/local height, and daemon status; it does not open a wallet or provide a financial proof. The probe is opt-in and does not run on app startup.
|
||||
|
||||
@@ -8,9 +8,10 @@ Updated: 2026-10-22. Implementation plan for a private, operator-supervised func
|
||||
- **Operator infrastructure update:** a Monero full node is hosted on the operator's network and is intended to be reused; the pilot does not need to deploy or synchronize another daemon. Its network mode, RPC reachability from the pilot host/container, authentication/transport boundary and sync health still need verification. This does not provide Wallet-RPC, deposit observation, address allocation or a spending wallet.
|
||||
- **Next integration slice implemented:** added an operator-triggered, read-only `monerod get_info` probe, opt-in Compose check service, and mocked regression coverage. The Linux test image builds and focused probe/deployment tests pass (6/6). The probe validates expected Mainnet/Stagenet identity and synchronization, but has not been run against the operator's node because its private endpoint/configuration has not been provided. It does not enable wallet access, addresses, deposits, or credits. See [the probe setup and security boundary](real-xmr-pilot-deployment.md).
|
||||
- **Pilot identity/schema slice implemented:** schema v10 additively records a separate identity binding `wikideck-randomx-pilot-1` + current pilot chain ID to Monero `mainnet`, with an immutable `identity-only` activation status. Explicit pilot-only schema-migration and identity-initialization CLI actions require exact no-funds acknowledgements, reject non-pilot profiles, and refuse any target DB with pre-existing mock/financial/deposit data. No runtime roles/grants or deposit/withdrawal routes were added. Existing v1-v9 migration definitions/digests remain unchanged.
|
||||
- **Validation:** serial offline suite passed (latest run exited 0); PostgreSQL integration suite passed 42/42, including v1/v2 upgrades through v10, one-time pilot identity/replay/mismatch checks, dirty-database rejection, and no role access to the identity table. The isolated pilot identity operation itself requires a separate fresh database and has not been run outside disposable tests.
|
||||
- **Pilot deposit-contract slice implemented:** added a separate signed pilot request schema that explicitly binds the Wikideck pilot network/chain, Monero Mainnet, custodian, beneficiary and watch-only wallet generation. Its validator checks signature, expiry, identity-only status and wallet context, and returns only a validated intent digest—no address/index or RPC/DB side effect. It is not connected to HTTP or an allocator.
|
||||
- **Validation:** the latest serial offline suite passed 189/189; the new pilot contract tests passed 3/3. PostgreSQL integration suite passed 42/42 after schema v10, including v1/v2 upgrades, identity replay/mismatch checks, dirty-database rejection, and no role access to the identity table. The isolated pilot identity operation itself requires a separate fresh database and has not been run outside disposable tests.
|
||||
- **Step 1 is not complete.** The current pilot stack does not connect to that external daemon and still has no Wallet-RPC gateway, observer, real-fund deposit or withdrawal path. It is useful for isolated UI/mock-flow work only and must not receive funds.
|
||||
- **Validation:** pilot-specific network/startup/storage/Compose tests pass (10/10); pilot runtime Docker image builds. The attempted serial full offline suite had one native RandomX HTTP peer-gossip convergence failure in `tests/api.test.ts`; the other reported tests passed. Treat full-suite status as not yet clean and rerun after the next increment.
|
||||
- **Validation history:** earlier pilot-only checks passed 10/10. An earlier full-suite attempt had a transient RandomX peer-gossip failure; the most recent serial offline suite subsequently exited 0.
|
||||
- **Steps 2–5:** not started. Existing deposit/withdrawal APIs still require the separately gated mainnet profile; the new pilot record is identity metadata only. Sale-lock transactions remain candidate-only and are not accepted by active consensus.
|
||||
|
||||
## Finish line
|
||||
@@ -120,4 +121,4 @@ Keep the functional essentials: signed authorization, correct network binding, p
|
||||
|
||||
## Next coding task
|
||||
|
||||
**Next: verify the pilot identity contract on its dedicated database, then design a separate pilot deposit request/observation adapter and migration/role boundaries without enabling HTTP enrollment.** Run the read-only daemon check when the protected endpoint config is available; no duplicate daemon is planned. Existing observer/deposit contracts are mainnet-Wikideck-specific, so do not simply enable their flags on the pilot. Keep deployment mock-only until network binding, reviewed credit, actual withdrawal execution, recovery, and sale settlement are connected. Continue treasury execution and active sale-lock settlement as parallel tracks once their shared identity boundary is defined. Completion means a buyer purchasing a card and the seller receiving withdrawable XMR—not another scaffold or release checklist.
|
||||
**Next: add the separate durable pilot allocation and observer identity path, then test it only with disposable fixtures—still without enabling an HTTP enrollment route or creating addresses.** The signed pilot request contract now defines the dual-network authorization boundary. Next migration/role work must preserve `identity-only`, separate pilot records from mock custody, and reject credit until reviewed funding, withdrawal execution and recovery are ready. Run the read-only daemon check when the protected endpoint config is available; no duplicate daemon is planned. Continue treasury execution and active sale-lock settlement as parallel tracks once their shared identity boundary is defined. Completion means a buyer purchasing a card and the seller receiving withdrawable XMR—not another scaffold or release checklist.
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { IS_PILOT, NETWORK } from '../../shared/network.ts';
|
||||
import { pilotMoneroDepositRequestMessage, pilotMoneroDepositRequestSchema } from '../../shared/pilot-monero-deposit.ts';
|
||||
import type { PilotCustodyIdentity } from './pilot-custody-identity.ts';
|
||||
import { address, CHAIN_ID, verifySignature } from '../testnet/consensus.ts';
|
||||
|
||||
const MAX_REQUEST_LIFETIME_MS = 5 * 60_000;
|
||||
|
||||
export type PilotDepositRequestContext = {
|
||||
identity: PilotCustodyIdentity;
|
||||
walletGenerationId: string;
|
||||
walletFingerprint: string;
|
||||
accountIndex: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a signed pilot deposit intent without allocating an address or touching PostgreSQL/Wallet-RPC.
|
||||
* This boundary is intentionally identity-only until credit, withdrawal, and recovery are connected.
|
||||
*/
|
||||
export function validatePilotMoneroDepositRequest(context: PilotDepositRequestContext, rawRequest: unknown) {
|
||||
if (!IS_PILOT || NETWORK !== 'wikideck-randomx-pilot-1') {
|
||||
throw new Error('Pilot Monero deposit requests require the pilot market profile');
|
||||
}
|
||||
const request = pilotMoneroDepositRequestSchema.parse(rawRequest);
|
||||
const { identity } = context;
|
||||
if (identity.version !== 1 || identity.mode !== 'pilot-custody' || identity.policy !== 'pilot-custody-v1' ||
|
||||
identity.marketNetwork !== NETWORK || identity.marketChainId !== CHAIN_ID ||
|
||||
identity.moneroNetwork !== 'mainnet' || identity.activationStatus !== 'identity-only' ||
|
||||
request.marketNetwork !== identity.marketNetwork || request.marketChainId !== identity.marketChainId ||
|
||||
request.moneroNetwork !== identity.moneroNetwork || request.custodianId !== identity.custodianId ||
|
||||
request.walletGenerationId !== context.walletGenerationId || request.walletFingerprint !== context.walletFingerprint ||
|
||||
request.accountIndex !== context.accountIndex) {
|
||||
throw new Error('Pilot deposit request does not match the identity, Monero network, or wallet generation');
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/.test(context.walletFingerprint) ||
|
||||
!Number.isSafeInteger(context.accountIndex) || context.accountIndex < 0 || context.accountIndex > 0xffff_ffff) {
|
||||
throw new Error('Invalid configured pilot watch-only wallet identity');
|
||||
}
|
||||
if (address(request.publicKey) !== request.beneficiary) {
|
||||
throw new Error('Pilot deposit request signer does not own the requested beneficiary');
|
||||
}
|
||||
const { signature, ...unsigned } = request;
|
||||
const message = pilotMoneroDepositRequestMessage(unsigned);
|
||||
if (!verifySignature(request.publicKey, request.beneficiary, message, signature)) {
|
||||
throw new Error('Pilot deposit request signature is invalid');
|
||||
}
|
||||
const now = context.now?.() ?? Date.now();
|
||||
if (!Number.isSafeInteger(now) || now < 0 || request.expiresAt < now || request.expiresAt > now + MAX_REQUEST_LIFETIME_MS) {
|
||||
throw new Error('Pilot deposit request is expired or outside the allowed window');
|
||||
}
|
||||
const requestDigest = createHash('sha256').update(message).digest('hex');
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
custodianId: identity.custodianId,
|
||||
marketNetwork: identity.marketNetwork,
|
||||
marketChainId: identity.marketChainId,
|
||||
moneroNetwork: identity.moneroNetwork,
|
||||
beneficiary: request.beneficiary,
|
||||
walletGenerationId: request.walletGenerationId,
|
||||
walletFingerprint: request.walletFingerprint,
|
||||
accountIndex: request.accountIndex,
|
||||
expiresAt: request.expiresAt,
|
||||
requestDigest,
|
||||
} as const;
|
||||
}
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const hex64 = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const uuid = z.string().uuid();
|
||||
|
||||
/** Pilot-specific wallet authorization. Market and Monero network identities are both explicit and signed. */
|
||||
export const pilotMoneroDepositRequestSchema = z.object({
|
||||
version: z.literal(1),
|
||||
marketNetwork: z.literal('wikideck-randomx-pilot-1'),
|
||||
marketChainId: hex64,
|
||||
moneroNetwork: z.literal('mainnet'),
|
||||
custodianId: uuid,
|
||||
beneficiary: hex64,
|
||||
publicKey: z.string().min(1).max(256),
|
||||
requestId: uuid,
|
||||
walletGenerationId: uuid,
|
||||
walletFingerprint: hex64,
|
||||
accountIndex: z.number().int().min(0).max(0xffff_ffff).safe(),
|
||||
expiresAt: z.number().int().nonnegative().safe(),
|
||||
signature: z.string().min(1).max(256),
|
||||
}).strict();
|
||||
|
||||
export type PilotMoneroDepositRequest = z.infer<typeof pilotMoneroDepositRequestSchema>;
|
||||
export type PilotMoneroDepositRequestUnsigned = Omit<PilotMoneroDepositRequest, 'signature'>;
|
||||
|
||||
/** Canonical, domain-separated wallet authorization; both asset networks are signature-bound. */
|
||||
export function pilotMoneroDepositRequestMessage(request: PilotMoneroDepositRequestUnsigned): string {
|
||||
return JSON.stringify([
|
||||
'wikideck-pilot-monero-deposit-request-v1', request.version,
|
||||
request.marketNetwork, request.marketChainId, request.moneroNetwork, request.custodianId,
|
||||
request.beneficiary, request.publicKey, request.requestId, request.walletGenerationId,
|
||||
request.walletFingerprint, request.accountIndex, request.expiresAt,
|
||||
]);
|
||||
}
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
function run(code: string) {
|
||||
return JSON.parse(execFileSync(process.execPath, ['--experimental-strip-types', '--input-type=module', '-e', code], {
|
||||
env: { ...process.env, WIKIDECK_NETWORK: 'pilot' }, encoding: 'utf8', timeout: 30_000,
|
||||
}).trim());
|
||||
}
|
||||
|
||||
const requestFixture = `
|
||||
import { generateKeyPairSync, sign } from 'node:crypto';
|
||||
import { address } from './server/testnet/consensus.ts';
|
||||
import { CHAIN_ID } from './server/testnet/consensus.ts';
|
||||
import { pilotMoneroDepositRequestMessage } from './shared/pilot-monero-deposit.ts';
|
||||
import { validatePilotMoneroDepositRequest } from './server/market/pilot-monero-deposit-request.ts';
|
||||
const pair=generateKeyPairSync('ec',{namedCurve:'prime256v1'});
|
||||
const publicKey=pair.publicKey.export({type:'spki',format:'der'}).toString('base64');
|
||||
const beneficiary=address(publicKey), now=1_800_000_000_000;
|
||||
const identity={version:1,mode:'pilot-custody',policy:'pilot-custody-v1',marketNetwork:'wikideck-randomx-pilot-1',
|
||||
marketChainId:CHAIN_ID,moneroNetwork:'mainnet',custodianId:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
activationStatus:'identity-only',initializedAt:now-1};
|
||||
const context={identity,walletGenerationId:'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
walletFingerprint:'c'.repeat(64),accountIndex:2,now:()=>now};
|
||||
const unsigned={version:1,marketNetwork:'wikideck-randomx-pilot-1',marketChainId:CHAIN_ID,moneroNetwork:'mainnet',
|
||||
custodianId:identity.custodianId,beneficiary,publicKey,requestId:'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
walletGenerationId:context.walletGenerationId,walletFingerprint:context.walletFingerprint,accountIndex:2,expiresAt:now+60_000};
|
||||
const signature=sign('sha256',Buffer.from(pilotMoneroDepositRequestMessage(unsigned)),
|
||||
{key:pair.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64');
|
||||
const request={...unsigned,signature};
|
||||
`;
|
||||
|
||||
test('pilot deposit authorization binds both networks and validates without allocating an address', () => {
|
||||
const result = run(`${requestFixture}
|
||||
const intent=validatePilotMoneroDepositRequest(context,request);
|
||||
console.log(JSON.stringify({intent,hasAddress:Object.hasOwn(intent,'address'),hasAddressIndex:Object.hasOwn(intent,'addressIndex')}));`);
|
||||
assert.equal(result.intent.marketNetwork, 'wikideck-randomx-pilot-1');
|
||||
assert.match(result.intent.marketChainId, /^[a-f0-9]{64}$/);
|
||||
assert.equal(result.intent.moneroNetwork, 'mainnet');
|
||||
assert.equal(result.intent.beneficiary.length, 64);
|
||||
assert.match(result.intent.requestDigest, /^[a-f0-9]{64}$/);
|
||||
assert.equal(result.hasAddress, false);
|
||||
assert.equal(result.hasAddressIndex, false);
|
||||
});
|
||||
|
||||
test('pilot deposit authorization rejects domain, identity, wallet, signer, signature, and expiry mismatches', () => {
|
||||
const result = run(`${requestFixture}
|
||||
const rejected=[];
|
||||
const check=(label,ctx,raw)=>{try{validatePilotMoneroDepositRequest(ctx,raw);rejected.push([label,false]);}
|
||||
catch{rejected.push([label,true]);}};
|
||||
check('market-network',context,{...request,marketNetwork:'wikideck-randomx-mainnet-1'});
|
||||
check('monero-network',context,{...request,moneroNetwork:'stagenet'});
|
||||
check('chain-id',context,{...request,marketChainId:'e'.repeat(64)});
|
||||
check('custodian',context,{...request,custodianId:'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'});
|
||||
check('wallet-generation',{...context,walletGenerationId:'ffffffff-ffff-4fff-8fff-ffffffffffff'},request);
|
||||
check('wallet-fingerprint',{...context,walletFingerprint:'d'.repeat(64)},request);
|
||||
check('account-index',{...context,accountIndex:3},request);
|
||||
check('signer',context,{...request,beneficiary:'1'.repeat(64)});
|
||||
check('signature',context,{...request,signature:'A'.repeat(88)});
|
||||
check('expired',{...context,now:()=>request.expiresAt+1},request);
|
||||
check('too-far-future',{...context,now:()=>request.expiresAt+1},{...request,expiresAt:request.expiresAt+10*60_000});
|
||||
check('not-identity-only',{...context,identity:{...context.identity,activationStatus:'active'}},request);
|
||||
console.log(JSON.stringify(rejected));`);
|
||||
assert.deepEqual(result, [['market-network',true],['monero-network',true],['chain-id',true],['custodian',true],
|
||||
['wallet-generation',true],['wallet-fingerprint',true],['account-index',true],['signer',true],['signature',true],
|
||||
['expired',true],['too-far-future',true],['not-identity-only',true]]);
|
||||
});
|
||||
|
||||
test('pilot deposit authorization rejects malformed and unknown fields', () => {
|
||||
const result = run(`${requestFixture}
|
||||
let malformed=false,unknown=false;
|
||||
try{validatePilotMoneroDepositRequest(context,{...request,accountIndex:-1});}catch{malformed=true;}
|
||||
try{validatePilotMoneroDepositRequest(context,{...request,address:'4'.repeat(95)});}catch{unknown=true;}
|
||||
console.log(JSON.stringify({malformed,unknown}));`);
|
||||
assert.deepEqual(result, { malformed: true, unknown: true });
|
||||
});
|
||||
Reference in New Issue
Block a user