Sitelet https://github.com/ChainSafe/canton-snap/commit/ef53bb6ba7fe0f79d44b0ff16d76daac6352d5fb
Skip to content

Commit ef53bb6

Browse files
authored
feat(dapp): transfer by Canton party id with offer expiry (#84)
* feat(dapp): transfer by Canton party id with offer expiry Add a recipient-type toggle (EVM address / Canton party id) to the Transfer flow and route each path to the matching middleware endpoint: - Non-custodial sends (address or party) go through prepare/execute, sending `to` or `to_party_id` plus the now-mandatory `validity_seconds` (canton-middleware#334). - Custodial party-id sends use the server-signed POST /transfer/custodial endpoint; custodial address sends keep the existing ERC-20 path. The custodial-party receipt settles immediately with no eth tx hash. Add an Offer expiry control (presets + a custom value/unit) shown for any send that creates an on-ledger offer, feeding `validity_seconds`. Validate party ids and the expiry (positive, <= 365 days) client-side. Implements #80. * style(dapp): prettier formatting * fix(dapp): reset recipient type/expiry on Send another; decimal keypad Reset recipientType and validitySeconds in handleReset so the form starts clean (no orphaned custom expiry with nothing highlighted), and use inputMode="decimal" since the custom expiry accepts fractional values. Addresses Gemini review on #84. * feat(dapp): show offer expiry only for offer-based tokens Offer expiry applies only to offer-based tokens (USDCx today) where the recipient must accept; other tokens settle directly, so the expiry field is hidden for them. Which tokens are offer-based is configurable via VITE_OFFER_EXPIRY_TOKENS (comma-separated symbols, default USDCX). Gate the expiry control and its validation on the selected token, and adapt the custodial-party info copy for direct vs. offer sends. * refactor(dapp): offer-based token list as a config constant, not env Drop VITE_OFFER_EXPIRY_TOKENS; keep the offer-based token list as a plain constant (OFFER_BASED_TOKENS) in lib/config.ts. No env var needed.
1 parent 2556cf3 commit ef53bb6

4 files changed

Lines changed: 576 additions & 27 deletions

File tree

‎packages/dapp/src/lib/config.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,18 @@ export const NETWORK: NetworkConfig = {
5050
middlewareUrl,
5151
nativeCurrency: CANTON_NATIVE_CURRENCY,
5252
};
53+
54+
// Tokens whose transfers are offer-based: the recipient must accept, so the
55+
// sender sets an offer validity (expiry) and can reclaim it if it lapses.
56+
// Everything else settles directly, so the Transfer UI hides the expiry field
57+
// for those. USDCx is the only offer-based token today — add symbols here as
58+
// more are introduced.
59+
const OFFER_BASED_TOKENS: readonly string[] = ["USDCX"];
60+
61+
const OFFER_BASED_TOKEN_SET: ReadonlySet<string> = new Set(
62+
OFFER_BASED_TOKENS.map((s) => s.toUpperCase()),
63+
);
64+
65+
export function isOfferBasedToken(symbol: string | undefined): boolean {
66+
return symbol !== undefined && OFFER_BASED_TOKEN_SET.has(symbol.toUpperCase());
67+
}

‎packages/dapp/src/lib/transfer.ts‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,30 @@ export interface PrepareResult {
1010
expiresAt: string;
1111
}
1212

13+
// How the recipient field is interpreted. `address` → a registered user's EVM
14+
// address (`to`); `party` → an arbitrary Canton party id (`to_party_id`), which
15+
// may live on an external participant. The middleware requires exactly one of
16+
// the two, so the dapp sends whichever matches the chosen type.
17+
export type RecipientType = "address" | "party";
18+
19+
// Offer validity presets surfaced in the Transfer UI. The chosen value is sent
20+
// as `validity_seconds`: how long the recipient has to accept before the offer
21+
// expires on-ledger and the funds become reclaimable by the sender.
22+
export interface ValidityPreset {
23+
label: string;
24+
seconds: number;
25+
}
26+
27+
export const VALIDITY_PRESETS: readonly ValidityPreset[] = [
28+
{ label: "1 hour", seconds: 3600 },
29+
{ label: "6 hours", seconds: 21600 },
30+
{ label: "1 day", seconds: 86400 },
31+
{ label: "1 week", seconds: 604800 },
32+
];
33+
34+
// Default offer validity (1 day) — used when the caller doesn't specify one.
35+
export const DEFAULT_VALIDITY_SECONDS = 86400;
36+
1337
// Server-truncated party identifiers (e.g. `user_2dA…4680b7ec`). The endpoint
1438
// is unauthenticated, so the middleware redacts the fingerprint portion of the
1539
// party id; the truncated form is sufficient for the dapp to disambiguate
@@ -40,15 +64,20 @@ async function makeAuthHeaders(
4064
export async function prepareTransfer(
4165
baseUrl: string,
4266
address: string,
43-
to: string,
67+
recipient: string,
4468
token: string,
4569
amount: string,
70+
recipientType: RecipientType = "address",
71+
validitySeconds: number = DEFAULT_VALIDITY_SECONDS,
4672
): Promise<PrepareResult> {
4773
const authHeaders = await makeAuthHeaders(address);
74+
// The middleware accepts exactly one of `to` (EVM address) or `to_party_id`
75+
// (Canton party id); `validity_seconds` is mandatory (canton-middleware#334).
76+
const recipientField = recipientType === "party" ? { to_party_id: recipient } : { to: recipient };
4877
const res = await fetch(`${baseUrl}/api/v2/transfer/prepare`, {
4978
method: "POST",
5079
headers: { "Content-Type": "application/json", ...authHeaders },
51-
body: JSON.stringify({ to, amount, token }),
80+
body: JSON.stringify({ ...recipientField, amount, token, validity_seconds: validitySeconds }),
5281
});
5382
if (!res.ok) throw new Error(friendlyError(res.status, await res.text()));
5483
const data = await res.json();
@@ -76,6 +105,33 @@ export async function executeTransfer(
76105
if (!res.ok) throw new Error(friendlyError(res.status, await res.text()));
77106
}
78107

108+
// Custodial single-call transfer to a Canton party id. The middleware holds the
109+
// custodial user's Canton key and signs server-side, so prepare + execute happen
110+
// in one request — there's no hash for the dapp to sign and no snap involved.
111+
// Settles directly (the recipient still accepts if they're external). Custodial
112+
// transfers to a plain EVM address keep using the existing ERC-20 path.
113+
export async function sendCustodialTransfer(
114+
baseUrl: string,
115+
address: string,
116+
toPartyId: string,
117+
token: string,
118+
amount: string,
119+
validitySeconds: number = DEFAULT_VALIDITY_SECONDS,
120+
): Promise<void> {
121+
const authHeaders = await makeAuthHeaders(address);
122+
const res = await fetch(`${baseUrl}/api/v2/transfer/custodial`, {
123+
method: "POST",
124+
headers: { "Content-Type": "application/json", ...authHeaders },
125+
body: JSON.stringify({
126+
to_party_id: toPartyId,
127+
amount,
128+
token,
129+
validity_seconds: validitySeconds,
130+
}),
131+
});
132+
if (!res.ok) throw new Error(friendlyError(res.status, await res.text()));
133+
}
134+
79135
// GET /api/v2/transfer/incoming?address=<addr>. Unauthenticated.
80136
export async function listIncomingTransfers(
81137
baseUrl: string,

‎packages/dapp/src/pages/TransferPage.module.css‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,170 @@
148148
margin-bottom: 16px;
149149
}
150150

151+
/* ── Recipient type toggle (EVM Address / Canton Party ID) ── */
152+
.rtypeGrid {
153+
display: grid;
154+
grid-template-columns: 1fr 1fr;
155+
gap: 10px;
156+
}
157+
158+
.rtypeOption {
159+
display: flex;
160+
align-items: center;
161+
gap: 11px;
162+
padding: 13px 15px;
163+
border-radius: 12px;
164+
border: 1px solid #353a5a;
165+
background: #1a1d2e;
166+
cursor: pointer;
167+
text-align: left;
168+
font-family: var(--font-sans);
169+
transition:
170+
border-color 0.15s,
171+
background 0.15s;
172+
}
173+
174+
.rtypeOption:hover {
175+
border-color: #43496e;
176+
}
177+
178+
.rtypeOptionSelected {
179+
border-color: var(--teal);
180+
background: rgba(0, 212, 164, 0.07);
181+
}
182+
183+
.rtypeIcon {
184+
width: 34px;
185+
height: 34px;
186+
border-radius: 9px;
187+
flex-shrink: 0;
188+
display: flex;
189+
align-items: center;
190+
justify-content: center;
191+
background: #0a0b14;
192+
border: 1px solid var(--border);
193+
}
194+
195+
.rtypeOptionSelected .rtypeIcon {
196+
border-color: rgba(0, 212, 164, 0.4);
197+
}
198+
199+
.rtypeText {
200+
display: flex;
201+
flex-direction: column;
202+
min-width: 0;
203+
}
204+
205+
.rtypeTitle {
206+
font-size: 13.5px;
207+
font-weight: 600;
208+
color: var(--text-primary);
209+
}
210+
211+
.rtypeDesc {
212+
font-size: 11px;
213+
color: var(--text-muted);
214+
margin-top: 2px;
215+
}
216+
217+
.rtypeRadio {
218+
width: 16px;
219+
height: 16px;
220+
border-radius: 50%;
221+
border: 1.5px solid #353a5a;
222+
margin-left: auto;
223+
flex-shrink: 0;
224+
}
225+
226+
.rtypeRadioSelected {
227+
border-color: var(--teal);
228+
background: radial-gradient(circle, var(--teal) 0 5px, transparent 6px);
229+
}
230+
231+
/* ── Offer expiry chips + custom value ── */
232+
.expiryRow {
233+
display: flex;
234+
flex-wrap: wrap;
235+
gap: 8px;
236+
}
237+
238+
.expiryChip {
239+
flex: 1;
240+
min-width: 72px;
241+
height: 44px;
242+
border-radius: 10px;
243+
border: 1px solid #353a5a;
244+
background: #1a1d2e;
245+
color: var(--text-secondary);
246+
font-size: 13px;
247+
font-weight: 600;
248+
font-family: var(--font-sans);
249+
cursor: pointer;
250+
transition:
251+
border-color 0.15s,
252+
background 0.15s,
253+
color 0.15s;
254+
}
255+
256+
.expiryChip:hover {
257+
border-color: #43496e;
258+
color: var(--text-primary);
259+
}
260+
261+
.expiryChipSelected {
262+
border-color: var(--teal);
263+
background: rgba(0, 212, 164, 0.07);
264+
color: var(--teal);
265+
}
266+
267+
.expiryCustomRow {
268+
display: flex;
269+
gap: 8px;
270+
margin-top: 8px;
271+
}
272+
273+
.expiryCustomInput {
274+
flex: 1;
275+
min-width: 0;
276+
height: 48px;
277+
background: #1a1d2e;
278+
border: 1px solid #353a5a;
279+
border-radius: 10px;
280+
padding: 0 14px;
281+
color: var(--text-primary);
282+
font-family: var(--font-mono);
283+
font-size: 15px;
284+
font-weight: 600;
285+
}
286+
287+
.expiryCustomInput:focus {
288+
outline: none;
289+
border-color: var(--teal);
290+
}
291+
292+
.expiryCustomInput::placeholder {
293+
color: #353a5a;
294+
}
295+
296+
.expiryCustomUnit {
297+
height: 48px;
298+
background: #1a1d2e;
299+
border: 1px solid #353a5a;
300+
border-radius: 10px;
301+
padding: 0 12px;
302+
color: var(--text-primary);
303+
font-family: var(--font-sans);
304+
font-size: 13px;
305+
font-weight: 600;
306+
cursor: pointer;
307+
flex-shrink: 0;
308+
}
309+
310+
.expiryCustomUnit:focus {
311+
outline: none;
312+
border-color: var(--teal);
313+
}
314+
151315
/* ── Token dropdown ── */
152316
.tokenDropdownWrapper {
153317
position: relative;
@@ -781,6 +945,10 @@
781945
flex-wrap: wrap;
782946
}
783947

948+
.rtypeGrid {
949+
grid-template-columns: 1fr;
950+
}
951+
784952
.receiptRow {
785953
flex-direction: column;
786954
align-items: flex-start;

0 commit comments

Comments
 (0)