Skip to content

Commit e4597ad

Browse files
committed
feat: add custom amount and token confirmations
1 parent 7a38171 commit e4597ad

22 files changed

Lines changed: 1723 additions & 282 deletions

.github/TODO.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# TODO
22

33
- Add `@Tipbot` message support for sending payments, using Slack assistant/thread UI to show a “Sending payment” status while the transaction is in progress.
4+
- Use Workers AI to parse more flexible natural-language tip commands after explicit amount/token syntax is stable.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE "access_key"
2+
ADD COLUMN "token_address" TEXT;
3+
4+
CREATE INDEX "access_key_account_chain_token_idx" ON "access_key" ("account_id", "chain_id", "token_address");

db/schemas.gen.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

db/types.gen.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api.ts

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { Address, Base64, Hex } from 'ox'
33
import { z } from 'zod'
44
import * as Chat from '#/chat.ts'
55
import * as AccountLink from '#/lib/accountLink.ts'
6+
import { formatAmount, formatCurrencyAmount, formatTipAmount } from '#/lib/format.ts'
67
import * as hono from '#/lib/hono.ts'
78
import * as Nanoid from '#/lib/nanoid.ts'
89
import * as Slack from '#/lib/slack.ts'
910
import * as Tempo from '#/lib/tempo.ts'
11+
import * as Tip from '#/lib/tip.ts'
1012
import * as DB from '#db/client.ts'
1113

