Explorar el Código

feat: 每个地址增加 referrer_mode 设置 (self / follow_target)

- self 模式 (默认): 使用目标地址的仓位作为 referer
- follow_target 模式: 使用目标地址的同一个 referer (从 memo 指令解析)
- DB schema 增加 referrer_mode 列,含自动迁移
- parser 新增从 memo 指令提取 referer_position 的逻辑
- CopyEngine 根据地址设置选择对应的 referer
- API 和前端 UI 支持设置 referrer mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zhangchunrui hace 1 semana
padre
commit
b1aad816df

+ 20 - 0
src/app/addresses/page.tsx

@@ -12,6 +12,7 @@ interface AddressRow {
   enabled: number
   copy_multiplier: number | null
   copy_max_usd: number | null
+  referrer_mode: 'self' | 'follow_target'
   created_at: string
 }
 
@@ -29,6 +30,7 @@ function AddressItem({
   const [editing, setEditing] = useState(false)
   const [multiplier, setMultiplier] = useState(addr.copy_multiplier?.toString() ?? '')
   const [maxUsd, setMaxUsd] = useState(addr.copy_max_usd?.toString() ?? '')
+  const [referrerMode, setReferrerMode] = useState<'self' | 'follow_target'>(addr.referrer_mode ?? 'self')
   const [saving, setSaving] = useState(false)
 
   const handleSave = async () => {
@@ -41,6 +43,7 @@ function AddressItem({
           id: addr.id,
           copyMultiplier: multiplier || '',
           copyMaxUsd: maxUsd || '',
+          referrerMode,
         }),
       })
       setEditing(false)
@@ -72,6 +75,12 @@ function AddressItem({
                 {addr.copy_max_usd != null ? `$${addr.copy_max_usd}` : 'default'}
               </span>
             </span>
+            <span>
+              Referrer:{' '}
+              <span className="text-zinc-400">
+                {addr.referrer_mode === 'follow_target' ? 'follow target' : 'self'}
+              </span>
+            </span>
           </div>
         </div>
         <div className="flex items-center gap-2 ml-4">
@@ -123,6 +132,17 @@ function AddressItem({
               className="w-28 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
             />
           </div>
+          <div>
+            <label className="block text-[10px] text-zinc-500 mb-0.5">Referrer Mode</label>
+            <select
+              value={referrerMode}
+              onChange={(e) => setReferrerMode(e.target.value as 'self' | 'follow_target')}
+              className="w-32 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
+            >
+              <option value="self">Self</option>
+              <option value="follow_target">Follow Target</option>
+            </select>
+          </div>
           <button
             onClick={handleSave}
             disabled={saving}

+ 6 - 1
src/app/api/addresses/route.ts

@@ -79,16 +79,21 @@ export async function PATCH(req: NextRequest) {
  */
 export async function PUT(req: NextRequest) {
   const body = await req.json()
-  const { id, copyMultiplier, copyMaxUsd } = body
+  const { id, copyMultiplier, copyMaxUsd, referrerMode } = body
 
   if (!id) {
     return NextResponse.json({ error: 'id is required' }, { status: 400 })
   }
 
+  if (referrerMode && !['self', 'follow_target'].includes(referrerMode)) {
+    return NextResponse.json({ error: 'referrerMode must be "self" or "follow_target"' }, { status: 400 })
+  }
+
   updateWatchedAddressSettings(id, {
     copyMultiplier:
       copyMultiplier !== undefined && copyMultiplier !== '' ? Number(copyMultiplier) : null,
     copyMaxUsd: copyMaxUsd !== undefined && copyMaxUsd !== '' ? Number(copyMaxUsd) : null,
+    referrerMode: referrerMode || undefined,
   })
 
   return NextResponse.json({ success: true })

+ 19 - 2
src/lib/copier/index.ts

@@ -144,11 +144,16 @@ export class CopyEngine {
   /**
    * 获取地址的倍率和最大值设置(优先用地址单独设置,否则用全局默认)
    */
-  private getAddressSettings(signerAddress: string): { multiplier: number; maxUsd: number } {
+  private getAddressSettings(signerAddress: string): {
+    multiplier: number
+    maxUsd: number
+    referrerMode: 'self' | 'follow_target'
+  } {
     const addrRow = getWatchedAddressByAddress(signerAddress)
     return {
       multiplier: addrRow?.copy_multiplier ?? config.copyMultiplier,
       maxUsd: addrRow?.copy_max_usd ?? config.copyMaxUsd,
+      referrerMode: addrRow?.referrer_mode ?? 'self',
     }
   }
 
@@ -278,6 +283,18 @@ export class CopyEngine {
         await sleep(2000) // Wait for swap to settle
       }
 
+      // Determine referer position based on referrer mode:
+      // 'self'          → use target's personalPosition (our position references the target)
+      // 'follow_target' → use the same referer the target used (parsed from their memo)
+      const refererPubkey =
+        addrSettings.referrerMode === 'follow_target' && op.refererPosition
+          ? new PublicKey(op.refererPosition)
+          : new PublicKey(op.personalPosition)
+
+      console.log(
+        `[CopyEngine] Referrer mode: ${addrSettings.referrerMode}, referer: ${refererPubkey.toBase58()}`,
+      )
+
       // Execute position creation with referer_position memo
       const txid = await this.chain.createPosition({
         userAddress: getUserAddress(),
@@ -287,7 +304,7 @@ export class CopyEngine {
         base,
         baseAmount,
         otherAmountMax,
-        refererPosition: new PublicKey(op.personalPosition),
+        refererPosition: refererPubkey,
         signerCallback,
       })
 

+ 29 - 7
src/lib/db/queries.ts

@@ -1,5 +1,7 @@
 import { getDb } from './index'
 
+export type ReferrerMode = 'self' | 'follow_target'
+
 export interface WatchedAddress {
   id: number
   address: string
@@ -7,6 +9,7 @@ export interface WatchedAddress {
   enabled: number
   copy_multiplier: number | null
   copy_max_usd: number | null
+  referrer_mode: ReferrerMode
   created_at: string
 }
 
@@ -80,15 +83,34 @@ export function getWatchedAddressByAddress(address: string): WatchedAddress | un
 
 export function updateWatchedAddressSettings(
   id: number,
-  settings: { copyMultiplier?: number | null; copyMaxUsd?: number | null },
+  settings: {
+    copyMultiplier?: number | null
+    copyMaxUsd?: number | null
+    referrerMode?: ReferrerMode
+  },
 ) {
+  const sets: string[] = []
+  const values: unknown[] = []
+
+  if (settings.copyMultiplier !== undefined) {
+    sets.push('copy_multiplier = ?')
+    values.push(settings.copyMultiplier)
+  }
+  if (settings.copyMaxUsd !== undefined) {
+    sets.push('copy_max_usd = ?')
+    values.push(settings.copyMaxUsd)
+  }
+  if (settings.referrerMode !== undefined) {
+    sets.push('referrer_mode = ?')
+    values.push(settings.referrerMode)
+  }
+
+  if (sets.length === 0) return
+  values.push(id)
+
   return getDb()
-    .prepare('UPDATE watched_addresses SET copy_multiplier = ?, copy_max_usd = ? WHERE id = ?')
-    .run(
-      settings.copyMultiplier !== undefined ? settings.copyMultiplier : null,
-      settings.copyMaxUsd !== undefined ? settings.copyMaxUsd : null,
-      id,
-    )
+    .prepare(`UPDATE watched_addresses SET ${sets.join(', ')} WHERE id = ?`)
+    .run(...values)
 }
 
 // Position Mappings

+ 3 - 0
src/lib/db/schema.ts

@@ -62,4 +62,7 @@ export function initDb(db: Database.Database) {
   if (!colNames.has('copy_max_usd')) {
     db.exec('ALTER TABLE watched_addresses ADD COLUMN copy_max_usd REAL')
   }
+  if (!colNames.has('referrer_mode')) {
+    db.exec("ALTER TABLE watched_addresses ADD COLUMN referrer_mode TEXT NOT NULL DEFAULT 'self'")
+  }
 }

+ 29 - 0
src/lib/monitor/parser.ts

@@ -2,6 +2,7 @@ import { ParsedTransactionWithMeta, PublicKey } from '@solana/web3.js'
 import type { OperationType, ParsedOperation } from './types'
 
 const BYREAL_PROGRAM_ID = 'REALQqNEomY6cQGZJUGwywTBD2UmDT32rZcNnfxQ5N2'
+const MEMO_PROGRAM_ID = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'
 
 // Instruction discriminators from byreal_amm_v3.json IDL
 const DISCRIMINATORS: Record<string, { type: OperationType; bytes: number[] }> = {
@@ -240,6 +241,31 @@ function decodeLiquidityEvent(
   return null
 }
 
+/**
+ * Extract referer_position from memo instruction in the transaction.
+ * The memo format is: referer_position={pubkey}
+ */
+function extractRefererFromMemo(tx: ParsedTransactionWithMeta): string | undefined {
+  const instructions = tx.transaction.message.instructions
+  for (const ix of instructions) {
+    if ('programId' in ix && ix.programId.toBase58() === MEMO_PROGRAM_ID) {
+      try {
+        let memoText = ''
+        if ('data' in ix && typeof ix.data === 'string') {
+          memoText = Buffer.from(ix.data, 'base64').toString('utf-8')
+        } else if ('parsed' in ix && typeof ix.parsed === 'string') {
+          memoText = ix.parsed
+        }
+        const match = memoText.match(/referer_position=([1-9A-HJ-NP-Za-km-z]{32,44})/)
+        if (match) return match[1]
+      } catch {
+        // Skip unparseable memo
+      }
+    }
+  }
+  return undefined
+}
+
 export function parseTransaction(
   tx: ParsedTransactionWithMeta,
   watchedAddresses: Set<string>,
@@ -307,6 +333,9 @@ export function parseTransaction(
       result.mintA = ixAccounts[18] || ''
       result.mintB = ixAccounts[19] || ''
 
+      // Extract referer_position from memo instruction (if present)
+      result.refererPosition = extractRefererFromMemo(tx)
+
       // Decode event for tick range, amounts, and nftOwner validation
       const createEvent = decodeCreatePositionEvent(logs)
       if (createEvent) {

+ 2 - 0
src/lib/monitor/types.ts

@@ -18,5 +18,7 @@ export interface ParsedOperation {
   amountB?: string
   mintA?: string
   mintB?: string
+  /** The referer_position used by the target (parsed from memo instruction) */
+  refererPosition?: string
   timestamp: number
 }