Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
30ca30d
test(repo): run the gold app tests from the root test script
alexatsatoshi Sep 29, 2026
a1f95b4
fix(frontend): restore gold security headers lost in the netlify move
alexatsatoshi Sep 25, 2026
39cf116
perf(gold): serve the landing image as webp and shrink the wordmark
alexatsatoshi Sep 25, 2026
0a457a6
fix(gold): show a year of prices under 1A and each period's change
alexatsatoshi Sep 25, 2026
0fc26af
fix(gold): keep tracking a ramp through transient status errors
alexatsatoshi Sep 25, 2026
9aac903
fix(gold): count the pix deadline down from the clock
alexatsatoshi Sep 25, 2026
2fc5466
fix(gold): link the terms and privacy policy accepted at login
alexatsatoshi Sep 25, 2026
8080fca
fix(gold): raise legal and fee note contrast to wcag aa
alexatsatoshi Sep 25, 2026
f619dbd
fix(gold): read kyc readiness from the user's own avenia account
alexatsatoshi Sep 25, 2026
97548b2
fix(gold): resume a sell with the ramp's unsigned wallet transactions
alexatsatoshi Sep 25, 2026
8eef3db
fix(gold): stop abandoned ramps from blocking new operations
alexatsatoshi Sep 25, 2026
8a5c90e
fix(gold): count a resumed pix down to its real start deadline
alexatsatoshi Sep 25, 2026
8a0cf4a
fix(gold): request the e-mail code in brazilian portuguese
alexatsatoshi Sep 25, 2026
0f025e0
fix(gold): keep the vortex session on transient refresh failures
alexatsatoshi Sep 25, 2026
aeb4b15
fix(gold): submit avenia kyc with the alpha-3 country code
alexatsatoshi Sep 25, 2026
2ef6986
docs(repo): list the gold node tests in the testing strategy
alexatsatoshi Sep 25, 2026
f12a756
feat(gold): skip the e-mail code while the vortex session is valid
alexatsatoshi Sep 25, 2026
0ed289e
perf(gold): drop the landing png that the webp replaced
alexatsatoshi Sep 29, 2026
82cff92
fix(gold): lock the buy amount while its quote is prepared
ebma Sep 29, 2026
7953652
fix(gold): clear the previous step's error when going back
ebma Sep 29, 2026
eef5e57
ci(repo): run the gold app tests in the test job
ebma Sep 29, 2026
94ed2d6
fix(gold): keep failed operations in the history with their code
ebma Sep 29, 2026
1a0d846
fix(gold): point the price change arrow down when the price fell
ebma Sep 29, 2026
14812f7
fix(gold): refuse wallet transactions that are not the quoted sell
ebma Sep 29, 2026
082d0fe
fix(gold): never broadcast a sell after its start window closed
ebma Sep 29, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,6 @@ jobs:

- name: 🧪 Frontend tests (coverage-gated)
run: cd apps/frontend && bun run test:coverage

- name: 🧪 Gold tests
run: bun run test:gold
12 changes: 12 additions & 0 deletions apps/frontend/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,15 @@
# apps/gold or the shared/sdk packages would be skipped as "no content change". Build when
# any input of this site changed (paths are relative to the base directory).
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- . ../gold ../../packages/shared ../../packages/sdk"

# Gold signs Ethereum transactions and collects PIX keys and KYC data, so it must not be framed
# by other sites (clickjacking) and keeps the baseline headers of its standalone release
# (gold.satoshipay.io). Scoped to Gold so the rest of the site, e.g. the widget, is unchanged.
[[headers]]
for = "/pt-br/gold/*"
[headers.values]
Content-Security-Policy = "frame-ancestors 'none'"
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "camera=(self), microphone=(), geolocation=()"
29 changes: 29 additions & 0 deletions apps/frontend/src/tests/netlify-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

