Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5b84fbd
add Cobe
Sharqiewicz Mar 23, 2026
495e266
fix cobe position
Sharqiewicz Mar 23, 2026
1b33539
improve Cobe size
Sharqiewicz Mar 24, 2026
b823521
improve Globe responsiveness
Sharqiewicz Mar 31, 2026
5e8f6b8
fix Globe position
Sharqiewicz Mar 31, 2026
5c00be4
Merge branch 'staging' into feat/introduce-cobe
Sharqiewicz Mar 31, 2026
101c6e1
add vertical rotation, 2-axis drag, and fix DOM attribute leak
Sharqiewicz Apr 1, 2026
bdd2edd
fix vortex-primary-inverse button hover color
Sharqiewicz Apr 1, 2026
45a3fa6
address PR comments
Sharqiewicz Apr 1, 2026
128d6b6
address PR comments
Sharqiewicz Apr 1, 2026
5402c82
Merge pull request #1103 from pendulum-chain/fix/button-hover-colors
ebma Apr 2, 2026
aecd4e8
fix(security): autofix Express is not emitting security headers
aikido-autofix[bot] Apr 2, 2026
2ad5863
fix(security): autofix 3rd party Github Actions should be pinned
aikido-autofix[bot] Apr 2, 2026
4971165
Merge pull request #1100 from pendulum-chain/feat/introduce-cobe
Sharqiewicz Apr 2, 2026
b790f7e
Merge pull request #1108 from pendulum-chain/fix/aikido-security-sast…
ebma Apr 2, 2026
d5d3b8a
Update ci.yml
ebma Apr 2, 2026
826f689
Merge pull request #1111 from pendulum-chain/improve-dependency-manag…
ebma Apr 2, 2026
74df56f
Remove dependency
ebma Apr 2, 2026
b7ee81a
Merge pull request #1107 from pendulum-chain/fix/aikido-security-sast…
ebma Apr 2, 2026
6124c07
disable button verifications
gianfra-t Apr 6, 2026
1b7c183
add empty callback field
gianfra-t Apr 6, 2026
02404a1
ammend type
gianfra-t Apr 7, 2026
c1d4b39
Merge pull request #1115 from pendulum-chain/hotfix-kyc-request
ebma Apr 7, 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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ jobs:
uses: actions/checkout@v3

- name: 🧩 Setup Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.1

- name: 🧩 Install dependencies
run: bun install
run: bun install --frozen-lockfile

- name: Check versions
run: |
Expand Down
2 changes: 2 additions & 0 deletions apps/api/webhooks-cache/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import bodyParser from "body-parser";
import express, { type NextFunction, type Request, type Response } from "express";
import helmet from "helmet";
import httpStatus from "http-status";
import logger from "../src/config/logger";

Expand Down Expand Up @@ -32,6 +33,7 @@ class EventStore {
}

const app = express();
app.use(helmet());
const PORT = process.env.PORT || 3000;
const PASSWORD = process.env.PASSWORD || "bananas";
const MAX_EVENTS = 1000;
Expand Down
56 changes: 8 additions & 48 deletions apps/frontend/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,7 @@
}

.btn-vortex-accent {
@apply bg-gray-200;
@apply text-gray-700;
@apply rounded-full;
@apply border;
@apply border-gray-300;
@apply duration-200;
@apply bg-gray-200 text-gray-700 rounded-full border border-gray-300;
transition: scale 0.1s ease-in-out;
}

Expand All @@ -185,12 +180,7 @@
}

.btn-vortex-success {
@apply bg-success;
@apply text-success-content;
@apply rounded-[var(--radius-field)];
@apply border;
@apply border-success;
@apply duration-200;
@apply bg-success text-success-content rounded-[var(--radius-field)] border border-success;
transition: scale 0.1s ease-in-out;
}

Expand Down Expand Up @@ -228,12 +218,7 @@
}

.btn-vortex-primary-inverse {
@apply bg-white;
@apply text-primary;
@apply rounded-[var(--radius-field)];
@apply border;
@apply border-primary;
@apply cursor-pointer;
@apply bg-white text-primary rounded-[var(--radius-field)] border border-primary cursor-pointer;
transition: scale 0.1s ease-in-out;
}

Expand All @@ -242,31 +227,15 @@
}

.btn-vortex-primary-inverse:hover {
@apply bg-primary/20;
@apply border-primary;
@apply bg-gray-300 border-primary text-primary;
}

.btn-vortex-primary-inverse:disabled {
@apply bg-white;
@apply text-primary;
@apply border-primary;
@apply opacity-40;
@apply cursor-not-allowed;
}

.btn-vortex-primary-inverse:active,
.btn-vortex-primary-inverse:focus {
@apply bg-primary/20;
@apply text-primary;
@apply border-primary;
@apply bg-white text-primary border-primary opacity-40 cursor-not-allowed;
}

.btn-vortex-secondary {
@apply text-white;
@apply bg-pink-600;
@apply rounded-[var(--radius-field)];
@apply border-pink-600;
@apply shadow-none;
@apply text-white bg-pink-600 rounded-[var(--radius-field)] border-pink-600 shadow-none;
transition: scale 0.1s ease-in-out;
}

