Skip to content

Commit f37859b

Browse files
jmlagoclaude
andauthored
reclaim: assign buyer as its own operator (unblocks requestClose) (#79)
requestClose/withdraw on AntseedChannels are gated on the buyer's OPERATOR (an AntseedDeposits role), not the buyer directly — so every close reverted NotAuthorized() even though channels(id).buyer == our wallet, the deadline had long expired, and funds were still reserved. The normal deposit/reserve/settle flow never assigns an operator, so it was never set. Add the one-time self-assignment (per AntSeed: the operator is your own wallet, you just have to assign it): - reclaim.mjs: new `set-operator` phase — signs the EIP-712 SetOperator auth (AntseedDeposits domain) and submits setOperator(buyer, buyer, nonce, sig). `list` now also reports operator / operatorSet / operatorIsSelf. - control.js: /reclaim/set-operator (on-chain, serialized with the other txs). - shim.py + auth_proxy.py: /x/wallet/reclaim/set-operator proxies. - Panel: when the buyer isn't its own operator, the reclaim section shows a one-time "Enable reclaim" setup (single tx, moves no funds) and gates Request close / Withdraw behind it. Verified live on the deployed buyer: operator was 0x0 -> after set-operator, operatorIsSelf=true and requestClose staticCall is authorized on all active channels (was NotAuthorized). Grants a role only; moves no funds. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5eedda2 commit f37859b

4 files changed

Lines changed: 65 additions & 9 deletions

File tree

antseed/control.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,11 @@ const server = http.createServer(async (req, res) => {
151151
return send(res, r.data.ok ? 200 : 502, r.data);
152152
}
153153

154-
// On-chain, one tx per eligible channel. Serialized with deposits/withdraws:
155-
// concurrent buyer wallet txs would race the nonce.
156-
if (req.method === 'POST' && (url === '/reclaim/request-close' || url === '/reclaim/withdraw')) {
157-
const phase = url === '/reclaim/request-close' ? 'request-close' : 'withdraw';
154+
// On-chain, one tx per eligible channel (set-operator is a single tx).
155+
// Serialized with deposits/withdraws: concurrent buyer wallet txs race the nonce.
156+
if (req.method === 'POST' && (url === '/reclaim/set-operator' || url === '/reclaim/request-close' || url === '/reclaim/withdraw')) {
157+
const phase = url === '/reclaim/set-operator' ? 'set-operator'
158+
: url === '/reclaim/request-close' ? 'request-close' : 'withdraw';
158159
return serialize(async () => {
159160
const r = await runReclaim(phase, RECLAIM_TX_TIMEOUT_MS);
160161
if (!r.data) {

antseed/reclaim.mjs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@
88
// every action here is idempotent and guarded per-channel — a revert (e.g. the
99
// 15-min window not elapsed) is caught and reported, never fund loss.
1010
//
11+
// requestClose/withdraw are gated on the buyer's OPERATOR (an AntseedDeposits
12+
// role): the buyer must first assign a wallet to act on its behalf, else every
13+
// close reverts NotAuthorized() even though it owns the channel. We self-assign
14+
// the buyer wallet as its own operator (set-operator phase) — the one-time setup
15+
// the normal deposit/reserve flow never needs.
16+
//
1117
// Usage: node reclaim.mjs <phase>
12-
// list read-only: enumerate channels + on-chain reclaimable (default)
18+
// list read-only: enumerate channels + on-chain reclaimable + operator (default)
19+
// set-operator one-time: assign the buyer wallet as its own deposits operator
1320
// request-close fire requestClose on channels that still hold buyer funds
1421
// withdraw withdraw channels whose challenge window has elapsed
1522
//
@@ -19,10 +26,18 @@ import { loadConfig } from '@antseed/cli/dist/config/loader.js';
1926
import {
2027
loadCryptoContext,
2128
createChannelsClient,
29+
createDepositsClient,
30+
requireCryptoConfig,
2231
openChannelStore,
2332
formatUsdc,
2433
} from '@antseed/cli/dist/cli/payment-utils.js';
2534

35+
const ZERO_ADDR = '0x0000000000000000000000000000000000000000';
36+
// EIP-712 for AntseedDeposits.setOperator — mirrors @antseed/node's
37+
// makeDepositsDomain + SET_OPERATOR_TYPES (contract-level constants).
38+
const DEPOSITS_DOMAIN_NAME = 'AntseedDeposits';
39+
const SET_OPERATOR_TYPES = { SetOperator: [{ name: 'operator', type: 'address' }, { name: 'nonce', type: 'uint256' }] };
40+
2641
const PHASE = process.argv[2] || 'list';
2742
const DATA_DIR = process.env.ANTSEED_DATA_DIR || '/data';
2843
// Same defaults the vendored CLI uses for deposit/withdraw: a missing config
@@ -34,7 +49,7 @@ function out(obj) { process.stdout.write(JSON.stringify(obj)); }
3449
function big(x) { try { return BigInt(x ?? '0'); } catch { return 0n; } }
3550

3651
async function main() {
37-
if (!['list', 'request-close', 'withdraw'].includes(PHASE)) {
52+
if (!['list', 'set-operator', 'request-close', 'withdraw'].includes(PHASE)) {
3853
out({ ok: false, error: `unknown phase '${PHASE}'` });
3954
process.exit(2);
4055
return;
@@ -43,6 +58,30 @@ async function main() {
4358
const config = await loadConfig(CONFIG_PATH);
4459
const { wallet, address } = await loadCryptoContext(DATA_DIR);
4560
const client = createChannelsClient(config);
61+
const deposits = createDepositsClient(config);
62+
const crypto = requireCryptoConfig(config);
63+
64+
// Current operator (the party authorized to requestClose/withdraw on the
65+
// buyer's behalf). Unset (zero) → reclaim is blocked until set-operator runs.
66+
let operator = ZERO_ADDR;
67+
try { operator = await deposits.getOperator(address); } catch { /* read best-effort */ }
68+
const operatorIsSelf = String(operator).toLowerCase() === address.toLowerCase();
69+
const operatorSet = String(operator).toLowerCase() !== ZERO_ADDR.toLowerCase();
70+
71+
if (PHASE === 'set-operator') {
72+
// Assign the buyer wallet as its own operator: sign the EIP-712 auth, then
73+
// submit setOperator. Grants the reclaim role; moves no funds.
74+
if (operatorIsSelf) {
75+
out({ ok: true, phase: PHASE, address, operator, operatorIsSelf, action: 'skip', reason: 'already self' });
76+
return;
77+
}
78+
const nonce = await deposits.getOperatorNonce(address);
79+
const domain = { name: DEPOSITS_DOMAIN_NAME, version: '1', chainId: crypto.evmChainId, verifyingContract: crypto.depositsContractAddress };
80+
const buyerSig = await wallet.signTypedData(domain, SET_OPERATOR_TYPES, { operator: address, nonce });
81+
const tx = await deposits.setOperator(wallet, address, address, nonce, buyerSig);
82+
out({ ok: true, phase: PHASE, address, operator: address, operatorIsSelf: true, action: 'setOperator', tx });
83+
return;
84+
}
4685

4786
const store = openChannelStore(DATA_DIR);
4887
let sessions;
@@ -133,6 +172,9 @@ async function main() {
133172
ok: true,
134173
phase: PHASE,
135174
address,
175+
operator, // current on-chain operator (zero = unset)
176+
operatorSet, // any operator assigned?
177+
operatorIsSelf, // is the buyer wallet its own operator? (reclaim ready)
136178
reclaimableTotal: formatUsdc(reclaimableTotal),
137179
count: channels.length,
138180
skipped, // settled/timeout channels: already returned funds, not reclaimable

auth_proxy.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2679,10 +2679,17 @@ async def dashboard_wallet_refresh(request: Request) -> Response:
26792679

26802680
@app.post("/dashboard/api/wallet/reclaim/scan")
26812681
async def dashboard_wallet_reclaim_scan(request: Request) -> Response:
2682-
# Read-only: enumerate channels + on-chain reclaimable USDC.
2682+
# Read-only: enumerate channels + on-chain reclaimable USDC + operator status.
26832683
return await _wallet_proxy(request, "reclaim/scan", timeout=100.0)
26842684

26852685

2686+
@app.post("/dashboard/api/wallet/reclaim/set-operator")
2687+
async def dashboard_wallet_reclaim_set_operator(request: Request) -> Response:
2688+
# On-chain one-time: assign the buyer wallet as its own deposits operator
2689+
# (unblocks requestClose/withdraw). Moves no funds.
2690+
return await _wallet_proxy(request, "reclaim/set-operator", timeout=260.0)
2691+
2692+
26862693
@app.post("/dashboard/api/wallet/reclaim/request-close")
26872694
async def dashboard_wallet_reclaim_request_close(request: Request) -> Response:
26882695
# On-chain: start the ~15-min close challenge on idle channels.
@@ -4756,8 +4763,8 @@ def _dashboard_html() -> str:
47564763
function walletDo(op){const el=$('walletAmt');const amount=el?String(el.value).trim():'';if(!/^\\d+(\\.\\d{1,6})?$/.test(amount)||Number(amount)<=0){showErr('Enter a valid USDC amount (>0, ≤6 decimals)');return}if(op==='withdraw'&&lastWallet&&lastWallet.deposits_available!=null&&Number(amount)>Number(lastWallet.deposits_available)){showErr('You can only withdraw what is available in escrow ('+Number(lastWallet.deposits_available).toFixed(6)+' USDC). USDC reserved in channels frees up when they settle.');return}if(!confirm((op==='deposit'?'Top up':'Withdraw')+' '+amount+' USDC?\\nOn-chain tx on Base, irreversible.'))return;walletOpAmount(op,amount)}
47574764
async function walletOpAmount(op,amount){try{toast(op+' '+amount+' USDC…');const r=await fetch('/dashboard/api/wallet/'+op,{method:'POST',headers:{'content-type':'application/json'},credentials:'same-origin',body:JSON.stringify({amount})});if(r.status===401){showLogin();return}const t=await r.text();let d={};try{d=JSON.parse(t)}catch(_){}if(!r.ok){const raw=d.error?.message||(t&&t.trim()[0]!=='<'?t:('server error '+r.status));throw new Error(String(raw).split('\\n')[0].slice(0,240))}toast(op+' ok · '+amount+' USDC');const el=$('walletAmt');if(el)el.value='';loadMarket();loadConfig()}catch(e){showErr(e.message)}}
47584765
async function walletReclaimScan(){const v=$('walletReclaimView');if(v)v.innerHTML='<span class="muted">Scanning channels on-chain…</span>';try{const r=await fetch('/dashboard/api/wallet/reclaim/scan',{method:'POST',credentials:'same-origin'});if(r.status===401){showLogin();return}const t=await r.text();let d={};try{d=JSON.parse(t)}catch(_){}if(!r.ok){const raw=d.error?.message||(t&&t.trim()[0]!=='<'?t:('scan '+r.status));throw new Error(String(raw).split('\\n')[0].slice(0,240))}renderReclaim(d)}catch(e){if(v)v.innerHTML='';showErr(e.message)}}
4759-
function renderReclaim(d){const v=$('walletReclaimView');if(!v)return;const ch=(d&&d.channels)||[];if(!ch.length){v.innerHTML='<span class="muted">No channels holding reclaimable USDC.</span>';return}const pend=ch.filter(c=>Number(c.reclaimable||0)>0&&!c.closeRequested);const ready=ch.filter(c=>c.closeRequested);const rows=ch.map(c=>{const id=String(c.id||'').slice(0,10)+'…';const st=c.error?'<span class="pill bad" title="'+esc(c.error)+'">error</span>':c.closeRequested?'<span class="pill warn">closing · withdraw in ~15 min</span>':'<span class="pill">idle</span>';return '<div style="display:flex;gap:8px;align-items:center;padding:3px 0;border-top:1px solid rgba(128,128,128,.12)"><code style="font-size:11px">'+esc(id)+'</code><span class="muted" style="font-size:11px">→ '+esc(String(c.seller||'').slice(0,10))+'…</span><span style="margin-left:auto;font-variant-numeric:tabular-nums">'+Number(c.reclaimable||0).toFixed(4)+' USDC</span>'+st+'</div>';}).join('');const actions=[];if(pend.length)actions.push('<button class="btn" onclick="walletReclaimAct(\\'request-close\\')">Request close ('+pend.length+')</button>');if(ready.length)actions.push('<button class="btn" onclick="walletReclaimAct(\\'withdraw\\')">Withdraw closed ('+ready.length+')</button>');const sk=d.skipped||{};const skN=(sk.settled||0)+(sk.timeout||0)+(sk.other||0);const skTxt=skN?' · '+skN+' already settled (not reclaimable)':'';v.innerHTML='<div class="muted" style="margin-bottom:4px">'+ch.length+' active channel(s) · up to '+esc(d.reclaimableTotal||'0')+' USDC reclaimable'+skTxt+'</div>'+rows+'<div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap">'+(actions.join('')||'<span class="muted">Request close, wait ~15 min, then withdraw.</span>')+'</div>';}
4760-
async function walletReclaimAct(phase){const label=phase==='request-close'?'Request close on idle channels':'Withdraw closed channels';if(!confirm(label+'?\\nOn-chain tx(s) on Base — one per channel, irreversible.'+(phase==='request-close'?'\\nWithdraw becomes possible ~15 min after.':'')))return;const v=$('walletReclaimView');if(v)v.innerHTML='<span class="muted">Sending on-chain tx(s)…</span>';try{const r=await fetch('/dashboard/api/wallet/reclaim/'+phase,{method:'POST',credentials:'same-origin'});if(r.status===401){showLogin();return}const t=await r.text();let d={};try{d=JSON.parse(t)}catch(_){}if(!r.ok){const raw=d.error?.message||(t&&t.trim()[0]!=='<'?t:(phase+' '+r.status));throw new Error(String(raw).split('\\n')[0].slice(0,240))}const done=((d.channels)||[]).filter(c=>c.tx).length;toast(phase==='request-close'?('Close requested · '+done+' channel(s) · withdraw in ~15 min'):('Withdrew '+done+' channel(s)'));walletReclaimScan();loadConfig()}catch(e){showErr(e.message)}}
4766+
function renderReclaim(d){const v=$('walletReclaimView');if(!v)return;const ch=(d&&d.channels)||[];const isSelf=!!d.operatorIsSelf;let banner='';if(!isSelf){banner='<div style="padding:8px 10px;border:1px solid rgba(230,160,30,.5);border-radius:8px;margin-bottom:8px"><div class="small"><b>One-time setup needed.</b> To reclaim channels the buyer wallet must be assigned as its own on-chain operator — requestClose is operator-gated. Single tx, moves no funds.</div><div style="margin-top:6px"><button class="btn" onclick="walletReclaimAct(\\'set-operator\\')">Enable reclaim</button></div></div>';}const pend=ch.filter(c=>Number(c.reclaimable||0)>0&&!c.closeRequested);const ready=ch.filter(c=>c.closeRequested);const rows=ch.map(c=>{const id=String(c.id||'').slice(0,10)+'…';const st=c.error?'<span class="pill bad" title="'+esc(c.error)+'">error</span>':c.closeRequested?'<span class="pill warn">closing · withdraw in ~15 min</span>':'<span class="pill">idle</span>';return '<div style="display:flex;gap:8px;align-items:center;padding:3px 0;border-top:1px solid rgba(128,128,128,.12)"><code style="font-size:11px">'+esc(id)+'</code><span class="muted" style="font-size:11px">→ '+esc(String(c.seller||'').slice(0,10))+'…</span><span style="margin-left:auto;font-variant-numeric:tabular-nums">'+Number(c.reclaimable||0).toFixed(4)+' USDC</span>'+st+'</div>';}).join('');const sk=d.skipped||{};const skN=(sk.settled||0)+(sk.timeout||0)+(sk.other||0);const skTxt=skN?' · '+skN+' already settled (not reclaimable)':'';const summary='<div class="muted" style="margin-bottom:4px">'+(ch.length?ch.length+' active channel(s) · up to '+esc(d.reclaimableTotal||'0')+' USDC reclaimable':'No active channels holding reclaimable USDC')+skTxt+'</div>';let actions='';if(isSelf){const a=[];if(pend.length)a.push('<button class="btn" onclick="walletReclaimAct(\\'request-close\\')">Request close ('+pend.length+')</button>');if(ready.length)a.push('<button class="btn" onclick="walletReclaimAct(\\'withdraw\\')">Withdraw closed ('+ready.length+')</button>');actions='<div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap">'+(a.join('')||'<span class="muted">Nothing to close right now.</span>')+'</div>';}v.innerHTML=banner+summary+rows+actions;}
4767+
async function walletReclaimAct(phase){const label=phase==='set-operator'?'Enable reclaim — assign your wallet as its own channel operator':phase==='request-close'?'Request close on idle channels':'Withdraw closed channels';const note=phase==='set-operator'?'\\nOne-time on-chain tx on Base. Moves no funds.':'\\nOn-chain tx(s) on Base — one per channel, irreversible.'+(phase==='request-close'?'\\nWithdraw becomes possible ~15 min after.':'');if(!confirm(label+'?'+note))return;const v=$('walletReclaimView');if(v)v.innerHTML='<span class="muted">Sending on-chain tx…</span>';try{const r=await fetch('/dashboard/api/wallet/reclaim/'+phase,{method:'POST',credentials:'same-origin'});if(r.status===401){showLogin();return}const t=await r.text();let d={};try{d=JSON.parse(t)}catch(_){}if(!r.ok){const raw=d.error?.message||(t&&t.trim()[0]!=='<'?t:(phase+' '+r.status));throw new Error(String(raw).split('\\n')[0].slice(0,240))}if(phase==='set-operator'){toast('Reclaim enabled · operator set')}else{const done=((d.channels)||[]).filter(c=>c.tx).length;toast(phase==='request-close'?('Close requested · '+done+' channel(s) · withdraw in ~15 min'):('Withdrew '+done+' channel(s)'))}walletReclaimScan();loadConfig()}catch(e){showErr(e.message)}}
47614768
function walletPanel(w){if(!w)return '<div class="muted small" style="padding:6px 2px">Loading wallet balance…</div>';const num=(v,d=2)=>v==null?'—':Number(v).toFixed(d);const rw=w.runway||'';const pill=rw==='empty'?'<span class="pill bad">empty · top up</span>':rw==='low'?'<span class="pill warn">low · top up</span>':rw==='ok'?'<span class="pill ok">funded</span>':'';const noGas=w.wallet_eth!=null&&Number(w.wallet_eth)===0;const box=(l,v,u,s)=>`<div style="min-width:150px;padding:10px 14px;border:1px solid rgba(128,128,128,.22);border-radius:10px"><div class="muted small">${l}</div><div style="font-size:24px;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.15">${v}<span class="muted" style="font-size:13px;font-weight:400"> ${u}</span></div><div class="muted small" style="margin-top:3px">${s||'&nbsp;'}</div></div>`;return `<div style="display:flex;gap:12px;flex-wrap:wrap;padding:6px 2px 2px">`+box('In wallet',num(w.wallet_usdc),'USDC','not in escrow yet')+box('Gas',num(w.wallet_eth,5),'ETH',noGas?'<span class="pill bad">no gas</span>':'for Base txs')+box('In escrow · spendable',num(w.deposits_available),'USDC',pill)+box('Reserved in channels',num(w.deposits_reserved),'USDC','frees on settle → reclaimable')+`</div><div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px 2px 12px;margin-bottom:12px;border-bottom:1px solid rgba(128,128,128,.25)"><input id="walletAmt" type="number" step="0.000001" min="0" placeholder="USDC" style="width:120px;padding:7px 9px;border:1px solid rgba(128,128,128,.35);border-radius:8px;font-variant-numeric:tabular-nums"><button class="btn" onclick="walletDo('deposit')">Top up</button><button class="btn ghost" onclick="walletDo('withdraw')">Withdraw</button><span class="muted small" style="margin-left:4px">quick:</span><button class="btn ghost small" onclick="walletQuick(5)">5</button><button class="btn ghost small" onclick="walletQuick(20)">20</button><button class="btn ghost small" onclick="walletQuick(80)">80</button><button class="btn ghost small" onclick="walletQuick('max')" title="All wallet USDC">Max</button><button class="btn iconBtn ghost" title="Refresh balance" onclick="walletRefresh()">↻</button>${w.address?`<code class="muted small" style="margin-left:auto;font-size:11px;word-break:break-all">${esc(w.address)}</code><button class="btn iconBtn ghost" title="Copy address" onclick="copyAddr(${jsarg(w.address)})">⧉</button>`:''}</div><div style="padding:0 2px 4px"><div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><span class="muted small" style="flex:1">Reclaim USDC locked in idle payment channels (${num(w.deposits_reserved)} USDC reserved). Request close → wait ~15 min → withdraw.</span><button class="btn ghost small" onclick="walletReclaimScan()">Scan idle channels</button></div><div id="walletReclaimView" class="small" style="margin-top:8px"></div></div>`;}
47624769
function renderConfig(knobs){const groups={};knobs.forEach(k=>{(groups[k.provider]=groups[k.provider]||[]).push(k)});const order=['antseed','codex','openrouter','compaction'];const provs=order.filter(p=>groups[p]).concat(Object.keys(groups).filter(p=>!order.includes(p)));const inp=k=>k.type==='list'?`<input id="cfg_${esc(k.key)}" data-kind="list" type="text" value="${esc((k.value||[]).join(', '))}" placeholder="peer-id, peer-id…">`:`<input id="cfg_${esc(k.key)}" data-kind="num" type="number" step="any" value="${k.value}">`;const hint=k=>k.type==='list'?`up to ${k.max} items`:`default ${k.default} · [${k.min}, ${k.max}]`;$('config').innerHTML=provs.map(p=>`<div class="card span6"><div class="toolbar"><div class="label">${esc(p)}</div><span class="muted small" style="margin-left:auto">${groups[p].length} knob${groups[p].length===1?'':'s'}</span></div>${p==='antseed'?walletPanel(lastWallet):''}${groups[p].map(k=>`<div class="cfgRow"><div class="cfgDesc"><b>${esc(k.label)}</b>${k.overridden?' <span class="pill warn">override</span>':''}<div class="muted small">${esc(k.help)}</div></div><div class="cfgCtl">${inp(k)}<div class="muted small" style="margin-top:5px">${hint(k)}</div></div><div class="cfgAct"><button class="btn ghost small" onclick="saveConfigKnob(${jsarg(k.key)})">Save</button>${k.overridden?`<button class="btn ghost small" title="Reset to default" onclick="postConfig({[${jsarg(k.key)}]:null})">Reset</button>`:''}</div></div>`).join('')}</div>`).join('')||'<div class="empty">No tunable knobs.</div>'}
47634770
async function saveConfigKnob(key){const el=$('cfg_'+key);if(!el)return;const v=el.value.trim();if(el.dataset.kind==='list'){await postConfig({[key]:v?v.split(',').map(s=>s.trim()).filter(Boolean):[]});return}await postConfig({[key]:v===''?null:Number(v)})}

shim.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,12 @@ async def _wallet_reclaim(op: str, timeout: float, with_wallet: bool):
447447
async def wallet_reclaim_scan():
448448
return await _wallet_reclaim("scan", 95.0, with_wallet=False)
449449

450+
@app.post("/x/wallet/reclaim/set-operator")
451+
async def wallet_reclaim_set_operator():
452+
# One-time: assign the buyer wallet as its own deposits operator so
453+
# requestClose/withdraw stop reverting NotAuthorized(). Moves no funds.
454+
return await _wallet_reclaim("set-operator", 250.0, with_wallet=False)
455+
450456
@app.post("/x/wallet/reclaim/request-close")
451457
async def wallet_reclaim_request_close():
452458
return await _wallet_reclaim("request-close", 250.0, with_wallet=False)

0 commit comments

Comments
 (0)