@@ -0,0 +1,90 @@
|
||||
{
|
||||
"note": "Two failed probes in a row. Not offline, so no offline banner, and the config is still current.",
|
||||
"status": "degraded",
|
||||
"last_online": 1786998200,
|
||||
"first_seen": 1761080000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"note": "lekmint.cash/api as indexed on 2026-08-20: both NUTs disabled true with empty methods arrays.",
|
||||
"status": "online",
|
||||
"last_online": 1786999910,
|
||||
"first_seen": 1784408000,
|
||||
"info": {
|
||||
"version": "LekMint/1.1.1",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [],
|
||||
"disabled": true
|
||||
},
|
||||
"5": {
|
||||
"methods": [],
|
||||
"disabled": true
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": false
|
||||
},
|
||||
"9": {
|
||||
"supported": false
|
||||
},
|
||||
"10": {
|
||||
"supported": false
|
||||
},
|
||||
"11": {
|
||||
"supported": false
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"note": "mint.minibits.cash/Bitcoin, cdk-mintd 0.17.5, bolt11 plus onchain methods, both NUTs enabled.",
|
||||
"status": "online",
|
||||
"last_online": 1786999880,
|
||||
"first_seen": 1752440000,
|
||||
"info": {
|
||||
"version": "cdk-mintd/0.17.5",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"min_amount": 1,
|
||||
"max_amount": 1000000,
|
||||
"description": true
|
||||
},
|
||||
{
|
||||
"method": "onchain",
|
||||
"unit": "sat",
|
||||
"min_amount": 10000,
|
||||
"max_amount": 1000000,
|
||||
"options": {
|
||||
"confirmations": 3
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"min_amount": 1,
|
||||
"max_amount": 1000000,
|
||||
"amountless": true
|
||||
},
|
||||
{
|
||||
"method": "onchain",
|
||||
"unit": "sat",
|
||||
"min_amount": 10000,
|
||||
"max_amount": 1000000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_mint_quote",
|
||||
"bolt11_melt_quote",
|
||||
"proof_state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "onchain",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"onchain_mint_quote",
|
||||
"onchain_melt_quote",
|
||||
"proof_state"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"19": {
|
||||
"ttl": 3600,
|
||||
"cached_endpoints": [
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/v1/swap"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/v1/mint/bolt11"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/v1/melt/bolt11"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/v1/mint/onchain"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/v1/melt/onchain"
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"max_batch_size": 100,
|
||||
"methods": [
|
||||
"bolt11",
|
||||
"onchain"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"note": "mint.macadamia.cash, Nutshell 0.20.3, single bolt11 method per NUT.",
|
||||
"status": "online",
|
||||
"last_online": 1786999940,
|
||||
"first_seen": 1761080000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"note": "Synthetic: mint.macadamia.cash payload with NUT-05 disabled flipped to true. No mint indexed on 2026-08-20 published this state.",
|
||||
"status": "online",
|
||||
"last_online": 1786999910,
|
||||
"first_seen": 1769720000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000
|
||||
}
|
||||
],
|
||||
"disabled": true
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"note": "mint.belgianbitcoinembassy.org as indexed on 2026-08-20: NUT-04 disabled true, NUT-05 enabled.",
|
||||
"status": "online",
|
||||
"last_online": 1786999910,
|
||||
"first_seen": 1743800000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.18.2",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"min_amount": 0,
|
||||
"max_amount": 100000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": true
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"min_amount": 0,
|
||||
"max_amount": 100000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"note": "Synthetic: NUT-04 enabled but every method is onchain, so no sats can be minted over Lightning.",
|
||||
"status": "online",
|
||||
"last_online": 1786999910,
|
||||
"first_seen": 1769720000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "onchain",
|
||||
"unit": "sat",
|
||||
"min_amount": 10000,
|
||||
"max_amount": 1000000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"note": "Synthetic: a mint that publishes neither NUT-04 nor NUT-05. Not published is not the same as disabled, so no banner.",
|
||||
"status": "online",
|
||||
"last_online": 1786999910,
|
||||
"first_seen": 1769720000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"note": "Offline 64 days and NUT-05 was disabled when last seen: the combined case.",
|
||||
"status": "offline",
|
||||
"last_online": 1781470400,
|
||||
"first_seen": 1761080000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000
|
||||
}
|
||||
],
|
||||
"disabled": true
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"note": "Healthy config, offline for 64 days: tier 3.",
|
||||
"status": "offline",
|
||||
"last_online": 1781470400,
|
||||
"first_seen": 1761080000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"note": "Offline 12 days and NUT-04 was disabled when last seen: the critical offline banner outranks the melt-only warning.",
|
||||
"status": "offline",
|
||||
"last_online": 1785963200,
|
||||
"first_seen": 1761080000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"min_amount": 0,
|
||||
"max_amount": 100000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": true
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"min_amount": 0,
|
||||
"max_amount": 100000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"note": "Healthy config, offline for 12 days: tier 2.",
|
||||
"status": "offline",
|
||||
"last_online": 1785963200,
|
||||
"first_seen": 1761080000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"note": "mint.azzamo.net shape: offline with no last_online and no cached info at all.",
|
||||
"status": "offline",
|
||||
"last_online": null,
|
||||
"first_seen": 1786222400,
|
||||
"info": null
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"note": "Offline 3 days and NUT-05 was disabled when last seen: the critical capability banner outranks the tier 1 offline one.",
|
||||
"status": "offline",
|
||||
"last_online": 1786740800,
|
||||
"first_seen": 1761080000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000
|
||||
}
|
||||
],
|
||||
"disabled": true
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"note": "Healthy config, offline for 3 days: tier 1.",
|
||||
"status": "offline",
|
||||
"last_online": 1786740800,
|
||||
"first_seen": 1761080000,
|
||||
"info": {
|
||||
"version": "Nutshell/0.20.3",
|
||||
"nuts": {
|
||||
"4": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000,
|
||||
"options": {
|
||||
"description": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"5": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"method_name": "bolt11",
|
||||
"min_amount": 0,
|
||||
"max_amount": 50000
|
||||
}
|
||||
],
|
||||
"disabled": false
|
||||
},
|
||||
"7": {
|
||||
"supported": true
|
||||
},
|
||||
"8": {
|
||||
"supported": true
|
||||
},
|
||||
"9": {
|
||||
"supported": true
|
||||
},
|
||||
"10": {
|
||||
"supported": true
|
||||
},
|
||||
"11": {
|
||||
"supported": true
|
||||
},
|
||||
"12": {
|
||||
"supported": true
|
||||
},
|
||||
"14": {
|
||||
"supported": true
|
||||
},
|
||||
"15": {
|
||||
"methods": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"17": {
|
||||
"supported": [
|
||||
{
|
||||
"method": "bolt11",
|
||||
"unit": "sat",
|
||||
"commands": [
|
||||
"bolt11_melt_quote",
|
||||
"proof_state",
|
||||
"bolt11_mint_quote"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"20": {
|
||||
"supported": true
|
||||
},
|
||||
"29": {
|
||||
"supported": true,
|
||||
"max_batch_size": 1000,
|
||||
"methods": [
|
||||
"bolt11"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@cashumints/shared",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": { "typescript": "^5.6.3" }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types.js';
|
||||
export * from './nostr.js';
|
||||
export * from './normalize.js';
|
||||
export * from './score.js';
|
||||
export * from './nuts.js';
|
||||
export * from './warnings.js';
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* One mint URL normalizer, used by the indexer, the API and the browser.
|
||||
*
|
||||
* The old site had three different ad-hoc normalizers that disagreed (see NOTES.md):
|
||||
* mints were keyed on the raw tag string (so a trailing slash created a duplicate mint)
|
||||
* while reviews were matched on bare hostname (so a review of one path counted for every
|
||||
* path on that host). Rules below, per BACKEND.md:
|
||||
*
|
||||
* - lowercase scheme and host, force https, strip trailing slash, strip default port
|
||||
* - keep the path: mint.minibits.cash/Bitcoin is a different mint from mint.minibits.cash
|
||||
* - reject non-https-able, localhost, private IPs and .onion
|
||||
*/
|
||||
|
||||
export interface NormalizedMint {
|
||||
/** Canonical URL, the primary key. */
|
||||
url: string;
|
||||
/** URL-safe routing slug, unique per url. */
|
||||
host: string;
|
||||
}
|
||||
|
||||
const PRIVATE_IPV4 =
|
||||
/^(10\.|127\.|0\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/;
|
||||
|
||||
/** Hostnames that are never a public mint. */
|
||||
function isDisallowedHost(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||
if (hostname.endsWith('.onion')) return true;
|
||||
if (hostname.endsWith('.local')) return true;
|
||||
if (hostname === '::1' || hostname === '[::1]') return true;
|
||||
if (PRIVATE_IPV4.test(hostname)) return true;
|
||||
// A bare label with no dot cannot be a public host.
|
||||
if (!hostname.includes('.') && !hostname.includes(':')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mint URL as found in a Nostr `u` tag or typed by a user.
|
||||
* Returns null when the input is not a usable public mint URL.
|
||||
*/
|
||||
export function normalizeMintUrl(input: string): NormalizedMint | null {
|
||||
const raw = input?.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
// Tolerate a missing scheme ("mint.example.com/x"), which users paste constantly.
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(withScheme);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// http:// and https:// are the only schemes a mint speaks; http is upgraded, not kept,
|
||||
// because the browser cannot fetch http from an https page anyway (mixed content).
|
||||
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
||||
u.protocol = 'https:';
|
||||
|
||||
const hostname = u.hostname.toLowerCase();
|
||||
if (!hostname || isDisallowedHost(hostname)) return null;
|
||||
|
||||
// Drop the default port, query and fragment: none of them identify a mint.
|
||||
const port = u.port === '443' || u.port === '80' ? '' : u.port;
|
||||
const path = u.pathname.replace(/\/+$/, '');
|
||||
|
||||
const url = `https://${hostname}${port ? `:${port}` : ''}${path}`;
|
||||
return { url, host: slugFor(hostname, port, path) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing slug. Hostname alone when there is no path, otherwise hostname plus the path
|
||||
* segments joined with `-`. Deterministic; the row is looked up by slug, so it does not
|
||||
* need to be reversible.
|
||||
*/
|
||||
function slugFor(hostname: string, port: string, path: string): string {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const base = port ? `${hostname}-${port}` : hostname;
|
||||
if (segments.length === 0) return base;
|
||||
const tail = segments
|
||||
.join('-')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return tail ? `${base}-${tail}` : base;
|
||||
}
|
||||
|
||||
/** Display domain for a mint: hostname plus path, no scheme. */
|
||||
export function displayDomain(url: string): string {
|
||||
return url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Short display name derived from the URL, for mints whose /v1/info has no name.
|
||||
* Matches the old site's `hostname.replace(/^mint\./,'').replace(/^www\./,'')`.
|
||||
*/
|
||||
export function nameFromUrl(url: string): string {
|
||||
const domain = displayDomain(url);
|
||||
const host = domain.split('/')[0] ?? domain;
|
||||
return host.replace(/^mint\./, '').replace(/^www\./, '');
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* NIP-87 constants and parse rules.
|
||||
*
|
||||
* Kinds, tags, relays and the rating encoding are taken verbatim from the old site
|
||||
* (see NOTES.md) so the new site sees exactly the same events. Do not "fix" these
|
||||
* to match a spec reading: what matters is what is actually on the relays.
|
||||
*/
|
||||
import type { Profile } from './types.js';
|
||||
|
||||
/** NIP-87 mint recommendation / review. */
|
||||
export const KIND_REVIEW = 38000;
|
||||
/** NIP-87 Cashu mint announcement. */
|
||||
export const KIND_MINT_ANNOUNCEMENT = 38172;
|
||||
/** Profile metadata. */
|
||||
export const KIND_PROFILE = 0;
|
||||
|
||||
/**
|
||||
* Union of the old site's read pool (utils/ndk.ts CASHU_RELAY_POOL) and its write pool
|
||||
* (services/reviewPublisher.ts RELAY_URLS). The two lists differed, so reviews the old
|
||||
* site published to snort/primal were invisible to it. Reading the union fixes that.
|
||||
*
|
||||
* Minus `relay.damus.io`, which was in both of the old lists and earned neither place:
|
||||
* it answered a kind 38000 query with nothing at all, and it was the slowest relay in
|
||||
* the pool doing it. A query closes when its slowest relay does, so a relay that holds
|
||||
* none of what is being asked for costs the whole site latency for no events.
|
||||
*/
|
||||
export const DEFAULT_RELAYS = [
|
||||
'wss://relay.cashumints.space',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.azzamo.net',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.primal.net',
|
||||
] as const;
|
||||
|
||||
/** Relay hint used in the `a` tag, matching the old publisher's RELAY_URLS[0]. */
|
||||
export const PRIMARY_RELAY = 'wss://relay.cashumints.space';
|
||||
|
||||
/**
|
||||
* Relays queried for kind 0 metadata, on top of the review relays above.
|
||||
*
|
||||
* The review pool alone is not enough: `relay.cashumints.space` holds no kind 0 at
|
||||
* all, and `snort.social` and `primal.net` hold almost none, so a reviewer's name
|
||||
* often lives on a relay this site never asked. Measured against the 80 authors who
|
||||
* have reviewed mint.minibits.cash, hit rates were:
|
||||
*
|
||||
* nos.lol 29, relay.nostr.net 28, azzamo 24, damus 18, purplepag.es 15,
|
||||
* primal 5, snort 1, relay.cashumints.space 0
|
||||
*
|
||||
* 29 is the ceiling: the other 51 keys have no kind 0 on any relay tested, and no
|
||||
* other events either, so they are single-purpose review keys and stay as npubs.
|
||||
*
|
||||
* damus is no longer in this pool, despite that 18. It came out of DEFAULT_RELAYS on
|
||||
* latency and this list is built from that one, and the trade was worth taking: it
|
||||
* answered a 25-author kind 0 batch in 1217ms where every other relay here answered
|
||||
* in under 150ms, so it alone set the whole batch's duration. The handful of names it
|
||||
* was the only source for now render as a short npub, which is the same fallback the
|
||||
* other 51 keys already use.
|
||||
*
|
||||
* `relay.nostr.band` is deliberately NOT here. It answered author-filtered kind 0
|
||||
* queries with nothing at all (even for a single well known pubkey) and took the
|
||||
* full 7s to do it, which held the whole batch open until its timeout instead of
|
||||
* closing on EOSE in well under a second.
|
||||
*
|
||||
* Override with PUBLIC_PROFILE_RELAYS (browser) or PROFILE_RELAYS (build), comma
|
||||
* separated.
|
||||
*/
|
||||
export const PROFILE_RELAYS = [
|
||||
...DEFAULT_RELAYS,
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.nostr.net',
|
||||
] as const;
|
||||
|
||||
/** Minimal structural view of a Nostr event, so this module needs no dependency. */
|
||||
export interface NostrEventLike {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
kind: number;
|
||||
created_at: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
}
|
||||
|
||||
export function tagValue(tags: string[][], name: string): string | null {
|
||||
for (const t of tags) if (t[0] === name && t[1]) return t[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
export function tagValues(tags: string[][], name: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const t of tags) if (t[0] === name && t[1]) out.push(t[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a rating from a review event.
|
||||
*
|
||||
* Order matches the old parser (utils/reviewHelpers.ts parseNIP87Review) with one
|
||||
* deliberate change: the old code defaulted to 5 when nothing parsed, which is why
|
||||
* every mint on the old site showed 5/5. Unparseable now returns null and is left
|
||||
* out of averages.
|
||||
*/
|
||||
export function parseRating(event: NostrEventLike): number | null {
|
||||
const tag = tagValue(event.tags, 'rating');
|
||||
if (tag) {
|
||||
const n = Number.parseInt(tag, 10);
|
||||
if (n >= 1 && n <= 5) return n;
|
||||
// Some clients write a 0..1 fraction instead of 1..5 (NIP-87 suggests this).
|
||||
const f = Number.parseFloat(tag);
|
||||
if (Number.isFinite(f) && f >= 0 && f <= 1) return Math.max(1, Math.round(f * 5));
|
||||
}
|
||||
|
||||
// The old publisher wrote no rating tag at all, only a `[N/5]` content prefix.
|
||||
// This is how the great majority of real reviews encode their rating.
|
||||
const bracket = /^\s*\[([1-5])\/5\]/.exec(event.content);
|
||||
if (bracket?.[1]) return Number.parseInt(bracket[1], 10);
|
||||
|
||||
const loose = /rating[:\s]*([1-5])|([1-5])\s*\/\s*5|([1-5])\s*star/i.exec(event.content);
|
||||
if (loose) {
|
||||
const raw = loose[1] ?? loose[2] ?? loose[3];
|
||||
if (raw) {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (n >= 1 && n <= 5) return n;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Strip the `[N/5]` prefix and the "Reviewing: <url>" footer the old site appended. */
|
||||
/**
|
||||
* Shortest body that counts as a written review.
|
||||
*
|
||||
* Anything under this is a rating with a stray character attached, not something
|
||||
* someone wrote to be read. Written reviews render as cards; the rest collapse into
|
||||
* one summary line. The build time feed and the browser islands both apply this, so
|
||||
* it lives here rather than in either of them.
|
||||
*/
|
||||
export const WRITTEN_MIN_CHARS = 3;
|
||||
|
||||
export function cleanReviewContent(content: string): string {
|
||||
return content
|
||||
.replace(/^\s*\[[1-5]\/5\]\s*/, '')
|
||||
.replace(/\n+\s*Reviewing:\s*https?:\/\/\S+\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint URLs referenced by an event, in tag priority order.
|
||||
*
|
||||
* `u` is where both the announcement (38172) and the review (38000) put the mint URL.
|
||||
* `a` is the addressable pointer `38172:<pubkey>:<d>`, which carries no URL, so it is
|
||||
* only useful for resolving via the mint's own pubkey (see `mintPubkeyRef`).
|
||||
*/
|
||||
export function mintUrlsFromEvent(event: NostrEventLike): string[] {
|
||||
return tagValues(event.tags, 'u');
|
||||
}
|
||||
|
||||
/**
|
||||
* The mint pubkey a review points at, from the `d` tag. The old publisher set
|
||||
* `d` to the pubkey from the mint's own `/v1/info`, so this resolves a review to a
|
||||
* mint even when the `u` tag is missing or misspelled.
|
||||
*/
|
||||
export function mintPubkeyRef(event: NostrEventLike): string | null {
|
||||
const d = tagValue(event.tags, 'd');
|
||||
if (d && /^[0-9a-f]{64,66}$/i.test(d)) return d.toLowerCase();
|
||||
|
||||
const a = tagValue(event.tags, 'a');
|
||||
if (a) {
|
||||
const parts = a.split(':');
|
||||
if (parts[0] === String(KIND_MINT_ANNOUNCEMENT) && parts[1]) {
|
||||
const p = parts[1];
|
||||
if (/^[0-9a-f]{64,66}$/i.test(p)) return p.toLowerCase();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when the event's `k` tag marks it as being about a Cashu mint. */
|
||||
export function isCashuMintReview(event: NostrEventLike): boolean {
|
||||
return tagValue(event.tags, 'k') === String(KIND_MINT_ANNOUNCEMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every spelling of a mint URL that may appear in a `u` tag.
|
||||
*
|
||||
* The old site published and queried these six variants (useReviews.ts), so a relay
|
||||
* filter that only asks for the canonical form misses most real reviews. Used by both
|
||||
* the indexer and the browser so the two always agree on what belongs to a mint.
|
||||
*/
|
||||
export function mintUrlSpellings(mintUrl: string): string[] {
|
||||
const bare = mintUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||
return [...new Set([
|
||||
mintUrl,
|
||||
`${mintUrl}/`,
|
||||
bare,
|
||||
`https://${bare}`,
|
||||
`https://${bare}/`,
|
||||
`http://${bare}`,
|
||||
])];
|
||||
}
|
||||
|
||||
/* ---------- kind 0 metadata ---------- */
|
||||
|
||||
/** Longest reviewer name rendered before it is cut, per the review card spec. */
|
||||
export const MAX_PROFILE_NAME = 24;
|
||||
|
||||
/**
|
||||
* Strip characters that have no business in a rendered name.
|
||||
*
|
||||
* Written as a code point scan rather than a regex because the set is exactly the
|
||||
* invisible ones: C0 and C1 controls, zero width joiners and the bidi overrides that
|
||||
* let a name reorder the text around it.
|
||||
*/
|
||||
function sanitizeText(value: unknown, max: number): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
let out = '';
|
||||
for (const ch of value) {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) continue;
|
||||
if (code >= 0x200b && code <= 0x200f) continue;
|
||||
if (code >= 0x202a && code <= 0x202e) continue;
|
||||
if (code >= 0x2066 && code <= 0x2069) continue;
|
||||
if (code === 0xfeff) continue;
|
||||
out += ch;
|
||||
}
|
||||
|
||||
const cleaned = out.replace(/\s+/g, ' ').trim();
|
||||
if (!cleaned) return undefined;
|
||||
return cleaned.length > max ? `${cleaned.slice(0, max - 1)}...` : cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* A NIP-05 is `name@domain`, and only that.
|
||||
*
|
||||
* Some clients park other things in the field: one reviewer's kind 0 carries their
|
||||
* own npub there, which the card would otherwise render in violet as though it had
|
||||
* been verified against a domain. Anything not shaped like an address is dropped.
|
||||
*/
|
||||
function sanitizeNip05(value: unknown, max: number): string | undefined {
|
||||
const text = sanitizeText(value, max);
|
||||
if (!text || text.length > max) return undefined;
|
||||
if (!/^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text)) return undefined;
|
||||
// "_@domain" is NIP-05's root form and reads better as the bare domain.
|
||||
return text.startsWith('_@') ? text.slice(2) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only http(s) picture URLs are accepted. A `data:` or `javascript:` picture from a
|
||||
* relay has no business being written into an `img src`.
|
||||
*/
|
||||
function sanitizePicture(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const url = value.trim();
|
||||
if (!/^https?:\/\/[^\s"'<>]+$/i.test(url) || url.length > 400) return undefined;
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a kind 0 `content` string into the fields the site renders.
|
||||
*
|
||||
* Defensive by construction: relay content is arbitrary text written by anyone, so
|
||||
* unparseable JSON, wrong types and junk fields all degrade to "no profile" rather
|
||||
* than throwing. Used by the browser island and the build time home page fetch, so
|
||||
* both apply the same rules.
|
||||
*/
|
||||
export function parseProfileContent(pubkey: string, content: string): Profile {
|
||||
let meta: Record<string, unknown>;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { pubkey, found: false };
|
||||
}
|
||||
meta = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return { pubkey, found: false };
|
||||
}
|
||||
|
||||
const profile: Profile = {
|
||||
pubkey,
|
||||
name: sanitizeText(meta['name'], MAX_PROFILE_NAME),
|
||||
display_name:
|
||||
sanitizeText(meta['display_name'], MAX_PROFILE_NAME)
|
||||
?? sanitizeText(meta['displayName'], MAX_PROFILE_NAME),
|
||||
picture: sanitizePicture(meta['picture']),
|
||||
nip05: sanitizeNip05(meta['nip05'], 64),
|
||||
found: true,
|
||||
};
|
||||
|
||||
// A kind 0 carrying nothing we render is the same as no kind 0 at all: the card
|
||||
// keeps its npub rather than showing an empty name line.
|
||||
if (!profile.name && !profile.display_name && !profile.picture && !profile.nip05) {
|
||||
return { pubkey, found: false };
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** The name a review card shows, or null when there is nothing human to show. */
|
||||
export function profileName(profile: Profile | null | undefined): string | null {
|
||||
if (!profile?.found) return null;
|
||||
return profile.display_name ?? profile.name ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a reviewer with no kind 0 is called.
|
||||
*
|
||||
* Most review keys on the network have published one event in their life, the review
|
||||
* itself, so there is no name to show and never will be. A wall of `npub1q8f…7x2v`
|
||||
* says nothing a reader can use; "Anon" says the same thing in a word. The full npub
|
||||
* is still one click away on the same control, and the avatar beside it is still
|
||||
* derived from the pubkey, so two anonymous reviewers remain distinguishable.
|
||||
*/
|
||||
export const ANON_REVIEWER = 'Anon';
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* NUT names and support parsing.
|
||||
*
|
||||
* The old site's utils/nutInfo.ts had several names simply wrong (it labelled NUT-04
|
||||
* "Token Spending" and NUT-07 "Swap"). These are the real titles from the Cashu NUT
|
||||
* specs. Numbers with no entry render as a bare chip rather than a wrong label.
|
||||
*/
|
||||
|
||||
export const NUT_NAMES: Record<string, string> = {
|
||||
'0': 'Notation and models',
|
||||
'1': 'Mint public key exchange',
|
||||
'2': 'Keysets and fees',
|
||||
'3': 'Swapping tokens',
|
||||
'4': 'Minting via Lightning',
|
||||
'5': 'Melting via Lightning',
|
||||
'6': 'Mint information',
|
||||
'7': 'Token state check',
|
||||
'8': 'Overpaid Lightning fees',
|
||||
'9': 'Restore signatures',
|
||||
'10': 'Spending conditions',
|
||||
'11': 'Pay to public key',
|
||||
'12': 'DLEQ proofs',
|
||||
'13': 'Deterministic secrets',
|
||||
'14': 'Hashed timelock contracts',
|
||||
'15': 'Multipath payments',
|
||||
'16': 'Animated QR codes',
|
||||
'17': 'WebSocket subscriptions',
|
||||
'18': 'Payment requests',
|
||||
'19': 'Cached responses',
|
||||
'20': 'Signed mint quotes',
|
||||
'21': 'Clear authentication',
|
||||
'22': 'Blind authentication',
|
||||
'23': 'BOLT11 payment method',
|
||||
'24': 'HTTP 402 payment required',
|
||||
'25': 'BOLT12 payment method',
|
||||
};
|
||||
|
||||
/** The five the mint page pulls out as named rows, per FRONTEND.md. */
|
||||
export const HIGHLIGHT_NUTS = ['4', '5', '7', '17', '20'] as const;
|
||||
|
||||
/** How many NUTs the site knows about, for the "X of Y known NUTs supported" line. */
|
||||
export const KNOWN_NUT_COUNT = Object.keys(NUT_NAMES).length;
|
||||
|
||||
export function nutName(n: string): string | null {
|
||||
return NUT_NAMES[n] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `nuts` object from a mint's /v1/info into a sorted list of supported
|
||||
* NUT numbers.
|
||||
*
|
||||
* Mints encode support three different ways, all seen in the wild:
|
||||
* "4": { "methods": [...], "disabled": false }
|
||||
* "7": { "supported": true }
|
||||
* "17": { "supported": [ { "method": "bolt11", ... } ] }
|
||||
* plus bare `true` from a couple of older implementations.
|
||||
*/
|
||||
export function parseNuts(nuts: unknown): string[] {
|
||||
if (!nuts || typeof nuts !== 'object') return [];
|
||||
const out: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(nuts as Record<string, unknown>)) {
|
||||
const num = nutNumber(key);
|
||||
if (num === null) continue;
|
||||
if (isSupported(value)) out.push(String(num));
|
||||
}
|
||||
|
||||
return out.sort((a, b) => Number(a) - Number(b));
|
||||
}
|
||||
|
||||
/** `"4"`, `"nut4"`, `"nut-04"` and `"NUT-04"` all name NUT-04. Anything else is not a NUT. */
|
||||
function nutNumber(key: string): number | null {
|
||||
const num = key.replace(/^nut[-_]?/i, '');
|
||||
return /^\d+$/.test(num) ? Number.parseInt(num, 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The object a mint published for one NUT, or null when it published none.
|
||||
*
|
||||
* Null means "not published", which is not the same as "switched off": see
|
||||
* readCapabilities in warnings.ts, which leans on that distinction.
|
||||
*/
|
||||
export function nutEntry(nuts: unknown, nut: number): Record<string, unknown> | null {
|
||||
if (!nuts || typeof nuts !== 'object') return null;
|
||||
|
||||
for (const [key, value] of Object.entries(nuts as Record<string, unknown>)) {
|
||||
if (nutNumber(key) !== nut) continue;
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSupported(value: unknown): boolean {
|
||||
if (value === true) return true;
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
|
||||
const v = value as Record<string, unknown>;
|
||||
if (v['disabled'] === true) return false;
|
||||
|
||||
const supported = v['supported'];
|
||||
if (supported === false) return false;
|
||||
if (supported === true) return true;
|
||||
if (Array.isArray(supported)) return supported.length > 0;
|
||||
|
||||
// `{ methods: [...] }` with no `disabled: true` means supported.
|
||||
if (Array.isArray(v['methods'])) return (v['methods'] as unknown[]).length > 0;
|
||||
|
||||
// An object with keys and no explicit negative: treat presence as support.
|
||||
return Object.keys(v).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint or melt limits, read from the NUT-04 (default) or NUT-05 method entry.
|
||||
* Returns the widest min/max across that entry's methods, which is what the
|
||||
* verdict strip shows.
|
||||
*/
|
||||
export interface MintLimits {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export function parseLimits(nuts: unknown, nut: 4 | 5 = 4): MintLimits | null {
|
||||
const entry = nutEntry(nuts, nut);
|
||||
if (!entry) return null;
|
||||
|
||||
const methods = entry['methods'];
|
||||
if (!Array.isArray(methods)) return null;
|
||||
|
||||
let min: number | null = null;
|
||||
let max: number | null = null;
|
||||
let unit = 'sat';
|
||||
|
||||
for (const m of methods as Record<string, unknown>[]) {
|
||||
if (typeof m['unit'] === 'string') unit = m['unit'];
|
||||
const lo = numeric(m['min_amount']);
|
||||
const hi = numeric(m['max_amount']);
|
||||
if (lo !== null) min = min === null ? lo : Math.min(min, lo);
|
||||
if (hi !== null) max = max === null ? hi : Math.max(max, hi);
|
||||
}
|
||||
|
||||
return min !== null || max !== null ? { min, max, unit } : null;
|
||||
}
|
||||
|
||||
function numeric(v: unknown): number | null {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Bayesian weighted rating, replacing the old mean-only sort (see NOTES.md).
|
||||
*
|
||||
* score = (v / (v + m)) * R + (m / (v + m)) * C
|
||||
*
|
||||
* so 4 reviews of 5.0 does not outrank 39 reviews of 4.6.
|
||||
*
|
||||
* IMPORTANT, and a deviation from BACKEND.md worth reading before changing anything:
|
||||
* BACKEND.md defines C as "global mean rating across all reviews". On this network that
|
||||
* mean is about 4.7, because nearly every Cashu review is five stars. Shrinking toward a
|
||||
* prior that high cannot reorder two means that are both above it, it only compresses
|
||||
* them, so with C = 4.7 the spec's own example still fails:
|
||||
*
|
||||
* 39 reviews @ 4.6 -> 4.611 4 reviews @ 5.0 -> 4.833 1 review @ 5.0 -> 4.750
|
||||
*
|
||||
* A single five star review would outrank 39 considered ones. With a neutral prior the
|
||||
* formula does what the spec says it should:
|
||||
*
|
||||
* 39 reviews @ 4.6 -> 4.418 4 reviews @ 5.0 -> 3.889 1 review @ 5.0 -> 3.333
|
||||
*
|
||||
* So the default prior mean is NEUTRAL_PRIOR_MEAN, not the observed global mean. Set
|
||||
* SCORE_PRIOR_MEAN=global to get the literal BACKEND.md behaviour back.
|
||||
*/
|
||||
|
||||
/** Prior weight: pretend every mint starts with 5 reviews at the prior mean. */
|
||||
export const PRIOR_WEIGHT = 5;
|
||||
|
||||
/** Midpoint of the 1..5 scale. An unreviewed mint is neither good nor bad. */
|
||||
export const NEUTRAL_PRIOR_MEAN = 3;
|
||||
/** Offline mints keep their listing but sink. */
|
||||
export const OFFLINE_PENALTY = 0.5;
|
||||
/** No reviews in this long counts as stale. */
|
||||
export const STALE_AFTER_S = 180 * 24 * 60 * 60;
|
||||
export const STALE_PENALTY = 0.9;
|
||||
|
||||
export interface ScoreInput {
|
||||
review_count: number;
|
||||
rating_avg: number | null;
|
||||
status: string;
|
||||
last_review_at: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param priorMean C, the rating an unreviewed mint is assumed to have
|
||||
* @param now unix seconds, injected so the value is testable and stable within a request
|
||||
*/
|
||||
export function bayesianScore(m: ScoreInput, priorMean: number, now: number): number {
|
||||
const v = m.review_count;
|
||||
const R = m.rating_avg ?? priorMean;
|
||||
let score = (v / (v + PRIOR_WEIGHT)) * R + (PRIOR_WEIGHT / (v + PRIOR_WEIGHT)) * priorMean;
|
||||
|
||||
if (m.status === 'offline') score *= OFFLINE_PENALTY;
|
||||
if (m.last_review_at === null || now - m.last_review_at > STALE_AFTER_S) score *= STALE_PENALTY;
|
||||
|
||||
return Math.round(score * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default sort: every online mint before every offline one, then by score descending.
|
||||
* Offline mints must stay findable (people need to reach them to review them), they
|
||||
* just never appear above a live mint.
|
||||
*/
|
||||
export function compareMints<T extends { status: string; score: number; review_count: number }>(
|
||||
a: T,
|
||||
b: T,
|
||||
): number {
|
||||
const aOff = a.status === 'offline' ? 1 : 0;
|
||||
const bOff = b.status === 'offline' ? 1 : 0;
|
||||
if (aOff !== bOff) return aOff - bOff;
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return b.review_count - a.review_count;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Shapes returned by the API. The web app builds against these. */
|
||||
|
||||
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown';
|
||||
|
||||
/** One item of `GET /api/mints`. */
|
||||
export interface MintListItem {
|
||||
url: string;
|
||||
host: string;
|
||||
name: string | null;
|
||||
icon: string | null;
|
||||
status: MintStatus;
|
||||
last_online: number | null;
|
||||
review_count: number;
|
||||
rating_avg: number | null;
|
||||
score: number;
|
||||
last_review_at: number | null;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface ProbeSample {
|
||||
ts: number;
|
||||
ok: 0 | 1;
|
||||
latency_ms: number | null;
|
||||
}
|
||||
|
||||
export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>;
|
||||
|
||||
/** `GET /api/mints/:host`. */
|
||||
export interface MintDetail extends MintListItem {
|
||||
description: string | null;
|
||||
pubkey: string | null;
|
||||
info: MintInfo | null;
|
||||
nuts: string[];
|
||||
first_seen: number;
|
||||
last_probe: number | null;
|
||||
updated_at: number;
|
||||
rating_distribution: RatingDistribution;
|
||||
/** Reviews in the last 90 days, for the verdict strip's activity cell. */
|
||||
reviews_90d: number;
|
||||
uptime_30d: number | null;
|
||||
probes_recent: ProbeSample[];
|
||||
}
|
||||
|
||||
/** `GET /api/stats`. */
|
||||
export interface Stats {
|
||||
mints_total: number;
|
||||
mints_online: number;
|
||||
mints_offline: number;
|
||||
mints_degraded: number;
|
||||
reviews_total: number;
|
||||
last_review_at: number | null;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/** `GET /api/health`. */
|
||||
export interface Health {
|
||||
status: 'ok' | 'degraded';
|
||||
uptime_s: number;
|
||||
last_probe_at: number | null;
|
||||
last_discovery_at: number | null;
|
||||
mints_tracked: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mint's `/v1/info` response (NUT-06). Every field is optional: mints in the wild
|
||||
* omit most of it, and the site must render whatever is there.
|
||||
*/
|
||||
export interface MintInfo {
|
||||
name?: string;
|
||||
pubkey?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
description_long?: string;
|
||||
contact?: MintContact[];
|
||||
motd?: string;
|
||||
icon_url?: string;
|
||||
urls?: string[];
|
||||
time?: number;
|
||||
tos_url?: string;
|
||||
nuts?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MintContact {
|
||||
method?: string;
|
||||
info?: string;
|
||||
}
|
||||
|
||||
/** A review as rendered in the browser, parsed from a Nostr event. */
|
||||
export interface Review {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
mint_url: string;
|
||||
/** null when the event carries no parseable rating. Never defaulted to 5. */
|
||||
rating: number | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Resolved kind-0 metadata for a reviewer. */
|
||||
export interface Profile {
|
||||
pubkey: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
picture?: string;
|
||||
nip05?: string;
|
||||
/** false when the kind-0 lookup returned nothing, used for the provenance note. */
|
||||
found: boolean;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* The states where a mint cannot do its basic job, derived in one place.
|
||||
*
|
||||
* A mint page, a mint card and the live-status island must never disagree about
|
||||
* whether a mint can take deposits or pay them out, so all three call this.
|
||||
*
|
||||
* Everything here is derived from what the API already returns: the cached
|
||||
* `/v1/info` (NUT-04 and NUT-05), plus `status` and `last_online`. The cache is the
|
||||
* point: an offline mint still has its last known configuration, and the copy says
|
||||
* "had" rather than "has" so nobody reads a stale flag as a live one.
|
||||
*/
|
||||
import type { MintInfo, MintStatus } from './types.js';
|
||||
import { nutEntry } from './nuts.js';
|
||||
|
||||
/** Methods that move sats over Lightning. Anything else cannot stand in for them. */
|
||||
const LIGHTNING_METHODS = new Set(['bolt11', 'bolt12']);
|
||||
|
||||
export interface MintCapabilities {
|
||||
/** NUT-04 off: no new ecash can be minted, so deposits are impossible. */
|
||||
mintDisabled: boolean;
|
||||
/** NUT-05 off: ecash cannot be melted, so withdrawals are impossible. */
|
||||
meltDisabled: boolean;
|
||||
/** false when the mint never published the entry at all, which is not the same as off. */
|
||||
mintPublished: boolean;
|
||||
meltPublished: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the NUT-04 / NUT-05 switches out of a mint's `nuts` object.
|
||||
*
|
||||
* Both cdk-mintd and Nutshell publish `{ "4": { methods: [...], disabled: bool } }`,
|
||||
* and every mint indexed so far uses that exact shape with plain numeric keys. Two
|
||||
* things still vary:
|
||||
*
|
||||
* - the flag can be absent, which means enabled (Nutshell omits it on some builds);
|
||||
* - the methods list can be empty while `disabled` is true (LekMint ships both).
|
||||
*
|
||||
* An entry that lists methods but none over Lightning is treated as off, because the
|
||||
* question this answers is "can sats move in and out over Lightning", and an
|
||||
* onchain-only or venmo-only method list answers no.
|
||||
*
|
||||
* A missing entry is NOT reported as disabled. Not publishing a NUT is a different
|
||||
* fact from switching it off, and warning about the first would cry wolf.
|
||||
*/
|
||||
export function readCapabilities(nuts: unknown): MintCapabilities {
|
||||
const mint = nutEntry(nuts, 4);
|
||||
const melt = nutEntry(nuts, 5);
|
||||
|
||||
return {
|
||||
mintDisabled: isDisabled(mint),
|
||||
meltDisabled: isDisabled(melt),
|
||||
mintPublished: mint !== null,
|
||||
meltPublished: melt !== null,
|
||||
};
|
||||
}
|
||||
|
||||
function isDisabled(entry: Record<string, unknown> | null): boolean {
|
||||
if (!entry) return false;
|
||||
if (entry['disabled'] === true) return true;
|
||||
|
||||
const methods = entry['methods'];
|
||||
if (Array.isArray(methods)) return !methods.some(isLightningMethod);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLightningMethod(method: unknown): boolean {
|
||||
if (!method || typeof method !== 'object') return false;
|
||||
const name = (method as Record<string, unknown>)['method'];
|
||||
return typeof name === 'string' && LIGHTNING_METHODS.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
export type MintWarningSeverity = 'critical' | 'warning';
|
||||
|
||||
export type MintWarningKind =
|
||||
| 'gone' // offline 30 days or more, or never reached at all
|
||||
| 'melt-disabled' // sats can get in but not out
|
||||
| 'frozen' // neither in nor out
|
||||
| 'offline-long' // offline 7 to 30 days
|
||||
| 'melt-only' // minting off, melting still works
|
||||
| 'offline'; // offline under 7 days
|
||||
|
||||
export interface MintWarning {
|
||||
kind: MintWarningKind;
|
||||
severity: MintWarningSeverity;
|
||||
/** The one short bold lead phrase, punctuation included. */
|
||||
lead: string;
|
||||
/** Plain sentences after the lead. Already formatted, never contains markup. */
|
||||
body: string;
|
||||
/** Short tail for the page's meta description, so search results carry the state. */
|
||||
meta: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the words come from.
|
||||
*
|
||||
* The decisions in this file (which state a mint is in, which banner outranks which,
|
||||
* what gets folded into the winner's last sentence) are language-free, and they have to
|
||||
* stay that way: three locales agreeing about the facts and disagreeing about the
|
||||
* severity order would be worse than not translating at all. So the logic stays here
|
||||
* and only the sentences are looked up, by key, through this.
|
||||
*
|
||||
* Keys are the catalog's own, minus the `mint.warnings.` prefix: `gone.lead`,
|
||||
* `meltOnly.body.online`, `dayCount` with an `n`. Left unset, `WARNING_COPY_EN` answers
|
||||
* and the output is exactly what it has always been, which is what the API's
|
||||
* fixture tests assert against.
|
||||
*/
|
||||
export type WarningStrings = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export interface MintWarningInput {
|
||||
status: MintStatus | string;
|
||||
last_online: number | null;
|
||||
/** Cached `/v1/info`, as `GET /api/mints/:host` returns it. */
|
||||
info?: MintInfo | null;
|
||||
/**
|
||||
* Precomputed switches, for a caller that already read them (a card built from the
|
||||
* list payload, which carries no info). Wins over `info` when both are given.
|
||||
*/
|
||||
capabilities?: Pick<MintCapabilities, 'mintDisabled' | 'meltDisabled'> | null;
|
||||
/** Only used to date "never answered a single check". */
|
||||
first_seen?: number;
|
||||
}
|
||||
|
||||
export interface MintWarningOptions {
|
||||
/** Unix seconds. Injected so the day counts are testable. */
|
||||
now?: number;
|
||||
formatDate?: (unix: number) => string;
|
||||
formatMonth?: (unix: number) => string;
|
||||
/** The locale's sentences. Defaults to English, see `WarningStrings`. */
|
||||
strings?: WarningStrings;
|
||||
}
|
||||
|
||||
/**
|
||||
* The English warning copy, and the only place it is written down.
|
||||
*
|
||||
* `web/src/i18n/en.json` carries the same keys under `mint.warnings.`, and
|
||||
* `web/scripts/check-i18n.mjs` diffs the two on every build: this file and that
|
||||
* catalog disagreeing about what "withdrawals disabled" says is exactly the kind of
|
||||
* drift nobody notices until a reader is looking at two different warnings on two
|
||||
* pages about the same mint.
|
||||
*/
|
||||
export const WARNING_COPY_EN: Record<string, string> = {
|
||||
'dayCount.one': '{n} day',
|
||||
'dayCount.other': '{n} days',
|
||||
|
||||
'gone.lead': 'Likely gone.',
|
||||
'gone.body.since':
|
||||
'This mint has been unreachable since {date} ({days}). Treat funds held there as at risk, and do not send new funds. Details below show its last known state.',
|
||||
'gone.body.neverDated':
|
||||
'This mint has not answered a single check since we started watching it in {month}. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.',
|
||||
'gone.body.never':
|
||||
'This mint has not answered a single check since we started watching it. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.',
|
||||
'gone.meta.since': 'Offline since {date}',
|
||||
'gone.meta.never': 'Never reachable since it was discovered',
|
||||
|
||||
'meltDisabled.lead': 'Withdrawals disabled.',
|
||||
'meltDisabled.body.online':
|
||||
'This mint has Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.',
|
||||
'meltDisabled.body.offline':
|
||||
'This mint had Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.',
|
||||
'meltDisabled.meta.online': 'Withdrawals currently disabled',
|
||||
'meltDisabled.meta.offline': 'Withdrawals were disabled when last seen',
|
||||
|
||||
'frozen.lead': 'Mint frozen.',
|
||||
'frozen.body.online': 'Both minting and melting are disabled. No sats can move in or out via Lightning.',
|
||||
'frozen.body.offline': 'Both minting and melting were disabled. No sats can move in or out via Lightning.',
|
||||
'frozen.meta.online': 'Currently frozen, nothing moves in or out',
|
||||
'frozen.meta.offline': 'Frozen when last seen',
|
||||
|
||||
'offlineLong.lead': 'Offline for {days}, since {date}.',
|
||||
'offlineLong.body': 'The mint may be gone. Everything below is its last known state, and reviews still work.',
|
||||
'offlineLong.meta': 'Offline since {date}',
|
||||
|
||||
'meltOnly.lead': 'Melt only.',
|
||||
'meltOnly.body.online':
|
||||
'New minting (NUT-04) is disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.',
|
||||
'meltOnly.body.offline':
|
||||
'New minting (NUT-04) was disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.',
|
||||
'meltOnly.meta.online': 'Currently melt only',
|
||||
'meltOnly.meta.offline': 'Melt only when last seen',
|
||||
|
||||
'offline.lead': 'Offline since {date}.',
|
||||
'offline.body': 'Details were last confirmed then. You can still write a review.',
|
||||
'offline.meta': 'Offline since {date}',
|
||||
|
||||
'also.meltDisabled': 'Before going offline it had also disabled withdrawals.',
|
||||
'also.mintDisabled': 'Before going offline it had also disabled new minting.',
|
||||
'also.offline': 'It has also been offline since {date} ({days}).',
|
||||
};
|
||||
|
||||
/**
|
||||
* The default resolver: English, with the one plural this file needs.
|
||||
*
|
||||
* English, Spanish and Dutch all split on `n === 1`, so the rule here is that split and
|
||||
* nothing more. A caller in a language that needs more categories passes its own
|
||||
* `strings`, which is backed by `Intl.PluralRules` and gets it right for every language
|
||||
* at once. Nothing in this file has to know that.
|
||||
*/
|
||||
const englishStrings: WarningStrings = (key, vars) => {
|
||||
const n = typeof vars?.['n'] === 'number' ? (vars['n'] as number) : undefined;
|
||||
const resolved =
|
||||
n !== undefined
|
||||
? (WARNING_COPY_EN[`${key}.${n === 1 ? 'one' : 'other'}`] ?? WARNING_COPY_EN[key])
|
||||
: WARNING_COPY_EN[key];
|
||||
if (resolved === undefined) return key;
|
||||
if (!vars) return resolved;
|
||||
return resolved.replace(/\{(\w+)\}/g, (whole, name: string) => {
|
||||
const value = vars[name];
|
||||
return value === undefined ? whole : String(value);
|
||||
});
|
||||
};
|
||||
|
||||
/** Severity order, highest first. Index 0 of the returned list is the one to show. */
|
||||
const ORDER: MintWarningKind[] = [
|
||||
'gone', 'melt-disabled', 'frozen', 'offline-long', 'melt-only', 'offline',
|
||||
];
|
||||
|
||||
const defaultDate = (unix: number): string =>
|
||||
new Date(unix * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
const defaultMonth = (unix: number): string =>
|
||||
new Date(unix * 1000).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' });
|
||||
|
||||
/**
|
||||
* Every warning that applies, most severe first.
|
||||
*
|
||||
* Pages show only `[0]`: a stack of banners is a wall nobody reads, and the secondary
|
||||
* conditions are folded into the winner's last sentence instead. The rest of the list
|
||||
* is still returned because a card needs to know a mint is frozen even when its
|
||||
* offline banner outranks that fact.
|
||||
*/
|
||||
export function getMintWarnings(
|
||||
mint: MintWarningInput,
|
||||
options: MintWarningOptions = {},
|
||||
): MintWarning[] {
|
||||
const now = options.now ?? Math.floor(Date.now() / 1000);
|
||||
const date = options.formatDate ?? defaultDate;
|
||||
const month = options.formatMonth ?? defaultMonth;
|
||||
const s = options.strings ?? englishStrings;
|
||||
|
||||
const caps = mint.capabilities ?? readCapabilities(mint.info?.nuts);
|
||||
const { mintDisabled, meltDisabled } = caps;
|
||||
|
||||
const offline = mint.status === 'offline';
|
||||
const since = mint.last_online;
|
||||
const days = offline && since !== null ? Math.max(0, Math.floor((now - since) / 86400)) : null;
|
||||
|
||||
// Offline with no last_online means it has never once answered, which is at least
|
||||
// as bad as a month of silence, so it lands in the top tier rather than the bottom.
|
||||
const tier = !offline ? 0 : days === null ? 3 : days >= 30 ? 3 : days >= 7 ? 2 : 1;
|
||||
|
||||
/*
|
||||
* Cached flags describe the last configuration seen, not the current one, and the
|
||||
* copy has to say so. English did that by swapping one verb ("has" for "had"); most
|
||||
* languages cannot, so the tense is a whole sentence and each state carries a
|
||||
* `.online` and an `.offline` variant. The key is picked here, the grammar lives in
|
||||
* the catalogs where a translator can see all of it at once.
|
||||
*/
|
||||
const tense = offline ? 'offline' : 'online';
|
||||
|
||||
const dayCount = days === null ? s('dayCount', { n: 0 }) : s('dayCount', { n: days });
|
||||
const out: MintWarning[] = [];
|
||||
|
||||
// Pushed in ORDER, so the first one added is the one that wins.
|
||||
if (tier === 3) {
|
||||
out.push({
|
||||
kind: 'gone',
|
||||
severity: 'critical',
|
||||
lead: s('gone.lead'),
|
||||
body:
|
||||
since !== null
|
||||
? s('gone.body.since', { date: date(since), days: dayCount })
|
||||
: mint.first_seen
|
||||
? s('gone.body.neverDated', { month: month(mint.first_seen) })
|
||||
: s('gone.body.never'),
|
||||
meta: since !== null ? s('gone.meta.since', { date: date(since) }) : s('gone.meta.never'),
|
||||
});
|
||||
}
|
||||
|
||||
if (meltDisabled && !mintDisabled) {
|
||||
out.push({
|
||||
kind: 'melt-disabled',
|
||||
severity: 'critical',
|
||||
lead: s('meltDisabled.lead'),
|
||||
body: s(`meltDisabled.body.${tense}`),
|
||||
meta: s(`meltDisabled.meta.${tense}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (meltDisabled && mintDisabled) {
|
||||
out.push({
|
||||
kind: 'frozen',
|
||||
severity: 'critical',
|
||||
lead: s('frozen.lead'),
|
||||
body: s(`frozen.body.${tense}`),
|
||||
meta: s(`frozen.meta.${tense}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (tier === 2 && since !== null) {
|
||||
out.push({
|
||||
kind: 'offline-long',
|
||||
severity: 'critical',
|
||||
lead: s('offlineLong.lead', { days: dayCount, date: date(since) }),
|
||||
body: s('offlineLong.body'),
|
||||
meta: s('offlineLong.meta', { date: date(since) }),
|
||||
});
|
||||
}
|
||||
|
||||
if (mintDisabled && !meltDisabled) {
|
||||
out.push({
|
||||
kind: 'melt-only',
|
||||
severity: 'warning',
|
||||
lead: s('meltOnly.lead'),
|
||||
body: s(`meltOnly.body.${tense}`),
|
||||
meta: s(`meltOnly.meta.${tense}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (tier === 1 && since !== null) {
|
||||
out.push({
|
||||
kind: 'offline',
|
||||
severity: 'warning',
|
||||
lead: s('offline.lead', { date: date(since) }),
|
||||
body: s('offline.body'),
|
||||
meta: s('offline.meta', { date: date(since) }),
|
||||
});
|
||||
}
|
||||
|
||||
const primary = out[0];
|
||||
if (primary) {
|
||||
const extra = collapsed(primary.kind, { mintDisabled, meltDisabled, offline, since, dayCount, date, s });
|
||||
if (extra) primary.body += ` ${extra}`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one sentence a losing condition earns inside the winner's text. Banners never
|
||||
* stack, but a mint that is both gone and had stopped paying out is a worse story
|
||||
* than either fact alone, and the page has to tell it.
|
||||
*/
|
||||
const CAPABILITY_KINDS = new Set<MintWarningKind>(['melt-disabled', 'frozen', 'melt-only']);
|
||||
|
||||
function collapsed(
|
||||
kind: MintWarningKind,
|
||||
ctx: {
|
||||
mintDisabled: boolean;
|
||||
meltDisabled: boolean;
|
||||
offline: boolean;
|
||||
since: number | null;
|
||||
dayCount: string;
|
||||
date: (unix: number) => string;
|
||||
s: WarningStrings;
|
||||
},
|
||||
): string | null {
|
||||
if (kind === 'gone' || kind === 'offline-long') {
|
||||
if (ctx.meltDisabled) return ctx.s('also.meltDisabled');
|
||||
if (ctx.mintDisabled) return ctx.s('also.mintDisabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
// A capability banner outranked an offline one: say the mint is also unreachable,
|
||||
// otherwise the page reads as if it were up and merely misconfigured. Only the
|
||||
// capability banners need this; an offline banner has already said it.
|
||||
if (CAPABILITY_KINDS.has(kind) && ctx.offline && ctx.since !== null) {
|
||||
return ctx.s('also.offline', { date: ctx.date(ctx.since), days: ctx.dayCount });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The winner, or null for a healthy mint. */
|
||||
export function primaryWarning(
|
||||
mint: MintWarningInput,
|
||||
options: MintWarningOptions = {},
|
||||
): MintWarning | null {
|
||||
return getMintWarnings(mint, options)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip a mint card carries next to its status.
|
||||
*
|
||||
* Cards say what is broken in two words and stop there; the banner on the mint page
|
||||
* does the explaining. Offline already has its own card styling, so only the
|
||||
* capability states produce a chip.
|
||||
*/
|
||||
export interface MintChip {
|
||||
label: string;
|
||||
severity: MintWarningSeverity;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip's three labels, in English. Keyed to match `mint.chip.*` in the catalogs.
|
||||
*
|
||||
* Separate from `WARNING_COPY_EN` because these are not warning sentences: they are two
|
||||
* words on a card, and a translator sizing them has a different job.
|
||||
*/
|
||||
export const CHIP_COPY_EN: Record<string, string> = {
|
||||
frozen: 'Frozen',
|
||||
noWithdrawals: 'No withdrawals',
|
||||
meltOnly: 'Melt only',
|
||||
};
|
||||
|
||||
export function mintChip(warnings: MintWarning[], strings?: WarningStrings): MintChip | null {
|
||||
const s = strings ?? ((key: string) => CHIP_COPY_EN[key] ?? key);
|
||||
const kinds = new Set(warnings.map((w) => w.kind));
|
||||
if (kinds.has('frozen')) return { label: s('frozen'), severity: 'critical' };
|
||||
// "Melt only" is the other direction, so it cannot double as the label here.
|
||||
if (kinds.has('melt-disabled')) return { label: s('noWithdrawals'), severity: 'critical' };
|
||||
if (kinds.has('melt-only')) return { label: s('meltOnly'), severity: 'warning' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Sort key, exported so a caller can assert the order rather than assume it. */
|
||||
export function warningRank(kind: MintWarningKind): number {
|
||||
return ORDER.indexOf(kind);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user