1214
export const api = new Hono<{
@@ -56,14 +58,14 @@ export const api = new Hono<{
5658
return c.json({
5759
accessKeyAddress: link.access_key_address,
5860
accessKeyExpiry: link.access_key_expires_at,
59-
accessKeyLimit: '10',
60-
accessKeyLimitPeriodSeconds: 24 * 60 * 60, // 1 day
61+
accessKeyLimit: AccountLink.reusableAccessKeyLimitText,
62+
accessKeyLimitPeriodSeconds: AccountLink.reusableAccessKeyPeriodSeconds,
6163
accessKeyPublicKey: link.access_key_public_key as `0x${string}`,
6264
chainId: link.chain_id,
6365
chainName: Tempo.getChainName(link.chain_id),
6466
expiresAt: link.expires_at,
6567
ok: true as const,
66-
tokenAddress: Address.checksum(link.default_token_address ?? Tempo.pathUsdAddress),
68+
tokenAddress: Address.checksum(link.default_token_address ?? Tempo.addressLookup.pathUsd),
6769
})
6870
})
6971
.post(
@@ -122,7 +124,7 @@ export const api = new Hono<{
122124
expiresAt: link.access_key_expires_at,
123125
keyAuthorization: body.keyAuthorization,
124126
rootAddress: body.address,
125-
tokenAddress: Address.checksum(link.default_token_address ?? Tempo.pathUsdAddress),
127+
tokenAddress: Address.checksum(link.default_token_address ?? Tempo.addressLookup.pathUsd),
126128
})
127129
const now = new Date().toISOString()
128130
const existingAccount = await c.var.db
@@ -186,6 +188,17 @@ export const api = new Hono<{
186188
.deleteFrom('access_key')
187189
.where('account_id', '=', account.id)
188190
.where('chain_id', '=', link.chain_id)
191+
.where(
192+
'token_address',
193+
'=',
194+
Address.checksum(link.default_token_address ?? Tempo.addressLookup.pathUsd),
195+
)
196+
.execute()
197+
await c.var.db
198+
.deleteFrom('access_key')
199+
.where('account_id', '=', account.id)
200+
.where('chain_id', '=', link.chain_id)
201+
.where('token_address', 'is', null)
189202
.execute()
190203
await c.var.db
191204
.insertInto('access_key')
@@ -199,6 +212,9 @@ export const api = new Hono<{
199212
expires_at: link.access_key_expires_at,
200213
id: Nanoid.generate(),
201214
revoked_at: null,
215+
token_address: Address.checksum(
216+
link.default_token_address ?? Tempo.addressLookup.pathUsd,
217+
),
202218
updated_at: now,
203219
})
204220
.execute()
@@ -252,6 +268,131 @@ export const api = new Hono<{
252268
}
253269
},
254270
)
271+
.get('/api/confirm/:token', async (c) => {
272+
try {
273+
const data = await Tip.getConfirmationData(c.env, c.req.param('token'))
274+
const metadata = Tempo.getTokenMetadataFallback(data.payload.tokenAddress)
275+
return c.json({
276+
accessKeyAddress: data.accessKey.address,
277+
accessKeyExpiry:
278+
data.payload.kind === 'reusable_access_key'
279+
? (data.payload.accessKeyExpiresAt ?? data.payload.expiresAt)
280+
: data.payload.expiresAt,
281+
accessKeyLimit:
282+
data.payload.kind === 'reusable_access_key'
283+
? AccountLink.reusableAccessKeyLimitText
284+
: formatAmount(data.payload.amount),
285+
accessKeyLimitPeriodSeconds:
286+
data.payload.kind === 'reusable_access_key'
287+
? AccountLink.reusableAccessKeyPeriodSeconds
288+
: AccountLink.confirmationLinkTtlMs / 1000,
289+
accessKeyPublicKey: data.accessKey.publicKey as `0x${string}`,
290+
amount: formatAmount(data.payload.amount),
291+
chainId: data.payload.chainId,
292+
kind: data.payload.kind,
293+
memo: data.payload.memo,
294+
ok: true as const,
295+
recipientProviderUserId: data.payload.recipientProviderUserId,
296+
tokenAddress: Address.checksum(data.payload.tokenAddress),
297+
tokenCurrency: metadata.currency,
298+
tokenSymbol: metadata.symbol,
299+
})
300+
} catch (error) {
301+
return c.json(
302+
{
303+
code: 'invalid_confirmation' as const,
304+
message: error instanceof Error ? error.message : 'Confirmation link expired',
305+
ok: false as const,
306+
},
307+
404,
308+
)
309+
}
310+
})
311+
.post(
312+
'/api/confirm/:token',
313+
hono.validator(
314+
'json',
315+
z.object({
316+
address: z.string().min(1),
317+
keyAuthorization: z.unknown(),
318+
}),
319+
),
320+
async (c) => {
321+
try {
322+
const body = c.req.valid('json')
323+
const data = await Tip.getConfirmationData(c.env, c.req.param('token'))
324+
const result = await Tip.confirmTipRequest(c.env, {
325+
address: body.address,
326+
keyAuthorization: body.keyAuthorization,
327+
token: c.req.param('token'),
328+
})
329+
if (!result.ok)
330+
return c.json(
331+
{
332+
code: result.code,
333+
message: result.message ?? 'Payment failed.',
334+
ok: false as const,
335+
},
336+
400,
337+
)
338+
339+
if (result.status === 'sent')
340+
c.executionCtx.waitUntil(
341+
(async () => {
342+
const installation = await Chat.getSlack().getInstallation(data.payload.providerId)
343+
if (!installation) return
344+
345+
const amount = result.isDefaultToken
346+
? formatCurrencyAmount(result.amount, result.tokenCurrency)
347+
: formatTipAmount(result.amount, result.tokenCurrency, result.tokenSymbol)
348+
const text = `<@${result.senderProviderUserId}> ${result.memo ? 'sent' : 'tipped'} <@${result.recipientProviderUserId}> ${amount}${result.memo ? ` for ${result.memo}` : ''}.`
349+
const body = new URLSearchParams()
350+
body.set(
351+
'blocks',
352+
JSON.stringify([
353+
{
354+
text: {
355+
text: `${text} <${Tempo.formatTxLink(result.chainId, result.transactionHash)}|Receipt>`,
356+
type: 'mrkdwn',
357+
},
358+
type: 'section',
359+
},
360+
]),
361+
)
362+
body.set('channel', data.payload.providerChannelId.replace(/^slack:/, ''))
363+
body.set('text', `${text} Receipt`)
364+
body.set('unfurl_links', 'false')
365+
body.set('unfurl_media', 'false')
366+
const response = await Chat.getSlack().withBotToken(installation.botToken, () =>
367+
fetch(`${c.env.SLACK_API_URL}/chat.postMessage`, {
368+
body,
369+
headers: { authorization: `Bearer ${installation.botToken}` },
370+
method: 'POST',
371+
}),
372+
)
373+
const json = z.parse(
374+
z.object({
375+
error: z.string().optional(),
376+
ok: z.boolean().optional(),
377+
}),
378+
await response.json(),
379+
)
380+
if (!json.ok) throw new Error(json.error ?? 'Slack API chat.postMessage failed.')
381+
})().catch(console.error),
382+
)
383+
return c.json({ ok: true as const, transactionHash: result.transactionHash })
384+
} catch (error) {
385+
return c.json(
386+
{
387+
code: 'confirmation_failed' as const,
388+
message: error instanceof Error ? error.message : 'Payment failed.',
389+
ok: false as const,
390+
},
391+
400,
392+
)
393+
}
394+
},
395+
)
255396
.post('/api/chat/slack', async (c) => {
256397
const request = c.req.raw
257398
const body = await request.text()

0 commit comments

Comments
 (0)