Currency by locale (the project lead, 7 October 2026: "£/day is for UK; we need other currencies"): no IP lookup; the web view's language (the OS locale) picks the region, its currency and its default tariff at first run (GBP, EUR, USD, CAD, AUD and the long tail by ISO region through ISO_CURRENCY, names from Intl.DisplayNames, an EU member on the euro takes the EU average), a currency picker in Settings and on the first-run step whose override lives in the engine's settings file (config.rs currency, state.rs, Cmd::Region's third field, api/region currency; the Rust round-trip test) and wins over the locale; a region with no currency row falls back to USD with the prompt visible; every money line goes through one formatter (fmtMoney: Intl.NumberFormat with the code, narrowSymbol, the symbol placed as the locale places it); the user's own tariff is the number, so no exchange rate anywhere (the rent line says so for a currency without a USD rate); zero-decimal currencies typed in the unit (priceFactor), the price field's ceiling 100,000; three view tests known-failed first (de-DE reads EUR with the Eurostat tariff; the override wins and survives restart; a locale with no row asks); the mock's euro scenario
self.shared.event("info",&format!("electricity: region {}{}",ifr.is_empty(){"not chosen".to_string()}else{r},ifp>0.0{format!(", {p:.2} per kWh (typed, never fetched)")}else{String::new()}));
self.shared.event("info",&format!("electricity: region {}{}{}",ifr.is_empty(){"not chosen".to_string()}else{r},ifp>0.0{format!(", {p:.2} per kWh (typed, never fetched)")}else{String::new()},ifc.is_empty(){String::new()}else{format!(", currency {c}")}));
functioncurrencyAsk(ch){returnch&&ch.prompt?'This app could not tell your currency from this machine ('+(ch.region?'region '+ch.region.toUpperCase():'no region in its locale')+'). Choose one and type what you pay per kWh. Figures show in US dollars until you do.':'';}
// the render path: set the module's currency and locale from the engine's settings and the web view's language
// the price note under the field: where the default came from, or that the miner types their own
functionpriceNote(code){
varr=regionOf(code),c=currencyOf(code);
if(!r)return'Choose a region to see a typical price. Every '+c.symbol+' figure uses what you type here. Nothing is fetched.';
return(r.price>0?'Typical: '+r.price+' '+c.minor+' per kWh ('+r.source+'). ':'No table price for this region. ')+'Type what you pay, in '+(c.code==='GBP'?'pence':c.code==='EUR'?'euro cents':'US cents')+' per kWh. Nothing is fetched.';
return(r.price>0?'Typical: '+r.price+' '+c.minor+' per kWh ('+r.source+'). ':'No table price for this region. ')+'Type what you pay, in '+minorOf(((override&¤cyInfo(override))||c).code).word+' per kWh. Nothing is fetched.';
}
// the measured rental rate of hash (docs/bench-log.md, "Rental cost of hash, 6 October 2026"); the test
// heat-region.test.mjs reads the bench log and fails when this number drifts from it
@ -890,7 +963,7 @@ var View = (function () {
// the miner's own cost of hash: watts per MH/s at the price, in the region's minor unit per MH/s-hour, against the rent.
// Live numbers while mining; the last tune's point otherwise; nothing without a draw reading or a price.
if(!(h>0&&w>0)){out.text='Your cost of hash: no reading yet';out.sub='needs a card mining with a power reading, or a finished tune. Rented hash costs USD '+RENT.usd_per_mhs_hour+' per MH/s-hour ('+RENT.source+', '+RENT.measured+').';returnout;}
if(!(price>0)){out.kind='price';out.text='Your cost of hash: set a price';out.sub=Math.round(w)+' W for '+(h>=100?h.toFixed(0):h.toFixed(1))+' MH/s is '+(w/h).toFixed(2)+' W per MH/s. Set your electricity price to compare it with rented hash at USD '+RENT.usd_per_mhs_hour+' per MH/s-hour.';returnout;}
varcost=w/h/1000*price;// minor units per MH/s-hour
if(!(c.usd>0)){out.kind='nocur';out.cost=cost;out.text='Your cost of hash: '+fmtMoney(cost/100,c.code,locale)+' per MH/s-hour';out.sub='no USD rate is carried for '+c.code+', so no comparison with rented hash (USD '+RENT.usd_per_mhs_hour+' per MH/s-hour). Nothing is fetched.';returnout;}
varperKwh=dayIgn!==null&&kwhDay>0?fmtIgn(dayIgn/kwhDay)+' per kWh':'';
out.cost=ppd===null?{text:watts>0?Math.round(watts)+' W':'no draw',sub:watts>0?'set a price in Settings'+(perKwh?' · '+perKwh:''):'no card reports its draw',field:FIELDS.watts}
:{text:money(ppd)+' a day',sub:'at '+pence+(minor||'p')+'/kWh for '+Math.round(watts)+' W'+(perKwh?' · '+perKwh:''),field:FIELDS.watts+' × 24 × the price in Settings'};
:{text:money(ppd)+' a day',sub:'at '+(pence/priceFactor(currency.code))+(minor||minorOf(currency.code).abbr)+'/kWh for '+Math.round(watts)+' W'+(perKwh?' · '+perKwh:''),field:FIELDS.watts+' × 24 × the price in Settings'};
varlife=m.accepted_total||0;
out.lifetime={text:withCommas(life)+' block'+(life===1?'':'s'),sub:c.minerIgn?fmtIgn(life*c.minerIgn)+' at today’s reward':'',field:FIELDS.found};
out.devFee=function(on,feeTotal){returnon?'Dev fee: 1 block in 100 pays the people who make this app'+(feeTotal?' ('+withCommas(feeTotal)+' block'+(feeTotal===1?'':'s')+' so far)':'')+'.':'Dev fee off. Every block pays your address.';};
@ -1197,7 +1271,7 @@ var View = (function () {
}
functionaddressAsk(a){return'Pay '+shortHex(a,6,4)+' from the next block? Mining restarts.';}
functiontrustAsk(on){returnon?'Include proofs the node never checked? Devnet only. The node restarts.':'Check proofs again? The node restarts.';}
varforcedUpdate=params.get('update');// a sample update state for screenshots: available|downloading|ready|waiting|applying|urgent|manual|error|updated
sel.innerHTML='<option value="">Follow the region ('+esc(follow)+')</option>'+View.CURRENCY_CODES.map(function(c){varn=names?names.of(c):c;return'<option value="'+c+'">'+esc(c+(n&&n!==c?' · '+n:''))+'</option>';}).join('');
// the region list: the table, plus the locale's region when it is not in the table (synthesised from ISO_CURRENCY)
functionfillRegions(sel){
if(sel.options.length)return;
sel.innerHTML='<option value="">Choose a region</option>'+View.REGIONS.map(function(r){return'<option value="'+esc(r.code)+'">'+esc(r.name)+'</option>';}).join('');
sel.innerHTML='<option value="">Choose a region</option>'+rows.map(function(r){return'<option value="'+esc(r.code)+'">'+esc(r.name)+'</option>';}).join('');
}
// the price field, unit, note and restricted line follow the chosen region; a typed price is kept unless `prefill`
$('s-sweep').addEventListener('change',function(){api('api/sweep/enable',{on:this.checked}).then(function(r){if(r.ok)toast($('s-sweep').checked?'Ember Tune on. Once after install, then every 7 days':'Ember Tune off');});});
$('s-climb').addEventListener('change',function(){varon=this.checked;api('api/tune/goal',{climb:on}).then(function(r){if(!r||!r.ok){toast('Needs the Ember Tune update');$('s-climb').checked=!on;}});});
if(!(p>=0)){toast('Type a price in '+c.minor+' per kWh, for example '+(View.regionOf(code)&&View.regionOf(code).price||26.32));return;}
saveRegion(code,p).then(function(){toast(p>0?'Price saved: '+p+' '+c.minor+' per kWh':'Price cleared');if(state)renderPage(state);}).catch(function(){toast(p>0?'Price saved on this window: '+p+' '+c.minor+' per kWh':'Price cleared');if(state)renderPage(state);});
if(!(p>=0)){toast('Type a price in '+View.minorOf(c.code).word+' per kWh, for example '+(View.regionOf(code)&&View.regionOf(code).price||26.32));return;}
saveRegion(code,p, $('s-currency').value).then(function(){toast(p>0?'Price saved: '+p+' '+c.minor+' per kWh':'Price cleared');if(state)renderPage(state);}).catch(function(){toast(p>0?'Price saved on this window: '+p+' '+c.minor+' per kWh':'Price cleared');if(state)renderPage(state);});
$('s-heat').addEventListener('change',function(){varon=this.checked;api('api/heat',{on:on}).then(function(r){if(r.ok)toast(on?'Heat mode on. The cards heat for a share of every 10 minutes':'Heat mode off');else{toast(r.error||'could not change');$('s-heat').checked=!on;}});});
setHidden('t-money-cell',!(watts>0));setText('t-money',ppd===null?'Set a price':money(ppd));$('t-money').classList.toggle('ask-price',ppd===null);setText('t-money-sub',ppd===null?'in Settings, per kWh':'electricity at '+price+View.currencyOf(regionNow()).minor +'/kWh');
setHidden('t-money-cell',!(watts>0));setText('t-money',ppd===null?'Set a price':money(ppd));$('t-money').classList.toggle('ask-price',ppd===null);setText('t-money-sub',ppd===null?'in Settings, per kWh':'electricity at '+(price/View.priceFactor(View.currencyNow().code))+View.minorOf(View.currencyNow().code).abbr +'/kWh');
setText('t-blocks',withCommas(m.accepted_session));setText('t-blocks-sub','this run · '+withCommas(m.accepted_total)+' lifetime'+(m.rejected_session?' · '+m.rejected_session+' rejected':''));
// the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider
<inputtype="number"class="addr-input mono short"id="region-price"min="0"max="500"step="0.01"placeholder="26.32"aria-label="Electricity price per kWh"><spanclass="note"id="region-unit">pence per kWh</span>
<inputtype="number"class="addr-input mono short"id="region-price"min="0"max="100000" step="0.01"placeholder="26.32"aria-label="Electricity price per kWh"><spanclass="note"id="region-unit">pence per kWh</span>
</div>
<pclass="help"id="region-note">Choose a region to see a typical price.</p>
<inputtype="number"class="addr-input mono short"id="s-price"min="0"max="500"step="0.01"placeholder="26.32"aria-label="Electricity price per kWh"><spanclass="note"id="s-price-unit">pence per kWh</span>
<inputtype="number"class="addr-input mono short"id="s-price"min="0"max="100000" step="0.01"placeholder="26.32"aria-label="Electricity price per kWh"><spanclass="note"id="s-price-unit">pence per kWh</span>
assert.equal(V.currencyChoice({},'fr-FR').code,'EUR');assert.ok(V.regionOf('fr').price>0,'an EU member without its own row takes the EU average');assert.match(V.regionOf('fr').source,/EU average/);
assert.equal(V.currencyChoice({},'sv-SE').code,'SEK');assert.equal(V.regionOf('se').price,0,'no table price outside the euro: the miner types one');
// one formatter, the symbol placed as the locale places it, no hand-built money string left
assert.ok(V.CURRENCY_CODES.indexOf('GBP')===0&&V.CURRENCY_CODES.slice(0,5).join()==='GBP,EUR,USD,CAD,AUD'&&V.CURRENCY_CODES.length>20,'the five first, then the long tail');
});
test('a locale with no currency row falls back to USD with the override prompt visible',()=>{
assert.ok(pageHtml.includes('id="s-currency-ask"')&&pageHtml.includes('id="region-currency-ask"'),'the prompt element on Settings and on the first-run step');
// GET /api/ladder answers the chain facts in src/chainfacts.rs's shape (the devnet's params, this key ranked);
// POST /api/card accepts the block card's PNG and answers a path.
// euro currency-21: region Germany, 38.69 c/kWh, every money line in euro (add &lang=de-DE for German placement)
// rig gpu-logos: one card per vendor (RTX 5090, RX 9070 XT, Arc B580, a UHD 770 iGPU off), every mark on one page
// heat Ember Heat (mission item 7): a 5090 and a 4070 in heat mode holding 20 °C (heating, 60% duty), the
// region United Kingdom at the Ofgem price; ?page=cards|earnings|settings for the three lines
@ -234,6 +235,11 @@ function stateNow() {
if(scenario==='drivers-error')s.drivers={...s.drivers,status:'error',vendor:'amd',version:'26.9.2',progress:1,message:'download: sha256 mismatch: the file is not the one the table names (page https://www.amd.com/en/support/download/drivers.html)',error:'download: sha256 mismatch: the file is not the one the table names (page https://www.amd.com/en/support/download/drivers.html)',started_at:t-120,finished_at:t-5};
if(scenario==='drivers-mac'){s.mining.cards=[{...recState.mining.cards[0],driver_offer:{vendor:'apple',status:'none',installed:'',wanted:'',min_version:'',size:0,url:'',hash_source:'',text:'Apple silicon: no driver step, macOS carries it.',installable:false}}];s.mining.hash_total=recState.mining.hash_total;s.display_name=recState.display_name;s.drivers.platform='macos';}
}
if(scenario==='euro'){
// currency-21: a German machine, the Eurostat tariff typed, no override: every money line in euro