Add payer-wallet override flow with ownership proof in store

This commit is contained in:
Joshua 2026-02-17 13:53:59 -08:00
parent 8f349a899a
commit a215a7d0f0
2 changed files with 142 additions and 9 deletions

View File

@ -44,6 +44,7 @@ Implemented now:
17. Download endpoints now validate wallet membership status before authorizing channel messaging. 17. Download endpoints now validate wallet membership status before authorizing channel messaging.
18. Governance install API contract, examples, backend handoff checklist, and conformance vectors. 18. Governance install API contract, examples, backend handoff checklist, and conformance vectors.
19. Repo boundary blueprint and free launcher specification aligned with first paid governance model. 19. Repo boundary blueprint and free launcher specification aligned with first paid governance model.
20. Store UI now supports distinct payer wallet overrides with ownership-proof signing before quote requests.
Remaining in this repo: Remaining in this repo:

View File

@ -111,6 +111,16 @@
font-size: 11px; font-size: 11px;
color: #3d434a; color: #3d434a;
} }
input {
border: 1px solid #c2c8d0;
background: #ffffff;
padding: 6px 8px;
font-family: 'IBM Plex Mono', 'Courier New', monospace;
font-size: 11px;
color: #3d434a;
width: 100%;
margin-top: 6px;
}
.status-log { .status-log {
margin-top: 12px; margin-top: 12px;
border-top: 1px solid #d0d5db; border-top: 1px solid #d0d5db;
@ -144,10 +154,14 @@
<p class="line">Wallet: <span class="mono" id="wallet-label">not connected</span></p> <p class="line">Wallet: <span class="mono" id="wallet-label">not connected</span></p>
<p class="line">Membership status: <span class="mono" id="membership-label">unknown</span></p> <p class="line">Membership status: <span class="mono" id="membership-label">unknown</span></p>
<p class="line">Gate decision: <span class="mono" id="gate-label">blocked</span></p> <p class="line">Gate decision: <span class="mono" id="gate-label">blocked</span></p>
<p class="line">Payer wallet override (optional):</p>
<input id="payer-wallet-input" type="text" placeholder="0x... (leave blank to use ownership wallet)">
<p class="line">Ownership proof: <span class="mono" id="proof-label">not required</span></p>
<span class="state block" id="gate-pill">membership required</span> <span class="state block" id="gate-pill">membership required</span>
<div class="actions"> <div class="actions">
<button id="connect-btn" type="button">connect wallet</button> <button id="connect-btn" type="button">connect wallet</button>
<button id="refresh-btn" type="button">refresh state</button> <button id="refresh-btn" type="button">refresh state</button>
<button id="sign-proof-btn" type="button">sign ownership proof</button>
<label class="line" for="mock-select">mock mode:</label> <label class="line" for="mock-select">mock mode:</label>
<select id="mock-select" aria-label="Mock state override"> <select id="mock-select" aria-label="Mock state override">
<option value="">live</option> <option value="">live</option>
@ -168,7 +182,7 @@
<p class="line" id="offer-summary">Catalog data pending.</p> <p class="line" id="offer-summary">Catalog data pending.</p>
<p class="line" id="offer-price">Price: --</p> <p class="line" id="offer-price">Price: --</p>
<p class="line" id="offer-policy">Policy: --</p> <p class="line" id="offer-policy">Policy: --</p>
<p class="line">Action chain: membership check -> quote -> wallet confirm -> entitlement receipt</p> <p class="line">Action chain: membership check -> ownership proof (if needed) -> quote -> wallet confirm -> entitlement receipt</p>
<div class="actions"> <div class="actions">
<button id="checkout-btn" type="button" disabled>request checkout quote</button> <button id="checkout-btn" type="button" disabled>request checkout quote</button>
</div> </div>
@ -193,6 +207,7 @@
const state = { const state = {
wallet: null, wallet: null,
ownershipProof: null,
membership: 'unknown', membership: 'unknown',
gate: false, gate: false,
source: 'live', source: 'live',
@ -208,8 +223,11 @@
const checkoutLog = document.getElementById('checkout-log'); const checkoutLog = document.getElementById('checkout-log');
const connectBtn = document.getElementById('connect-btn'); const connectBtn = document.getElementById('connect-btn');
const refreshBtn = document.getElementById('refresh-btn'); const refreshBtn = document.getElementById('refresh-btn');
const signProofBtn = document.getElementById('sign-proof-btn');
const checkoutBtn = document.getElementById('checkout-btn'); const checkoutBtn = document.getElementById('checkout-btn');
const mockSelect = document.getElementById('mock-select'); const mockSelect = document.getElementById('mock-select');
const payerWalletInput = document.getElementById('payer-wallet-input');
const proofLabel = document.getElementById('proof-label');
const offerSelect = document.getElementById('offer-select'); const offerSelect = document.getElementById('offer-select');
const offerTitle = document.getElementById('offer-title'); const offerTitle = document.getElementById('offer-title');
const offerSummary = document.getElementById('offer-summary'); const offerSummary = document.getElementById('offer-summary');
@ -221,6 +239,33 @@
return wallet.slice(0, 6) + '...' + wallet.slice(-4); return wallet.slice(0, 6) + '...' + wallet.slice(-4);
} }
function normalizeWallet(value) {
return String(value || '').trim();
}
function isValidWallet(wallet) {
return /^0x[a-fA-F0-9]{40}$/.test(wallet);
}
function getPayerWallet() {
const override = normalizeWallet(payerWalletInput.value);
return override || state.wallet;
}
function updateProofLabel() {
const payer = getPayerWallet();
if (!state.wallet || !payer || payer.toLowerCase() === state.wallet.toLowerCase()) {
proofLabel.textContent = 'not required';
return;
}
if (state.ownershipProof && state.ownershipProof.payer_wallet &&
state.ownershipProof.payer_wallet.toLowerCase() === payer.toLowerCase()) {
proofLabel.textContent = 'signed';
return;
}
proofLabel.textContent = 'required';
}
function setLog(message) { function setLog(message) {
statusLog.textContent = message; statusLog.textContent = message;
} }
@ -294,6 +339,7 @@
} }
checkoutBtn.disabled = !state.gate || !state.selectedOfferId; checkoutBtn.disabled = !state.gate || !state.selectedOfferId;
updateProofLabel();
} }
function getMockFromQuery() { function getMockFromQuery() {
@ -404,6 +450,7 @@
throw new Error('Wallet connection not approved.'); throw new Error('Wallet connection not approved.');
} }
state.wallet = accounts[0]; state.wallet = accounts[0];
state.ownershipProof = null;
setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.'); setLog('Wallet connected: ' + abbreviateWallet(state.wallet) + '.');
await refreshMembershipState(); await refreshMembershipState();
} catch (err) { } catch (err) {
@ -411,6 +458,62 @@
} }
} }
async function signOwnershipProof() {
if (!state.wallet) {
setLog('Connect ownership wallet first.');
return;
}
if (!window.ethereum) {
setLog('No wallet provider detected for signing.');
return;
}
const payer = getPayerWallet();
if (!payer) {
setLog('Payer wallet is empty.');
return;
}
if (!isValidWallet(payer)) {
setLog('Payer wallet format is invalid.');
return;
}
if (payer.toLowerCase() === state.wallet.toLowerCase()) {
state.ownershipProof = null;
updateProofLabel();
setLog('Ownership proof is not required when payer equals ownership wallet.');
return;
}
try {
const selectedOffer = state.selectedOfferId || 'none';
const issuedAt = new Date().toISOString();
const message = [
'EDUT OWNERSHIP PROOF',
'Ownership wallet: ' + state.wallet,
'Payer wallet: ' + payer,
'Offer: ' + selectedOffer,
'Issued at: ' + issuedAt,
].join('\\n');
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, state.wallet],
});
state.ownershipProof = {
payer_wallet: payer,
signature: signature,
message: message,
issued_at: issuedAt,
};
updateProofLabel();
setLog('Ownership proof signed for payer wallet override.');
} catch (err) {
setLog('Ownership proof signing failed: ' + err.message + '.');
}
}
async function requestCheckoutQuote() { async function requestCheckoutQuote() {
if (!state.gate) { if (!state.gate) {
setCheckoutLog('Checkout blocked: membership is not active.'); setCheckoutLog('Checkout blocked: membership is not active.');
@ -422,26 +525,49 @@
return; return;
} }
const payerWallet = getPayerWallet();
if (!payerWallet || !isValidWallet(payerWallet)) {
setCheckoutLog('Checkout blocked: payer wallet is invalid.');
return;
}
const usingDistinctPayer = payerWallet.toLowerCase() !== state.wallet.toLowerCase();
if (usingDistinctPayer) {
if (!state.ownershipProof ||
!state.ownershipProof.signature ||
!state.ownershipProof.payer_wallet ||
state.ownershipProof.payer_wallet.toLowerCase() !== payerWallet.toLowerCase()) {
setCheckoutLog('Checkout blocked: sign ownership proof for the payer wallet override.');
return;
}
}
setCheckoutLog('Requesting quote...'); setCheckoutLog('Requesting quote...');
try { try {
const payload = {
wallet: state.wallet,
offer_id: state.selectedOfferId,
};
if (usingDistinctPayer) {
payload.payer_wallet = payerWallet;
payload.ownership_proof = state.ownershipProof.signature;
}
const response = await fetch('/marketplace/checkout/quote', { const response = await fetch('/marketplace/checkout/quote', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload),
wallet: state.wallet,
offer_id: state.selectedOfferId,
}),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('HTTP ' + response.status); throw new Error('HTTP ' + response.status);
} }
const payload = await response.json(); const quotePayload = await response.json();
const quoteId = payload.quote_id || 'unknown'; const quoteId = quotePayload.quote_id || 'unknown';
const amount = payload.amount || payload.amount_atomic || 'unknown'; const amount = quotePayload.amount || quotePayload.amount_atomic || 'unknown';
const currency = payload.currency || 'unknown'; const currency = quotePayload.currency || 'unknown';
setCheckoutLog('Quote ready for ' + state.selectedOfferId + ': ' + quoteId + ' (' + amount + ' ' + currency + ').'); setCheckoutLog('Quote ready for ' + state.selectedOfferId + ': ' + quoteId + ' (' + amount + ' ' + currency + ').');
} catch (err) { } catch (err) {
setCheckoutLog('Quote request failed: ' + err.message + '. API wiring pending.'); setCheckoutLog('Quote request failed: ' + err.message + '. API wiring pending.');
@ -450,10 +576,16 @@
connectBtn.addEventListener('click', connectWallet); connectBtn.addEventListener('click', connectWallet);
refreshBtn.addEventListener('click', refreshMembershipState); refreshBtn.addEventListener('click', refreshMembershipState);
signProofBtn.addEventListener('click', signOwnershipProof);
checkoutBtn.addEventListener('click', requestCheckoutQuote); checkoutBtn.addEventListener('click', requestCheckoutQuote);
mockSelect.addEventListener('change', refreshMembershipState); mockSelect.addEventListener('change', refreshMembershipState);
payerWalletInput.addEventListener('input', function () {
state.ownershipProof = null;
updateProofLabel();
});
offerSelect.addEventListener('change', function () { offerSelect.addEventListener('change', function () {
state.selectedOfferId = offerSelect.value; state.selectedOfferId = offerSelect.value;
state.ownershipProof = null;
renderSelectedOffer(); renderSelectedOffer();
applyGateState(); applyGateState();
}); });