Goblin: goblin: payment deep link opens straight to send review

Web "Open in Goblin" pay buttons fired generic nostr: URIs, which the
OS routed to whatever social client claims nostr:. Register a dedicated
goblin: scheme and route it (plus nostr: pay URIs) to the prefilled
send-review screen, the same destination as scanning a checkout QR.

Parser (payuri): accept goblin: alongside nostr: (same payload, either
scheme); add is_pay_uri() to tell a payment link from a slatepack.

Runtime: an argv/intent/socket payload that is a pay URI is stashed and
opened by the Goblin surface via SendFlow::from_deeplink, which shares
the exact apply_scan path the camera uses. Desktop cold + warm start
(argv + single-instance socket) and Android cold + warm (intent-filter)
covered.

Registration: Linux .desktop x-scheme-handler/goblin, Android goblin
intent-filter, macOS CFBundleURLTypes, Windows WiX HKCR URL protocol.
This commit is contained in:
2ro
2026-07-05 01:53:18 -04:00
parent 9f019edfeb
commit ae0f36d287
10 changed files with 277 additions and 68 deletions
+10
View File
@@ -59,6 +59,16 @@
<data android:pathPattern=".*\\.slatepack" />
</intent-filter>
<!-- Goblin payment deep link: web "Open in Goblin" buttons and
goblin: QR/links open the wallet straight to the send-review
screen. BROWSABLE so a browser/click can resolve it. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="goblin" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="grim" />
</activity>
@@ -235,6 +235,16 @@ public class MainActivity extends GameActivity {
String action = intent.getAction();
// Check if file was open with the application.
if (action != null && action.equals(Intent.ACTION_VIEW)) {
Uri data = intent.getData();
String scheme = data != null ? data.getScheme() : null;
// Goblin payment deep link (goblin: / nostr:): pass the URI text
// straight to native code, which routes it to the send-review flow.
// These are NOT files, so they must skip the file-descriptor path.
if (scheme != null && (scheme.equalsIgnoreCase("goblin")
|| scheme.equalsIgnoreCase("nostr"))) {
onData(data.toString());
return;
}
Intent i = getIntent();
i.setData(intent.getData());
setIntent(i);
+2 -2
View File
@@ -1,7 +1,7 @@
[Desktop Entry]
Name=Goblin
Exec=goblin
Exec=goblin %u
Icon=goblin
Type=Application
Categories=Finance
MimeType=application/x-slatepack;text/plain;
MimeType=application/x-slatepack;text/plain;x-scheme-handler/goblin;
+13
View File
@@ -57,6 +57,19 @@
<string>Document</string>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>st.goblin.pay</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLSchemes</key>
<array>
<string>goblin</string>
</array>
</dict>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.finance</string>
<key>NSHumanReadableCopyright</key>
+8
View File
@@ -432,6 +432,14 @@ impl GoblinWalletView {
self.advanced = AdvancedState::default();
}
// A pending payment deep link (`goblin:` / `nostr:` pay URI, routed here
// from an OS launch/open) opens a prefilled send-review flow — the exact
// destination a scanned checkout QR lands on.
if let Some(uri) = crate::take_pending_pay_uri() {
let now = ui.input(|i| i.time);
self.send = Some(SendFlow::from_deeplink(&uri, wallet, now));
}
// Send flow takes the full surface when active.
if let Some(send) = &mut self.send {
let done = send.ui(ui, wallet, cb, &mut self.avatars);
+113 -53
View File
@@ -210,6 +210,78 @@ impl SendFlow {
self.stage = Stage::Recipient;
}
/// Build a send flow from a scanned/deep-linked payment payload and land it
/// on the SAME screen a scanned QR would: a full checkout (recipient key +
/// amount) opens the prefilled review directly; anything else drops into the
/// recipient search with any amount/memo prefilled. Used by the `goblin:` /
/// `nostr:` deep-link handler so a web "Open in Goblin" button reaches the
/// exact QR-to-review destination. `now` is the egui input time (debounce seed
/// for the search path).
pub fn from_deeplink(uri: &str, wallet: &Wallet, now: f64) -> Self {
let mut flow = Self::default();
flow.apply_scan(uri, wallet, now);
flow
}
/// Apply a scanned/deep-linked payment payload (npub / `nostr:` / `goblin:`
/// URI / @handle, with optional amount+memo), mutating this flow exactly as
/// the camera scan path does. Shared by the QR scanner and the deep-link
/// handler so both spell the same destination. `now` seeds the search-box
/// debounce.
fn apply_scan(&mut self, text: &str, wallet: &Wallet, now: f64) {
match recognize_scan(text) {
// GoblinPay checkout / full pay link: recipient key AND amount are
// both known, so skip discovery and open review directly, prefilled.
// (Owner-ordered: a merchant checkout shouldn't re-walk the recipient
// search.) A cheap LOCAL contact lookup supplies a friendly name when
// we already know them; otherwise the short npub — no network
// resolution on this path.
ScanPrefill::Review {
hex,
relay_hints,
amount,
memo,
} => {
let name = wallet
.nostr_service()
.and_then(|s| s.store.contact(&hex).map(|c| display_name(&c)))
.unwrap_or_else(|| short_npub(&hex));
self.recipient = Some(Recipient {
name,
npub: hex,
relay_hints,
});
self.amount = amount;
if let Some(memo) = memo {
self.note = memo;
}
self.error = None;
self.stage = Stage::Review;
}
// Only a recipient (or a name/@handle needing resolution): drop it
// into the search box so the picker's debounced lookup resolves +
// verifies it like typed input, exactly as before. Any amount/memo
// still prefill.
ScanPrefill::Search {
recipient,
amount,
memo,
} => {
self.search = recipient;
self.input_changed_at = now;
self.lookup_query.clear();
self.net_candidate = None;
if let Some(amount) = amount {
self.amount = amount;
}
if let Some(memo) = memo {
self.note = memo;
}
self.stage = Stage::Recipient;
}
}
}
/// Render the flow. Returns true when the flow is finished (close it).
pub fn ui(
&mut self,
@@ -680,59 +752,10 @@ impl SendFlow {
// seed words or slatepack contents into the search box.
match &result {
QrScanResult::Text(text) => {
// Classify the scan. UNTRUSTED input: the parser is pure and
// fail-closed, and every path still gates the send on the review
// screen (the user confirms) — nothing auto-SENDS.
match recognize_scan(text) {
// GoblinPay checkout QR: recipient key AND amount are both
// known, so skip discovery and open review directly, prefilled.
// (Owner-ordered: a merchant checkout shouldn't re-walk the
// recipient search.) A cheap LOCAL contact lookup supplies a
// friendly name when we already know them; otherwise the short
// npub — no network resolution on this path.
ScanPrefill::Review {
hex,
relay_hints,
amount,
memo,
} => {
let name = wallet
.nostr_service()
.and_then(|s| s.store.contact(&hex).map(|c| display_name(&c)))
.unwrap_or_else(|| short_npub(&hex));
self.recipient = Some(Recipient {
name,
npub: hex,
relay_hints,
});
self.amount = amount;
if let Some(memo) = memo {
self.note = memo;
}
self.error = None;
self.stage = Stage::Review;
}
// Only a recipient (or a name/@handle needing resolution):
// drop it into the search box so the picker's debounced lookup
// resolves + verifies it like typed input, exactly as before.
// Any amount/memo still prefill.
ScanPrefill::Search {
recipient,
amount,
memo,
} => {
self.search = recipient;
self.input_changed_at = ui.input(|i| i.time);
self.lookup_query.clear();
self.net_candidate = None;
if let Some(amount) = amount {
self.amount = amount;
}
if let Some(memo) = memo {
self.note = memo;
}
}
}
// Classify + apply the scan. UNTRUSTED input: the parser is pure
// and fail-closed, and every path still gates the send on the
// review screen (the user confirms) — nothing auto-SENDS.
self.apply_scan(text, wallet, ui.input(|i| i.time));
}
_ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()),
}
@@ -1633,6 +1656,43 @@ mod tests {
}
}
#[test]
fn goblin_scheme_pay_link_goes_straight_to_review() {
// The `goblin:` deep-link scheme (web "Open in Goblin" buttons) must
// classify identically to the `nostr:` QR payload: a full pay link
// (key + amount) lands on the prefilled review.
let uri = format!("goblin:{NPUB}?amount=1.5&memo=MM-ABC123");
match recognize_scan(&uri) {
ScanPrefill::Review {
hex,
amount,
memo,
relay_hints,
} => {
assert_eq!(hex.len(), 64);
assert_eq!(amount, "1.5");
assert_eq!(memo.as_deref(), Some("MM-ABC123"));
assert!(relay_hints.is_empty());
}
other => panic!("expected Review, got {other:?}"),
}
}
#[test]
fn goblin_and_nostr_schemes_classify_equivalently() {
// Same recipient + amount + memo under either scheme yields the same
// classification and the same extracted values.
for scheme in ["goblin", "nostr"] {
match recognize_scan(&format!("{scheme}:{NPUB}?amount=2&memo=Hi")) {
ScanPrefill::Review { amount, memo, .. } => {
assert_eq!(amount, "2");
assert_eq!(memo.as_deref(), Some("Hi"));
}
other => panic!("{scheme}: expected Review, got {other:?}"),
}
}
}
#[test]
fn key_without_amount_uses_search_box() {
// A bare recipient key with no amount is NOT a full payment; the picker
+11
View File
@@ -466,6 +466,17 @@ impl WalletsContent {
if !Content::is_dual_panel_mode(ui.ctx()) && Content::is_network_panel_open() {
Content::toggle_network_panel();
}
// Route a Goblin payment deep link (`goblin:` / `nostr:` pay URI) to the
// send-review flow instead of the slatepack message handler: stash it for
// the Goblin surface to open, and select/open the wallet with NO message
// so the surface shows. Same destination as scanning a checkout QR.
let data = match data {
Some(d) if crate::nostr::payuri::is_pay_uri(&d) => {
crate::set_pending_pay_uri(d);
None
}
other => other,
};
// Pass data to single wallet or show wallets selection.
if wallets_size == 1 {
let w = self.wallets.list()[0].clone();
+19
View File
@@ -455,6 +455,25 @@ pub fn notify_payment_requested(name: &str, amount: &str) {
lazy_static! {
/// Data provided from deeplink or opened file.
pub static ref INCOMING_DATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
/// A pending `goblin:` / `nostr:` payment deep link, waiting for the Goblin
/// wallet surface to open its send-review flow. Separate from
/// [`INCOMING_DATA`] (slatepack messages / opened files): a payment link is
/// routed here so it lands on the prefilled review screen rather than the
/// slatepack message handler. Consumed by the Goblin view once a wallet is
/// open and showing.
static ref PENDING_PAY_URI: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
}
/// Stash a payment deep link for the Goblin surface to open (see
/// [`take_pending_pay_uri`]). The most recent link wins.
pub fn set_pending_pay_uri(uri: String) {
*PENDING_PAY_URI.write() = Some(uri);
}
/// Take (and clear) a pending payment deep link, if any. The Goblin wallet view
/// polls this each frame and opens a prefilled send-review flow for it.
pub fn take_pending_pay_uri() -> Option<String> {
PENDING_PAY_URI.write().take()
}
/// Callback from Java code with passed data.
+74 -13
View File
@@ -12,12 +12,16 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Pay-URI parser for scanned payment QRs.
//! Pay-URI parser for scanned payment QRs and payment deep links.
//!
//! A GoblinPay checkout QR extends the plain `nostr:` URI with an optional
//! amount (and memo):
//! A GoblinPay checkout QR (or an "Open in Goblin" web button) carries an
//! optional amount (and memo) on the recipient, under either the `goblin:`
//! scheme (Goblin's own registered deep-link scheme, so the OS routes it to the
//! wallet) or the plain `nostr:` scheme (what a QR/social payload spells). Both
//! are the SAME payload — only the scheme differs:
//!
//! ```text
//! goblin:<nprofile-or-npub>?amount=<decimal GRIN>&memo=<percent-encoded>
//! nostr:<nprofile-or-npub>?amount=<decimal GRIN>&memo=<percent-encoded>
//! ```
//!
@@ -39,6 +43,11 @@ const MAX_URI_LEN: usize = 4096;
/// Memo byte cap (post control-strip), display / tx-message only.
const MAX_MEMO_BYTES: usize = 256;
/// Schemes that unlock amount/memo parsing. `goblin:` is Goblin's registered
/// deep-link scheme (web "Open in Goblin" buttons, so the OS opens the wallet);
/// `nostr:` is the equivalent QR/social payload. Both spell the same payment.
const PAY_SCHEMES: [&str; 2] = ["goblin:", "nostr:"];
/// A parsed pay-URI. `recipient` is fed to the existing resolver as-is (the
/// bech32/name that used to go straight into the search box). `amount` is the
/// raw decimal-GRIN string, present only when `amount_from_hr_string` accepted
@@ -74,11 +83,11 @@ pub fn parse(scanned: &str) -> PayUri {
return PayUri::bare(String::new());
}
// Strict scheme: only the `nostr:` prefix (case-insensitive) unlocks
// Strict scheme: only a `goblin:`/`nostr:` prefix (case-insensitive) unlocks
// amount/memo parsing, matching the scanner's existing strip logic. Any
// other payload (a bare npub, or some other scheme) is returned verbatim,
// exactly as the scanner treated it before pay-URIs existed.
let rest = match strip_nostr_prefix(text) {
let rest = match strip_pay_scheme(text) {
Some(rest) => rest,
None => return PayUri::bare(text.to_string()),
};
@@ -115,15 +124,27 @@ pub fn parse(scanned: &str) -> PayUri {
}
}
/// Strip a case-insensitive `nostr:` scheme prefix, returning the remainder.
/// Byte-safe against a leading multibyte char (no `[..6]` slice panic).
fn strip_nostr_prefix(text: &str) -> Option<&str> {
let head = text.get(..6)?;
if head.eq_ignore_ascii_case("nostr:") {
Some(&text[6..])
} else {
None
/// Strip a case-insensitive payment scheme prefix (`goblin:` or `nostr:`),
/// returning the remainder. Byte-safe against a leading multibyte char (the
/// `text.get(..n)` guards against a `[..n]` slice panic).
fn strip_pay_scheme(text: &str) -> Option<&str> {
for scheme in PAY_SCHEMES {
let n = scheme.len();
if let Some(head) = text.get(..n) {
if head.eq_ignore_ascii_case(scheme) {
return Some(&text[n..]);
}
}
}
None
}
/// True when `scanned` (once trimmed) carries a Goblin payment scheme
/// (`goblin:` or `nostr:`) — i.e. a payment deep link rather than a slatepack
/// message or opened file. Used to route an incoming argv/intent payload to the
/// send-review flow instead of the slatepack message handler.
pub fn is_pay_uri(scanned: &str) -> bool {
strip_pay_scheme(scanned.trim()).is_some()
}
/// Validate an `amount` value: percent-decode, then accept it ONLY if the
@@ -231,6 +252,46 @@ mod tests {
assert_eq!(out.amount.as_deref(), Some("2"));
}
#[test]
fn goblin_scheme_equivalent_to_nostr() {
// The `goblin:` deep-link scheme is the same payload as `nostr:` — it
// MUST parse to the identical recipient / amount / memo. This is the
// contract behind the web "Open in Goblin" buttons.
let nostr = parse(&format!("nostr:{NPROFILE}?amount=1.5&memo=Coffee"));
let goblin = parse(&format!("goblin:{NPROFILE}?amount=1.5&memo=Coffee"));
assert_eq!(goblin, nostr);
assert_eq!(goblin.recipient, NPROFILE);
assert_eq!(goblin.amount.as_deref(), Some("1.5"));
assert_eq!(goblin.memo.as_deref(), Some("Coffee"));
}
#[test]
fn goblin_scheme_case_insensitive() {
let out = parse(&format!("GOBLIN:{NPROFILE}?amount=2"));
assert_eq!(out.recipient, NPROFILE);
assert_eq!(out.amount.as_deref(), Some("2"));
}
#[test]
fn bare_goblin_nprofile_unchanged() {
let out = parse(&format!("goblin:{NPROFILE}"));
assert_eq!(out.recipient, NPROFILE);
assert_eq!(out.amount, None);
assert_eq!(out.memo, None);
}
#[test]
fn is_pay_uri_recognizes_both_schemes() {
assert!(is_pay_uri(&format!("goblin:{NPROFILE}?amount=1")));
assert!(is_pay_uri(&format!("nostr:{NPROFILE}")));
assert!(is_pay_uri(&format!(" GOBLIN:{NPROFILE} ")));
// A slatepack message / bare key / other scheme is NOT a pay URI.
assert!(!is_pay_uri("BEGINSLATEPACK. abc DEFG. ENDSLATEPACK."));
assert!(!is_pay_uri("npub1abcdef"));
assert!(!is_pay_uri("bitcoin:bc1qxyz?amount=1"));
assert!(!is_pay_uri(""));
}
#[test]
fn with_amount() {
let out = parse(&format!("nostr:{NPROFILE}?amount=1.5"));
+17
View File
@@ -59,6 +59,22 @@
</Extension>
</ProgId>
</Component>
<!-- Register the goblin: URL protocol so web "Open in Goblin" pay
buttons launch the wallet with the payment link as argv[1],
which routes to the prefilled send-review screen. -->
<Component Id="GoblinUrlProtocol" Guid="a4f1c2e8-9b3d-4e6a-8c7f-2d1b5e9a0c34">
<RegistryKey Root="HKCR" Key="goblin">
<RegistryValue Type="string" Value="URL:Goblin Protocol" KeyPath="yes"/>
<RegistryValue Name="URL Protocol" Type="string" Value=""/>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[#goblin.exe]&quot; &quot;%1&quot;"/>
</RegistryKey>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="[#goblin.exe]"/>
</RegistryKey>
</RegistryKey>
</Component>
</DirectoryRef>
<!-- Step 2: Add the shortcut to your installer package -->
@@ -77,6 +93,7 @@
<Feature Id="MainApplication" Title="Goblin" Level="1">
<ComponentRef Id="goblin.exe" />
<ComponentRef Id="GoblinUrlProtocol" />
<ComponentRef Id="License" />
<!-- Step 3: Tell WiX to install the shortcut -->
<ComponentRef Id="ApplicationShortcutDesktop" />