เมื่อต้นปีนี้ เราได้ทำยอดรวมการชำระเงินด้วยเหรียญมีเสถียรภาพ (stablecoin) ถึง 1,000 ล้านดอลลาร์สหรัฐแล้ว สำหรับบริบท เราได้ดำเนินการธุรกรรมด้วยเหรียญมีเสถียรภาพครั้งแรกเมื่อไม่ถึง 12 เดือนที่ผ่านมา มันเริ่มต้นจากลูกค้าไม่กี่รายที่ต้องการบางสิ่งที่ควรจะเป็นเรื่องง่าย: ความสามารถในการรับการชำระเงินด้วยเหรียญมีเสถียรภาพจากลูกค้าของพวกเขาทั่วโลก และให้เงินเหล่านั้น ชำระเป็นเงินดอลลาร์สหรัฐ- อย่างรวดเร็วและคาดการณ์ได้ โดยไม่ต้องกังวลกับค่าธรรมเนียมที่สูง โซลูชันการออกจากระบบที่มีอยู่เดิมมักเรียกเก็บค่าธรรมเนียม 1-3% ใช้เวลาหลายวันในการชำระเงิน และต้องผ่านกระบวนการปฏิบัติตามข้อกำหนดที่ยาวนานและน่าหงุดหงิด เราเห็นโอกาสและสร้าง MVP ขึ้นมาภายในสองสัปดาห์ ภายในหนึ่งเดือน เรามีปริมาณธุรกรรมที่แท้จริงและมีนัยสำคัญ

ความท้าทายคือการขยายขนาดมัน

สเตเบิลคอยน์สามารถชำระได้ในไม่กี่วินาที ทุกที่ ทุกเวลา ระบบธนาคารแบบดั้งเดิมไม่ได้ถูกสร้างมาเพื่อสิ่งนี้ - พวกมันทำงานด้วยการประมวลผลแบบกลุ่ม มีเวลาตัดยอดในวันทำการ และมีระยะเวลาการชำระหลายวัน ลูกค้าในสิงคโปร์ส่งเงิน 50,000 ดอลลาร์สหรัฐในรูปของ USDC ให้คุณตอนตี 2 ของวันเสาร์ บนเครือข่าย, มันจะเสร็จสิ้นภายในไม่กี่วินาที. การโอนเงิน ACH ไปยังบัญชีธนาคารของคุณจะไม่เริ่มดำเนินการจนถึงวันจันทร์, จะไม่มาถึงจนถึงวันพุธ, และอาจอยู่ในระหว่างการตรวจสอบที่ไหนสักแห่งระหว่างนั้น. การเชื่อมต่อสองโลกนี้หมายถึงการประสานงานระหว่างระบบที่ไม่เคยถูกออกแบบมาเพื่อสื่อสารกัน, แต่ละระบบมีสถานะของตัวเอง.

โพสต์นี้เกี่ยวกับโครงสร้างพื้นฐานสองอย่างที่พวกเราสร้างขึ้นเพื่อให้การเคลื่อนไหวของสเตเบิลคอยน์มีพฤติกรรมเหมือนการเคลื่อนไหวของเงินในระดับธนาคาร:

1. กระแสการไหลของเงินทุน: เครื่องมือจัดการแบบประกาศสำหรับกระบวนการทำงานทางการเงินที่ใช้เวลานานและเกี่ยวข้องกับหลายระบบ

2. ของเรา กรอบการทำงานการดำเนินการบนเชน: แบบจำลองวงจรชีวิตสำหรับการดำเนินงานบนเชนที่เชื่อถือได้

ทุกอย่างอื่น—ทางออก, ทางเข้า, และบัญชี Global USD ที่ไม่ใช่บัญชีผู้ดูแล—ถูกสร้างขึ้นโดยการประกอบสิ่งพื้นฐานเหล่านี้ หากคุณจะรับสิ่งหนึ่งจากโพสต์นี้: สเตเบิลคอยน์นั้นง่าย; สเตเบิลคอยน์ ธนาคาร ไม่ใช่

กระแสเงินทุน

เมื่อคุณกำลังโอนเงินผ่านระบบภายนอกหลายระบบ คุณต้องการมากกว่าการประสานงานแบบเฉพาะกิจ คุณต้องการวิธีการแสดงลำดับขั้นตอนทั้งหมดอย่างชัดเจน: อะไรควรเกิดขึ้น ตอบสนองต่อเหตุการณ์ใด พร้อมการรับประกันอะไรบ้าง และคุณต้องการให้ลำดับขั้นตอนนั้นสามารถตรวจสอบย้อนหลังได้ สามารถดำเนินการต่อได้เมื่อเกิดข้อผิดพลาด และถูกต้องแม้ในกรณีที่ขั้นตอนใดล้มเหลวระหว่างดำเนินการ นั่นคือสิ่งที่ Flow of Funds มอบให้เรา

ปัญหาเกี่ยวกับการเคลื่อนย้ายเงิน

ความท้าทายส่วนใหญ่ในการประสานงานในซอฟต์แวร์คือการจัดการกับความล้มเหลวอย่างราบรื่น การประสานงานทางการเงินมีข้อจำกัดที่ยากกว่า: เงินกำลังเคลื่อนไหวอยู่แล้ว.

พิจารณาการไหลของเงินฝากทันที ลูกค้าได้รับ USDC จำนวน $10,000 เราให้เครดิตบัญชีของพวกเขาทันที (ก่อนที่ ACH ที่อยู่เบื้องหลังจะเสร็จสิ้น) เพื่อให้พวกเขาสามารถใช้เงินทุนนั้นได้ทันที เบื้องหลัง เราได้ออกเงินกู้ เมื่อ ACH มาถึงในอีกไม่กี่วันต่อมา เราจะเก็บเงินคืนและค่าธรรมเนียม

นั่นคือการดำเนินการสี่รายการในสองระบบภายนอก: การชำระบัญชีคริปโต, การเบิกจ่ายเงินกู้, การชำระบัญชี ACH, การเก็บค่าธรรมเนียม แต่ละขั้นตอนขึ้นอยู่กับขั้นตอนก่อนหน้า และขั้นตอนใด ๆ ก็สามารถล้มเหลวได้ คุณไม่สามารถจัดการสิ่งนี้ได้ด้วยสถานะที่กระจัดกระจายและผู้จัดการแบบเฉพาะกิจ

การนามธรรมแกนกลาง

การไหลของเงินทุนเป็นระบบการจัดการแบบประกาศตามกฎ (declarative, rule-based orchestration system) การนามธรรมหลักประกอบด้วยสามส่วน:

กิจกรรม เป็นสัญญาณที่บ่งบอกว่ามีบางสิ่งเกิดขึ้น—เช่น การชำระเงิน ACH เสร็จสมบูรณ์ ได้รับเงินแล้ว การอนุมัติบัตรถูกบันทึกไว้ เป็นต้น เหตุการณ์เหล่านี้อาจมาจากระบบภายนอกหรือถูกสร้างขึ้นภายในองค์กรเอง

กฎ กำหนดสิ่งที่ควรเกิดขึ้นเพื่อตอบสนองต่อเหตุการณ์ต่างๆ แต่ละกฎจะระบุรายการของเหตุการณ์ที่กระตุ้นและลำดับของผลข้างเคียงที่ต้องดำเนินการ

ผลข้างเคียง คือการกระทำที่เราทำเพื่อตอบสนองต่อเหตุการณ์: เริ่มการโอน, สร้างการระงับ, จ่ายเงินกู้, เก็บค่าธรรมเนียม กฎจะถูกเรียกใช้เพียงครั้งเดียว เมื่อเหตุการณ์ที่ตรงกันมาถึงครั้งแรก ผลข้างเคียงจะดำเนินการตามลำดับ และกฎจะถูกใช้ไป สิ่งนี้รับประกันความเป็นเอกภาพภายในบริบทของการไหล

ทำไมต้องเป็นแบบประกาศ?

ทางเลือกคือการควบคุมแบบบังคับ: ผู้จัดการเรียกผู้จัดการ, สถานะกระจายอยู่ทั่วตาราง, "การไหล" มีอยู่เพียงในความร่วมมือโดยนัยระหว่างชิ้นส่วนของโค้ดเท่านั้น

