Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This project uses selective package publishing. Each release entry lists the pub
- The buyer attaches its latest SpendingAuth by default; the seller closes at whichever cumulative is higher (its own or the buyer's), so a seller that lost the last authorization can still be paid in full, and a buyer cannot use this path to settle below what it owes.
- Added `antseed buyer channels close <channelId>` (with `--no-auth` and `--json`), which runs the request through a running `antseed buyer start` daemon's live seller connection via the new `/_antseed/channels/close` control-plane endpoint.
- Added `AntseedNode.requestChannelClose(peerId, opts)` to `@antseed/node`, plus the `payments.cooperative-close.v1` capability advertised in discovery metadata and the connection handshake.
- Desktop: new "Deposit with GoodDollar" option under More options in the Add Credits chooser. It opens GoodDollar's hosted AI-credits page in the browser with the buyer address and an EIP-712 `SetOperator` signature (authorizing GoodDollar's operator wallet at the buyer's current on-chain nonce), so their side can submit `setOperator()` and run the $G deposit without the buyer paying gas. URL and operator are overridable via `payments.gooddollar.{url,operator}` in the desktop config.

### Changed

Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/main/ipc/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
readCardProviders,
readCrossmintClientKey,
readFunkitApiKey,
readGoodDollarConfig,
startPaymentsPortal,
} from '../payments/portal.js';
import {
Expand All @@ -63,7 +64,9 @@ import {
ANTSTokenClient,
EmissionsClient,
makeChannelsDomain,
makeDepositsDomain,
peerIdToAddress,
signSetOperator,
signSpendingAuth,
} from '@antseed/node';

Expand Down Expand Up @@ -195,6 +198,49 @@ export function registerPaymentsIpc(): void {
}
});

// GoodDollar checkout: signs SetOperator for GoodDollar's operator wallet
// with the buyer's current on-chain nonce and hands both to their hosted
// page, which submits setOperator() and runs the G$ deposit from there.
ipcMain.handle('payments:open-gooddollar', async () => {
try {
await ensureSecureIdentity();
const identity = getSecureIdentity();
if (!identity) return { ok: false, error: 'Identity not available' };
const { url, operator } = await readGoodDollarConfig();
const cc = await loadCachedCryptoConfig();
if (!cc) return { ok: false, error: 'Chain configuration unavailable' };
const nonce = await makeDepositsClient(cc).getOperatorNonce(identity.wallet.address);
const domain = makeDepositsDomain(cc.chainId, cc.depositsAddress);
const signature = await signSetOperator(identity.wallet, domain, { operator, nonce });

let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { ok: false, error: 'GoodDollar URL is invalid' };
}
const isLoopback = parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost';
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLoopback)) {
return { ok: false, error: 'GoodDollar URL must be https' };
}
parsed.searchParams.set('buyerAddress', identity.wallet.address);
parsed.searchParams.set('operatorSignature', signature);
const target = parsed.toString();

try {
await shell.openExternal(target);
return { ok: true, url: target };
} catch (err) {
console.warn('[payments] system browser launch failed:', err instanceof Error ? err.message : String(err));
}
console.log('[payments] no system browser available — using Electron popup');
openPaymentsPopup(target);
return { ok: true, url: target };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
});

