Skip to content
Closed
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
14 changes: 13 additions & 1 deletion app/features/agicash-db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,17 @@ export const agicashDb = createClient<Database>(supabaseUrl, supabaseAnonKey, {
},
});

export type AgicashDb = typeof agicashDb;
export const anonAgicashDb = createClient<Database>(
supabaseUrl,
supabaseAnonKey,
{
db: {
schema: 'wallet',
},
},
);

export type AgicashDb = typeof agicashDb | typeof anonAgicashDb;

export type AgicashDbUser = Database['wallet']['Tables']['users']['Row'];
export type AgicashDbAccount = Database['wallet']['Tables']['accounts']['Row'];
Expand All @@ -237,3 +247,5 @@ export type AgicashDbTransaction =
export type AgicashDbContact = Database['wallet']['Tables']['contacts']['Row'];
export type AgicashDbCashuSendSwap =
Database['wallet']['Tables']['cashu_send_swaps']['Row'];
export type AgicashDbLockedToken =
Database['wallet']['Tables']['locked_tokens']['Row'];
2 changes: 2 additions & 0 deletions app/features/locked-tokens/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './locked-token-repository';
export * from './locked-token-hooks';
63 changes: 63 additions & 0 deletions app/features/locked-tokens/locked-token-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Token } from '@cashu/cashu-ts';
import {
queryOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { getTokenHash } from '../shared/cashu';
import {
type AnonLockedTokenRepository,
useAnonLockedTokenRepository,
useLockedTokenRepository,
} from './locked-token-repository';

type CreateProps = {
token: Token;
userId: string;
accessCode?: string;
};

export function useCreateLockedToken() {
const repository = useLockedTokenRepository();

return useMutation({
mutationFn: async (props: CreateProps) => {
const { token, accessCode, userId } = props;

const tokenHash = await getTokenHash(token);

return repository.createLockedToken({
tokenHash,
token,
accessCode,
userId,
});
},
});
}

export const lockedTokenQueryOptions = ({
tokenHash,
accessCode,
repository,
}: {
tokenHash: string;
accessCode?: string;
repository: AnonLockedTokenRepository;
}) => {
return queryOptions({
queryKey: ['lockedToken', tokenHash, accessCode],
queryFn: () => repository.getLockedToken({ tokenHash, accessCode }),
staleTime: Number.POSITIVE_INFINITY,
enabled: !!tokenHash,
});
};

export const useGetLockedToken = () => {
const queryClient = useQueryClient();
const repository = useAnonLockedTokenRepository();
return async (tokenHash: string, accessCode?: string) =>
queryClient.fetchQuery(
lockedTokenQueryOptions({ tokenHash, accessCode, repository }),
);
};
154 changes: 154 additions & 0 deletions app/features/locked-tokens/locked-token-repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { type Token, getDecodedToken, getEncodedToken } from '@cashu/cashu-ts';
import type {
AgicashDb,
AgicashDbLockedToken,
} from '~/features/agicash-db/database';
import { agicashDb, anonAgicashDb } from '~/features/agicash-db/database';
import { computeSHA256 } from '~/lib/sha256';
import type { LockedToken } from './locked-token';

type CreateLockedTokenParams = {
/** The unique hash identifier for the token */
tokenHash: string;
/** The locked token to store */
token: Token;
/** Optional access code to protect the token. If not provided, token will be public */
accessCode?: string;
/** The user ID who owns this token */
userId: string;
};

export class LockedTokenRepository {
constructor(private readonly db: AgicashDb) {}

/**
* Creates a new locked token in the database.
* This method is idempotent - if a token with the same hash already exists, it will return the existing one.
* @returns The created or existing locked token.
*/
async createLockedToken({
tokenHash,
token,
accessCode,
userId,
}: CreateLockedTokenParams): Promise<LockedToken> {
const existing = await this.getExistingTokenByHash(tokenHash);
if (existing) {
return existing;
}

const accessCodeHash = accessCode ? await computeSHA256(accessCode) : null;

const query = this.db
.from('locked_tokens')
.insert({
token_hash: tokenHash,
token: getEncodedToken(token),
access_code_hash: accessCodeHash,
user_id: userId,
})
.select()
.single();

const { data, error } = await query;

if (error) {
console.error('Failed to create locked token', {
cause: error,
tokenHash,
userId,
});
throw new Error('Failed to create locked token', { cause: error });
}

if (!data) {
throw new Error('No data returned from create locked token');
}

return this.toLockedToken(data);
}

/**
* Private method to check if a token exists by hash.
* This bypasses access code verification and is only used internally.
* This method can only be called by the user that owns the token.
*/
private async getExistingTokenByHash(
tokenHash: string,
): Promise<LockedToken | null> {
const { data, error } = await this.db
.from('locked_tokens')
.select()
.eq('token_hash', tokenHash)
.single();

if (error || !data) {
return null;
}

return this.toLockedToken(data);
}

async toLockedToken(data: AgicashDbLockedToken): Promise<LockedToken> {
return {
tokenHash: data.token_hash,
token: getDecodedToken(data.token),
createdAt: data.created_at,
updatedAt: data.updated_at,
};
}
}

/**
* Repository for getting locked tokens that can be accessed by anyone.
*/
export class AnonLockedTokenRepository {
constructor(private readonly db: AgicashDb) {}

/**
* Retrieves a locked token by hash and optional access code.
* @returns The locked token data if access code is correct or token is public.
*/
async getLockedToken({
tokenHash,
accessCode,
}: {
/** The hash of the token to retrieve */
tokenHash: string;
/** Optional access code to verify access. Not needed for public tokens. */
accessCode?: string;
}): Promise<LockedToken | null> {
const accessCodeHash = accessCode ? await computeSHA256(accessCode) : null;

const { data, error } = await this.db.rpc('get_locked_token', {
p_token_hash: tokenHash,
p_access_code_hash: accessCodeHash ?? undefined,
});

if (error) {
throw new Error('Failed to get locked token', { cause: error });
}

console.log('getLockedToken data', data);

// TODO: make this return null instead of null values on each column
if (!data || !data.token_hash) {
return null;
}

return {
tokenHash: data.token_hash,
token: getDecodedToken(data.token),
createdAt: data.created_at,
updatedAt: data.updated_at,
};
}
}

export function useLockedTokenRepository() {
return new LockedTokenRepository(agicashDb);
}

export function useAnonLockedTokenRepository() {
return new AnonLockedTokenRepository(anonAgicashDb);
}
12 changes: 12 additions & 0 deletions app/features/locked-tokens/locked-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Token } from '@cashu/cashu-ts';