นั่นใช้ได้สำหรับกระบวนการที่เรียบง่าย สำหรับการดำเนินงานทางการเงินที่ใช้หลายวัน หลายระบบ พร้อมการระงับตามข้อกำหนดและการล้มเหลวบางส่วน จะกลายเป็นสิ่งที่ไม่สามารถบำรุงรักษาได้ การจัดการข้อผิดพลาดเป็นแบบเฉพาะกิจ เส้นทางการกู้คืนเป็นแบบนัยยะ สามเดือนต่อมา ไม่มีใครสามารถตอบได้อย่างมั่นใจ "จะเกิดอะไรขึ้นหากขั้นตอนที่ 3 ล้มเหลวหลังจากขั้นตอนที่ 2 สำเร็จ?"

กฎเชิงประกาศจะพลิกกลับโมเดล คุณกำหนดเครื่องสถานะอย่างชัดเจน: เหล่านี้ เหตุการณ์กระตุ้น เหล่านี้ การดำเนินการ เครื่องมือการประสานงานจะจัดการการดำเนินการ, การคงอยู่, และการกู้คืน. การไหล คือ เอกสาร

การรับประกัน

FoF มอบสิ่งที่ไม่เปลี่ยนแปลงสี่ประการที่เราสามารถพึ่งพาได้:

1. อิดเมพอตเทนซี - กฎจะถูกเรียกใช้เพียงครั้งเดียวต่อบริบทของโฟลว์เท่านั้น โดยไม่คำนึงถึงเหตุการณ์ที่ซ้ำกันหรือการลองทำซ้ำ

2. การกระทบยอดแบบกำหนดแน่นอน - เมื่อเกิดเหตุการณ์เดียวกัน การไหลจะนำไปสู่สถานะเดียวกัน

3. สามารถตรวจสอบได้ครบถ้วน - ทุกการดำเนินการของผลข้างเคียงจะถูกติดตามโดยมีการย้อนกลับไปยังเหตุการณ์ที่กระตุ้น

4. ความสามารถในการรวมกัน - การไหลที่ซับซ้อนถูกสร้างขึ้นจากกฎง่าย ๆ ที่ประกอบกันโดยไม่กลายเป็นสิ่งเดียว

การติดตามการดำเนินการ

เราติดตามการดำเนินการของผลข้างเคียงทุกครั้งผ่านบันทึกของ Node—แต่ละบันทึกเชื่อมโยงกับผู้ปกครองของมัน สร้างเป็นต้นไม้การดำเนินการที่สมบูรณ์ เมื่อมีความต้องการตรวจสอบการปฏิบัติตามข้อกำหนด เราสามารถติดตามเส้นทางที่แน่นอนผ่านระบบได้

Root Node (flow.begin)
├── Node: CreateOffRampTransaction
├── Node: SendNotification
└── Node: CreateSettlementRule
    └── (child rule, fires later)
        ├── Node: CollectLoanRepayment
        ├── Node: CollectFees
        └── Node: ResolveTransaction

ความสามารถในการรวมเข้าด้วยกัน: กฎที่ซ้อนกัน

กฎสามารถสร้างกฎย่อยได้ นี่คือวิธีที่กระบวนการที่ซับซ้อนและมีหลายขั้นตอนสามารถประกอบกันโดยไม่กลายเป็นก้อนเดียว เมื่อมีการสร้างธุรกรรมแบบแยกออก กฎเริ่มต้นจะไม่พยายามจัดการทุกอย่าง มันจะตั้งค่า อนาคต กฎ—ผู้ฟังที่รอเหตุการณ์ที่จะเกิดขึ้นในภายหลัง:

createRule({ events: ['flow.begin'] })
  .addSideEffect(CreateOffRampTransaction)
  .addSideEffect(SendNotification)
  .addSideEffect(
    CreateRuleSideEffect.create({
      rules: createRule({
        events: ['inbound_ach.completed', 'completed'],
      })
        .addSideEffect(CollectLoanRepayment)
        .addSideEffect(CollectFees)
        .addSideEffect(ResolveTransaction)
        .buildRule(),
    })
  )
  .buildRule();

ตรรกะการชำระเงินไม่ได้มีอยู่เป็นโค้ดที่ตายแล้วรอการเรียกใช้ แต่มีอยู่ในรูปแบบของกฎที่รอเหตุการณ์ เมื่อเว็บฮุคของผู้ให้บริการธนาคารมาถึงในอีกไม่กี่วันต่อมา กฎจะทำงานและกระบวนการจะดำเนินต่อไป กฎหลักจะถูกใช้และกฎย่อยจะนำบริบทต่อไป

นี่หมายความว่า การไหลของข้อมูลสามารถประกอบเข้าด้วยกันได้อย่างอิสระตามต้องการ ต้องการให้มีการฝากเงินทันทีใช่ไหม? ง่ายมาก เพียงเพิ่มกฎที่ปล่อยเงินกู้และตั้งค่าการชำระคืน ทุกประเด็นปัญหาถูกแยกออกจากกัน แต่ทั้งหมดสามารถประกอบเข้าด้วยกันเป็นกระบวนการที่สอดคล้องกัน

บริบทการดำเนินการของผลข้างเคียง

ผลข้างเคียงไม่เหมือนกันทั้งหมด บางอย่างจำเป็นต้องเป็นอะตอมิกกับการทำธุรกรรมฐานข้อมูล บางอย่างเรียก API ภายนอก บางอย่างเป็นแบบยิงแล้วลืม

MethodExecutionUse Case
addSideEffectSynchronous, same DB transactionAsync, no transaction
addAsyncSideEffectAsync via TemporalExternal API calls, long-running operations
addAsyncNonTransactionalSideEffectAsync, no transactionNotifications, logging, analytics

ผลตอบแทน

ประโยชน์ที่ใหญ่ที่สุดคือวิธีที่ FoF เปลี่ยนแปลงวิธีการเขียนโค้ดการจัดการระบบของวิศวกรที่ Slash หากไม่มีมัน วิศวกรทุกคนจะแก้ปัญหาเดียวกันแต่ใช้วิธีที่แตกต่างกัน ทุกคนสร้างล้อขึ้นมาใหม่ และล้อเหล่านั้นก็มีรูปร่างที่แตกต่างกันเล็กน้อย

FoF ยกระดับขั้นต่ำ คุณเป็นผู้กำหนด อะไร ควรเกิดขึ้น ไม่ใช่ อย่างไร เพื่อจัดการกับทุกโหมดของความล้มเหลว. เฟรมเวิร์กนี้จัดการกับการดำเนินการ, การคงอยู่, และการสังเกตได้. วิศวกรใหม่สามารถอ่านคำจำกัดความของโฟลว์และเข้าใจได้โดยไม่ต้องติดตามผ่านชั้นของโค้ดที่บังคับให้ทำตามคำสั่ง. และมันยากที่จะเขียนโค้ดการประสานงานที่ไม่ดีเมื่อการนามธรรมบังคับให้คุณต้องชัดเจนเกี่ยวกับเหตุการณ์, ผลข้างเคียง, และการเปลี่ยนแปลงสถานะ.

ทางลาด: การปฏิบัติจริงของ FoF

ด้วย FoF เป็นรากฐานในการจัดระเบียบกระแสการเงิน การสร้างผลิตภัณฑ์หลักของเราจึงกลายเป็นเรื่องของการกำหนดกฎที่เหมาะสมสำหรับแต่ละกระแส การทำให้เป็นนามธรรมไม่สนใจว่าระบบอะไรจะอยู่ปลายทาง มันเพียงแค่ประสานงานเท่านั้น

ทางออกและทางเข้ากลับเป็น "การไหล" ที่ถูกควบคุมโดยเครื่องยนต์นี้ ซึ่งแนะนำระบบภายนอกใหม่: ผู้ให้บริการคริปโต (หรือโต๊ะ OTC) ที่จัดการการแปลงระหว่าง stablecoin/fiat เช่นเดียวกับระบบภายนอกใด ๆ พวกเขาจะส่งการอัปเดตสถานะตามเงื่อนไขของตนเอง ซึ่งเราสามารถใช้เพื่อกระตุ้นเหตุการณ์ FoF จากนั้นก็เป็นการประกอบกระบวนการไหลเท่านั้น

ทางออก