ipcMain.handle('payments:crossmint-config', async () => {
try {
const clientKey = await readCrossmintClientKey();
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/main/payments/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,28 @@ export async function readFunkitApiKey(): Promise<string> {
return process.env.ANTSEED_FUNKIT_API_KEY ?? '';
}

// GoodDollar AI-credits checkout — a hosted page built by the GoodDollar team
// where users buy credits with G$. The page is handed the buyer address plus
// an EIP-712 SetOperator signature authorizing GoodDollar's operator wallet,
// which lets their side submit setOperator() and manage the deposit on the
// buyer's behalf (the buyer never pays gas). Overridable via
// config.payments.gooddollar.{url,operator}.
export const DEFAULT_GOODDOLLAR_URL = 'https://aicredits.gooddollar.org/';
export const DEFAULT_GOODDOLLAR_OPERATOR = '0x192288D921045aa96903e5286E116960e5fb4607';

export async function readGoodDollarConfig(): Promise<{ url: string; operator: string }> {
try {
const config = await readConfig(ACTIVE_CONFIG_PATH);
const record = asRecord(asRecord(config.payments).gooddollar);
return {
url: asString(record.url as string, '') || DEFAULT_GOODDOLLAR_URL,
operator: asString(record.operator as string, '') || DEFAULT_GOODDOLLAR_OPERATOR,
};
} catch {
return { url: DEFAULT_GOODDOLLAR_URL, operator: DEFAULT_GOODDOLLAR_OPERATOR };
}
}


/**
* Bearer token the portal server expects on its pages. Empty until
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ const api = {
paymentsOpenPayPage: (opts: { kind?: string; amountUsdc?: string; channelId?: string }) => ipcRenderer.invoke('payments:open-pay-page', opts),
paymentsCardProviders: () => ipcRenderer.invoke('payments:card-providers'),
paymentsOpenCardProvider: (opts?: { providerId?: string; amountUsdc?: string }) => ipcRenderer.invoke('payments:open-card-provider', opts),
paymentsOpenGoodDollar: () => ipcRenderer.invoke('payments:open-gooddollar'),
paymentsCrossmintConfig: () => ipcRenderer.invoke('payments:crossmint-config'),
paymentsFunkitConfig: () => ipcRenderer.invoke('payments:funkit-config'),
paymentsGetBuyerUsage: () => ipcRenderer.invoke('payments:get-buyer-usage'),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ export type DesktopBridge = {
paymentsOpenPayPage?: (opts: { kind?: 'deposit' | 'withdraw' | 'authorize' | 'claim' | 'diem' | 'close-channel'; amountUsdc?: string; channelId?: string }) => Promise<{ ok: boolean; url?: string; error?: string }>;
paymentsCardProviders?: () => Promise<{ ok: boolean; data?: Array<{ id: string; label: string }>; error?: string }>;
paymentsOpenCardProvider?: (opts?: { providerId?: string; amountUsdc?: string }) => Promise<{ ok: boolean; url?: string; error?: string }>;
paymentsOpenGoodDollar?: () => Promise<{ ok: boolean; url?: string; error?: string }>;
paymentsCrossmintConfig?: () => Promise<{ ok: boolean; data?: { clientKey: string; apiBase: string } | null; error?: string }>;
paymentsFunkitConfig?: () => Promise<{ ok: boolean; data?: { apiKey: string } | null; error?: string }>;
paymentsGetBuyerUsage?: () => Promise<{ ok: boolean; data: DesktopBuyerUsageTotals | null; error: string | null; lastActivityAt?: number | null }>;
Expand Down
73 changes: 73 additions & 0 deletions apps/desktop/src/renderer/ui/components/views/VprDepositView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,47 @@ function MeridianMark({ size = 20 }: { size?: number }) {
);
}

/** Official GoodDollar mark — the brand disc-with-G path (their "Logo Icon
Negative" asset knocks the G out of the disc, so a white backing circle
makes the G read white on the brand-blue disc, matching their app icon). */
const GOODDOLLAR_DISC_PATH =
'M407.836,203.914C407.836.719,243.112-164,39.907-164-163.268-164-328.008.71' +
'9-328.008,203.914s164.74,367.911,367.915,367.911c203.2,0,367.929-164.72,36' +
'7.929-367.911M223.494,29.631l-74.509,74.511c0-.011-.02-.011-.02-.027l-19.3' +
'78,19.374A120.335,120.335,0,1,0,44.493,328.917a120.542,120.542,0,0,0,117.3' +
'-93.454H17.606L71.362,181.7H216.494a174.549,174.549,0,0,1,2.084,26.873c0,9' +
'6.005-78.094,174.1-174.086,174.1A173.522,173.522,0,0,1-78.518,331.648l-25.' +
'343,25.341h-76.077l69.361-69.313a173.125,173.125,0,0,1-19.019-79.1c0-95.98' +
'9,78.1-174.086,174.09-174.086a173.246,173.246,0,0,1,79.162,18.954l23.813-2' +
'3.811Z';

function GoodDollarMark({ size = 20 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="11.8" fill="#fff" />
<path
d={GOODDOLLAR_DISC_PATH}
fill="#00AFFF"
transform="translate(-4.307 -4.307) scale(0.032614) translate(460.122 296.088)"
/>
</svg>
);
}

/** Official Celo mark (yellow disc + black C glyph). */
function CeloMark({ size = 18 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 2500 2500" aria-hidden="true">
<circle cx="1250" cy="1250" r="1250" fill="#FCFF52" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1949.3,546.2H550.7v1407.7h1398.7v-491.4h-232.1c-80,179.3-260.1,304.1-466.2,304.1c-284.1,0-514.2-233.6-514.2-517.5c0-284,230.1-515.6,514.2-515.6c210.1,0,390.2,128.9,470.2,312.1h228.1V546.2z"
/>
</svg>
);
}

/** Official Arbitrum mark (arbitrum.foundation brand asset). */
function ArbitrumMark({ size = 18 }: { size?: number }) {
return (
Expand Down Expand Up @@ -465,6 +506,20 @@ export function VprDepositView({ onSelectView }: Props) {
});
}, [amount]);

// GoodDollar's hosted page needs a fresh SetOperator signature, so the main
// process signs and builds the URL — this can take a beat (an on-chain
// nonce read), hence the pending guard.
const [goodDollarPending, setGoodDollarPending] = useState(false);
const openGoodDollar = useCallback(() => {
setCardNotice(null);
setGoodDollarPending(true);
void window.antseedDesktop?.paymentsOpenGoodDollar?.().then((result) => {
if (!result.ok) setCardNotice(result.error ?? 'Could not open the payment page.');
}).catch((err: unknown) => {
setCardNotice(err instanceof Error ? err.message : String(err));
}).finally(() => setGoodDollarPending(false));
}, []);

// The Fun API key: the main process resolves overrides (user config, then
// runtime environment); release builds fall back to the key baked in at
// build time (see vite.config.ts) so packaged installs work out of the
Expand Down Expand Up @@ -680,6 +735,24 @@ export function VprDepositView({ onSelectView }: Props) {
</span>
</button>

<button
type="button"
className={styles.methodCta}
onClick={openGoodDollar}
disabled={goodDollarPending}
>
<span className={styles.methodCtaIcon}>
<GoodDollarMark />
</span>
<span className={styles.methodCtaText}>
<span className={styles.methodCtaTitle}>Deposit with GoodDollar</span>
<span className={styles.methodCtaCaption}>Pay with $G</span>
</span>
<span className={styles.methodBadges} aria-hidden="true">
<CeloMark />
</span>
</button>

{SHOW_STRIPE_OPTION && (
<button type="button" className={styles.methodCta} onClick={() => openCardProvider('antseed-pay')}>
<span className={styles.methodCtaIcon}>
Expand Down
Loading