GoblinPay: receive-only Grin payment server

A self-hostable Grin payment server for shops, creators, and sites: show a
code, Grin lands in your wallet, with a verifiable Grin payment proof on
receive. Workspace crates (gp-core / gp-nostr / gp-server / gp-wallet /
gp-goblin-sender), a WooCommerce connector, a hosted /pay/<token> checkout,
and NIP-44 v3 gift-wrapped payment DMs carried over the Nym mixnet. All
secrets are read from the environment; none are committed.
This commit is contained in:
2ro
2026-07-02 04:29:54 -04:00
commit bd67bfc92e
74 changed files with 24862 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
# Installing GoblinPay for WooCommerce
## 1. Package the plugin
Zip the plugin directory so the archive contains a single top-level folder named
`goblinpay-woocommerce` with the plugin files inside it:
```
cd connectors
zip -r goblinpay-woocommerce.zip woocommerce \
-x '*/.git/*'
```
If you prefer the folder name to match the plugin, rename `woocommerce` to
`goblinpay-woocommerce` before zipping. WordPress does not require the folder
name to match; it reads the plugin header from `goblinpay-woocommerce.php`.
## 2. Upload and activate
In WordPress, open Plugins, then Add New Plugin, then Upload Plugin. Choose the
zip, install it, and activate. WooCommerce 8.0 or newer must already be active.
Alternatively, copy the `woocommerce` folder into
`wp-content/plugins/goblinpay-woocommerce/` on the server and activate from the
Plugins screen.
## 3. Configure the gateway
Open WooCommerce, then Settings, then Payments, then GoblinPay (Grin), and set:
- GoblinPay URL: the base URL of your GoblinPay server, for example
`http://127.0.0.1:8192` when GoblinPay runs on the same host. No trailing
slash.
- API Token: the GoblinPay create-invoice bearer token (`GP_API_TOKEN`).
- Webhook Secret: the shared HMAC secret (`GP_WEBHOOK_SECRET`).
- Matching mode: leave on Per-invoice identity (recommended) unless you have a
reason to match by order reference or amount.
- Checkout experience: Redirect (recommended) or Embed the QR on the
order-received page.
- Payment window: minutes before an unpaid order is cancelled (0 disables it).
Enable the method and save.
## 4. Register the webhook in GoblinPay
Point your GoblinPay server at this site so it can report payments. Set these on
the GoblinPay side:
- `GP_WEBHOOK_URL` = `https://YOUR-SITE/wp-json/goblinpay/v1/webhook`
- `GP_WEBHOOK_SECRET` = the same secret you entered in the gateway settings.
- `GP_API_TOKEN` = the same token you entered as the API Token.
GoblinPay signs each delivery with `X-GoblinPay-Signature: sha256=<hmac>` over
the raw body and sends an idempotency key in `X-GoblinPay-Delivery`. The plugin
verifies the signature, dedupes on the event id, and completes the matching
order.
The exact POST target the plugin exposes (the value to use for
`GP_WEBHOOK_URL`) is:
```
https://YOUR-SITE/wp-json/goblinpay/v1/webhook
```
Make sure the WordPress REST API is reachable from the GoblinPay host. If the
webhook is ever missed, the plugin also polls
`GET {GoblinPay URL}/invoice/{invoice_id}` (with the bearer token) as a
fallback.
## 5. Test
Place a test order, choose Grin (GRIN), and confirm:
- Redirect mode sends you to the GoblinPay `/pay/<token>` page.
- Embed mode shows the Goblin QR on the order-received page.
- Paying from a Goblin Wallet moves the order to processing or completed once
GoblinPay delivers the `payment.received` webhook.
Turn on Debug logging in the gateway settings to trace requests and webhooks in
WooCommerce, then Status, then Logs, source `goblinpay`.
## Refund caveat
Refunds are not automated. GoblinPay is receive-only and never sends Grin, so
any refund is a manual Grin send performed by the merchant from a wallet under
their control.
+75
View File
@@ -0,0 +1,75 @@
# GoblinPay for WooCommerce
Accept Grin (GRIN / MimbleWimble) payments in WooCommerce through a self-hosted
GoblinPay server. The customer pays from their Goblin Wallet by scanning an
`nprofile` QR code. The payment travels as a gift-wrapped slatepack over Nostr
(optionally over the Nym mixnet). GoblinPay receives it, returns the reply
slatepack to the payer, watches the chain to confirm, and notifies WooCommerce.
This plugin is a thin client. All of the Grin and Nostr work happens in
GoblinPay; WooCommerce only talks HTTP to your GoblinPay instance. No BTCPay, no
node exposed to the store, no wallet RPC.
## What it does
- Adds a "Grin (GRIN)" payment method to both the classic checkout and the
WooCommerce Blocks (Cart/Checkout block) checkout.
- On checkout, calls GoblinPay to create an invoice for the order, then either
redirects the customer to GoblinPay's hosted `/pay/<token>` page (the default)
or shows the Goblin QR on the order-received page (the embedded option).
- Marks the order complete when GoblinPay reports the payment, via a signed
webhook. If a webhook is missed, the plugin polls GoblinPay for the invoice
status as a fallback.
- Declares HPOS (custom order tables) and Cart/Checkout Blocks compatibility.
## Requirements
- WordPress with WooCommerce 8.0 or newer (tested against WooCommerce 10.8).
- PHP 8.0 or newer (target host runs PHP 8.2).
- A running GoblinPay server reachable from the WordPress host.
## Settings
Open WooCommerce, then Settings, then Payments, then GoblinPay (Grin).
- GoblinPay URL: base URL of your GoblinPay server, for example
`http://127.0.0.1:8192`. No trailing slash.
- API Token: the GoblinPay create-invoice bearer token (`GP_API_TOKEN` on the
server).
- Webhook Secret: the shared HMAC secret (`GP_WEBHOOK_SECRET` on the server).
- Matching mode: how GoblinPay ties an incoming payment to the order. The
default, per-invoice identity, gives each order its own QR and is the most
reliable. Order reference (memo) and amount-only are also available.
- Checkout experience: redirect to the hosted GoblinPay checkout (the default),
or embed the QR on the order-received page.
- Payment window: minutes before an unpaid order is cancelled. Set 0 to disable.
Point your GoblinPay server's `GP_WEBHOOK_URL` at this site's webhook endpoint,
shown in the Webhook Secret field, which is:
```
https://YOUR-SITE/wp-json/goblinpay/v1/webhook
```
## Refunds
Refunds are not automated. GoblinPay is receive-only: it never sends Grin. A
refund is therefore a manual, out-of-band Grin send by the merchant from a
wallet under their control. This plugin marks refunds as unsupported for that
reason, the same caveat the Grin BTCPay connector carries.
## Security notes
- The webhook is authenticated by an HMAC-SHA256 signature over the exact raw
request body, compared in constant time (`hash_equals`). A bad or missing
signature is rejected with HTTP 401.
- Webhook deliveries are deduplicated on their event id, and order completion is
idempotent, so a retried or duplicated delivery is a no-op.
- The QR SVG rendered on the order-received page is passed through a strict
`wp_kses` allowlist (svg, g, rect, path, image, title), so a compromised or
misconfigured endpoint cannot inject script.
- Secrets live in the gateway settings, never in code.
## Credit
Built by Claude (Anthropic) for the Goblin project.
@@ -0,0 +1,41 @@
/* global window */
/**
* WooCommerce Blocks (Checkout/Cart block) integration for the GoblinPay
* payment gateway. No build step: uses the globals WooCommerce Blocks exposes
* (wc-blocks-registry, wc-settings, wp-element, wp-html-entities). This is a
* redirect/on-site gateway: the block submits to the Store API, which runs the
* server-side process_payment() and follows the returned redirect (to the
* hosted GoblinPay /pay page, or the order-received page for the embedded QR).
*/
( function () {
'use strict';
if ( ! window.wc || ! window.wc.wcBlocksRegistry || ! window.wp || ! window.wp.element ) {
return;
}
var registerPaymentMethod = window.wc.wcBlocksRegistry.registerPaymentMethod;
var getSetting = window.wc.wcSettings.getSetting;
var createElement = window.wp.element.createElement;
var decodeEntities = ( window.wp.htmlEntities && window.wp.htmlEntities.decodeEntities ) || function ( s ) { return s; };
var data = getSetting( 'goblinpay_data', {} );
var title = decodeEntities( data.title || 'Pay with Grin (GRIN)' );
var description = decodeEntities( data.description || '' );
var Content = function () {
return createElement( 'div', { className: 'goblinpay-blocks-description' }, description );
};
registerPaymentMethod( {
name: 'goblinpay',
label: createElement( 'span', null, title ),
content: createElement( Content, null ),
edit: createElement( Content, null ),
canMakePayment: function () { return true; },
ariaLabel: title,
supports: {
features: data.supports || [ 'products' ],
},
} );
} )();
@@ -0,0 +1,516 @@
<?php
/**
* Plugin Name: GoblinPay for WooCommerce
* Plugin URI: https://git.us-ea.st/GRIN/GoblinPay
* Description: Accept Grin (GRIN / MimbleWimble) payments in WooCommerce through a self-hosted GoblinPay server. The customer pays from their Goblin Wallet by scanning an nprofile QR; payment travels as a gift-wrapped slatepack over Nostr (optionally over the Nym mixnet). Works with the classic and the Blocks checkout. HPOS-compatible.
* Version: 1.0.0
* Author: GoblinPay
* License: GPL-2.0-or-later
* Requires PHP: 8.0
* Requires at least: 6.0
* WC requires at least: 8.0
* WC tested up to: 10.8
* Text Domain: goblinpay-woocommerce
*
* GoblinPay is a receive-only Grin payment server. This gateway talks to its
* REST API directly:
* POST {gp_url}/invoice
* Authorization: Bearer <api_token>
* { order_ref, amount_fiat, currency, memo, match_mode, expiry_secs }
* -> { invoice_id, token, pay_url, nprofile, npub, qr_svg, amount, status, ... }
* and receives payment events at /wp-json/goblinpay/v1/webhook
* (HMAC-SHA256 over the raw body, header "X-GoblinPay-Signature: sha256=<hex>",
* idempotency key in "X-GoblinPay-Delivery: <event_id>").
*
* Refunds are NOT automated: GoblinPay is receive-only (it never sends), so a
* refund is a manual, out-of-band Grin send by the merchant. See README.md.
*
* @package GoblinPayWooCommerce
*/
if (!defined('ABSPATH')) {
exit;
}
define('GOBLINPAY_WC_VERSION', '1.0.0');
define('GOBLINPAY_WC_PLUGIN_FILE', __FILE__);
define('GOBLINPAY_WC_WH_NS', 'goblinpay/v1'); // keep stable: this is the webhook URL registered in GoblinPay
define('GOBLINPAY_WC_GATEWAY_ID', 'goblinpay'); // keep stable: ties to the saved settings option
define('GOBLINPAY_WC_EXPIRE_HOOK', 'goblinpay_wc_expire_check');
define('GOBLINPAY_WC_POLL_HOOK', 'goblinpay_wc_poll_check');
/* HPOS (custom order tables) + Cart/Checkout Blocks compatibility. */
add_action('before_woocommerce_init', function () {
if (class_exists('\Automattic\WooCommerce\Utilities\FeaturesUtil')) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true);
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('cart_checkout_blocks', __FILE__, true);
}
});
/* Block checkout payment integration (Woo Blocks, merged into WC core). */
add_action('woocommerce_blocks_payment_method_type_registration', function ($registry) {
if (class_exists('Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType')) {
require_once __DIR__ . '/includes/class-blocks.php';
$registry->register(new GoblinPay_WC_Blocks_Support());
}
});
/* Register the gateway. */
add_filter('woocommerce_payment_gateways', function ($gateways) {
$gateways[] = 'WC_Gateway_GoblinPay';
return $gateways;
});
/* Settings link on the Plugins list. */
add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) {
$url = admin_url('admin.php?page=wc-settings&tab=checkout&section=' . GOBLINPAY_WC_GATEWAY_ID);
array_unshift($links, '<a href="' . esc_url($url) . '">' . esc_html__('Settings', 'goblinpay-woocommerce') . '</a>');
return $links;
});
add_action('plugins_loaded', function () {
if (!class_exists('WC_Payment_Gateway')) {
return;
}
class WC_Gateway_GoblinPay extends WC_Payment_Gateway {
public function __construct() {
$this->id = GOBLINPAY_WC_GATEWAY_ID;
$this->method_title = __('GoblinPay (Grin)', 'goblinpay-woocommerce');
$this->method_description = __('Accept Grin (GRIN) payments through a self-hosted GoblinPay server. Customers pay from their Goblin Wallet.', 'goblinpay-woocommerce');
$this->has_fields = false;
$this->supports = array('products');
$this->init_form_fields();
$this->init_settings();
$this->title = $this->get_option('title', __('Grin (GRIN)', 'goblinpay-woocommerce'));
$this->description = $this->get_option('description');
$this->enabled = $this->get_option('enabled', 'no');
add_action('woocommerce_update_options_payment_gateways_' . $this->id, array($this, 'process_admin_options'));
add_action('woocommerce_thankyou_' . $this->id, array($this, 'thankyou_page'));
}
public function init_form_fields() {
$webhook_url = esc_html(rest_url(GOBLINPAY_WC_WH_NS . '/webhook'));
$this->form_fields = array(
'enabled' => array(
'title' => __('Enable/Disable', 'goblinpay-woocommerce'),
'type' => 'checkbox',
'label' => __('Enable Grin payments via GoblinPay', 'goblinpay-woocommerce'),
'default' => 'no',
),
'title' => array(
'title' => __('Title', 'goblinpay-woocommerce'),
'type' => 'text',
'default' => __('Grin (GRIN)', 'goblinpay-woocommerce'),
'desc_tip' => true,
'description' => __('Payment method title shown at checkout.', 'goblinpay-woocommerce'),
),
'description' => array(
'title' => __('Description', 'goblinpay-woocommerce'),
'type' => 'textarea',
'default' => __('Pay with Grin from your Goblin Wallet. You will be shown a QR code (or redirected to a secure checkout) to complete the payment.', 'goblinpay-woocommerce'),
),
'gp_url' => array(
'title' => __('GoblinPay URL', 'goblinpay-woocommerce'),
'type' => 'text',
'default' => 'http://127.0.0.1:8192',
'placeholder' => 'http://127.0.0.1:8192',
'desc_tip' => true,
'description' => __('Base URL of your GoblinPay server (no trailing slash).', 'goblinpay-woocommerce'),
),
'api_token' => array(
'title' => __('API Token', 'goblinpay-woocommerce'),
'type' => 'password',
'desc_tip' => true,
'description' => __('Bearer token for the GoblinPay create-invoice API (GP_API_TOKEN on the server).', 'goblinpay-woocommerce'),
),
'webhook_secret' => array(
'title' => __('Webhook Secret', 'goblinpay-woocommerce'),
'type' => 'password',
'description' => sprintf(
/* translators: %s: webhook URL */
__('Shared HMAC secret (GP_WEBHOOK_SECRET on the server). Set GoblinPay\'s GP_WEBHOOK_URL to: %s', 'goblinpay-woocommerce'),
'<code>' . $webhook_url . '</code>'
),
),
'match_mode' => array(
'title' => __('Matching mode', 'goblinpay-woocommerce'),
'type' => 'select',
'default' => 'derived',
'options' => array(
'derived' => __('Per-invoice identity (recommended)', 'goblinpay-woocommerce'),
'memo' => __('Order reference (memo)', 'goblinpay-woocommerce'),
'amount' => __('Amount only', 'goblinpay-woocommerce'),
'' => __('Server default', 'goblinpay-woocommerce'),
),
'desc_tip' => true,
'description' => __('How GoblinPay matches an incoming payment to this order. Per-invoice identity gives each order its own QR and is the most reliable.', 'goblinpay-woocommerce'),
),
'checkout_ux' => array(
'title' => __('Checkout experience', 'goblinpay-woocommerce'),
'type' => 'select',
'default' => 'redirect',
'options' => array(
'redirect' => __('Redirect to the hosted GoblinPay checkout (recommended)', 'goblinpay-woocommerce'),
'embed' => __('Show the QR on the order-received page', 'goblinpay-woocommerce'),
),
'desc_tip' => true,
'description' => __('Redirect sends the customer to GoblinPay\'s /pay page. Embed keeps them on your site and shows the Goblin QR on the order-received page.', 'goblinpay-woocommerce'),
),
'payment_window' => array(
'title' => __('Payment window (minutes)', 'goblinpay-woocommerce'),
'type' => 'number',
'default' => '1440',
'desc_tip' => true,
'description' => __('If still unpaid after this many minutes, the order is cancelled. Set 0 to disable.', 'goblinpay-woocommerce'),
),
'debug' => array(
'title' => __('Debug logging', 'goblinpay-woocommerce'),
'type' => 'checkbox',
'label' => __('Log requests/webhooks (WooCommerce -> Status -> Logs, source "goblinpay")', 'goblinpay-woocommerce'),
'default' => 'no',
),
);
}
private function log($msg) {
if ('yes' === $this->get_option('debug', 'no') && function_exists('wc_get_logger')) {
wc_get_logger()->info(is_string($msg) ? $msg : wp_json_encode($msg), array('source' => 'goblinpay'));
}
}
public function process_payment($order_id) {
$order = wc_get_order($order_id);
$gp_url = rtrim((string) $this->get_option('gp_url'), '/');
$token = trim((string) $this->get_option('api_token'));
if (!$order || '' === $gp_url || '' === $token) {
wc_add_notice(__('Grin payments are not fully configured.', 'goblinpay-woocommerce'), 'error');
return array('result' => 'failure');
}
$window = (int) $this->get_option('payment_window', 1440);
$mode = (string) $this->get_option('match_mode', 'derived');
$payload = array(
'order_ref' => (string) $order->get_id(),
'amount_fiat' => (string) $order->get_total(),
'currency' => $order->get_currency(),
'memo' => sprintf(
/* translators: 1: order number, 2: site name */
__('Order %1$s at %2$s', 'goblinpay-woocommerce'),
$order->get_order_number(),
wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES)
),
);
if ('' !== $mode) {
$payload['match_mode'] = $mode;
}
if ($window > 0) {
$payload['expiry_secs'] = $window * 60;
}
$this->log(array('create_invoice' => $gp_url . '/invoice', 'payload' => $payload));
$resp = wp_remote_post($gp_url . '/invoice', array(
'timeout' => 30,
'headers' => array(
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
),
'body' => wp_json_encode($payload),
));
if (is_wp_error($resp)) {
$this->log(array('create_invoice_error' => $resp->get_error_message()));
wc_add_notice(__('Could not reach the GoblinPay server. Please try again.', 'goblinpay-woocommerce'), 'error');
return array('result' => 'failure');
}
$code = wp_remote_retrieve_response_code($resp);
$body = json_decode(wp_remote_retrieve_body($resp), true);
if ($code < 200 || $code >= 300 || !is_array($body) || empty($body['invoice_id']) || empty($body['pay_url'])) {
$err = (is_array($body) && isset($body['error'])) ? $body['error'] : ('HTTP ' . $code);
$this->log(array('create_invoice_bad_response' => $code, 'body' => $body));
wc_add_notice(
sprintf(
/* translators: %s: error message */
__('Grin payment could not be started: %s', 'goblinpay-woocommerce'),
esc_html((string) $err)
),
'error'
);
return array('result' => 'failure');
}
// Persist the checkout details for the order-received page and reconciliation.
$order->update_meta_data('_goblinpay_invoice_id', sanitize_text_field((string) $body['invoice_id']));
$order->update_meta_data('_goblinpay_pay_url', esc_url_raw((string) $body['pay_url']));
if (!empty($body['token'])) {
$order->update_meta_data('_goblinpay_token', sanitize_text_field((string) $body['token']));
}
if (!empty($body['nprofile'])) {
$order->update_meta_data('_goblinpay_nprofile', sanitize_text_field((string) $body['nprofile']));
}
if (!empty($body['amount'])) {
$order->update_meta_data('_goblinpay_amount', sanitize_text_field((string) $body['amount']));
}
if (!empty($body['qr_svg'])) {
// Sanitised on output; store the raw SVG returned by our own GoblinPay.
$order->update_meta_data('_goblinpay_qr_svg', (string) $body['qr_svg']);
}
// Awaiting payment -> on-hold (reserves stock; avoids WooCommerce's
// default unpaid-order auto-cancel that would kill slow crypto payments).
$order->update_status('on-hold', sprintf(
/* translators: %s: GoblinPay invoice id */
__('Awaiting Grin payment (GoblinPay invoice %s).', 'goblinpay-woocommerce'),
sanitize_text_field((string) $body['invoice_id'])
));
$order->save();
// Webhook-miss safety net: poll the invoice once, mid-window.
wp_schedule_single_event(time() + 5 * MINUTE_IN_SECONDS, GOBLINPAY_WC_POLL_HOOK, array($order->get_id()));
// Expiry fallback.
if ($window > 0) {
wp_schedule_single_event(time() + $window * MINUTE_IN_SECONDS, GOBLINPAY_WC_EXPIRE_HOOK, array($order->get_id()));
}
if (function_exists('WC') && WC()->cart) {
WC()->cart->empty_cart();
}
$ux = (string) $this->get_option('checkout_ux', 'redirect');
if ('embed' === $ux) {
// Stay on-site; the QR renders on the order-received page.
return array('result' => 'success', 'redirect' => $this->get_return_url($order));
}
return array('result' => 'success', 'redirect' => esc_url_raw((string) $body['pay_url']));
}
/** Render the Goblin QR + nprofile panel on the order-received page (embed UX). */
public function thankyou_page($order_id) {
if ('embed' !== (string) $this->get_option('checkout_ux', 'redirect')) {
return;
}
$order = wc_get_order($order_id);
if (!$order || $order->get_payment_method() !== GOBLINPAY_WC_GATEWAY_ID) {
return;
}
if ($order->is_paid()) {
echo '<section class="goblinpay-panel goblinpay-paid"><p>'
. esc_html__('Grin payment received. Thank you!', 'goblinpay-woocommerce')
. '</p></section>';
return;
}
$qr = (string) $order->get_meta('_goblinpay_qr_svg');
$nprofile = (string) $order->get_meta('_goblinpay_nprofile');
$pay_url = (string) $order->get_meta('_goblinpay_pay_url');
$amount = (string) $order->get_meta('_goblinpay_amount');
echo '<section class="goblinpay-panel" style="margin:1.5em 0;padding:1em;border:1px solid #e0e0e0;border-radius:8px;max-width:420px">';
echo '<h2 style="margin-top:0">' . esc_html__('Pay with Goblin (GRIN)', 'goblinpay-woocommerce') . '</h2>';
echo '<p>' . esc_html__('Scan this code with your Goblin Wallet to pay.', 'goblinpay-woocommerce') . '</p>';
if ('' !== $amount) {
echo '<p><strong>' . esc_html__('Amount:', 'goblinpay-woocommerce') . '</strong> ' . esc_html($amount) . '</p>';
}
if ('' !== $qr) {
echo '<div class="goblinpay-qr" style="max-width:280px">' . goblinpay_wc_kses_svg($qr) . '</div>';
}
if ('' !== $nprofile) {
echo '<p style="word-break:break-all;font-family:monospace;font-size:12px">' . esc_html($nprofile) . '</p>';
}
if ('' !== $pay_url) {
echo '<p><a href="' . esc_url($pay_url) . '" target="_blank" rel="noopener">'
. esc_html__('Open the secure GoblinPay checkout', 'goblinpay-woocommerce') . '</a></p>';
}
echo '<p class="goblinpay-status">' . esc_html__('Waiting for payment. This page refreshes automatically.', 'goblinpay-woocommerce') . '</p>';
// Zero-JS live refresh while the order is unpaid (mirrors the hosted page).
echo '<meta http-equiv="refresh" content="20">';
echo '</section>';
}
}
});
/* ----------------------------------------------------------------------- *
* Webhook receiver: POST /wp-json/goblinpay/v1/webhook
* ----------------------------------------------------------------------- */
add_action('rest_api_init', function () {
register_rest_route(GOBLINPAY_WC_WH_NS, '/webhook', array(
'methods' => 'POST',
'permission_callback' => '__return_true', // authenticated by the HMAC signature below
'callback' => 'goblinpay_wc_handle_webhook',
));
});
function goblinpay_wc_log($m) {
$s = get_option('woocommerce_' . GOBLINPAY_WC_GATEWAY_ID . '_settings', array());
if (is_array($s) && !empty($s['debug']) && 'yes' === $s['debug'] && function_exists('wc_get_logger')) {
wc_get_logger()->info(is_string($m) ? $m : wp_json_encode($m), array('source' => 'goblinpay'));
}
}
/**
* Handle a GoblinPay payment webhook. Verifies the HMAC over the exact raw
* body, dedupes on the event id, maps order_ref -> WC order, and settles.
*/
function goblinpay_wc_handle_webhook(WP_REST_Request $request) {
$settings = get_option('woocommerce_' . GOBLINPAY_WC_GATEWAY_ID . '_settings', array());
$secret = (is_array($settings) && isset($settings['webhook_secret'])) ? $settings['webhook_secret'] : '';
$raw = $request->get_body();
$sig = (string) $request->get_header('x-goblinpay-signature');
if ('' === (string) $secret) {
return new WP_REST_Response(array('error' => 'webhook secret not configured'), 500);
}
// Verify HMAC-SHA256 over the EXACT raw body bytes, constant-time compare.
$expected = 'sha256=' . hash_hmac('sha256', $raw, (string) $secret);
if (!hash_equals($expected, $sig)) {
goblinpay_wc_log(array('webhook_bad_sig' => $sig));
return new WP_REST_Response(array('error' => 'invalid signature'), 401);
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return new WP_REST_Response(array('error' => 'bad payload'), 400);
}
goblinpay_wc_log(array('webhook' => $data));
// Idempotency: dedupe on the event id (also carried in X-GoblinPay-Delivery).
$event_id = isset($data['event_id']) ? (string) $data['event_id'] : (string) $request->get_header('x-goblinpay-delivery');
if ('' !== $event_id) {
$key = 'goblinpay_evt_' . md5($event_id);
if (false !== get_transient($key)) {
return new WP_REST_Response(array('ok' => true, 'dedupe' => true), 200); // already processed
}
set_transient($key, 1, WEEK_IN_SECONDS);
}
$event_type = isset($data['event_type']) ? (string) $data['event_type'] : '';
$order_ref = isset($data['order_ref']) ? (string) $data['order_ref'] : '';
$invoice_id = isset($data['invoice_id']) ? (string) $data['invoice_id'] : '';
$payment = (isset($data['payment']) && is_array($data['payment'])) ? $data['payment'] : array();
$slate_id = isset($payment['slate_id']) ? (string) $payment['slate_id'] : '';
if ('' === $order_ref) {
return new WP_REST_Response(array('ok' => true, 'note' => 'no order_ref'), 200); // ack, nothing to do
}
$order = wc_get_order((int) $order_ref);
if (!$order || $order->get_payment_method() !== GOBLINPAY_WC_GATEWAY_ID) {
return new WP_REST_Response(array('ok' => true, 'note' => 'order not found'), 200);
}
// Bind the webhook to the invoice we created for this order (defence in depth).
$known = (string) $order->get_meta('_goblinpay_invoice_id');
if ('' !== $known && '' !== $invoice_id && !hash_equals($known, $invoice_id)) {
goblinpay_wc_log(array('invoice_mismatch' => array('order' => $order_ref, 'known' => $known, 'got' => $invoice_id)));
return new WP_REST_Response(array('ok' => true, 'note' => 'invoice mismatch'), 200);
}
switch ($event_type) {
case 'payment.received':
// Funds received off-chain (S2 returned). Complete the order.
goblinpay_wc_settle_order($order, $slate_id, __('Grin payment received via GoblinPay.', 'goblinpay-woocommerce'));
break;
case 'payment.confirmed':
// On-chain confirmation may arrive after payment.received. Idempotent:
// complete if not already paid, otherwise just note the confirmation.
if (!$order->is_paid()) {
goblinpay_wc_settle_order($order, $slate_id, __('Grin payment confirmed on chain via GoblinPay.', 'goblinpay-woocommerce'));
} else {
$height = isset($payment['confirmed_height']) ? $payment['confirmed_height'] : null;
$order->add_order_note(
null === $height
? __('Grin payment confirmed on chain.', 'goblinpay-woocommerce')
: sprintf(
/* translators: %s: block height */
__('Grin payment confirmed on chain at height %s.', 'goblinpay-woocommerce'),
(string) $height
)
);
}
break;
default:
goblinpay_wc_log(array('unhandled_event' => $event_type));
}
return new WP_REST_Response(array('ok' => true), 200);
}
/** Complete an order once, idempotently. */
function goblinpay_wc_settle_order($order, $slate_id, $note) {
if ($order->is_paid()) {
return;
}
$order->payment_complete('' !== (string) $slate_id ? $slate_id : '');
$order->add_order_note($note);
}
/* Poll fallback: if a webhook was missed, ask GoblinPay for the invoice status. */
add_action(GOBLINPAY_WC_POLL_HOOK, 'goblinpay_wc_poll_invoice');
function goblinpay_wc_poll_invoice($order_id) {
$order = wc_get_order($order_id);
if (!$order || $order->get_payment_method() !== GOBLINPAY_WC_GATEWAY_ID || $order->is_paid()) {
return;
}
$settings = get_option('woocommerce_' . GOBLINPAY_WC_GATEWAY_ID . '_settings', array());
$gp_url = isset($settings['gp_url']) ? rtrim((string) $settings['gp_url'], '/') : '';
$token = isset($settings['api_token']) ? trim((string) $settings['api_token']) : '';
$invoice_id = (string) $order->get_meta('_goblinpay_invoice_id');
if ('' === $gp_url || '' === $token || '' === $invoice_id) {
return;
}
$resp = wp_remote_get($gp_url . '/invoice/' . rawurlencode($invoice_id), array(
'timeout' => 20,
'headers' => array(
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
),
));
if (is_wp_error($resp)) {
goblinpay_wc_log(array('poll_error' => $resp->get_error_message()));
return;
}
$body = json_decode(wp_remote_retrieve_body($resp), true);
if (is_array($body) && isset($body['status']) && 'paid' === $body['status']) {
goblinpay_wc_settle_order($order, '', __('Grin payment reconciled via GoblinPay status poll.', 'goblinpay-woocommerce'));
}
}
/* WooCommerce-side expiry fallback (polls once more before cancelling). */
add_action(GOBLINPAY_WC_EXPIRE_HOOK, 'goblinpay_wc_maybe_expire_order');
function goblinpay_wc_maybe_expire_order($order_id) {
goblinpay_wc_poll_invoice($order_id); // last chance to catch a missed webhook
$order = wc_get_order($order_id);
if (!$order || $order->get_payment_method() !== GOBLINPAY_WC_GATEWAY_ID) {
return;
}
if (!$order->is_paid() && $order->has_status(array('on-hold', 'pending'))) {
$order->update_status('cancelled', __('Grin payment window elapsed without payment.', 'goblinpay-woocommerce'));
}
}
/**
* Sanitise a GoblinPay-generated QR SVG for safe output. Allows only the small
* tag/attribute set the server emits (svg/g/rect/path/image), so a compromised
* or misconfigured endpoint cannot inject script into the order-received page.
*/
function goblinpay_wc_kses_svg($svg) {
$allowed = array(
'svg' => array('xmlns' => true, 'width' => true, 'height' => true, 'viewbox' => true, 'viewBox' => true, 'role' => true, 'shape-rendering' => true, 'class' => true),
'g' => array('fill' => true, 'transform' => true),
'rect' => array('x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true, 'fill' => true),
'path' => array('d' => true, 'fill' => true),
'image' => array('x' => true, 'y' => true, 'width' => true, 'height' => true, 'href' => true, 'xlink:href' => true, 'preserveaspectratio' => true),
'title' => array(),
);
return wp_kses($svg, $allowed);
}
@@ -0,0 +1,51 @@
<?php
/**
* WooCommerce Blocks payment-method integration for the GoblinPay gateway.
*
* @package GoblinPayWooCommerce
*/
if (!defined('ABSPATH')) {
exit;
}
use Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType;
final class GoblinPay_WC_Blocks_Support extends AbstractPaymentMethodType {
protected $name = 'goblinpay';
/** @var array Named to avoid clashing with the parent's protected $settings. */
private $gw_settings = array();
public function initialize() {
$this->gw_settings = get_option('woocommerce_goblinpay_settings', array());
if (!is_array($this->gw_settings)) {
$this->gw_settings = array();
}
}
public function is_active() {
return !empty($this->gw_settings['enabled']) && 'yes' === $this->gw_settings['enabled'];
}
public function get_payment_method_script_handles() {
$handle = 'goblinpay-blocks';
wp_register_script(
$handle,
plugins_url('assets/js/blocks.js', GOBLINPAY_WC_PLUGIN_FILE),
array('wc-blocks-registry', 'wc-settings', 'wp-element', 'wp-html-entities'),
GOBLINPAY_WC_VERSION,
true
);
return array($handle);
}
public function get_payment_method_data() {
return array(
'title' => !empty($this->gw_settings['title']) ? $this->gw_settings['title'] : 'Grin (GRIN)',
'description' => isset($this->gw_settings['description']) ? $this->gw_settings['description'] : '',
'supports' => array('products'),
);
}
}