ทางออกช่วยให้ลูกค้าสามารถรับการชำระเงินด้วยสเตเบิลคอยน์และชำระเป็น USD ในบัญชี Slash ของพวกเขาได้ กระบวนการนี้ตรงไปตรงมา:

  1. ลูกค้าจะได้รับ USDC หรือ USDT ที่ที่อยู่ฝากเงินที่เราสร้างขึ้นผ่านผู้ให้บริการคริปโตของเรา
  2. ผู้ให้บริการตรวจพบการฝากเงิน, ทำการแปลงเป็น USD, และเริ่มดำเนินการ ACH หรือโอนเงิน
  3. ผู้ให้บริการธนาคารของเราได้รับเงินโอนเข้า
  4. เราทำการกระทบยอดการโอนกับรายการธุรกรรมเดิมและให้เครดิตในบัญชี

สำหรับการฝากเงินทันที—ซึ่งเราจะเครดิตเงินเข้าบัญชีลูกค้าทันทีและเรียกเก็บเงินคืนเมื่อการชำระเงิน ACH ได้รับการยืนยัน—กระบวนการจะประกอบด้วย การเบิกจ่ายเงินกู้ การเรียกเก็บเงินคืน และการบันทึกค่าธรรมเนียม แต่ละประเด็นจะเป็นกฎแยกกัน โดยแต่ละกฎจะคอยรับฟังเหตุการณ์ที่เกี่ยวข้อง และรวมเข้าเป็นกระบวนการที่ต่อเนื่องกันอย่างเป็นระบบ คำจำกัดความของ FoF จะมีลักษณะประมาณนี้:

const offRampInstantDepositFlow = createRule({
  name: 'instant_deposit',
  events: ['provider.deposit_received'],
})
  .addSideEffect(
    DisburseLoan.create((ctx) => ({
      accountId: ctx.accountId,
      amount: ctx.providerDeposit.amount,
    }))
  )
  .addSideEffect(
	  // The settlement logic doesn't run immediately
	  // it waits as a rule until the ACH actually settles
	  // which might be days later
    CreateRule.create((ctx) => ({
      traceId: ctx.deposit.ach_trace_number,

      rule: createRule({
        events: ['inbound_ach.settled'],
      })
        .addSideEffect(RecollectLoan.create({ loanId: ctx.loanId }))
        .addSideEffect(
          CollectFees.create({ depositId: ctx.providerDeposit.id })
        ),
    }))
  );

เส้นทางเข้าสู่ระบบ

ทางขึ้นเป็นสิ่งที่ตรงกันข้าม: ลูกค้าส่ง USD จากบัญชี Slash ของพวกเขาและได้รับเหรียญ stablecoin ที่กระเป๋าเงินภายนอก กระบวนการ:

  1. ลูกค้าเริ่มการโอนไปยังที่อยู่กระเป๋าเงินปลายทาง
  2. เราสร้างการระงับการใช้งานบัญชีของพวกเขาสำหรับจำนวนเงินบวกค่าธรรมเนียม
  3. เราส่ง ACH หรือโอนเงินผ่านธนาคารไปยังคำแนะนำการฝากเงินที่ผู้ให้บริการคริปโตของเรา
  4. ผู้ให้บริการได้รับเงินและส่งมอบเหรียญคงที่ไปยังปลายทาง
const providerAccountId = '...';
const userAccountId = '...';

const onRampFlow = createRule({
  name: 'on_ramp',
  events: ['flow.begin'],
})
  .addSideEffect(
    InitiateTransfer.create((ctx) => ({
		  type: 'wire',
		  source: userAccountId,
		  destination: providerAccountId;
		  // ...
    }))
  )
  .addSideEffect(
    CreateRule.create((ctx) => ({
      traceId: ctx.transaction.id,
      rule: createRule({
        events: ['outbound_wire.completed'],
      })
        .addSideEffect(CaptureFees.create({ transactionId: ctx.transaction.id }))
    }))
  )
  .addSideEffect(
    CreateRule.create((ctx) => ({
      traceId: ctx.transaction.id,
      rule: createRule({
        events: ['provider.delivery_failed'],
      })
        .addSideEffect(CancelPendingFees.create({ transactionId: ctx.transaction.id }))
        .addSideEffect(InitiateTransfer.create({ 
			     type: 'book',
			     from: operatingAccountId,
			     destination: userAccountId,
			     description: 'Refund',
			     // ...
         })),
    }))
  );

สิ่งที่น่าสังเกตคือโครงสร้างพื้นฐานใหม่ที่ต้องใช้นั้นมีน้อยมาก กรอบการทำงาน FoF และตรรกะการกระทบยอดที่เราสร้างขึ้นสำหรับ off-ramps สามารถนำมาใช้โดยตรง ส่วน on-ramps เป็นกฎที่ต่างกันซึ่งคอยฟังเหตุการณ์ที่แตกต่างกัน—แต่กลไกพื้นฐานยังคงเหมือนเดิม

วงจรชีวิตบนบล็อกเชน

FoF แก้ปัญหาการประสานงานในด้านฟิอัต—ระบบธนาคาร, ผู้ให้บริการ, การปฏิบัติตามกฎระเบียบ แต่เมื่อเราเริ่มสร้าง Global USD เราเจอกับพื้นผิวใหม่: ตัวบล็อกเชนเอง การนำธุรกรรมเข้าสู่บล็อกเชน, ยืนยันว่าธุรกรรมนั้นเกิดขึ้นจริง, จัดการกับความล้มเหลวและการจัดเรียงบล็อกใหม่ของเชน, และการสรุปสถานะที่ถูกต้องจากผลลัพธ์—นั่นคือปัญหาการประสานงานที่แตกต่างออกไป เราต้องการการรับประกันแบบเดียวกับที่เรามีใน FoF แต่สำหรับการดำเนินการบนบล็อกเชน

รูปแบบ: ตั้งใจ → ดำเนินการ → ตรวจสอบความถูกต้อง

เราใช้รูปแบบที่สม่ำเสมอในทุกการดำเนินการบนบล็อกเชน:

1. เจตนา: ประกาศสิ่งที่เรากำลังพยายามทำ

2. ดำเนินการ: ส่งธุรกรรมและดำเนินการจนได้รับการรวมเข้าบล็อก

3 ปรับให้สอดคล้อง: ประมวลผลบล็อกที่ถูกยืนยัน, อัปเดตสถานะภายใน, เรียกใช้กระบวนการต่อเนื่อง

หากคุณมาจากวงการการเงินแบบดั้งเดิม การเปรียบเทียบนั้นตรงไปตรงมา:

  • เจตนา ≈ คำสั่งชำระเงิน
  • ดำเนินการ ≈ รอการดำเนินการ
  • กระทบยอด ≈ โพสต์แล้ว

แต่ละเฟสมีความรับผิดชอบและรูปแบบความล้มเหลวที่แตกต่างกัน ส่วนที่เหลือของหัวข้อนี้จะอธิบายถึงวิธีการที่เราสร้างแต่ละชั้น

ก่อนที่เราจะสามารถดำเนินการใด ๆ ได้ เราจำเป็นต้องกำหนด อะไร เรากำลังดำเนินการอยู่ การทำธุรกรรมบนบล็อกเชนโดยพื้นฐานแล้วคือคำสั่ง (ฟังก์ชันที่ถูกเรียกใช้พร้อมพารามิเตอร์) แต่ตัวเชนเองไม่เข้าใจคำสั่งที่มนุษย์อ่านได้ ทุกอย่างจะถูกเข้ารหัสเป็น ข้อมูลการโทร - ก้อนข้อมูลแบบเฮกซ์ไบต์ที่ระบุฟังก์ชันที่จะเรียกใช้และอาร์กิวเมนต์ที่จะส่งผ่าน

ตัวอย่างเช่น การโอน USDC แบบง่าย ๆ — "ส่ง 500 USDC ไปยังที่อยู่ X" — จะกลายเป็น:

0xa9059cbb0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182000000000000000000000000000000000000000000000000000000001dcd6500

ข้อมูลการโทรดิบเช่นนี้ไม่โปร่งใสและไม่ได้บอกอะไรเกี่ยวกับ ทำไม เงินถูกโอนย้าย และเมื่อคุณกำลังสร้างระบบที่ต้องติดตามบริบททางธุรกิจ—ไม่ใช่แค่ว่ามีการโอนสินทรัพย์เท่านั้น แต่เป็นการเก็บค่าธรรมเนียมสำหรับใบแจ้งหนี้หมายเลข 1234—คุณจำเป็นต้องรักษาบริบทนั้นไว้

เราแก้ไขปัญหานี้ด้วยทะเบียนคำจำกัดความการเรียกใช้งานที่มีการกำหนดประเภท:

```tsx
export const erc20Transfer = blockchainCallRegistry.define(
  defineBlockchainCall({
    key: 'erc20.transfer',
    abi: 'function transfer(address to, uint256 amount)',
    request: {
      input: tbox.obj({
        contract: tbox.hex(),
        recipient: tbox.hex(),
        amount: tbox.bigIntStr(),
      }),
      categories: [
        'outbound_wire_transfer',
        'outbound_ach_credit',
        'outbound_transfer_fee',
      ],
      tags: [],
      context: tbox.obj({
        relatedAuthorizationId: tbox.opt(tbox.id('authorizationRequest')),
      }),
    },
    encode: ({ input, encodeFunctionData }) => ({
      to: input.contract,
      data: encodeFunctionData([input.recipient, BigInt(input.amount)]),
      value: bigIntStr(0n),
    }),
  })
);

```

วิศวกรทำงานในแง่ของโดเมน—สัญญา, ผู้รับ, จำนวน—แทนที่จะเป็นสตริงเลขฐานสิบหก รีจิสทรีตรวจสอบความถูกต้องของข้อมูลที่ป้อนเข้า, จัดการการเข้ารหัส, และรักษาข้อมูลเมตาที่เราจะต้องการในภายหลัง: หมวดหมู่, แท็ก, และบริบททางธุรกิจ

การสร้างสายโทรศัพท์กลายเป็นเรื่องง่าย:

const call = erc20Transfer.create({
  walletId: 'wallet_12345',
  chain: 'base',
  request: {
    input: {
      contract: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
      recipient: BASE_USDC_FEE_ACCOUNT,
      amount: bigIntStr(25_000_000n), // 25 USD
    },
    category: 'outbound_transfer_fee',
    tags: [],
    description: 'USDC Fee Transfer - SLASH FINANCE',
    context: {}
  },
})

แต่ละการโทรจะกลายเป็นบันทึก BlockchainCall:

interface BlockchainCall {
  id: string;
  type: string;                      // 'erc20.transfer'
  chain: Chain;
  walletId: string;
  to: Hex;                           // Target contract
  value: BigIntStr;                  // ETH value (usually 0)
  data: Hex;                         // Encoded calldata
  request: BlockchainCallRequest;    // Original typed request with metadata

  // Intent association
  intentId: string;
  indexInBatch: number // Position in the batched transaction
}

เราถือว่า BlockchainCall เป็นหน่วยย่อยของงานที่แยกออกจากกันไม่ได้ การทำธุรกรรมอาจมีการเรียกหลายครั้งในครั้งเดียว แต่แต่ละการเรียกจะเป็นการดำเนินการที่รับผิดชอบได้เพียงหนึ่งเดียวเท่านั้น ฟิลด์คำขอจะเก็บรักษาข้อมูลอินพุตที่ระบุประเภทไว้อย่างครบถ้วน รวมถึงไบต์ที่ถูกเข้ารหัสแล้ว - รวมถึงเมตาดาต้าและบริบทที่ไม่จำกัด การเมตาดาต้าเหล่านี้คือสิ่งที่ช่วยให้เราสามารถตอบคำถามว่า "การโอนเงิน $500 นี้มีวัตถุประสงค์อะไร?" เมื่อเราทำการปรับสมดุลสถานะของเชนกลับไปยังการดำเนินงานทางธุรกิจ

การดำเนินการ: การดูแลธุรกรรมให้เสร็จสมบูรณ์

การส่งธุรกรรมฟังดูง่าย แต่ในทางปฏิบัติ มีอุปสรรคมากมายระหว่าง "ส่งสิ่งนี้" กับ "มันถึงแล้ว"

เมื่อคุณส่งธุรกรรม มันจะไม่เข้าสู่บล็อกโดยตรง มันจะเข้าสู่ เมมปูล พื้นที่รอซึ่งธุรกรรมจะถูกเก็บไว้จนกว่าผู้ผลิตบล็อกจะเลือกไปดำเนินการ ในขณะที่รอ ธุรกรรมอาจถูกยกเลิก (เนื่องจาก mempool เต็ม) ถูกเสนอราคาสูงกว่า (มีผู้อื่นจ่ายค่าธรรมเนียมสูงกว่า) หรือติดค้าง (ราคาแก๊สต่ำเกินไปสำหรับสภาพเครือข่ายปัจจุบัน)

ก๊าซ คือวิธีที่เครือข่ายที่ใช้ Ethereum กำหนดราคาการคำนวณ ทุกการดำเนินการมีค่าใช้จ่ายเป็นก๊าซ และคุณจ่ายก๊าซด้วยโทเค็นดั้งเดิมของเครือข่าย เมื่อคุณส่งธุรกรรม คุณระบุราคาสูงสุดของก๊าซที่คุณยินดีจ่าย หากความแออัดของเครือข่ายเพิ่มขึ้นหลังจากที่คุณส่ง ธุรกรรมของคุณอาจไม่แข่งขันได้อีกต่อไป - ธุรกรรมของคุณจะอยู่ใน mempool รออยู่ อาจเป็นไปตลอดกาล

แม้หลังจากธุรกรรมจะอยู่ในบล็อกแล้ว มันก็ยังไม่ถือว่าสิ้นสุดอย่างแท้จริง บล็อกเชนสามารถประสบกับ การปรับโครงสร้างองค์กร (การปรับโครงสร้าง) สถานการณ์ที่เครือข่ายทิ้งบล็อกล่าสุดและแทนที่ด้วยบล็อกเชนอื่น ธุรกรรมที่คุณคิดว่ารับรองแล้วอาจหายไป สิ่งนี้เกิดขึ้นไม่บ่อยในเครือข่ายที่เติบโตเต็มที่แล้ว แต่ "ไม่บ่อย" ไม่ใช่ "ไม่เคยเกิดขึ้น" เมื่อคุณกำลังโอนเงินจริง

แต่ละความล้มเหลวเหล่านี้เกิดขึ้นที่ชั้นที่แตกต่างกัน: การประมาณค่าก๊าซ, การลงนาม, การส่ง, การยืนยัน และการกู้คืนจากความล้มเหลวแต่ละอย่างต้องการการแก้ไขที่แตกต่างกัน - ธุรกรรมที่ติดขัดต้องส่งใหม่ด้วยค่าก๊าซที่สูงขึ้น, ลายเซ็นที่ไม่ถูกต้องต้องลงนามใหม่, การจัดระเบียบใหม่ต้องให้ทั้งกระบวนการลองใหม่อีกครั้ง

เราจัดการเรื่องนี้โดยการจำลองวงจรการดำเนินการเป็นลำดับชั้นของ 4 หน่วยงาน ที่ด้านบนสุดคือผลลัพธ์ทางธุรกิจที่เราพยายามจะบรรลุ ด้านล่างนั้นจะมีชั้นที่มีความเฉพาะเจาะจงมากขึ้นเรื่อยๆ ซึ่งจัดการการเตรียมการ การลงนาม และการส่ง แต่ละชั้นมีขอบเขตความล้มเหลวของตนเองและสามารถลองใหม่ได้ด้วยตัวเองก่อนที่จะยกระดับไปยังชั้นถัดไป:

BlockchainIntent (what we're trying to achieve)
    └── PreparedCall (unsigned transaction, gas estimates locked)
            └── PreparedCallExecution (signing attempt)
                    └── PreparedCallExecutionNode (submission attempt)

บล็อกเชนอินเทนต์ แสดงผลลัพธ์ทางธุรกิจ: "โอน 500 USDC ไปยังที่อยู่นี้เพื่อชำระใบแจ้งหนี้ #1234" เป็นผู้ควบคุมระดับสูงสุดที่ติดตามวงจรชีวิตทั้งหมดและสามารถสร้างการลองใหม่ได้หากจำเป็น

เตรียมการโทร เป็นธุรกรรมที่ไม่สามารถเปลี่ยนแปลงได้ ไม่มีค่าลบ พร้อมการประมาณค่าก๊าซที่ถูกล็อกไว้ หากการประมาณค่าก๊าซหมดอายุ (เงื่อนไขของเครือข่ายเปลี่ยนแปลง) เราจะสร้าง PreparedCall ใหม่

การดำเนินการเรียกใช้ที่เตรียมไว้ล่วงหน้า แสดงถึงความพยายามในการลงนาม สำหรับการดำเนินการฝั่งเซิร์ฟเวอร์ เราจะลงนามโดยอัตโนมัติ สำหรับการดำเนินการที่ผู้ใช้ต้องเห็น (เช่น Global USD) ผู้ใช้จะต้องอนุมัติผ่าน OTP ไม่ว่าจะกรณีใด เมื่อลงนามเรียบร้อยแล้ว เราพร้อมที่จะส่งข้อมูล

โหนดการดำเนินการเรียกใช้ที่เตรียมไว้ คือความพยายามในการส่งธุรกรรมเพียงครั้งเดียว เราจะส่งธุรกรรมไปยังเครือข่ายและตรวจสอบว่ามีการรวมเข้าหรือไม่ หากล้มเหลวด้วยเหตุผลที่สามารถลองใหม่ได้ (เช่น การหมดเวลาของเครือข่าย หรือถูกถอดออกจาก mempool) เราจะสร้างโหนดใหม่และลองอีกครั้ง

แต่ละชั้นจะจัดการกับโดเมนความล้มเหลวของตนเอง:

FailureLayerResolution
Network timeoutExecutionNodeRetry with new node
Retry with new nodeRe-org after confirmationRetry with new node
Invalid signaturePreparedCallExecutionRequire new signature
Gas underestimateExecutionNode → IntentEscalate after N retries
Expired gas estimatesPreparedCall → IntentEscalate, create new PreparedCall
Re-org after confirmationBlockchainIntentSpawn child intent

ข้อคิดสำคัญคือการที่ความล้มเหลวจะยกระดับไปยังชั้นบนเมื่อชั้นนั้นหมดทางเลือกในการแก้ไขปัญหา พิจารณาการประเมินค่าแก๊สต่ำกว่าความเป็นจริงอย่างต่อเนื่อง การแออัดของเครือข่ายเพิ่มขึ้นอย่างรวดเร็ว และค่าพารามิเตอร์ก๊าซที่ล็อกไว้ใน PreparedCall ของเราไม่มีความสามารถในการแข่งขันอีกต่อไป โหนดการดำเนินการจะลองทำซ้ำอีกสองสามครั้ง และอาจมีการคลี่คลายของการแออัด หลังจากล้มเหลว N ครั้ง มันไม่สามารถทำได้อีก ความล้มเหลวจะยกระดับไปสู่การดำเนินการ ซึ่งถึงสถานะสุดท้ายและยกระดับไปสู่เจตนา เจตนาจะสร้างเจตนาลูกที่มีตัวคูณก๊าซสูงขึ้น สร้าง PreparedCall ใหม่ และวงจรจะเริ่มต้นใหม่อีกครั้ง

แต่ละชั้นจะจัดการกับโดเมนความล้มเหลวของตนเอง แต่การยกระดับจะเป็นการกระทำที่ชัดเจน เจตนาหลักจะเก็บรักษาประวัติทั้งหมดไว้ ส่วนเจตนาลูกจะได้รับการลองใหม่พร้อมพารามิเตอร์ที่ปรับแล้ว เราจะไม่สูญเสียบริบทเกี่ยวกับ ทำไม เรากำลังลองใหม่อยู่

การปรับให้สอดคล้อง: จากเหตุการณ์ต่อเนื่องสู่สถานะของผลิตภัณฑ์

ธุรกรรมถูกบรรจุไว้ในบล็อกแล้ว แล้วจะทำอะไรต่อไป?

บล็อกเชนไม่ได้บอกเราโดยตรงว่ามีการโอน 1000 USDC เกิดขึ้น มันบอกเราว่ามีการทำธุรกรรมและมีการสร้างบางสิ่งบางอย่าง บันทึกเหตุการณ์เราจำเป็นต้องแยกวิเคราะห์บันทึกเหล่านั้น, ทำความเข้าใจว่ามันหมายถึงอะไร, และปรับปรุงสถานะภายในของเราให้สอดคล้องกัน

บันทึกเหตุการณ์คือวิธีที่สัญญาอัจฉริยะสื่อสารสิ่งที่เกิดขึ้นระหว่างการดำเนินการ เมื่อคุณเรียก โอน ฟังก์ชันบนสัญญา USDC, สัญญาจะปล่อยออกมา โอน เหตุการณ์ที่มีข้อมูลสามส่วน: ใครเป็นผู้ส่ง, ใครเป็นผู้รับ, และจำนวนเท่าไร. เหตุการณ์นี้จะถูกบันทึกไว้ในใบเสร็จรับเงินธุรกรรมเป็นรายการบันทึก.

แต่บันทึกจะถูกเข้ารหัสเป็นฟิลด์หัวข้อและข้อมูลซึ่งมีค่าที่เข้ารหัสแบบ hex การแยกวิเคราะห์ต้องรู้ลายเซ็นของเหตุการณ์และถอดรหัสพารามิเตอร์ บันทึกการถ่ายโอนแบบดิบจะมีลักษณะประมาณนี้:

// A 5 USDC transfer on Base
{
  "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
  "topics": [
    "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
    "0x0000000000000000000000007e2f5e1fd4d79ed41118fc6f59b53b575c51f182",
    "0x000000000000000000000000a6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373"
  ],
  "data": "0x00000000000000000000000000000000000000000000000000000000004c4b40",
  "transactionIndex": "0x34",
  "logIndex": "0x15d",
}

เราจะทราบได้อย่างไรว่านี่คือการโอน? แต่ละบันทึก หัวข้อ[0] คือค่าแฮช keccak256 ของลายเซ็นเหตุการณ์ - ในกรณีนี้ 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef คือแฮชของ โอน (ที่อยู่ต้นทาง (ดัชนี), ที่อยู่ปลายทาง (ดัชนี), ค่า uint256)- เหตุการณ์การโอนมาตรฐาน ERC-20 พารามิเตอร์ที่ระบุไว้ จัดทำดัชนี ถูกเก็บไว้ในอาร์เรย์หัวข้อตามลำดับการประกาศ ตามลำดับแฮชของลายเซ็น สำหรับเหตุการณ์การโอนนี้ จาก อยู่ใน หัวข้อ[1] และ ถึง ใน หัวข้อ[2]พารามิเตอร์ที่ไม่ถูกจัดทำดัชนี เช่น มูลค่า ถูกเข้ารหัสด้วย ABI ใน ข้อมูล.

การดึงรายละเอียดการโอนจากบันทึกนี้:

  • จาก: หัวข้อ[1] (32 ไบต์, เพิ่มศูนย์ให้เต็ม) → 0x7e2f5e1fd4d79ed41118fc6f59b53b575c51f182
  • ถึง: หัวข้อ[2] (32 ไบต์, เพิ่มศูนย์ให้เต็ม) → 0xa6dbc393e2b1c30cff2fbc3930c3e4ddfc9d1373
  • มูลค่า: ข้อมูล ถอดรหัสเป็น uint256 → 0x4c4b40 = 5,000,000 (5 USDC, เนื่องจาก USDC มีทศนิยม 6 ตำแหน่ง)

การต้องรับภาระทางความคิดในการรู้วิธีแยกวิเคราะห์บันทึกทุกประเภทนั้นดูเหมือนฝันร้าย—นี่จึงเป็นเหตุผลที่เราได้พัฒนาตัวประมวลผลบันทึกที่รู้วิธีแยกวิเคราะห์เหตุการณ์เฉพาะประเภทและแปลงข้อมูลเหล่านั้นให้กลายเป็นสถานะของโดเมน:

โปรเซสเซอร์จะรับข้อมูลบันทึกดิบเข้ามา ประมวลผลแยกข้อมูลออกเป็นฟิลด์ที่มีประเภทชัดเจน (แทนที่จะเป็นสตริงเลขฐานสิบหก) และสร้างเอนทิตีของโดเมนขึ้นมา

const transferLogProcessor = createLogProcessor({
  abi: 'event Transfer(address indexed from, address indexed to, uint256 value)',
  requires: ({ log, chain }) => ({
    token: TokensDependency.args({ chain, contracts: [log.address] }),
  }),
  process: ({ log, tx, parsed, deps }) => {
    return {
      erc20Transfers: [
        {
          from: parsed.from,
          to: parsed.to,
          amount: parsed.value,
          contract: log.address,
          decimals: deps.token.decimals,
		      txHash: tx.hash,
        },
      ],
    };
  },
});

การรวมเข้ากับการยืนยัน

เราดูแลสองขั้นตอนที่แตกต่างกันของวงจรชีวิต:

  1. การรวมเข้าไว้ด้วยกัน ธุรกรรมปรากฏครั้งแรกในบล็อก สถานะเป็นเพียงชั่วคราว - บล็อกอาจถูกจัดเรียงใหม่และหายไป
  2. การยืนยัน - บล็อกถึงความลึกที่เพียงพอ (มีบล็อกต่อเนื่องเพียงพอที่จะขจัดความเป็นไปได้ของการจัดเรียงใหม่) สถานะเป็นขั้นสุดท้าย

ความแตกต่างนี้มีความสำคัญ เราอาจอัปเดต UI เพื่อแสดงสถานะรอดำเนินการเมื่อรวมข้อมูลเข้า แต่จะไม่เริ่มกระบวนการ FoF ที่เกี่ยวข้องจนกว่าจะได้รับการยืนยัน ต้นทุนของการดำเนินการในสถานะที่ยังไม่แน่นอนนั้นไม่มีขีดจำกัด

ตัวประมวลผลบันทึกข้อมูลจะจัดการกับเหตุการณ์แต่ละรายการ แต่บ่อยครั้งเราจำเป็นต้องประสานงานระหว่างเหตุการณ์เหล่านั้นหรือเพิ่มสถานะในระดับธุรกรรม ตัวประมวลผลธุรกรรมจะรวบรวมข้อมูลเหล่านี้: พวกเขาจะรับเอาผลลัพธ์ที่รวมกันแล้วจากตัวประมวลผลบันทึกข้อมูลทั้งหมดและสามารถเปลี่ยนแปลง เพิ่มเติม หรือกระตุ้นผลกระทบเพิ่มเติมในขั้นตอนต่อไปได้ ที่นี่เองที่วงจรชีวิตสองระยะจะปรากฏขึ้น กระบวนการทำธุรกรรม ทำงานเมื่อรวมเข้าด้วยกัน - เราสร้างสถานะเบื้องต้น ยืนยันกระบวนการ ทำงานเมื่อบล็อกถูกยืนยันแล้ว - โดยทั่วไปแล้วนี่คือจุดที่เราดำเนินการวงจรชีวิตให้เสร็จสมบูรณ์สำหรับการดำเนินงานทางการเงิน

const processor = createBlockchainTransactionProcessor()
  .requires(({ transaction }) => ({
    // Transaction-level dependencies
  }))
  .processTransaction(async ({ logResults }) => {
    const transfers = logResults.erc20Transfers ?? [];

    // ...

    return {
 	  // ...
      erc20Transfers:
		transfers.map((t) => ({ type: 'insert', entity: t })),
    };
  })
  .processConfirmation(async ({ logResults }) => {
    // Runs when block reaches finality

    const flowOfFundsRulesToTrigger = await Promise.all([
      findFlowOfFundsRules({
        for: logResults.erc20Transfers.map((t) => t.fromAddress),
        events: ['on_chain_transfer.source.confirmed'],
      }),
      findFlowOfFundsRules({
        for: logResults.erc20Transfers.map((t) => t.toAddress),
        events: ['on_chain_transfer.destination.confirmed'],
      }),
    ]);

    // ...

    return {
      // ...
      flowOfFundsRulesToTrigger: flowOfFundsRulesToTrigger.flat(),
      erc20Transfers: logResults.erc20Transfers.map((t) => ({
        type: 'update',
        entity: t,
        changes: {
          status: 'confirmed',
        },
      })),
    };
  })
  .build();

เชื่อมต่อบันทึกกลับไปยังการโทร

เมื่อตัวประมวลผลบันทึกของเราสร้างบันทึกการโอนย้าย มันจำเป็นต้องเชื่อมโยงกลับไปยัง BlockchainCall ที่เริ่มต้น บันทึกจะบอกเราว่า อะไร เกิดขึ้น—สินทรัพย์ถูกย้ายจาก A ไปยัง B. BlockchainCall บอกเราว่า ทำไม—นี่คือการเรียกเก็บค่าธรรมเนียม หรือการชำระเงินให้กับผู้ขาย หรือการคืนเงิน สำหรับธุรกรรมที่ง่าย ๆ ที่มีการเรียกใช้เพียงครั้งเดียว นี่เป็นเรื่องที่ตรงไปตรงมา สำหรับธุรกรรมแบบกลุ่ม—ที่เราจะรวมการดำเนินการหลาย ๆ อย่างไว้ในธุรกรรมบนเชนเพียงครั้งเดียวเพื่อประหยัดค่าแก๊ส—มันจะยากขึ้น ใบเสร็จให้เราเป็นรายการแบบแบนของบันทึกทั้งหมดที่ถูกส่งออกในระหว่างการดำเนินการ โดยไม่มีการบ่งชี้ว่าการเรียกใช้ใด ๆ ที่ทำให้เกิดบันทึกใด ๆ เราแก้ไขปัญหานี้ด้วยการติดตามกรอบการเรียกใช้ (call-frame tracing) ซึ่งเราจะกล่าวถึงในส่วนขั้นสูงด้านล่าง

ขั้นสูง: การระบุบันทึกที่จัดกลุ่มให้กับการโทรแต่ละครั้ง

ส่วนนี้ครอบคลุมถึงความท้าทายทางเทคนิคเฉพาะเกี่ยวกับธุรกรรมแบบกลุ่ม หากคุณไม่ได้ทำงานกับ ERC-4337 หรือการดำเนินการแบบกลุ่ม สามารถข้ามไปยัง Global USD ได้เลย ก่อนหน้านี้เราได้กล่าวถึงการเชื่อมต่อล็อกกลับไปยัง BlockchainCall ที่มาของมันซึ่งเป็นเรื่องง่ายสำหรับธุรกรรมที่เรียบง่าย แต่สำหรับธุรกรรมแบบกลุ่มนั้นไม่ใช่เรื่องง่าย

ปัญหา

เมื่อเรารวมการดำเนินการหลายรายการเป็นธุรกรรมเดียว—เช่น การชำระเงิน $500 บวกค่าธรรมเนียม $1—ทั้งสองรายการจะดำเนินการอย่างสมบูรณ์พร้อมกัน หลักฐานการรับธุรกรรมจะให้รายการบันทึกทั้งหมดที่สร้างขึ้นระหว่างการดำเนินการ:

{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "type": "0x2",
        "status": "0x1",
        "logs": [
            {
                "address": "0x0000000071727de22e5e9d8baf0edac6f37da032",
                "topics": [ /* ... */ ],
                "data": "0x",
                "logIndex": "0x3db",
            },
            {
                "address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                "topics": ["0xddf252ad...", /* ... */ ],
                "data": "0x000000000000000000000000000000000000000000000000000000001dcd6500",
                "logIndex": "0x3dc",
            },
            {
                "address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                "topics": ["0xddf252ad...", /* ... */ ],
                "data": "0x000000000000000000000000000000000000000000000000000000000010c8e0",
                "logIndex": "0x3dd",
            },
            {
                "address": "0x0000000071727de22e5e9d8baf0edac6f37da032",
                "topics": ["0x49628fd1...", /* ... */],
                "data": "0x000000000000000000000000000000000000000000000001000000000000000400000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000009206e5fb300000000000000000000000000000000000000000000000000000000000036f83",
                "logIndex": "0x3de",
            }
        ],
        "logsBloom": "0x...",
        "transactionHash": "0x95aaaa96f3ccefbfcedb1fe5e41d817bc663fcac305ebfc3861f2a189f664cda",
        "transactionIndex": "0x18e",
        "blockHash": "0xfdc6c014e387c5b6f95edc17a25e81f694a5ad46fc75a247aeb0ad3f5dcd8004",
        "blockNumber": "0x25b1de6",
        "gasUsed": "0x28c6d",
        "effectiveGasPrice": "0x1b3ed0",
        "blobGasUsed": "0x24078",
        "from": "0x8dc6004dcbfdceeff98f16e7d58b0fde5fab1412",
        "to": "0x0000000071727de22e5e9d8baf0edac6f37da032",
    }
}