// Reads the [[headers]] blocks of netlify.toml: a `for = "<path>"` line followed by `Name = "value"` lines.
const headerRules = readFileSync(new URL("../../netlify.toml", import.meta.url), "utf8")
.split("[[headers]]")
.slice(1)
.map(block => ({
path: block.match(/^\s*for\s*=\s*"([^"]+)"/m)?.[1],
values: Object.fromEntries(
[...block.matchAll(/^\s*([A-Za-z-]+)\s*=\s*"([^"]*)"\s*$/gm)]
.filter(([, name]) => name !== "for")
.map(([, name, value]) => [name, value])
)
}));

describe("Netlify headers", () => {
it("keeps the gold app out of third-party frames", () => {
expect(headerRules.find(rule => rule.path === "/pt-br/gold/*")?.values).toMatchObject({
"Content-Security-Policy": "frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY"
});
});

it("does not apply the anti-framing headers beyond gold", () => {
expect(headerRules.filter(rule => rule.values["X-Frame-Options"]).map(rule => rule.path)).toEqual(["/pt-br/gold/*"]);
});
});
Binary file removed apps/gold/public/assets/gold-bar-cutout.png
Binary file not shown.
Binary file added apps/gold/public/assets/gold-bar-cutout.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/gold/public/brand/ouro-wordmark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 32 additions & 21 deletions apps/gold/src/App.jsx

Large diffs are not rendered by default.

22 changes: 13 additions & 9 deletions apps/gold/src/SellFlow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { useEffect, useRef, useState } from "react";
import { Bank, CheckCircle, Copy, WarningCircle } from "@phosphor-icons/react";
import { formatUnits } from "viem";
import { gramsToPaxg, readPaxgBalance } from "./lib/paxg.js";
import { clearActiveRamp, getActiveRamp, saveActiveRamp } from "./lib/pilot-store.js";
import { classifyRamp, createPaxgSellQuote, createVortexClient, getBrazilBuyReadiness, getPaxgAvailability, pollRamp, registerPaxgSell, requestVortexOtp, startRampSafely, submitWalletTransactions, verifyVortexOtp } from "./lib/vortex.js";
import { clearActiveRamp, failActiveRamp, getActiveRamp, saveActiveRamp } from "./lib/pilot-store.js";
import { classifyRamp, createPaxgSellQuote, createVortexClient, getBrazilBuyReadiness, getPaxgAvailability, getRampWithUnsignedTxs, hasVortexSession, pollRamp, registerPaxgSell, requestVortexOtp, startRampSafely, submitWalletTransactions, verifyVortexOtp } from "./lib/vortex.js";