export type LockedToken = {
/** Hash of the locked token that is used as the primary key */
tokenHash: string;
/** The locked token */
token: Token;
/** Date and time the token was created in ISO 8601 format. */
createdAt: string;
/** Date and time the token was updated in ISO 8601 format. */
updatedAt: string;
};
3 changes: 3 additions & 0 deletions app/features/merchant/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { MerchantProvider, useMerchantStore } from './merchant-provider';
export type { MerchantState } from './merchant-store';
export { MerchantShareCashuToken } from './merchant-share-cashu-token';
56 changes: 56 additions & 0 deletions app/features/merchant/merchant-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
type PropsWithChildren,
createContext,
useContext,
useState,
} from 'react';
import { useStore } from 'zustand';
import type { Account } from '~/features/accounts/account';
import {
useAccountsCache,
useGetLatestAccount,
} from '../accounts/account-hooks';
import { useGetCashuSendSwapQuote } from '../send/cashu-send-swap-hooks';
import {
type MerchantState,
type MerchantStore,
createMerchantStore,
} from './merchant-store';

const MerchantContext = createContext<MerchantStore | null>(null);

type Props = PropsWithChildren<{
/** Usually the user's default account. This sets the initial account to send from. */
initialAccount: Account;
}>;

export const MerchantProvider = ({ initialAccount, children }: Props) => {
const accountsCache = useAccountsCache();
const getLatestAccount = useGetLatestAccount();
const { mutateAsync: getCashuSendSwapQuote } = useGetCashuSendSwapQuote();

const [store] = useState(() =>
createMerchantStore({
initialAccount,
accountsCache,
getLatestAccount,
getCashuSendSwapQuote,
}),
);

return (
<MerchantContext.Provider value={store}>
{children}
</MerchantContext.Provider>
);
};

export const useMerchantStore = <T = MerchantState>(
selector?: (state: MerchantState) => T,
): T => {
const store = useContext(MerchantContext);
if (!store) {
throw new Error('Missing MerchantProvider in the tree');
}
return useStore(store, selector ?? ((state) => state as T));
};
Loading