diff --git a/docs/PREMIUM_2_0_UPGRADE.md b/docs/PREMIUM_2_0_UPGRADE.md
index 24d1766..8d5b70c 100644
--- a/docs/PREMIUM_2_0_UPGRADE.md
+++ b/docs/PREMIUM_2_0_UPGRADE.md
@@ -4,7 +4,19 @@
### Status
-Draft implementation plan for the next major premium-design arc.
+Implementation in progress. The core Premium 2.0 purchase path is now
+implemented in the static browser client:
+
+- admin-signed premium policy lookup and publish stamping;
+- encrypted premium public storefront events;
+- split-zap unlock through NWC;
+- buyer-private purchase-copy publication;
+- purchase-vault v2 index entries that point at private copies;
+- partial-payment resume;
+- pending private-copy retry;
+- legacy vault migration;
+- purchase repair tools;
+- claim-window enforcement for first-time public claims.
Premium 1.0 already shipped the static soft-gate, split-zap unlock flow,
watermarked previews, and NIP-44 purchase vault. This Premium 2.0 plan
@@ -24,6 +36,165 @@ admin-controlled policy.
---
+## 0. Current Implementation Summary
+
+Premium 2.0 now treats a paid design purchase as a multi-step browser
+transaction. Each step is independently recoverable because payment,
+decryption, private-copy publication, and vault-index publication can fail
+at different times.
+
+The happy path is:
+
+1. Buyer clicks `Unlock` or `Resume payment` on a premium card.
+2. Artstr resolves the creator's Lightning endpoint from the creator kind-0
+ profile. If the feed cache is cold after a browser refresh, Artstr
+ fetches the profile before deciding the creator lacks `lud16`/`lud06`.
+3. Artstr resolves the platform Lightning endpoint from the pinned platform
+ profile.
+4. Artstr asks the buyer's NIP-07 signer to sign creator and platform
+ NIP-57 zap requests.
+5. Artstr fetches BOLT11 invoices for both split-zap legs.
+6. Artstr pays both invoices through the buyer's NWC wallet connection.
+7. Artstr records each returned preimage in local partial-payment state.
+8. When both legs are paid, Artstr derives the soft-gate key and decrypts
+ the public premium payload locally.
+9. Artstr publishes a buyer-private encrypted purchase-copy event under the
+ buyer's pubkey.
+10. Artstr upserts the buyer's encrypted purchase-vault index with a v2 item
+ pointing at that private copy.
+11. Artstr clears pending local save state, updates the Purchased tab if it
+ is open, and shows final unlock success.
+
+The user-facing status toasts intentionally show these boundaries:
+
+```text
+Starting unlock...
+Resolving Lightning endpoints...
+Signing zap requests...
+Fetching invoices...
+Paying creator + platform fee...
+Paying creator...
+Paying platform fee...
+Unlocked
+Decrypting design...
+Unlocked. Saving your private purchased copy...
+Private copy saved. Syncing purchase vault...
+Unlocked "
"
+```
+
+If the last visible status is `Private copy saved. Syncing purchase vault...`,
+the payment and private-copy publication have already succeeded. The next
+state is either final success or a recoverable vault-sync error.
+
+### 0.1 Event Roles
+
+Premium 2.0 uses three separate Nostr objects:
+
+```text
+public premium event
+ kind: 30078
+ author: creator
+ role: storefront, preview, price, encrypted source payload
+
+buyer-private purchase copy
+ kind: 30078
+ author: buyer
+ d: artstr:purchase-copy:
+ role: buyer-owned encrypted snapshot of the purchased design
+
+purchase vault
+ kind: 30078
+ author: buyer
+ d: artstr:purchase-vault:v1
+ role: encrypted private index of owned premium designs
+```
+
+The public premium event remains useful as a storefront and provenance
+source. The buyer-private purchase copy is the durable usable asset. The
+purchase vault is the private inventory index that lets the Purchased tab
+find and open buyer-owned copies across browsers/devices.
+
+### 0.2 Purchased Tab Behavior
+
+The Purchased tab is backed by the buyer's encrypted purchase vault. It does
+not list a design merely because the public card is currently unlocked in
+memory. It lists designs once Artstr can load a vault item, and for v2 items
+it then fetches and decrypts the referenced private purchase copy.
+
+Current behavior:
+
+- on tab open, render cached vault items immediately if `_vaultCache` exists;
+- retry locally pending private-copy saves before rendering;
+- re-query relays for the latest vault;
+- render each vault item as a normal feed card that opens the decrypted
+ payload;
+- show `Retry saving private copy` if a payment/decrypt succeeded but relay
+ publish did not finish;
+- show `Repair purchases` to retry pending saves, migrate legacy inline
+ entries, and validate private-copy pointers;
+- after a successful new purchase, refresh the Purchased tab if it is
+ already open.
+
+Because relays are eventually consistent, a just-purchased item may be
+visible from the in-memory cache before every relay has the latest vault
+event. The `Refresh` button forces a relay re-query.
+
+### 0.3 NWC Timeout and Partial-Payment Recovery
+
+NWC responses are not the same thing as Lightning settlement. A wallet can
+settle an invoice but fail to return a `pay_invoice` response before the
+browser timeout. Premium 2.0 handles that as a recoverable state.
+
+Artstr now:
+
+- uses a longer timeout for `pay_invoice` than ordinary NWC requests;
+- stores fetched BOLT11 invoices and returned preimages per leg;
+- calls `lookup_invoice` before paying a cached invoice on retry;
+- sends BOLT11 invoices to `lookup_invoice` as `{ invoice }`, and non-invoice
+ values as `{ payment_hash }`;
+- after a `pay_invoice` timeout, immediately calls `lookup_invoice` on that
+ BOLT11 before treating the leg as failed;
+- only retries legs without a cached preimage.
+
+This matters for split zaps. If the platform fee succeeds and the creator leg
+times out, the buyer should be able to click `Resume payment`; Artstr checks
+cached invoices/preimages and should not repay the platform leg.
+
+### 0.4 Local Pending Save State
+
+Payment success does not guarantee relay persistence. If the buyer paid and
+the browser decrypted the design, but private-copy or vault publication
+fails, Artstr stores a local pending private-copy record.
+
+The pending record exists so the buyer can recover without paying again. The
+Purchased tab surfaces it with `Retry saving private copy`, and the repair
+workflow attempts the same retry automatically.
+
+Pending state should be cleared only after:
+
+```text
+private purchase copy published
+purchase vault index published
+vault cache updated
+```
+
+### 0.5 Success Criteria for a Completed Purchase
+
+A Premium 2.0 purchase is complete when all of these are true:
+
+- both split-zap legs have preimages;
+- the premium payload decrypts;
+- the buyer-private purchase-copy event publishes;
+- the purchase vault publishes with a v2 pointer to that private copy;
+- the local pending-save record is cleared;
+- the Purchased tab can render the vault item.
+
+If only the public premium card says unlocked, the buyer has local access.
+If the Purchased tab also shows the design, the buyer has the intended
+Premium 2.0 durable inventory path.
+
+---
+
## 1. Goals
Premium 2.0 should:
diff --git a/src/index.html b/src/index.html
index 3d8caa4..f186a77 100644
--- a/src/index.html
+++ b/src/index.html
@@ -7815,6 +7815,7 @@
await upsertPurchaseVaultItem(vaultItem);
clearPendingPrivateCopy(row);
vaultSynced = true;
+ refreshPurchasedTabIfVisible({ force: false });
} catch (err) {
vaultErr = err;
savePendingPrivateCopy({
@@ -9559,6 +9560,17 @@
}
}
}
+
+ function refreshPurchasedTabIfVisible(opts = {}) {
+ const wall = $('feedWallMine');
+ const panel = $('panelMine');
+ if (!wall || !panel) return;
+ if (_feedState.filterMine !== 'purchased') return;
+ if (panel.style.display === 'none') return;
+ loadPurchasedDesignsIntoWall(wall, opts).catch((err) => {
+ console.warn('[purchased tab] post-purchase refresh failed', err);
+ });
+ }
// ---- end Purchase vault ------------------------------------------------
diff --git a/tests/premium/premium2-index-integration.test.mjs b/tests/premium/premium2-index-integration.test.mjs
index 5b54abd..e188332 100644
--- a/tests/premium/premium2-index-integration.test.mjs
+++ b/tests/premium/premium2-index-integration.test.mjs
@@ -141,6 +141,15 @@ test('index page migrates legacy inline vault purchases to private copies', asyn
assert.match(html, /await migrateLegacyVaultPrivateCopies\(\{ silent: false \}\)/);
});
+test('index page refreshes the Purchased tab after a successful Premium 2.0 purchase', async () => {
+ const html = await indexHtml();
+
+ assert.match(html, /function refreshPurchasedTabIfVisible\(opts = \{\}\)/);
+ assert.match(html, /if \(_feedState\.filterMine !== 'purchased'\) return/);
+ assert.match(html, /loadPurchasedDesignsIntoWall\(wall, opts\)\.catch/);
+ assert.match(html, /refreshPurchasedTabIfVisible\(\{ force: false \}\)/);
+});
+
test('index page exposes Premium 2.0 purchase repair workflow', async () => {
const html = await indexHtml();