const brl = (amount) => new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(Number(amount));
const gramsLabel = (amount) => new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 6 }).format(Number(amount));
Expand Down Expand Up @@ -37,6 +37,7 @@ export function SellFlow({ ui: { Modal, OtpStep, KycStep }, email, name, walletA
catch (e) {
setError(e.code === 4001 ? "Confirmação cancelada. Você pode continuar esta mesma operação." : e.message || "Não foi possível continuar.");
if (e.status === 401 || e.code === "AUTH_REQUIRED") { setSent(false); setOtp(""); setStep("otp"); }
if (e.code === "START_WINDOW_CLOSED") setStep("issue");
} finally { actionLock.current = false; setBusy(false); }
};

Expand All @@ -52,7 +53,7 @@ export function SellFlow({ ui: { Modal, OtpStep, KycStep }, email, name, walletA
const current = await client.current.getRampStatus(id);
setRamp(current);
if (classifyRamp(current) === "success") { complete(current); return; }
if (classifyRamp(current) === "failure") { setStep("issue"); throw new Error("Esta venda não foi concluída. Consulte o suporte com o código abaixo antes de tentar outra operação."); }
if (classifyRamp(current) === "failure") { failActiveRamp(current.id); setStep("issue"); throw new Error("Esta venda não foi concluída. Consulte o suporte com o código abaixo antes de tentar outra operação."); }
setStep(current.currentPhase === "initial" ? "sign" : "processing");
};
useEffect(() => { if (initialResume.current) run(recover); }, []);
Expand All @@ -62,7 +63,7 @@ export function SellFlow({ ui: { Modal, OtpStep, KycStep }, email, name, walletA
const controller = new AbortController();
pollRamp(client.current, ramp.id, { signal: controller.signal, onUpdate: setRamp }).then((current) => {
if (classifyRamp(current) === "success") complete(current);
else { setStep("issue"); setError("A venda não foi concluída. Consulte o suporte com o código da operação."); }
else { failActiveRamp(current.id); setStep("issue"); setError("A venda não foi concluída. Consulte o suporte com o código da operação."); }
}).catch((e) => { if (e.name !== "AbortError") { setStep("recover"); setError(e.message); } });
return () => controller.abort();
}, [step, ramp?.id]);
Expand All @@ -80,12 +81,15 @@ export function SellFlow({ ui: { Modal, OtpStep, KycStep }, email, name, walletA
setHasGas(balance > 0n);
const result = await createPaxgSellQuote(maxAmount || gramsToPaxg(amount), walletAddress);
client.current = result.client; setQuote(result.quote);
const readiness = await getBrazilBuyReadiness(result.client);
const readiness = await getBrazilBuyReadiness();
if (readiness.kycStatus !== "approved") setStep("kyc");
else if (!readiness.canSell) throw new Error("Sua conta ainda não está liberada para receber PIX. Verifique sua situação com a Vortex.");
else setStep("review");
};

// As in the buy flow: reuse a session verified in this tab for this e-mail, else ask for the code.
const continueFromAmount = () => hasVortexSession(otpEmail) ? run(prepareQuote) : setStep("otp");

const chooseAll = () => run(async () => {
if (demo) { setAmount(String(Math.floor(availableGrams * 1e6) / 1e6)); return; }
const balance = await readPaxgBalance(await getEthereumProvider(), walletAddress);
Expand All @@ -110,22 +114,22 @@ export function SellFlow({ ui: { Modal, OtpStep, KycStep }, email, name, walletA

const continueSigning = () => run(async () => {
if (!client.current) client.current = await createVortexClient();
const current = await client.current.getRampStatus(active.id);
const current = await getRampWithUnsignedTxs(active.id);
setRamp(current);
if (classifyRamp(current) === "success") { complete(current); return; }
if (classifyRamp(current) === "failure") { setStep("issue"); throw new Error("Esta operação não pode ser retomada. Consulte o suporte."); }
if (classifyRamp(current) === "failure") { failActiveRamp(current.id); setStep("issue"); throw new Error("Esta operação não pode ser retomada. Consulte o suporte."); }
if (current.currentPhase !== "initial") { setStep("processing"); return; }
const saved = getActiveRamp(walletAddress);
if (saved?.stage === "registering") throw new Error("O registro precisa ser conferido pela Vortex. Envie o código abaixo ao suporte; não faça uma nova venda.");
const transactions = (current.unsignedTxs || []).filter((tx) => tx.signer?.toLowerCase() === walletAddress.toLowerCase());
if (saved?.stage !== "ready" && !transactions.length) throw new Error("Não foi possível recuperar as confirmações. Consulte o suporte com o código abaixo.");
if (saved?.stage !== "ready") await submitWalletTransactions(client.current, current.id, transactions, walletAddress, await getEthereumProvider());
if (saved?.stage !== "ready") await submitWalletTransactions(client.current, current, transactions, walletAddress, await getEthereumProvider(), saved?.inputAmount);
saveActiveRamp({ rampId: current.id, walletAddress, inputAmount: current.inputAmount, outputAmount: current.outputAmount, rampType: "SELL", stage: "ready" });
setRamp(await startRampSafely(client.current, current.id)); setStep("processing");
});

return <Modal title="Vender ouro" onClose={onClose} wide closeDisabled={busy}>
{step === "amount" && <div className="flow-step"><div className="flow-title"><Bank size={28} /><div><h3>Ouro de volta em reais</h3><p>Receba na sua conta pelo PIX.</p></div></div><label className="amount-field"><span>Quantidade em gramas</span><div><input aria-label="Gramas para vender" inputMode="decimal" value={amount} onChange={(e) => { setMaxAmount(null); setAmount(e.target.value.replace(/[^\d,.]/g, "")); }} /><small>g</small></div></label><p className="conversion-hint">Disponível: {gramsLabel(availableGrams)} g <button className="inline-button" type="button" onClick={chooseAll} disabled={busy}>Usar tudo</button></p><label className="vortex-email-field"><span>Sua chave PIX</span><input value={pix} aria-label="Chave PIX" onChange={(e) => setPix(e.target.value)} placeholder="CPF, e-mail, celular ou chave aleatória" /></label><p className="legal-note">Use uma chave PIX da sua própria conta. Você revisará o valor líquido antes de confirmar.</p><button className="button button--dark button--full" disabled={!valid || busy} onClick={() => setStep("otp")}>Continuar</button></div>}
{step === "amount" && <div className="flow-step"><div className="flow-title"><Bank size={28} /><div><h3>Ouro de volta em reais</h3><p>Receba na sua conta pelo PIX.</p></div></div><label className="amount-field"><span>Quantidade em gramas</span><div><input aria-label="Gramas para vender" inputMode="decimal" value={amount} onChange={(e) => { setMaxAmount(null); setAmount(e.target.value.replace(/[^\d,.]/g, "")); }} /><small>g</small></div></label><p className="conversion-hint">Disponível: {gramsLabel(availableGrams)} g <button className="inline-button" type="button" onClick={chooseAll} disabled={busy}>Usar tudo</button></p><label className="vortex-email-field"><span>Sua chave PIX</span><input value={pix} aria-label="Chave PIX" onChange={(e) => setPix(e.target.value)} placeholder="CPF, e-mail, celular ou chave aleatória" /></label><p className="legal-note">Use uma chave PIX da sua própria conta. Você revisará o valor líquido antes de confirmar.</p><button className="button button--dark button--full" disabled={!valid || busy} onClick={continueFromAmount}>{busy ? "Cotando…" : "Continuar"}</button></div>}
{step === "otp" && <OtpStep email={otpEmail} setEmail={setOtpEmail} otp={otp} setOtp={setOtp} sent={sent} loading={busy} error="" demo={demo} onSend={() => run(async () => { if (!demo) await requestVortexOtp(otpEmail); setSent(true); })} onVerify={() => run(async () => { if (demo) { if (otp !== "123456") throw new Error("Use 123456 no teste."); } else await verifyVortexOtp(otpEmail, otp); if (active) await recover(); else await prepareQuote(); })} />}
{step === "kyc" && <KycStep quote={quote} email={otpEmail} initialName={name} onApproved={() => run(prepareQuote)} />}
{step === "review" && quote && <div className="flow-step"><h3>Revise sua venda</h3><div className="quote-breakdown"><div><span>Ouro a vender</span><b>{gramsLabel(quote.grams)} g</b></div><div><span>Conversão e serviço</span><b>{brl(quote.serviceFee)}</b></div><div><span>Taxas na cotação</span><b>{brl(quote.networkFee)}</b></div><div className="quote-total"><span>Você recebe no PIX</span><b>{brl(quote.outputAmount)}</b></div><small>Taxas acima já descontadas do valor em reais.</small></div><p className="pix-recipient">Chave PIX: <strong>{pix}</strong></p><div className={hasGas === false ? "warning-card" : "info-card"}><WarningCircle size={22} /><span><b>Taxa da sua carteira Ethereum</b>As confirmações de rede são pagas em ETH e exibidas na carteira antes da aprovação. Essa taxa é adicional ao valor da cotação.{hasGas === false && " Sua carteira ainda não tem ETH. Adicione ETH na rede Ethereum ao endereço abaixo para continuar."}</span></div>{hasGas === false && <WalletAddress address={walletAddress} copied={copied} setCopied={setCopied} />}<label className="confirm-check"><input type="checkbox" checked={accepted} onChange={(e) => setAccepted(e.target.checked)} /><span>Conferi minha chave PIX, o valor a receber e as taxas de rede.</span></label><button className="button button--dark button--full" disabled={!accepted || busy || hasGas === false} onClick={execute}>{busy ? "Preparando a venda…" : "Confirmar venda"}</button><button className="link-button centered" disabled={busy} onClick={() => run(prepareQuote)}>Atualizar cotação e saldo de ETH</button></div>}
Expand Down
27 changes: 19 additions & 8 deletions apps/gold/src/lib/market.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,41 @@
const PRICE_KEY = "satoshi:paxg-market:v1";
const PRICE_KEY = "satoshi:paxg-market:v2";
const MAX_AGE = 10 * 60 * 1000;
const HISTORY_DAYS = 365;
// Daily points per chart period: one more point than days, so the change spans the whole period.
const PERIOD_POINTS = { "7D": 8, "30D": 31, "1A": HISTORY_DAYS + 1 };
export const CHART_PERIODS = Object.keys(PERIOD_POINTS);

function buildDemoPoints() {
const labels = Array.from({ length: 42 }, (_, index) => {
const labels = Array.from({ length: HISTORY_DAYS + 1 }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() - (41 - index));
date.setDate(date.getDate() - (HISTORY_DAYS - index));
return new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" }).format(date);
});
return labels.map((label, index) => ({ label, value: Number((559 + index * 0.47 + Math.sin(index / 2.8) * 4.2 + Math.cos(index / 5) * 2.1).toFixed(2)) }));
}

export function getDemoMarket() {
const points = buildDemoPoints();
return { brlPerGram: points.at(-1).value, brlPerOunce: points.at(-1).value * 31.1034768, change: 2.34, points, source: "demo" };
return { brlPerGram: points.at(-1).value, brlPerOunce: points.at(-1).value * 31.1034768, points, source: "demo" };
}

export function chartWindow(points, period) {
const shown = points.slice(-PERIOD_POINTS[period]);
const first = shown[0]?.value;
const last = shown.at(-1)?.value;
return { points: shown, change: first && last ? ((last - first) / first) * 100 : 0 };
}

export async function fetchPaxgMarket() {
const cached = JSON.parse(localStorage.getItem(PRICE_KEY) || "null");
if (cached && Date.now() - cached.savedAt < MAX_AGE) return cached.data;
const response = await fetch("https://api.coingecko.com/api/v3/coins/pax-gold/market_chart?vs_currency=brl&days=42&interval=daily", { headers: { accept: "application/json" } });
const response = await fetch(`https://api.coingecko.com/api/v3/coins/pax-gold/market_chart?vs_currency=brl&days=${HISTORY_DAYS}&interval=daily`, { headers: { accept: "application/json" } });
if (!response.ok) throw new Error("Não foi possível atualizar o preço do ouro.");
const json = await response.json();
const points = json.prices.map(([timestamp, ouncePrice]) => ({ label: new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" }).format(new Date(timestamp)), value: Number((ouncePrice / 31.1034768).toFixed(2)) }));
const first = points[0]?.value || 1;
const last = points.at(-1)?.value || first;
const data = { brlPerGram: last, brlPerOunce: last * 31.1034768, change: ((last - first) / first) * 100, points, source: "coingecko" };
if (!points.length) throw new Error("Não foi possível atualizar o preço do ouro.");
const last = points.at(-1).value;
const data = { brlPerGram: last, brlPerOunce: last * 31.1034768, points, source: "coingecko" };
localStorage.setItem(PRICE_KEY, JSON.stringify({ savedAt: Date.now(), data }));
return data;
}
23 changes: 22 additions & 1 deletion apps/gold/src/lib/paxg.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { createPublicClient, custom, formatUnits, getAddress, parseAbi, parseUnits, toHex } from "viem";
import { createPublicClient, custom, decodeFunctionData, formatUnits, getAddress, parseAbi, parseUnits, toHex } from "viem";
import { mainnet } from "viem/chains";

export const PAXG_ADDRESS = getAddress("0x45804880De22913dAFE09f4980848ECE6EcbAf78");
// Squid's router on Ethereum: the API builds a PAXG sell as an approve to it and a swap through it.
export const SQUID_ROUTER = getAddress("0xce16F69375520ab01377ce7B88f5BA8C48F8D666");
export const TROY_OUNCE_GRAMS = 31.1034768;
const PAXG_ABI = parseAbi(["function balanceOf(address owner) view returns (uint256)"]);
const APPROVE_ABI = parseAbi(["function approve(address spender, uint256 amount) returns (bool)"]);

export async function readPaxgBalance(ethereumProvider, walletAddress) {
if (!ethereumProvider || !walletAddress) return { paxg: 0, grams: 0 };
Expand All @@ -25,6 +28,24 @@ export async function assertSellBalance(provider, address, amount) {
if (parseUnits(String(amount), 18) <= 0n || balance.raw < parseUnits(String(amount), 18)) throw new Error("Saldo de ouro insuficiente. Atualize o saldo ou escolha um valor menor.");
}

// The wallet only signs a PAXG sell as the API builds it: approve at most the sold amount to the Squid
// router, then the router swap. The swap calldata is opaque, so the approval bounds what can be lost.
export function assertPaxgSellTransactions(transactions, { walletAddress, inputAmount }) {
let valid = false;
try {
const [approve, swap] = transactions;
const { functionName, args: [spender, amount] } = decodeFunctionData({ abi: APPROVE_ABI, data: approve.txData.data });
valid = transactions.length === 2 && approve.phase === "squidRouterApprove" && swap.phase === "squidRouterSwap"
&& transactions.every((tx) => tx.network === "ethereum" && getAddress(tx.signer) === getAddress(walletAddress))
&& getAddress(approve.txData.to) === PAXG_ADDRESS && BigInt(approve.txData.value || 0) === 0n
// decodeFunctionData ignores trailing bytes; approve(address,uint256) calldata is exactly 4 + 2 * 32 bytes.
&& approve.txData.data.length === 138 && functionName === "approve"
&& getAddress(spender) === SQUID_ROUTER && amount <= parseUnits(String(inputAmount), 18)
&& getAddress(swap.txData.to) === SQUID_ROUTER;
} catch { valid = false; }
if (!valid) throw new Error("As confirmações recebidas não correspondem a esta venda. Não confirme nada e consulte o suporte com o código da operação.");
}

export function ethereumTransaction(transaction, address) {
if (transaction.chainId != null && BigInt(transaction.chainId) !== 1n) throw new Error("A transação não pertence à rede Ethereum.");
const tx = { from: getAddress(address), to: getAddress(transaction.to), data: transaction.data || "0x" };
Expand Down
9 changes: 9 additions & 0 deletions apps/gold/src/lib/pilot-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ export function clearActiveRamp(rampId) {
if (!rampId || active?.rampId === rampId) localStorage.removeItem(ACTIVE_KEY);
}

// A failed or abandoned operation stops blocking new ones but stays listed with its code for support.
export function failActiveRamp(rampId) {
const active = read(ACTIVE_KEY, null);
if (!rampId || active?.rampId !== rampId) return;
const { walletAddress, inputAmount, outputAmount, rampType } = active;
addRampHistory({ rampId, walletAddress, inputAmount, outputAmount, rampType, status: "failed" });
localStorage.removeItem(ACTIVE_KEY);
}

export function addRampHistory(item) {
const history = read(HISTORY_KEY, []);
const next = [{ ...item, completedAt: Date.now() }, ...history.filter((entry) => entry.rampId !== item.rampId)].slice(0, 12);
Expand Down
Loading
Loading