เราจะได้รับอาร์เรย์แบบแบนของทุก ๆ ล็อกที่ถูกส่งออกในระหว่างการดำเนินการ เมื่อดูที่ใบเสร็จนี้ เราสามารถระบุเหตุการณ์ Transfer สองรายการได้ที่ดัชนีล็อก 1 และ 2 (ทั้งสองรายการมี 0xddf252ad... การถ่ายโอนลายเซ็นเหตุการณ์ที่เราได้พูดคุยกันก่อนหน้านี้

แต่รายการไหนคือการชำระเงินและรายการไหนคือค่าธรรมเนียม? ใบเสร็จไม่ได้บอกเรา—บันทึกจะถูกเชื่อมโยงกับธุรกรรมระดับบนสุด ไม่ใช่กับการเรียกแต่ละรายการภายในชุดธุรกรรม คุณอาจคิดว่า: แค่จับคู่บันทึกกับการเรียกตามลำดับก็พอ แต่วิธีนี้จะใช้ได้ก็ต่อเมื่อแต่ละการเรียกสร้างบันทึกเพียงหนึ่งรายการเท่านั้น การโอนแบบง่ายจะสร้างหนึ่งรายการ การสลับอาจสร้างถึงห้า หากไม่ทราบขอบเขตของแต่ละรายการ คุณก็ไม่สามารถจับคู่ได้อย่างแม่นยำ

การติดตามกรอบการโทร

คำตอบปรากฏว่า debug_traceTransaction- เมธอด RPC ของโหนดเก็บถาวร Geth ที่คนส่วนใหญ่ใช้สำหรับการดีบักธุรกรรมที่ล้มเหลว แต่จริงๆ แล้วมันทำอย่างอื่นด้วย: มันเล่นธุรกรรมซ้ำและส่งคืนต้นไม้เฟรมการเรียกทั้งหมด พร้อมบันทึกที่แนบมาในระดับความลึกที่ถูกต้องในลำดับชั้นการเรียก

{
  "jsonrpc": "2.0",
  "method": "debug_traceTransaction",
  "params": [
    "0x...",
    {
        "tracer": "callTracer",
        "tracerConfig": {
            "withLog": true
        }
    }
  ],
  "id": 1
}

ผลลัพธ์คือโครงสร้างที่ซ้อนกันแบบเรียกซ้ำของกรอบการเรียก (ย่อให้เข้าใจง่ายขึ้นเพื่อการอ่าน)

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "from": "0x8dc6004dcbfdceeff98f16e7d58b0fde5fab1412",
    "to": "0x0000000071727de22e5e9d8baf0edac6f37da032",
    "input": "0x765e827f...",
    "calls": [
      { /* ... */ },
      { /* ... */ },
      {
        "from": "0x0000000071727de22e5e9d8baf0edac6f37da032",
        "to": "0x0000000071727de22e5e9d8baf0edac6f37da032",
        "input": "0x0042dc53...",
        "calls": [
          {
			// ...
            "calls": [
              {
                "from": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                "to": "0xe1452ff880739d26f341c2510222e808def38d10",
                "input": "0xa9059cbb...",
                "logs": [
                  {
                    "address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                    "topics": [ /* ... */ ],
                    "data": "0x...",
                    "position": "0x0",
                    "index": "0x1"
                  }
                ],
                "value": "0x0",
                "type": "DELEGATECALL"
              }
            ]
          },
          {
			// ...
            "calls": [
              {
                "from": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                "to": "0xe1452ff880739d26f341c2510222e808def38d10",
                "input": "0xa9059cbb...",
                "logs": [
                  {
                    "address": "0xcc7b940e22e1eef83c0170608aed6d5fd386cdad",
                    "topics": [ /* ... */ ],
                    "data": "0x...",
                    "position": "0x0",
                    "index": "0x2"
                  }
                ],
                "value": "0x0",
                "type": "DELEGATECALL"
              }
            ]
          }
        ]
      }
    ],
    "logs": [],
    "value": "0x0",
    "type": "CALL"
  }
}