Expand All @@ -285,12 +254,7 @@
}

.btn-vortex-danger {
@apply bg-error;
@apply text-error-content;
@apply rounded-xl;
@apply border;
@apply border-error;
@apply shadow-none;
@apply bg-error text-error-content rounded-xl border border-error shadow-none;
transition: scale 0.1s ease-in-out;
}

Expand All @@ -305,11 +269,7 @@
}

.btn-vortex-danger:disabled {
@apply bg-error;
@apply text-error-content;
@apply border-error;
@apply opacity-40;
@apply cursor-not-allowed;
@apply bg-error text-error-content border-error opacity-40 cursor-not-allowed;
}

.btn-vortex-danger:active,
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "catalog:",
"crypto-js": "^4.2.0",
"i18next": "^24.2.3",
"input-otp": "^1.4.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany
};

// formState.isValid is not working as expected, so we need to check the errors
const isFormInvalid = Object.keys(form.formState.errors).length > 0 || !form.formState.isDirty || form.formState.isSubmitting;
const isFormInvalid = Object.keys(form.formState.errors).length > 0 || form.formState.isSubmitting;

return (
<FormProvider {...form}>
<motion.form
animate={{ opacity: 1, scale: 1 }}
className="mt-8 mb-4 flex w-full flex-col"
initial={{ opacity: 0.8, scale: 0.9 }}
onSubmit={handleSubmit(onSubmit)}
onSubmit={onSubmit}
transition={{ duration: 0.3 }}
>
<div className="flex-1 pb-36">
Expand Down
215 changes: 215 additions & 0 deletions apps/frontend/src/components/Globe/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import createGlobe from "cobe";
import { useEffect, useRef, useSyncExternalStore } from "react";
import ARS_ICON from "../../assets/coins/ARS.png";
import BRL_ICON from "../../assets/coins/BRL.png";
import COP_ICON from "../../assets/coins/COP.png";
import EUR_ICON from "../../assets/coins/EU.png";
import MXN_ICON from "../../assets/coins/MXN.png";
import USD_ICON from "../../assets/coins/USD.png";
import { prefersReducedMotion } from "../../constants/animations";
import { cn } from "../../helpers/cn";

const GLOBE_THETA = -0.08;
const GLOBE_INITIAL_PHI = -1.32;
const NORMAL_SPEED = 0.0005;
const VERTICAL_SPEED = -0.0005;
const GLOBE_COLOR: [number, number, number] = [0.07, 0.23, 0.72];

const GLOBE_SIZES = {
lg: 960,
md: 780,
sm: 560
} as const;

const CURRENCY_MARKERS = [
{ currency: "usd", icon: USD_ICON, lat: 38.91, lng: -77.04 },
{ currency: "brl", icon: BRL_ICON, lat: -15.8, lng: -47.89 },
{ currency: "eur", icon: EUR_ICON, lat: 50.85, lng: 4.35 },
{ currency: "mxn", icon: MXN_ICON, lat: 19.43, lng: -99.13 },
{ currency: "cop", icon: COP_ICON, lat: 4.71, lng: -74.07 },
{ currency: "ars", icon: ARS_ICON, lat: -34.61, lng: -58.38 }
] as const;

function getGlobeSize(): number {
if (window.matchMedia("(min-width: 1024px)").matches) return GLOBE_SIZES.lg;
if (window.matchMedia("(min-width: 640px)").matches) return GLOBE_SIZES.md;
return GLOBE_SIZES.sm;
}

function createGlobeConfig(size: number) {
const bufferSize = size * window.devicePixelRatio;
return {
arcColor: [0.3, 0.5, 1] as [number, number, number],
arcHeight: 0.25,
arcs: CURRENCY_MARKERS.flatMap((a, i) =>
CURRENCY_MARKERS.slice(i + 1).map(b => ({
from: [a.lat, a.lng] as [number, number],
to: [b.lat, b.lng] as [number, number]
}))
),
arcWidth: 0.3,
baseColor: GLOBE_COLOR,
dark: 1,
devicePixelRatio: window.devicePixelRatio,
diffuse: 1.2,
glowColor: GLOBE_COLOR,
height: bufferSize,
mapBrightness: 3,
mapSamples: 12000,
markerColor: GLOBE_COLOR,
markers: [],
phi: 0,
theta: GLOBE_THETA,
width: bufferSize
};
}

// Matches cobe's shader coordinate system exactly:
// sphere point: px = -cos(lat)*cos(lng), py = sin(lat), pz = cos(lat)*sin(lng)
// rotation: screen = R_x(theta) * R_y(phi) * p (derived from mat3 A(theta,phi) in cobe's GLSL)
function projectToScreen(lat: number, lng: number, phi: number, theta: number, size: number) {
const pixelRadius = 0.8 * (size / 2);
const latRad = (lat * Math.PI) / 180;
const lngRad = (lng * Math.PI) / 180;

const px = -Math.cos(latRad) * Math.cos(lngRad);
const py = Math.sin(latRad);
const pz = Math.cos(latRad) * Math.sin(lngRad);

const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const cosTheta = Math.cos(theta);
const sinTheta = Math.sin(theta);

const sx = cosPhi * px + sinPhi * pz;
const sy = sinPhi * sinTheta * px + cosTheta * py - cosPhi * sinTheta * pz;
const sz = -sinPhi * cosTheta * px + sinTheta * py + cosPhi * cosTheta * pz;

if (sz <= 0) return null;

return {
x: size / 2 + sx * pixelRadius,
y: size / 2 - sy * pixelRadius
};
}

function updateMarkers(phi: number, theta: number, size: number, markerRefs: React.RefObject<(HTMLImageElement | null)[]>) {
for (let i = 0; i < CURRENCY_MARKERS.length; i++) {
const el = markerRefs.current[i];
if (!el) continue;
const pos = projectToScreen(CURRENCY_MARKERS[i].lat, CURRENCY_MARKERS[i].lng, phi + Math.PI, theta, size);
if (pos) {
el.style.display = "";
el.style.left = `${pos.x}px`;
el.style.top = `${pos.y}px`;
} else {
el.style.display = "none";
}
}
}

function useDragRotation(phiRef: React.RefObject<number>, thetaRef: React.RefObject<number>, size: number) {
const isDraggingRef = useRef(false);
const lastPointerXRef = useRef(0);
const lastPointerYRef = useRef(0);

const onPointerDown = (e: React.PointerEvent) => {
isDraggingRef.current = true;
lastPointerXRef.current = e.clientX;
lastPointerYRef.current = e.clientY;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};

const onPointerMove = (e: React.PointerEvent) => {
if (!isDraggingRef.current) return;
phiRef.current += ((e.clientX - lastPointerXRef.current) / size) * Math.PI;
thetaRef.current += ((e.clientY - lastPointerYRef.current) / size) * Math.PI;
lastPointerXRef.current = e.clientX;
lastPointerYRef.current = e.clientY;
};

const onPointerUp = () => {
isDraggingRef.current = false;
};

return { isDraggingRef, onPointerCancel: onPointerUp, onPointerDown, onPointerMove, onPointerUp };
}

function subscribeToBreakpoints(callback: () => void) {
const mqLg = window.matchMedia("(min-width: 1024px)");
const mqSm = window.matchMedia("(min-width: 640px)");
mqLg.addEventListener("change", callback);
mqSm.addEventListener("change", callback);
return () => {
mqLg.removeEventListener("change", callback);
mqSm.removeEventListener("change", callback);
};
}

interface GlobeProps {
className?: string;
}

export const Globe = ({ className }: GlobeProps) => {
const reducedMotion = prefersReducedMotion();
const size = useSyncExternalStore(subscribeToBreakpoints, getGlobeSize, getGlobeSize);

const canvasRef = useRef<HTMLCanvasElement>(null);
// positions written directly to DOM each frame, skipping React re-renders
const markerRefs = useRef<(HTMLImageElement | null)[]>(CURRENCY_MARKERS.map(() => null));
const phiRef = useRef(GLOBE_INITIAL_PHI);
const thetaRef = useRef(GLOBE_THETA);

const { isDraggingRef, ...dragHandlers } = useDragRotation(phiRef, thetaRef, size);

useEffect(() => {
if (!canvasRef.current || reducedMotion) return;
let rafId: number;
const globe = createGlobe(canvasRef.current, createGlobeConfig(size));
const tick = () => {
if (!isDraggingRef.current) {
phiRef.current += NORMAL_SPEED;
thetaRef.current += VERTICAL_SPEED;
}
globe.update({ phi: phiRef.current, theta: thetaRef.current });
updateMarkers(phiRef.current, thetaRef.current, size, markerRefs);
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
globe.destroy();
cancelAnimationFrame(rafId);
};
}, [reducedMotion, size]);

return (
<div
className={cn("absolute cursor-grab select-none active:cursor-grabbing", className)}
style={{ height: size, touchAction: "manipulation", width: size }}
{...dragHandlers}
>
<canvas
height={size * window.devicePixelRatio}
ref={canvasRef}
style={{ height: size, width: size }}
width={size * window.devicePixelRatio}
/>
<div className="pointer-events-none absolute inset-0">
{CURRENCY_MARKERS.map((m, i) => (
<img
alt={m.currency.toUpperCase()}
className="-translate-x-1/2 -translate-y-1/2 absolute rounded-full shadow-lg ring-2 ring-white/60"
height={32}
key={m.currency}
ref={el => {
markerRefs.current[i] = el;
}}
src={m.icon}
style={{ display: "none", left: 0, top: 0 }}
width={32}
/>
))}
</div>
</div>
);
};
4 changes: 2 additions & 2 deletions apps/frontend/src/components/Navbar/DesktopNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export const DesktopNavbar = () => {
</div>

<div className="flex items-center">
<Link className="btn btn-vortex-secondary rounded-3xl" to="/{-$locale}/widget">
Buy & Sell
<Link className="btn btn-vortex-secondary !rounded-3xl" to="/{-$locale}/widget">
Open App
</Link>
</div>
</>
Expand Down
Loading
Loading