เราทำให้โครงสร้างแบบวนซ้ำนี้แบนราบเป็นสคีมาที่รักษาความสัมพันธ์แบบต้นไม้ไว้:

export type EvmCallType =
  | ('CALL' | 'DELEGATECALL' | 'STATICCALL' | 'CREATE' | 'CREATE2')
  | (string & {});

/**
 * Represents a single frame in an EVM transaction's call stack trace.
 *
 * Each call frame captures the execution of one contract call, including:
 * - Its position in the call tree (parent, depth, index)
 * - Call details (type, from, to, value, input, output)
 * - Gas metrics (gas available, gas used)
 *
 * Call frames form a tree structure via the parentId relationship, where:
 * - Root frame (depth 0): The initial transaction call
 * - Child frames: Calls made by the parent contract (CALL, DELEGATECALL, etc.)
 */
export interface IEvmCallFrameSchema {
  id: Id<'evmCallFrame'>;
  transactionHash: string;
  parentId: Id<'evmCallFrame'> | undefined;
  depth: number;
  index: number;
  type: EvmCallType;
  from: `0x${string}`;
  to: `0x${string}` | undefined;
  value: string;
  input: `0x${string}`;
  output: `0x${string}` | undefined;
  gas: string | undefined;
  gasUsed: string | undefined;
  logs: {
    address: `0x${string}`;
    topics: `0x${string}`[];
    data: `0x${string}`;
    position: number;
    index: number;
  }[];
}
const root = debugTraceTransactionResp.result;

const recursivelyBuildSchemas = (
  frame: typeof root,
  index: number,
  parent?: InsertSchema<IEvmCallFrameSchema>
): InsertSchema<IEvmCallFrameSchema>[] => {
  const schema: InsertSchema<IEvmCallFrameSchema> = {
    id: generateId('evmCallFrame'),
    transactionHash,
    parentId: parent?.id,
    depth: (parent?.depth ?? -1) + 1,
    index,
    type: frame.type,
    from: frame.from,
    to: frame.to,
    value: (frame.value ? BigInt(frame.value) : BigInt(0)).toString(),
    input: frame.input,
    output: frame.output,
    gas: frame.gas ? BigInt(frame.gas).toString() : undefined,
    gasUsed: frame.gasUsed ? BigInt(frame.gasUsed).toString() : undefined,
    logs: (frame.logs ?? []).map((log) => ({
      ...log,
      position: Number(log.position),
      index: Number(log.index),
    })),
  };

  return [
    schema,
    ...(frame.calls ?? []).flatMap((call, index) =>
      recursivelyBuildSchemas(call, index, schema)
    ),
  ];
};

const schemas = recursivelyBuildSchemas(root, 0);

const trace: InsertSchema<IEvmTransactionTraceSchema> = {
  transactionHash,
  chain,
  rootFrameId: schemas[0].id,
};

await persistTransactionTrace({
  trace,
  callFrames: schemas,
});

พิจารณา UserOp ที่ถูกจัดเป็นชุดซึ่งมีการโอน USDC สองรายการ: การชำระเงิน $500 และค่าธรรมเนียม $1.10 ซึ่งแสดงโดยร่องรอยการดำเนินการด้านบน ร่องรอยนี้ให้ข้อมูลแก่เรา:

EntryPoint.handleOps()
...
└── SmartWallet.executeBatch()
    ├── Call 0: USDC.transfer(500.00 USDC to 0x...)
   └── Transfer(from, to, 500000000)
    ├── Call 1: USDC.transfer(1.10 USDC to Slash)
   └── Transfer(from, to, 1100000)
    └── ...

ขณะนี้ธุรกรรมทั้งหมดสามารถแสดงเป็นต้นไม้ได้แล้ว ซึ่งเป็นการปรับกรอบปัญหาทั้งหมดใหม่: แทนที่จะอนุมานโครงสร้างจากอาร์เรย์บันทึกแบบแบน เรากำลังสร้างต้นไม้การดำเนินการใหม่ขึ้นมา—ซึ่งลำดับชั้นของการเรียกใช้จะชัดเจน และบันทึกจะถูกแนบอยู่กับเฟรมที่สร้างบันทึกนั้นขึ้นมา

จากนั้น การระบุแหล่งที่มาเป็นเรื่องง่าย ค้นหาโหนดที่สอดคล้องกับ ดำเนินการเป็นชุด() เรียก, ทำซ้ำผ่านลูกหลานของมันที่ดัชนี 0..N-1และรวบรวมบันทึกจากแต่ละย่อยของต้นไม้แบบวนซ้ำ แต่ละดัชนีลูก 0..N-1 เชื่อมโยงโดยตรงกับ BlockchainCall ที่สอดคล้องกัน ดัชนีในชุดข้อมูล. ตอนนี้เราทราบแล้วว่าเสียงเรียกใดที่สร้างบันทึกใดขึ้นมา

เนื่องจากแทบทุกธุรกรรมต้องการการระบุแหล่งที่มา เราจึงได้สร้างฟังก์ชันนี้ไว้ในตัวประมวลผลบันทึกของเราโดยตรง มันจะสร้างลำดับการเรียกทั้งหมดขึ้นมาใหม่ จับคู่บันทึกกับเฟรมต้นทาง และแก้ไข BlockchainCalls ทั้งหมดในชุดข้อมูล จากนั้นแต่ละตัวประมวลผลบันทึกจะได้รับการเรียกและบริบทของเฟรมเฉพาะสำหรับบันทึกที่กำลังดำเนินการ:

const transferLogProcessor = createLogProcessor({
  abi: 'event Transfer(address indexed from, address indexed to, uint256 value)',
  requires: ({ call, callFrame }) => ({
    // ...
  }),
  process: ({ call, callFrame }) => {

    return {
     // ...
    };
  },
});

สายการอ้างอิงครบถ้วน:

```
Transfer log
 EvmCallFrame (index 0 under executeBatch)
 BlockchainCall (indexInBatch: 0)
 type: 'erc20.transfer'
 category: 'outbound_ach_credit'
 context: { recipientName: 'Acme Corp' }
```

เงินดอลลาร์สหรัฐทั่วโลก: โครงการสำเร็จการศึกษา

ทางออกและทางเข้ากำจัดปัญหาให้กับลูกค้าปัจจุบันของเรา—ธุรกิจที่มีบัญชีธนาคารในสหรัฐฯ ที่ต้องการโอนเงินระหว่างสกุลเงินทั่วไปและคริปโต แต่เรายังคงได้ยินจากกลุ่มลูกค้าอีกกลุ่มหนึ่ง: ธุรกิจระหว่างประเทศที่ต้องการเข้าถึงระบบเงินดอลลาร์สหรัฐฯ แต่ไม่สามารถทำได้โดยง่าย

หากคุณเป็นผู้รับเหมาซอฟต์แวร์ในอาร์เจนตินา, ผู้ค้าปลีกออนไลน์ในไนจีเรีย, หรือบริษัท SaaS ในเอเชียตะวันออกเฉียงใต้ การเปิดบัญชีธนาคารในสหรัฐฯ มักต้องการการจัดตั้งนิติบุคคลในสหรัฐฯ—ทนายความ, ตัวแทนจดทะเบียน, และค่าใช้จ่ายหลายเดือน หลายธุรกิจที่ถูกต้องตามกฎหมายถูกกีดกันออกจากเศรษฐกิจดอลลาร์ ไม่ใช่เพราะสิ่งที่พวกเขาทำ แต่เพราะสถานที่ที่พวกเขาจดทะเบียนบริษัท

Stablecoins เปลี่ยนแปลงสิ่งนี้ ยอดคงเหลือของ USDC คือยอดคงเหลือเป็นดอลลาร์ USD ทั่วโลกเป็นความพยายามของเราในการสร้างโครงสร้างพื้นฐานทางการเงินบนพื้นฐานของแนวคิดนี้

การออกแบบที่ไม่ถือครองสิทธิ์

เราสร้าง Global USD ขึ้นเป็นระบบที่ไม่มีการเก็บรักษาทรัพย์สิน (non-custodial) การตัดสินใจนี้ได้รับแรงผลักดันจากสองปัจจัย: ความซับซ้อนทางกฎหมาย และ ความไว้วางใจ

การถือครองเงินทุนของลูกค้าต้องปฏิบัติตามข้อกำหนดด้านใบอนุญาตซึ่งแตกต่างกันไปตามเขตอำนาจศาล สถาปัตยกรรมแบบไม่เก็บรักษาทรัพย์สินของลูกค้า (non-custodial) ช่วยลดความซับซ้อนของสถานะใบอนุญาตของเราในตลาดหลายแห่ง ในด้านความไว้วางใจ ลูกค้าเป็นผู้ควบคุมกุญแจของตนเอง—โดยออกแบบให้ Slash ไม่สามารถเริ่มการโอนได้หากไม่ได้รับการอนุญาตทางเข้ารหัสจากผู้ลงนามบัญชี

แกนของปรมาณูคือ กระเป๋าสตางค์อัจฉริยะ: สัญญาอัจฉริยะที่ทำหน้าที่เป็นกระเป๋าเงินแต่มีการควบคุมการเข้าถึงที่สามารถโปรแกรมได้

บัญชี Global USD แต่ละบัญชีเป็นกระเป๋าเงินอัจฉริยะที่ควบคุมโดยระบบหลายลายเซ็น ทุกสมาชิกที่ได้รับอนุญาตของธุรกิจถือกุญแจไว้ การโอนเงินต้องได้รับการอนุมัติจากพวกเขาก่อนดำเนินการ Slash สามารถ เตรียม ธุรกรรม แต่เราไม่สามารถ ดำเนินการ โดยไม่ได้รับอนุญาตจากผู้ลงนาม

การลงนามโดยไม่มีสิทธิปกครองบุตร

นี่ทำให้เกิดคำถามเกี่ยวกับประสบการณ์ผู้ใช้: หากผู้ใช้ควบคุมคีย์เอง พวกเขาไม่จำเป็นต้องจัดการกับวลีเมล็ดพันธุ์และลงนามในธุรกรรมด้วยตนเองหรือ?

เราใช้โครงสร้างพื้นฐานกระเป๋าเงินแบบฝังจาก Privy และ Alchemy เมื่อผู้ใช้สร้างบัญชี คีย์ส่วนตัวจะถูกสร้างขึ้นภายในหน่วยความจำที่แยกออกจากฮาร์ดแวร์ (สภาพแวดล้อมการประมวลผลที่เชื่อถือได้ หรือ TEE) กุญแจมีอยู่ แต่ถูกออกแบบมาให้ไม่สามารถเข้าถึงได้โดยตรงโดย Slash หรือผู้ใดก็ตาม เมื่อผู้ใช้เริ่มการโอน พวกเขาจะอนุมัติผ่าน OTP ซึ่งจะอนุญาตให้ TEE ลงนามแทนพวกเขาได้ จากนั้นธุรกรรมที่ลงนามแล้วจะถูกส่งไปยังเครือข่าย

จากมุมมองของผู้ใช้ รู้สึกเหมือนกับการอนุมัติการโอนเงินผ่านธนาคาร จากมุมมองของการดูแลรักษา เราไม่เคยแตะต้องกุญแจส่วนตัว

สิ่งที่ปลดล็อกได้

ธุรกิจในลากอสสามารถถือครองดอลลาร์, รับชำระเงินจากลูกค้าในสหรัฐอเมริกา, และชำระเงินให้กับผู้ขายระหว่างประเทศได้—ทั้งหมดนี้โดยไม่ต้องมีบัญชีธนาคารในสหรัฐอเมริกา, ไม่มีความเสี่ยงในการเก็บรักษา, และมีเส้นทางการตรวจสอบและการทำงานด้านการปฏิบัติตามข้อกำหนดเช่นเดียวกับที่เราใช้กับลูกค้าของ Slash ทุกคน

นั่นคือสิ่งที่เหรียญมีเสถียรภาพสามารถเป็นได้จริง ๆ: ไม่ใช่แค่เพียงวิธีการชำระเงิน แต่เป็นโครงสร้างพื้นฐานสำหรับระบบการเงินที่เข้าถึงได้มากขึ้น

อะไรต่อไป

เครื่องมือพื้นฐานที่เราสร้างขึ้นไม่ได้มีไว้เพียงแค่การโอนเงินระหว่างสกุลเงินทั่วไปกับคริปโตเท่านั้น แต่ยังเป็นรากฐานสำหรับทุกสิ่งที่เรากำลังพัฒนาที่ Slash เรากำลังขยายบริการบัญชีทั่วโลกของเรา เพื่อให้ธุรกิจต่างๆ สามารถเข้าถึงระบบชำระเงินแบบ USD ได้มากขึ้น ไม่ว่าจะจดทะเบียนในประเทศใดก็ตาม และเรากำลังสร้างบัตรระดับโลกของเรา: บัตรที่ให้เงินคืนสูง รองรับด้วย stablecoin ที่ช่วยให้ลูกค้าสามารถใช้ยอดเงินของตนได้ทุกที่ ทั้งสองอย่างนี้พึ่งพาอาศัยกันอย่างมากในกรอบการจัดการและการดำเนินการที่เราได้อธิบายไว้ที่นี่ หากคุณมาถึงจุดนี้แล้ว และคุณเป็นวิศวกรที่ต้องการแก้ปัญหาโครงสร้างพื้นฐานที่ยากสำหรับลูกค้าจริงในบริษัทที่กำลังเติบโตอย่างรวดเร็ว เราเปิดรับสมัคร

Read more from us